Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "markdown_parser"
|
||||
authors = ["Warp Team <dev@warp.dev>"]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
enum-iterator.workspace = true
|
||||
itertools.workspace = true
|
||||
nom.workspace = true
|
||||
html5ever.workspace = true
|
||||
markup5ever_rcdom = "0.35.0"
|
||||
serde_yaml.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,227 @@
|
||||
# Markdown Table Test Cases
|
||||
|
||||
## 01_simple_2x2
|
||||
|
||||
| Header 1 | Header 2 |
|
||||
| --- | --- |
|
||||
| Cell 1 | Cell 2 |
|
||||
|
||||
---
|
||||
|
||||
## 02_three_columns
|
||||
|
||||
| Name | Age | City |
|
||||
| --- | --- | --- |
|
||||
| Alice | 30 | NYC |
|
||||
| Bob | 25 | LA |
|
||||
|
||||
---
|
||||
|
||||
## 03_multiple_rows
|
||||
|
||||
| ID | Value |
|
||||
| --- | --- |
|
||||
| 1 | Apple |
|
||||
| 2 | Banana |
|
||||
| 3 | Cherry |
|
||||
| 4 | Date |
|
||||
| 5 | Elderberry |
|
||||
|
||||
---
|
||||
|
||||
## 04_left_aligned
|
||||
|
||||
| Left 1 | Left 2 |
|
||||
| :--- | :--- |
|
||||
| Short | Text |
|
||||
| Much longer text | Another |
|
||||
|
||||
---
|
||||
|
||||
## 05_right_aligned
|
||||
|
||||
| Right 1 | Right 2 |
|
||||
| ---: | ---: |
|
||||
| Short | Text |
|
||||
| Much longer text | Another |
|
||||
|
||||
---
|
||||
|
||||
## 06_center_aligned
|
||||
|
||||
| Center 1 | Center 2 |
|
||||
| :---: | :---: |
|
||||
| Short | Text |
|
||||
| Much longer text | Another |
|
||||
|
||||
---
|
||||
|
||||
## 07_mixed_alignment
|
||||
|
||||
| Left | Center | Right |
|
||||
| :--- | :---: | ---: |
|
||||
| L | C | R |
|
||||
| Left-aligned | Centered | Right-aligned |
|
||||
|
||||
---
|
||||
|
||||
## 08_bold
|
||||
|
||||
| Header | Value |
|
||||
| --- | --- |
|
||||
| **Bold** | Normal |
|
||||
| Text | **Bold too** |
|
||||
|
||||
---
|
||||
|
||||
## 09_italic
|
||||
|
||||
| Header | Value |
|
||||
| --- | --- |
|
||||
| *Italic* | Normal |
|
||||
| Text | *Italic too* |
|
||||
|
||||
---
|
||||
|
||||
## 10_inline_code
|
||||
|
||||
| Function | Returns |
|
||||
| --- | --- |
|
||||
| `foo()` | `String` |
|
||||
| `bar()` | `i32` |
|
||||
|
||||
---
|
||||
|
||||
## 11_links
|
||||
|
||||
| Site | URL |
|
||||
| --- | --- |
|
||||
| Google | [Link](https://google.com) |
|
||||
| GitHub | [Link](https://github.com) |
|
||||
|
||||
---
|
||||
|
||||
## 12_strikethrough
|
||||
|
||||
| Item | Status |
|
||||
| --- | --- |
|
||||
| ~~Deprecated~~ | Old |
|
||||
| Active | Current |
|
||||
|
||||
---
|
||||
|
||||
## 13_mixed_formatting
|
||||
|
||||
| Feature | Description |
|
||||
| --- | --- |
|
||||
| **Bold** with *italic* | Mixed |
|
||||
| `code` and **bold** | Combined |
|
||||
| ~~Strike~~ and *italic* | More |
|
||||
|
||||
---
|
||||
|
||||
## 14_empty_cells
|
||||
|
||||
| A | B | C |
|
||||
| --- | --- | --- |
|
||||
| | filled | |
|
||||
| filled | | filled |
|
||||
|
||||
---
|
||||
|
||||
## 15_whitespace_cells
|
||||
|
||||
| A | B |
|
||||
| --- | --- |
|
||||
| | space |
|
||||
| tab | text |
|
||||
|
||||
---
|
||||
|
||||
## 16_escaped_pipes
|
||||
|
||||
| Expression | Result |
|
||||
| --- | --- |
|
||||
| A \| B | OR operation |
|
||||
| X \| Y \| Z | Multiple |
|
||||
|
||||
---
|
||||
|
||||
## 17_long_content
|
||||
|
||||
| Short | Very Long Content |
|
||||
| --- | --- |
|
||||
| A | This is a very long cell with lots of text that should wrap or truncate |
|
||||
| B | Another cell with substantial content |
|
||||
|
||||
---
|
||||
|
||||
## 18_html_entities
|
||||
|
||||
| Symbol | Code |
|
||||
| --- | --- |
|
||||
| < | Less than |
|
||||
| > | Greater than |
|
||||
| & | Ampersand |
|
||||
|
||||
---
|
||||
|
||||
## 19_unicode_emoji
|
||||
|
||||
| Icon | Name |
|
||||
| --- | --- |
|
||||
| 🚀 | Rocket |
|
||||
| ⭐ | Star |
|
||||
| 🎉 | Party |
|
||||
|
||||
---
|
||||
|
||||
## 20_wide_table
|
||||
|
||||
| Build Identifier | Release Channel | Feature Flag State | Workspace Session Token | Active Pane Title | Suggested Command Preview | Generated File Path | Git Branch Name | Pull Request Status | Reviewer Assignment | Telemetry Event Name | Render Mode | Table Layout Strategy | Horizontal Overflow Sentinel | Unbroken Content Sample | Final Notes |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| build_2026_04_08_very_long_identifier_alpha | dogfood_internal_preview_rollout_candidate | markdown_table_horizontal_scroll_enabled | ws_session_token_01_ABCDEFGHIJKLMNOPQRSTUVWXYZ | agent_mode_diff_review_surface_with_extra_context | cargo_nextest_run_no_fail_fast_workspace_markdown_parser | crates/markdown_parser/examples/table-demo/all_test_cases.md | zach/wide-markdown-table-scroll | awaiting_follow_up_visual_regression_check | reviewer_assignment_pending_product_design | markdown_table_rendered_in_example_viewport | constrained_width_preview_panel | preserve_column_intrinsic_widths_before_wrapping | horizontal_scroll_should_be_required_here | SUPERLONGUNBROKENTEXTVALUE0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ | first_row_designed_to_force_width |
|
||||
| build_2026_04_08_very_long_identifier_beta | stable_candidate_post_validation | markdown_table_horizontal_scroll_enabled | ws_session_token_02_ZYXWVUTSRQPONMLKJIHGFEDCBA | markdown_parser_demo_showing_extreme_width_case | cargo_run_features_with_local_server_markdown_demo | crates/markdown_parser/examples/table-demo/render_snapshot_reference.png | zach/wide-markdown-table-scroll | local_only_validation_before_pr | reviewer_assignment_not_requested_yet | markdown_table_horizontal_scroll_exercised | embedded_example_renderer | keep_headers_verbose_and_cells_intentionally_wide | overflow_region_should_extend_far_past_viewport | ANOTHEREXTREMELYLONGUNBROKENVALUE_for_horizontal_scroll_testing_only | second_row_keeps_pressure_on_layout |
|
||||
| build_2026_04_08_very_long_identifier_gamma | canary_rollout_with_extra_observability | markdown_table_horizontal_scroll_enabled | ws_session_token_03_0123456789_repeat_repeat | full_width_table_case_for_manual_agent_testing | cargo_clippy_workspace_all_targets_all_features_tests | app/src/features/markdown/table_renderer/visual_debug_reference.rs | zach/wide-markdown-table-scroll | no_pr_needed_for_manual_local_test | reviewer_assignment_not_applicable | markdown_table_scroll_behavior_verified_manually | split_pane_code_review_view | avoid_collapsing_columns_even_with_dense_content | viewport_must_scroll_horizontally_to_reveal_tail_columns | YETANOTHERLONGUNBROKENCONTENTBLOCK_THAT_SHOULD_NOT_WRAP_EASILY | third_row_confirms_consistent_behavior |
|
||||
|
||||
---
|
||||
|
||||
## 21_deep_table
|
||||
|
||||
| ID | Value |
|
||||
| --- | --- |
|
||||
| 1 | Row 1 |
|
||||
| 2 | Row 2 |
|
||||
| 3 | Row 3 |
|
||||
| 4 | Row 4 |
|
||||
| 5 | Row 5 |
|
||||
| 6 | Row 6 |
|
||||
| 7 | Row 7 |
|
||||
| 8 | Row 8 |
|
||||
| 9 | Row 9 |
|
||||
| 10 | Row 10 |
|
||||
| 11 | Row 11 |
|
||||
| 12 | Row 12 |
|
||||
| 13 | Row 13 |
|
||||
| 14 | Row 14 |
|
||||
| 15 | Row 15 |
|
||||
| 16 | Row 16 |
|
||||
| 17 | Row 17 |
|
||||
| 18 | Row 18 |
|
||||
| 19 | Row 19 |
|
||||
| 20 | Row 20 |
|
||||
|
||||
---
|
||||
|
||||
## 22_large_grid
|
||||
|
||||
| C1 | C2 | C3 | C4 | C5 | C6 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| R1C1 | R1C2 | R1C3 | R1C4 | R1C5 | R1C6 |
|
||||
| R2C1 | R2C2 | R2C3 | R2C4 | R2C5 | R2C6 |
|
||||
| R3C1 | R3C2 | R3C3 | R3C4 | R3C5 | R3C6 |
|
||||
| R4C1 | R4C2 | R4C3 | R4C4 | R4C5 | R4C6 |
|
||||
| R5C1 | R5C2 | R5C3 | R5C4 | R5C5 | R5C6 |
|
||||
| R6C1 | R6C2 | R6C3 | R6C4 | R6C5 | R6C6 |
|
||||
| R7C1 | R7C2 | R7C3 | R7C4 | R7C5 | R7C6 |
|
||||
| R8C1 | R8C2 | R8C3 | R8C4 | R8C5 | R8C6 |
|
||||
@@ -0,0 +1,580 @@
|
||||
use anyhow::Result;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
collections::{HashMap, VecDeque},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use html5ever::{
|
||||
Attribute, ParseOpts, parse_document, tendril::TendrilSink, tree_builder::TreeBuilderOpts,
|
||||
};
|
||||
use markup5ever_rcdom::{Node, NodeData, RcDom};
|
||||
|
||||
use crate::{
|
||||
CodeBlockText, FormattedIndentTextInline, FormattedTaskList, FormattedText,
|
||||
FormattedTextFragment, FormattedTextHeader, FormattedTextInline, FormattedTextLine,
|
||||
FormattedTextStyles, Hyperlink, OrderedFormattedIndentTextInline,
|
||||
markdown_parser::RUNNABLE_BLOCK_MARKDOWN_LANG, weight::CustomWeight,
|
||||
};
|
||||
|
||||
// Top element element tags we are not parsing for right now.
|
||||
// Note that we have "<b>" here because GDocs always include a top level <b> element to add additional
|
||||
// GDocs specific meta-data for its rich text content.
|
||||
const TOP_LEVEL_ELEMENT_TAGS_TO_SKIP: &[&str] = &[
|
||||
"head", "body", "html", "meta", "table", "b", "div", "ul", "ol", "li", "input",
|
||||
];
|
||||
const PHRASING_ELEMENT_TAGS: &[&str] = &[
|
||||
"span", "i", "code", "strong", "em", "br", "a", "s", "u", "ins",
|
||||
];
|
||||
|
||||
pub const WARP_EMBED_ATTRIBUTE_NAME: &str = "data-warp-embedded-item";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ListArg {
|
||||
indent_level: usize,
|
||||
item_type: ListType,
|
||||
start_number: Rc<Cell<Option<usize>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ListType {
|
||||
Checkbox(bool),
|
||||
ListItem { ordered: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Styling {
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strikethrough: bool,
|
||||
inline_code: bool,
|
||||
link: Option<String>,
|
||||
}
|
||||
|
||||
impl Styling {
|
||||
fn update_with_attributes(&mut self, attributes: &[Attribute]) {
|
||||
for attribute in attributes {
|
||||
let attribute_name = attribute.name.local.to_string();
|
||||
|
||||
if attribute_name == "style" {
|
||||
let attribute_value = attribute.value.to_string();
|
||||
let style_dict = parse_style_into_dict(attribute_value.as_str());
|
||||
|
||||
if let Some(font_style) = style_dict.get("font-style") {
|
||||
self.italic = *font_style == "italic";
|
||||
}
|
||||
|
||||
if let Some(text_decoration) = style_dict.get("text-decoration") {
|
||||
// `text-decoration` can be used for multiple things. If we are missing line-through
|
||||
// here, don't unset the strikethrough.
|
||||
if *text_decoration == "line-through" {
|
||||
self.strikethrough = true;
|
||||
}
|
||||
if *text_decoration == "underline" {
|
||||
self.underline = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(font_weight) = style_dict.get("font-weight") {
|
||||
if *font_weight == "bold" || *font_weight == "bolder" {
|
||||
self.bold = true;
|
||||
} else {
|
||||
let maybe_integer = font_weight.parse::<i32>();
|
||||
|
||||
if let Ok(weight_value) = maybe_integer {
|
||||
self.bold = weight_value > 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note that there is not a definitive way in HTML to represent inline code.
|
||||
// Different text editors all use different ways to represent inline code and are
|
||||
// not always compatible to each other, here we chose Notion's way of representing
|
||||
// inline code which is a non-transparent background. We could revisit this in the future.
|
||||
if let Some(color) = style_dict.get("background") {
|
||||
self.inline_code = *color != "transparent";
|
||||
}
|
||||
} else if attribute_name == "href" {
|
||||
let attribute_value = attribute.value.to_string();
|
||||
self.link = Some(attribute_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an attribute by name, if it's present.
|
||||
fn get_attribute<'a>(attributes: &'a [Attribute], name: &str) -> Option<&'a str> {
|
||||
attributes.iter().find_map(|attribute| {
|
||||
if &attribute.name.local == name {
|
||||
Some(attribute.value.as_ref())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn includes_attribute(attributes: &[Attribute], name: &str) -> bool {
|
||||
get_attribute(attributes, name).is_some()
|
||||
}
|
||||
|
||||
fn type_matches(attributes: &[Attribute], value: &str) -> bool {
|
||||
get_attribute(attributes, "type") == Some(value)
|
||||
}
|
||||
|
||||
// Top-level function to parse a HTML string into a FormattedText document.
|
||||
pub fn parse_html(html: &str) -> Result<FormattedText> {
|
||||
let opts = ParseOpts {
|
||||
tree_builder: TreeBuilderOpts {
|
||||
drop_doctype: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Parse the document into a Dom element tree.
|
||||
let dom = parse_document(RcDom::default(), opts)
|
||||
.from_utf8()
|
||||
.read_from(&mut html.as_bytes())?;
|
||||
|
||||
let mut result = VecDeque::new();
|
||||
|
||||
// Top-level nodes to visit.
|
||||
let mut nodes: Vec<(Rc<Node>, Option<ListArg>)> = Vec::new();
|
||||
nodes.push((Rc::clone(&dom.document), None));
|
||||
let mut pending_inline_nodes = Vec::new();
|
||||
|
||||
// Active indent level decorating the current node.
|
||||
let mut last_active_indent_level: Option<ListArg> = None;
|
||||
|
||||
while let Some((node, mut indent_level)) = nodes.pop() {
|
||||
// If the indentation level has changed, we should push all pending inline nodes into the previous
|
||||
// indentation level first.
|
||||
if last_active_indent_level != indent_level && !pending_inline_nodes.is_empty() {
|
||||
if let Some(parsed_node) =
|
||||
parse_pending_inline_nodes(&pending_inline_nodes, last_active_indent_level.as_ref())
|
||||
{
|
||||
result.push_back(parsed_node);
|
||||
}
|
||||
pending_inline_nodes.clear();
|
||||
}
|
||||
match &node.data {
|
||||
// Nodes we are not processing. Just push its children into the visit queue.
|
||||
NodeData::Document
|
||||
| NodeData::Doctype { .. }
|
||||
| NodeData::ProcessingInstruction { .. }
|
||||
| NodeData::Comment { .. } => {
|
||||
for child in node.children.borrow().iter().rev() {
|
||||
nodes.push((Rc::clone(child), indent_level.clone()));
|
||||
}
|
||||
}
|
||||
// If we observe plain text in the top level nodes. Add them as plain text lines.
|
||||
NodeData::Text { contents } => {
|
||||
if !contents.borrow().trim().is_empty() {
|
||||
pending_inline_nodes.push(node);
|
||||
}
|
||||
}
|
||||
NodeData::Element { name, attrs, .. } => {
|
||||
let node_name = name.local.to_string();
|
||||
let mut decorated_styling = Styling::default();
|
||||
|
||||
// Confluence does not follow the common pattern of marking code blocks in <pre> specifically in its view mode.
|
||||
// Instead, it marks code blocks in <span> with a specific attribute.
|
||||
let is_confluence_code_block = node_name.as_str() == "span"
|
||||
&& includes_attribute(&attrs.borrow(), "data-ds--code--code-block");
|
||||
|
||||
// If the node is an element we are skip processing, push its children into the visit queue and skip
|
||||
// to the next iteration.
|
||||
if TOP_LEVEL_ELEMENT_TAGS_TO_SKIP.contains(&node_name.as_str()) {
|
||||
let is_unordered_list = node_name.as_str() == "ul";
|
||||
let is_ordered_list = node_name.as_str() == "ol";
|
||||
let start_number = if is_ordered_list {
|
||||
get_attribute(&attrs.borrow(), "start").and_then(|value| value.parse().ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if is_unordered_list || is_ordered_list {
|
||||
let level = match &indent_level {
|
||||
Some(level) => level.indent_level + 1,
|
||||
_ => 0,
|
||||
};
|
||||
indent_level = Some(ListArg {
|
||||
item_type: ListType::ListItem {
|
||||
ordered: is_ordered_list,
|
||||
},
|
||||
start_number: Rc::new(Cell::new(start_number)),
|
||||
indent_level: level,
|
||||
});
|
||||
};
|
||||
|
||||
// Check if the first node represents input.
|
||||
if let Some(NodeData::Element {
|
||||
name,
|
||||
attrs: child_node_attr,
|
||||
..
|
||||
}) = node.children.borrow().iter().next().map(|node| &node.data)
|
||||
{
|
||||
let child_node_name = name.local.to_string();
|
||||
if let Some(indent_arg) = &mut indent_level
|
||||
&& child_node_name.as_str() == "input"
|
||||
&& type_matches(&child_node_attr.borrow(), "checkbox")
|
||||
{
|
||||
indent_arg.item_type = ListType::Checkbox(includes_attribute(
|
||||
&child_node_attr.borrow(),
|
||||
"checked",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for child in node.children.borrow().iter().rev() {
|
||||
nodes.push((Rc::clone(child), indent_level.clone()));
|
||||
}
|
||||
|
||||
if !pending_inline_nodes.is_empty() {
|
||||
if let Some(parsed_nodes) = parse_pending_inline_nodes(
|
||||
&pending_inline_nodes,
|
||||
last_active_indent_level.as_ref(),
|
||||
) {
|
||||
result.push_back(parsed_nodes);
|
||||
}
|
||||
pending_inline_nodes.clear();
|
||||
}
|
||||
|
||||
last_active_indent_level = indent_level;
|
||||
continue;
|
||||
} else if PHRASING_ELEMENT_TAGS.contains(&node_name.as_str())
|
||||
&& !is_confluence_code_block
|
||||
{
|
||||
pending_inline_nodes.push(node);
|
||||
last_active_indent_level = indent_level;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !pending_inline_nodes.is_empty() {
|
||||
if let Some(parsed_nodes) = parse_pending_inline_nodes(
|
||||
&pending_inline_nodes,
|
||||
last_active_indent_level.as_ref(),
|
||||
) {
|
||||
result.push_back(parsed_nodes);
|
||||
}
|
||||
pending_inline_nodes.clear();
|
||||
}
|
||||
|
||||
// Update styling based on the node's attribute.
|
||||
decorated_styling.update_with_attributes(&attrs.borrow());
|
||||
|
||||
result.push_back(match node_name.as_str() {
|
||||
// If it's a code block, process its children node as plain text.
|
||||
"pre" => {
|
||||
if let Some(val) = get_attribute(&attrs.borrow(), WARP_EMBED_ATTRIBUTE_NAME)
|
||||
{
|
||||
FormattedTextLine::Embedded(Mapping::from_iter([(
|
||||
Value::String("id".to_string()),
|
||||
Value::String(val.to_string()),
|
||||
)]))
|
||||
} else {
|
||||
// TODO: Support Github's code block representation.
|
||||
let (content, language) =
|
||||
parse_code_block_and_language(&node.children.borrow());
|
||||
FormattedTextLine::CodeBlock(CodeBlockText {
|
||||
lang: language.unwrap_or(RUNNABLE_BLOCK_MARKDOWN_LANG.to_string()),
|
||||
code: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
"span" if is_confluence_code_block => {
|
||||
FormattedTextLine::CodeBlock(CodeBlockText {
|
||||
lang: get_attribute(&attrs.borrow(), "data-code-lang")
|
||||
.unwrap_or(RUNNABLE_BLOCK_MARKDOWN_LANG)
|
||||
.to_string(),
|
||||
code: parse_text_only(&node.children.borrow()),
|
||||
})
|
||||
}
|
||||
"h1" => FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 1,
|
||||
text: parse_phrasing_content(
|
||||
&node.children.borrow(),
|
||||
decorated_styling.clone(),
|
||||
),
|
||||
}),
|
||||
"h2" => FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 2,
|
||||
text: parse_phrasing_content(
|
||||
&node.children.borrow(),
|
||||
decorated_styling.clone(),
|
||||
),
|
||||
}),
|
||||
"h3" => FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 3,
|
||||
text: parse_phrasing_content(
|
||||
&node.children.borrow(),
|
||||
decorated_styling.clone(),
|
||||
),
|
||||
}),
|
||||
"h4" => FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 4,
|
||||
text: parse_phrasing_content(
|
||||
&node.children.borrow(),
|
||||
decorated_styling.clone(),
|
||||
),
|
||||
}),
|
||||
"h5" => FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 5,
|
||||
text: parse_phrasing_content(
|
||||
&node.children.borrow(),
|
||||
decorated_styling.clone(),
|
||||
),
|
||||
}),
|
||||
"h6" => FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 6,
|
||||
text: parse_phrasing_content(
|
||||
&node.children.borrow(),
|
||||
decorated_styling.clone(),
|
||||
),
|
||||
}),
|
||||
"br" => FormattedTextLine::LineBreak,
|
||||
"hr" => FormattedTextLine::HorizontalRule,
|
||||
_ => {
|
||||
// Take into consideration the indent level when parsing the nodes.
|
||||
let parsed_node = parse_pending_inline_nodes(
|
||||
&node.children.borrow(),
|
||||
indent_level.as_ref(),
|
||||
);
|
||||
|
||||
match parsed_node {
|
||||
Some(node) => node,
|
||||
None => FormattedTextLine::Line(Vec::new()),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
last_active_indent_level = indent_level;
|
||||
}
|
||||
|
||||
if !pending_inline_nodes.is_empty() {
|
||||
if let Some(parsed_nodes) =
|
||||
parse_pending_inline_nodes(&pending_inline_nodes, last_active_indent_level.as_ref())
|
||||
{
|
||||
result.push_back(parsed_nodes);
|
||||
}
|
||||
pending_inline_nodes.clear();
|
||||
}
|
||||
|
||||
Ok(FormattedText { lines: result })
|
||||
}
|
||||
|
||||
// Push all pending inline nodes into the result. Take into consideration the active indent level.
|
||||
fn parse_pending_inline_nodes(
|
||||
nodes: &[Rc<Node>],
|
||||
last_active_indent_level: Option<&ListArg>,
|
||||
) -> Option<FormattedTextLine> {
|
||||
let internal = parse_phrasing_content(nodes, Default::default());
|
||||
|
||||
if !internal.is_empty() {
|
||||
Some(match last_active_indent_level {
|
||||
Some(list) => match list.item_type {
|
||||
ListType::ListItem { ordered: true } => {
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
// Take the start number, so that it's only applied to the first item in
|
||||
// the list.
|
||||
number: list.start_number.take(),
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: list.indent_level,
|
||||
text: internal,
|
||||
},
|
||||
})
|
||||
}
|
||||
ListType::ListItem { ordered: false } => {
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: list.indent_level,
|
||||
text: internal,
|
||||
})
|
||||
}
|
||||
ListType::Checkbox(checked) => FormattedTextLine::TaskList(FormattedTaskList {
|
||||
complete: checked,
|
||||
indent_level: list.indent_level,
|
||||
text: internal,
|
||||
}),
|
||||
},
|
||||
None => FormattedTextLine::Line(internal),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the phrasing content: https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content
|
||||
// into an inline formatted text.
|
||||
fn parse_phrasing_content(nodes: &[Rc<Node>], text_styling: Styling) -> FormattedTextInline {
|
||||
let mut result = Vec::new();
|
||||
for node in nodes {
|
||||
if is_spacing_span(node) {
|
||||
result.push(phrasing_to_formatted_text(" ", &text_styling));
|
||||
continue;
|
||||
}
|
||||
|
||||
match &node.data {
|
||||
// We should not observe these in the phrasing content.
|
||||
NodeData::Document
|
||||
| NodeData::Doctype { .. }
|
||||
| NodeData::ProcessingInstruction { .. }
|
||||
| NodeData::Comment { .. } => {}
|
||||
// Push text fragment based on the current text_styling.
|
||||
NodeData::Text { contents } => {
|
||||
let content = contents.borrow().trim_end_matches('\n').to_string();
|
||||
|
||||
// Filter out singular empty lines after trimming as they are from HTML formatting and shouldn't
|
||||
// be inserted into the content. The rare valid case we might be missing here is <p>\n</p>.
|
||||
// But all major rich text editors I have tested use <br> to represent it instead.
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(phrasing_to_formatted_text(content, &text_styling));
|
||||
}
|
||||
NodeData::Element { name, attrs, .. } => {
|
||||
let node_name = name.local.to_string();
|
||||
let mut decorated_styling = text_styling.clone();
|
||||
decorated_styling.update_with_attributes(&attrs.borrow());
|
||||
match node_name.as_ref() {
|
||||
"b" | "strong" => decorated_styling.bold = true,
|
||||
"i" | "em" => decorated_styling.italic = true,
|
||||
"s" => decorated_styling.strikethrough = true,
|
||||
"u" | "ins" => decorated_styling.underline = true,
|
||||
"code" => decorated_styling.inline_code = true,
|
||||
// TODO: We need to add more phrasing styling we support (e.g. links) here.
|
||||
// https://linear.app/warpdotdev/issue/CLD-335/add-html-parsing-for-headers-and-lists
|
||||
_ => (),
|
||||
};
|
||||
|
||||
result.extend(parse_phrasing_content(
|
||||
node.children.borrow().as_ref(),
|
||||
decorated_styling,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Converts styled phrasing text to a fragment of formatted text.
|
||||
fn phrasing_to_formatted_text(text: impl Into<String>, styling: &Styling) -> FormattedTextFragment {
|
||||
let weight = if styling.bold {
|
||||
Some(CustomWeight::Bold)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
FormattedTextFragment {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
weight,
|
||||
italic: styling.italic,
|
||||
underline: styling.underline,
|
||||
strikethrough: styling.strikethrough,
|
||||
hyperlink: styling.link.clone().map(Hyperlink::Url),
|
||||
inline_code: styling.inline_code,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Chrome and Safari replace some spaces in copied content with non-breaking spaces. This matches
|
||||
/// such spaces.
|
||||
///
|
||||
/// See [this ProseMirror thread](https://discuss.prosemirror.net/t/non-breaking-spaces-being-added-to-pasted-html/3911/4).
|
||||
fn is_spacing_span(node: &Rc<Node>) -> bool {
|
||||
if let NodeData::Element { name, attrs, .. } = &node.data {
|
||||
if &name.local != "span" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The span must have either no attrs or a single `class="Apple-converted-space"` class.
|
||||
let attrs = attrs.borrow();
|
||||
if attrs.len() > 1 {
|
||||
return false;
|
||||
}
|
||||
let css_class = attrs.first().filter(|attr| &attr.name.local == "class");
|
||||
if css_class.is_some_and(|class| &*class.value != "Apple-converted-space") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let content = node.children.borrow();
|
||||
if content.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
match &content[0].data {
|
||||
NodeData::Text { contents } => &**contents.borrow() == "\u{00a0}",
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Only parse out text content in the provided nodes and their children.
|
||||
fn parse_text_only(nodes: &[Rc<Node>]) -> String {
|
||||
let mut text = String::new();
|
||||
for node in nodes {
|
||||
if let NodeData::Text { contents } = &node.data {
|
||||
text.push_str(contents.borrow().to_string().as_str());
|
||||
} else {
|
||||
text.push_str(&parse_text_only(&node.children.borrow()));
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
// Recursively parse out the code block content and it's language info.
|
||||
fn parse_code_block_and_language(nodes: &[Rc<Node>]) -> (String, Option<String>) {
|
||||
let mut text = String::new();
|
||||
let mut language = None;
|
||||
for node in nodes {
|
||||
match &node.data {
|
||||
NodeData::Text { contents } => text.push_str(contents.borrow().to_string().as_str()),
|
||||
NodeData::Element { name, attrs, .. } => {
|
||||
let node_name = name.local.to_string();
|
||||
|
||||
if node_name == "code"
|
||||
&& let Some(parsed_lang) = get_attribute(&attrs.borrow(), "class")
|
||||
.and_then(|s| s.strip_prefix("language-").map(|result| result.to_string()))
|
||||
{
|
||||
language = Some(parsed_lang);
|
||||
}
|
||||
|
||||
let (child_str, new_lang) = parse_code_block_and_language(&node.children.borrow());
|
||||
text.push_str(&child_str);
|
||||
|
||||
if new_lang.is_some() {
|
||||
language = new_lang;
|
||||
}
|
||||
}
|
||||
_ => text.push_str(&parse_text_only(&node.children.borrow())),
|
||||
}
|
||||
}
|
||||
|
||||
(text, language)
|
||||
}
|
||||
|
||||
// Parse a HMTL style string into its corresponding name -> value hashmap
|
||||
// For example "font-style:italic;font-weight:400" will be parsed into
|
||||
// {"font-style": "italic", "font-weight": "400"}.
|
||||
fn parse_style_into_dict(style: &str) -> HashMap<&str, &str> {
|
||||
let style_pairs = style.split(';');
|
||||
let mut style_dict = HashMap::new();
|
||||
|
||||
for pair in style_pairs {
|
||||
if pair.contains(':') {
|
||||
let name_and_value: Vec<&str> = pair.split(':').collect();
|
||||
style_dict.insert(name_and_value[0].trim(), name_and_value[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
style_dict
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "html_parser_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,567 @@
|
||||
use super::*;
|
||||
|
||||
// Simple transformer to make testing easier.
|
||||
fn test_parse_html(source: &str) -> Vec<FormattedTextLine> {
|
||||
parse_html(source).unwrap().lines.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plain_text() {
|
||||
assert_eq!(
|
||||
test_parse_html("<meta charset='utf-8'>Some"),
|
||||
vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("Some"),
|
||||
])]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html("<meta charset='utf-8'><p>Some</p><p>tests</p>"),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("Some"),]),
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("tests"),])
|
||||
]
|
||||
);
|
||||
|
||||
// Example from GDocs.
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-27b0e865-7fff-b40d-5b19-8e9e7ccf7c8c\">\
|
||||
<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\"><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;\
|
||||
font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">Some plain</span></p><p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\">\
|
||||
<span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;\
|
||||
white-space:pre-wrap;\">text</span></p></b>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("Some plain"),]),
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("text"),])
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_styles() {
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'>So<span style=\"font-weight:600\" data-token-index=\"1\" class=\"notion-enable-hover\">me</span>"
|
||||
),
|
||||
vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("So"),
|
||||
FormattedTextFragment::bold("me"),
|
||||
])]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><p>So<strong>me</strong></p><p><em><strong>tes</strong></em>ts</p>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("So"),
|
||||
FormattedTextFragment::bold("me")
|
||||
]),
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::bold_italic("tes"),
|
||||
FormattedTextFragment::plain_text("ts")
|
||||
])
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-a96b449f-7fff-d755-78a4-efcebc867940\">\
|
||||
<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\"><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;\
|
||||
font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">So</span>\
|
||||
<span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">\
|
||||
me</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:700;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">\
|
||||
p</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">\
|
||||
la</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">in\
|
||||
</span></p><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">t\
|
||||
</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">ext</span></b>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("So"),
|
||||
FormattedTextFragment::italic("me"),
|
||||
FormattedTextFragment::bold_italic("p"),
|
||||
FormattedTextFragment::bold("la"),
|
||||
FormattedTextFragment::plain_text("in")
|
||||
]),
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("t"),
|
||||
FormattedTextFragment::italic("ext")
|
||||
])
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-27b0e865-7fff-b40d-5b19-8e9e7ccf7c8c\">\
|
||||
<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\"><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;\
|
||||
font-variant:normal;text-decoration:underline;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">This is underlined</span></p><p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\">\
|
||||
<span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;\
|
||||
white-space:pre-wrap;\"> text</span></p></b>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::underline("This is underlined")]),
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(" text")]),
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'>abc <a href=\"https://google.com/\" style=\"cursor:pointer;color:inherit;word-wrap:break-word;text-decoration:inherit\" class=\"notion-link-token notion-focusable-token notion-enable-hover\" rel=\"noopener noreferrer\"\
|
||||
data-token-index=\"1\" tabindex=\"0\"><span style=\"border-bottom:0.05em solid;border-color:rgba(55,53,47,0.4);opacity:0.7\" class=\"link-annotation-unknown-block-id--760432549\">NewLinadafekene</span></a> ghi def"
|
||||
),
|
||||
vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("abc "),
|
||||
FormattedTextFragment::hyperlink("NewLinadafekene", "https://google.com/"),
|
||||
FormattedTextFragment::plain_text(" ghi def")
|
||||
]),]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
r#"<meta charset='utf-8'>the <span style="font-family:"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace;line-height:normal;background:rgba(135,131,120,.15);color:#EB5757;border-radius:3px;font-size:85%;padding:0.2em 0.4em" data-token-index="1" spellcheck="false" class="notion-enable-hover"><ul></span> and <span style="font-family:"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace;line-height:normal;background:rgba(135,131,120,.15);color:#EB5757;border-radius:3px;font-size:85%;padding:0.2em 0.4em" data-token-index="3" spellcheck="false" class="notion-enable-hover"><li></span>"#
|
||||
),
|
||||
vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("the "),
|
||||
FormattedTextFragment::inline_code("<ul>"),
|
||||
FormattedTextFragment::plain_text(" and "),
|
||||
FormattedTextFragment::inline_code("<li>"),
|
||||
]),]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
r#"<meta charset='utf-8'><span style="text-decoration:line-through" data-token-index="0" class="notion-enable-hover">Strike</span><span style="font-weight:600" data-token-index="1" class="notion-enable-hover">Bold</span>"#
|
||||
),
|
||||
vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::strikethrough("Strike"),
|
||||
FormattedTextFragment::bold("Bold"),
|
||||
]),]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block() {
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><pre><code class=\"language-jsx\">git checkout -b branch</code></pre>"
|
||||
),
|
||||
vec![FormattedTextLine::CodeBlock(CodeBlockText {
|
||||
lang: "jsx".to_string(),
|
||||
code: "git checkout -b branch".to_string()
|
||||
})]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><p>tests</p><pre><code class=\"language-jsx\">git checkout -b branch</code></pre><p>More</p>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("tests"),]),
|
||||
FormattedTextLine::CodeBlock(CodeBlockText {
|
||||
lang: "jsx".to_string(),
|
||||
code: "git checkout -b branch".to_string()
|
||||
}),
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("More"),])
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_non_breaking_spaces() {
|
||||
let expected_text = vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Open the Docker desktop app. This is necessary to create the symbolic links that will make the",
|
||||
),
|
||||
FormattedTextFragment::plain_text(" "),
|
||||
FormattedTextFragment::inline_code("docker"),
|
||||
FormattedTextFragment::plain_text(" "),
|
||||
FormattedTextFragment::plain_text("CLI available."),
|
||||
])];
|
||||
|
||||
let chromium_html = r#"Open the Docker desktop app. This is necessary to create the symbolic links that will make the<span> </span><code>docker</code><span> </span>CLI available."#;
|
||||
assert_eq!(test_parse_html(chromium_html), expected_text);
|
||||
|
||||
let safari_html = r#"Open the Docker desktop app. This is necessary to create the symbolic links that will make the<span class="Apple-converted-space"> </span><code>docker</code><span class="Apple-converted-space"> </span>CLI available."#;
|
||||
assert_eq!(test_parse_html(safari_html), expected_text);
|
||||
}
|
||||
|
||||
// TODO: remove/update this test when we eventually support these HTML element types!
|
||||
#[test]
|
||||
fn test_unsupported_html_types() {
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><p>Test test</p><blockquote><p>Block quotes with <strong>bold text</strong></p></blockquote>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("Test test"),]),
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("Block quotes with "),
|
||||
FormattedTextFragment::bold("bold text")
|
||||
])
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><table><thead><tr><th>Text 1</th><th>Text 2</th></tr></thead><tbody><tr><td>Test</td><td>Test</td></tr></tbody></table>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("Text 1"),
|
||||
FormattedTextFragment::plain_text("Text 2")
|
||||
]),
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("Test"),
|
||||
FormattedTextFragment::plain_text("Test")
|
||||
])
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_lists() {
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><ul><li>def<ul><li>abc<ul><li>sub-list</li></ul></li><li>abc</li></ul></li></ul>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("def")]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("abc")]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 2,
|
||||
text: vec![FormattedTextFragment::plain_text("sub-list")]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("abc")]
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><ul><li>d<strong>ef</strong><ul><li><em>abc</em><ul><li>sub-list</li></ul></li><li>abc</li></ul></li></ul><p>normal text</p>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![
|
||||
FormattedTextFragment::plain_text("d"),
|
||||
FormattedTextFragment::bold("ef")
|
||||
]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::italic("abc")]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 2,
|
||||
text: vec![FormattedTextFragment::plain_text("sub-list")]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("abc")]
|
||||
}),
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("normal text")])
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><ul><li>abc<ul><li>def</li></ul></li></ul><ol><li>abc<ol><li>def</li></ol></li></ol>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("abc")]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("def")]
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("abc".to_string())]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("def".to_string())]
|
||||
}
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html("<meta charset='utf-8'><ul><li>abc<ol><li>def</li></ol></li></ul>"),
|
||||
vec![
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("abc".to_string())]
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("def")]
|
||||
}
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formatted_sub_lists() {
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<meta charset='utf-8'><ul>\n<li>By default, the client goes to staging\n<ul>\n<li>To target localhost, build the client with</li>\n</ul>\n</li>\n</ul>\n"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text(
|
||||
"By default, the client goes to staging"
|
||||
)]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text(
|
||||
"To target localhost, build the client with"
|
||||
)]
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ordered_lists() {
|
||||
let html = r#"
|
||||
<meta charset='utf-8'>
|
||||
<ol start="3">
|
||||
<li>First</li>
|
||||
<li>Second<ol>
|
||||
<li>A</li>
|
||||
<li>B</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Third</li>
|
||||
<li>Fourth<ol start="invalid">
|
||||
<li>G</li>
|
||||
<li>H</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
test_parse_html(html),
|
||||
vec![
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: Some(3),
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("First")]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("Second")]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("A")]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("B")]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("Third")]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("Fourth")]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("G")]
|
||||
}
|
||||
}),
|
||||
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
|
||||
number: None,
|
||||
indented_text: FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("H")]
|
||||
}
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_horizontal_rules() {
|
||||
assert_eq!(
|
||||
test_parse_html("<meta charset='utf-8'><ul>\n<li>bcf</li>\n</ul>\n<hr>\n<p>abc</p>"),
|
||||
vec![
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("bcf")]
|
||||
}),
|
||||
FormattedTextLine::HorizontalRule,
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("abc")])
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_headings() {
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("Heading 1")]
|
||||
}),
|
||||
FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 2,
|
||||
text: vec![FormattedTextFragment::plain_text("Heading 2")]
|
||||
}),
|
||||
FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 3,
|
||||
text: vec![FormattedTextFragment::plain_text("Heading 3")]
|
||||
}),
|
||||
FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 4,
|
||||
text: vec![FormattedTextFragment::plain_text("Heading 4")]
|
||||
}),
|
||||
FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 5,
|
||||
text: vec![FormattedTextFragment::plain_text("Heading 5")]
|
||||
}),
|
||||
FormattedTextLine::Heading(FormattedTextHeader {
|
||||
heading_size: 6,
|
||||
text: vec![FormattedTextFragment::plain_text("Heading 6")]
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_lists() {
|
||||
// HTML copied from Github.
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
r#"<meta charset='utf-8'><ul data-sourcepos="3:1-4:26" class="contains-task-list" style="box-sizing: border-box; padding-left: 2em; margin-top: 0px; margin-bottom: 0px !important; position: relative; color: rgb(31, 35, 40); font-family: -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><li data-sourcepos="3:1-4:26" class="task-list-item" style="box-sizing: border-box; list-style-type: none;"><input type="checkbox" id="" disabled="" class="task-list-item-checkbox" style="box-sizing: border-box; font: inherit; margin: 0px 0.2em 0.25em -1.4em; overflow: visible; padding: 0px; vertical-align: middle;"><span> </span>Checklist item 1<ul data-sourcepos="4:5-4:26" class="contains-task-list" style="box-sizing: border-box; padding-left: 2em; margin-top: 0px; margin-bottom: 0px; position: relative;"><li data-sourcepos="4:5-4:26" class="task-list-item" style="box-sizing: border-box; list-style-type: none;"><input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked="" style="box-sizing: border-box; font: inherit; margin: 0px 0.2em 0.25em -1.4em; overflow: visible; padding: 0px; vertical-align: middle;"><span> </span>Checklist item 2</li></ul></li></ul>"#
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::TaskList(FormattedTaskList {
|
||||
complete: false,
|
||||
indent_level: 0,
|
||||
text: vec![
|
||||
FormattedTextFragment::plain_text(" "),
|
||||
FormattedTextFragment::plain_text("Checklist item 1")
|
||||
]
|
||||
}),
|
||||
FormattedTextLine::TaskList(FormattedTaskList {
|
||||
complete: true,
|
||||
indent_level: 1,
|
||||
text: vec![
|
||||
FormattedTextFragment::plain_text(" "),
|
||||
FormattedTextFragment::plain_text("Checklist item 2")
|
||||
]
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_google_docs_formatted_sub_list() {
|
||||
assert_eq!(
|
||||
test_parse_html(
|
||||
r#"<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-0eb9725b-7fff-4ed4-502c-49a79adaf702"><ul style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:disc;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Favoriting drive objects</span></p></li><ul style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:circle;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="2"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Something else</span></p></li></ul><li dir="ltr" style="list-style-type:disc;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Drive keyboard nav</span></p></li></ul></b>"#,
|
||||
),
|
||||
vec![
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text(
|
||||
"Favoriting drive objects"
|
||||
)]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 1,
|
||||
text: vec![FormattedTextFragment::plain_text("Something else")]
|
||||
}),
|
||||
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
|
||||
indent_level: 0,
|
||||
text: vec![FormattedTextFragment::plain_text("Drive keyboard nav")]
|
||||
}),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_and_inline_code() {
|
||||
let github_code_block = r#"<meta charset='utf-8'><p data-sourcepos="59:1-59:27" dir="auto" style="box-sizing: border-box; margin-top: 0px; margin-bottom: 16px; color: rgb(31, 35, 40); font-family: -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">Some<span> </span><code style="box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.6px; padding: 0.2em 0.4em; margin: 0px; white-space: break-spaces; background-color: var(--bgColor-neutral-muted, var(--color-neutral-muted)); border-radius: 6px;">inline code</code><span> </span>to parse</p><div class="snippet-clipboard-content notranslate position-relative overflow-auto" style="box-sizing: border-box; position: relative !important; overflow: auto !important; color: rgb(31, 35, 40); font-family: -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><pre class="notranslate" style="box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.6px; margin-top: 0px; margin-bottom: 16px; overflow-wrap: normal; padding: 16px; overflow: auto; line-height: 1.45; color: var(--fgColor-default, var(--color-fg-default)); background-color: var(--bgColor-muted, var(--color-canvas-subtle)); border-radius: 6px;"><code style="box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.6px; padding: 0px; margin: 0px; white-space: pre; background: transparent; border-radius: 6px; word-break: normal; border: 0px; display: inline; overflow: visible; line-height: inherit; overflow-wrap: normal;">Some code block</code></pre></div>"#;
|
||||
assert_eq!(
|
||||
test_parse_html(github_code_block),
|
||||
vec![
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("Some"),
|
||||
FormattedTextFragment::plain_text(" "),
|
||||
FormattedTextFragment::inline_code("inline code"),
|
||||
FormattedTextFragment::plain_text(" "),
|
||||
FormattedTextFragment::plain_text("to parse"),
|
||||
]),
|
||||
FormattedTextLine::CodeBlock(CodeBlockText {
|
||||
lang: RUNNABLE_BLOCK_MARKDOWN_LANG.to_string(),
|
||||
code: "Some code block".to_string()
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Test for CLD-860
|
||||
#[test]
|
||||
fn test_confluence_code_block() {
|
||||
let confluence_code_block = r#"<span data-code-lang="shell" data-ds--code--code-block="" class="prismjs css-1vd0zfg"><code class="language-shell"><span class="comment linenumber ds-line-number" data-ds--line-number="1" style="flex-shrink: 0; box-sizing: border-box; padding-left: 8px; margin-right: 8px; text-align: right; user-select: none; display: inline-block !important; min-width: calc(1ch + 16px) !important; font-style: normal !important; color: var(--ds-text-subtlest, #505F79) !important; padding-right: 8px !important; float: left;"></span><span class="">This is a code block</span></code></span>"#;
|
||||
assert_eq!(
|
||||
test_parse_html(confluence_code_block),
|
||||
vec![FormattedTextLine::CodeBlock(CodeBlockText {
|
||||
lang: "shell".to_string(),
|
||||
code: "This is a code block".to_string()
|
||||
}),]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
use std::any::Any;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use std::{collections::VecDeque, fmt, fmt::Debug};
|
||||
|
||||
pub mod html_parser;
|
||||
pub mod markdown_parser;
|
||||
pub mod weight;
|
||||
pub use html_parser::parse_html;
|
||||
use itertools::Itertools;
|
||||
pub use markdown_parser::{
|
||||
parse_image_prefix, parse_image_run_line, parse_inline_markdown, parse_markdown,
|
||||
parse_markdown_with_gfm_tables,
|
||||
};
|
||||
use serde_yaml::Mapping;
|
||||
use weight::CustomWeight;
|
||||
|
||||
/// Trait for an "action" that can be dispatched via a hyperlink click handler.
|
||||
/// This purposefully shadows the `Action` trait from `warpui`.
|
||||
///
|
||||
/// Since `warpui` depends on this crate, we can't depend on the `warpui::Action` trait directly.
|
||||
/// Instead, we create a new trait with a blanket implementation that implicitly results
|
||||
/// in any `warpui::Action` implementing this `Action`.
|
||||
pub trait Action: Any + Debug + Send + Sync {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
impl<T> Action for T
|
||||
where
|
||||
T: Any + Debug + Send + Sync,
|
||||
{
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait LineCount {
|
||||
fn num_lines(&self) -> usize;
|
||||
}
|
||||
|
||||
/// A simple line-based delta between two [`FormattedText`] values.
|
||||
///
|
||||
/// `common_prefix_lines` is the number of leading lines that are identical
|
||||
/// between the old and new formatted text. `new_suffix`
|
||||
/// contains the replacement lines from the new value to replace from after common_prefix_lines
|
||||
/// to the end of the buffer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FormattedTextDelta {
|
||||
/// The number of actual lines in the common prefix (corresponding to a row in Point)
|
||||
/// Note that a FormattedTextLine can have multiple lines - this refers to the actual line count,
|
||||
/// not the FormattedTextLine count.
|
||||
pub common_prefix_lines: usize,
|
||||
/// The number of existing formatted text lines to be replaced
|
||||
pub old_suffix_formatted_text_lines: usize,
|
||||
pub new_suffix: VecDeque<FormattedTextLine>,
|
||||
}
|
||||
|
||||
impl FormattedTextDelta {
|
||||
pub fn is_noop(&self) -> bool {
|
||||
self.old_suffix_formatted_text_lines == 0 && self.new_suffix.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_formatted_text_delta(old: FormattedText, new: FormattedText) -> FormattedTextDelta {
|
||||
let mut common_prefix_formatted_text_lines = 0usize;
|
||||
let mut common_prefix_lines = 0usize;
|
||||
let old_len = old.lines.len();
|
||||
let new_len = new.lines.len();
|
||||
let shared_len = old_len.min(new_len);
|
||||
|
||||
while common_prefix_formatted_text_lines < shared_len {
|
||||
let old_line = &old.lines[common_prefix_formatted_text_lines];
|
||||
let new_line = &new.lines[common_prefix_formatted_text_lines];
|
||||
|
||||
// Special handling for code blocks: only compare the code, not the language
|
||||
// This is because the lang string in our internal buffer representation may not match
|
||||
// the lang string in the parsed markdown exactly (e.g. "Python" vs "python path=/path/to/file.py start=1")
|
||||
let lines_equal = match (old_line, new_line) {
|
||||
(FormattedTextLine::CodeBlock(old_block), FormattedTextLine::CodeBlock(new_block)) => {
|
||||
old_block.code == new_block.code
|
||||
}
|
||||
_ => old_line == new_line,
|
||||
};
|
||||
|
||||
if !lines_equal {
|
||||
break;
|
||||
}
|
||||
|
||||
common_prefix_formatted_text_lines += 1;
|
||||
common_prefix_lines += old_line.num_lines();
|
||||
}
|
||||
|
||||
let old_suffix_formatted_text_lines =
|
||||
old_len.saturating_sub(common_prefix_formatted_text_lines);
|
||||
let new_suffix = new
|
||||
.lines
|
||||
.iter()
|
||||
.skip(common_prefix_formatted_text_lines)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
FormattedTextDelta {
|
||||
common_prefix_lines,
|
||||
old_suffix_formatted_text_lines,
|
||||
new_suffix,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FormattedText {
|
||||
pub lines: VecDeque<FormattedTextLine>,
|
||||
}
|
||||
|
||||
impl FormattedText {
|
||||
pub fn new(lines: impl Into<VecDeque<FormattedTextLine>>) -> Self {
|
||||
Self {
|
||||
lines: lines.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new FormattedText where the first and last line breaks are removed, if any.
|
||||
pub fn new_trimmed(lines: impl Into<VecDeque<FormattedTextLine>>) -> Self {
|
||||
let mut new = Self::new(lines);
|
||||
new.trim();
|
||||
new
|
||||
}
|
||||
|
||||
fn trim(&mut self) {
|
||||
// Since we exhaust contiguous new lines into a single line break,
|
||||
// there won't be multiple contiguous line breaks; there's at most one to remove.
|
||||
if let Some(FormattedTextLine::LineBreak) = self.lines.front() {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
|
||||
// Similarly for the end.
|
||||
if let Some(FormattedTextLine::LineBreak) = self.lines.back() {
|
||||
self.lines.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw text of the markdown, without any of the markdown
|
||||
/// markers.
|
||||
pub fn raw_text(&self) -> String {
|
||||
self.lines.iter().map(|line| line.raw_text()).join("")
|
||||
}
|
||||
|
||||
pub fn append_line(mut self, line: FormattedTextLine) -> Self {
|
||||
self.lines.push_back(line);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum FormattedTextLine {
|
||||
Heading(FormattedTextHeader),
|
||||
Line(FormattedTextInline),
|
||||
OrderedList(OrderedFormattedIndentTextInline),
|
||||
UnorderedList(FormattedIndentTextInline),
|
||||
CodeBlock(CodeBlockText),
|
||||
TaskList(FormattedTaskList),
|
||||
LineBreak,
|
||||
HorizontalRule,
|
||||
Embedded(Mapping),
|
||||
Image(FormattedImage),
|
||||
Table(FormattedTable),
|
||||
}
|
||||
|
||||
impl FormattedTextLine {
|
||||
pub fn raw_text(&self) -> String {
|
||||
let mut text = match self {
|
||||
Self::CodeBlock(text) => text.code.clone(),
|
||||
Self::Heading(header) => header
|
||||
.text
|
||||
.iter()
|
||||
.map(|fragment| fragment.raw_text())
|
||||
.join(""),
|
||||
Self::Line(line) => line.iter().map(|fragment| fragment.raw_text()).join(""),
|
||||
Self::TaskList(line) => line
|
||||
.text
|
||||
.iter()
|
||||
.map(|fragment| fragment.raw_text())
|
||||
.join(""),
|
||||
Self::OrderedList(list) => list
|
||||
.indented_text
|
||||
.text
|
||||
.iter()
|
||||
.map(|fragment| fragment.raw_text())
|
||||
.join(""),
|
||||
Self::UnorderedList(list) => list
|
||||
.text
|
||||
.iter()
|
||||
.map(|fragment| fragment.raw_text())
|
||||
.join(""),
|
||||
Self::LineBreak | Self::HorizontalRule | Self::Embedded(_) => "\n".to_string(),
|
||||
Self::Image(image) => format!("{}\n", image.alt_text),
|
||||
Self::Table(table) => table.to_internal_format(),
|
||||
};
|
||||
// Each `FormattedTextLine` unit represents a complete line. If it doesn't already end in
|
||||
// a newline, add one.
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub fn set_weight(&mut self, weight: Option<CustomWeight>) -> &Self {
|
||||
match self {
|
||||
Self::Heading(header) => {
|
||||
for fragment in &mut header.text {
|
||||
fragment.styles.weight = weight;
|
||||
}
|
||||
}
|
||||
Self::Line(line) => {
|
||||
for fragment in line {
|
||||
fragment.styles.weight = weight;
|
||||
}
|
||||
}
|
||||
Self::OrderedList(list) => {
|
||||
for fragment in &mut list.indented_text.text {
|
||||
fragment.styles.weight = weight;
|
||||
}
|
||||
}
|
||||
Self::UnorderedList(list) => {
|
||||
for fragment in &mut list.text {
|
||||
fragment.styles.weight = weight;
|
||||
}
|
||||
}
|
||||
Self::TaskList(list) => {
|
||||
for fragment in &mut list.text {
|
||||
fragment.styles.weight = weight;
|
||||
}
|
||||
}
|
||||
Self::Table(_)
|
||||
| Self::CodeBlock(_)
|
||||
| Self::LineBreak
|
||||
| Self::HorizontalRule
|
||||
| Self::Embedded(_)
|
||||
| Self::Image(_) => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn inline_fragments(&self) -> Option<&FormattedTextInline> {
|
||||
match &self {
|
||||
FormattedTextLine::Heading(header) => Some(&header.text),
|
||||
FormattedTextLine::Line(texts) => Some(texts),
|
||||
FormattedTextLine::OrderedList(texts) => Some(&texts.indented_text.text),
|
||||
FormattedTextLine::UnorderedList(texts) => Some(&texts.text),
|
||||
FormattedTextLine::TaskList(list) => Some(&list.text),
|
||||
FormattedTextLine::CodeBlock(_)
|
||||
| FormattedTextLine::LineBreak
|
||||
| FormattedTextLine::HorizontalRule
|
||||
| FormattedTextLine::Embedded(_)
|
||||
| FormattedTextLine::Image(_)
|
||||
| FormattedTextLine::Table(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hyperlinks(&self, skip_raw_links: bool) -> Vec<(Range<usize>, Hyperlink)> {
|
||||
let mut hyperlinks: Vec<(Range<usize>, Hyperlink)> = Vec::new();
|
||||
if let Some(inline_fragments) = self.inline_fragments() {
|
||||
let mut char_count = 0;
|
||||
for fragment in inline_fragments {
|
||||
let range_start = char_count;
|
||||
char_count += fragment.text.chars().count();
|
||||
if let Some(link) = &fragment.styles.hyperlink
|
||||
&& (!skip_raw_links
|
||||
|| !matches!(&link, Hyperlink::Url(url) if url == &fragment.text))
|
||||
{
|
||||
hyperlinks.push((range_start..char_count, link.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
hyperlinks
|
||||
}
|
||||
|
||||
pub fn is_empty_line(&self) -> bool {
|
||||
matches!(self, Self::Line(line) if line.iter().all(|fragment| fragment.text.is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
impl LineCount for FormattedTextLine {
|
||||
fn num_lines(&self) -> usize {
|
||||
match self {
|
||||
Self::CodeBlock(text) => text.code.matches('\n').count(),
|
||||
Self::Heading(_) => 1,
|
||||
Self::Line(_) => 1,
|
||||
Self::OrderedList(_) => 1,
|
||||
Self::UnorderedList(_) => 1,
|
||||
Self::TaskList(_) => 1,
|
||||
Self::LineBreak => 0,
|
||||
Self::HorizontalRule => 0,
|
||||
Self::Embedded(_) => 1,
|
||||
Self::Image(_) => 1,
|
||||
Self::Table(table) => 1 + table.rows.len(), // Header + data rows (separator not counted as a line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct FormattedTextHeader {
|
||||
pub heading_size: usize,
|
||||
pub text: FormattedTextInline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct FormattedTaskList {
|
||||
pub complete: bool,
|
||||
pub indent_level: usize,
|
||||
pub text: FormattedTextInline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct FormattedIndentTextInline {
|
||||
pub indent_level: usize,
|
||||
pub text: FormattedTextInline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct CodeBlockText {
|
||||
pub lang: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct OrderedFormattedIndentTextInline {
|
||||
/// The number of this item, which may be `None` if it was unspecified or invalid in the source
|
||||
/// document.
|
||||
pub number: Option<usize>,
|
||||
pub indented_text: FormattedIndentTextInline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct FormattedImage {
|
||||
pub alt_text: String,
|
||||
pub source: String,
|
||||
/// Optional CommonMark image title, e.g. the `title` in ``.
|
||||
/// Empty titles are normalized to `None` by the parser.
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// Column alignment for table cells
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Hash)]
|
||||
pub enum TableAlignment {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// A formatted table with headers, alignments, and rows
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct FormattedTable {
|
||||
pub headers: Vec<FormattedTextInline>,
|
||||
pub alignments: Vec<TableAlignment>,
|
||||
pub rows: Vec<Vec<FormattedTextInline>>,
|
||||
}
|
||||
|
||||
impl FormattedTable {
|
||||
/// Parse from the internal tab-separated format used in `warp-markdown-table` code blocks.
|
||||
pub fn from_internal_format(content: &str) -> Self {
|
||||
let parse_line = |line: &str| -> Vec<FormattedTextInline> {
|
||||
line.split('\t')
|
||||
.map(|cell| vec![FormattedTextFragment::plain_text(cell)])
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut lines = content.lines().peekable();
|
||||
let headers = lines.next().map(parse_line).unwrap_or_default();
|
||||
let rows: Vec<Vec<FormattedTextInline>> = lines.map(parse_line).collect();
|
||||
let col_count = headers.len();
|
||||
|
||||
Self {
|
||||
headers,
|
||||
alignments: vec![TableAlignment::default(); col_count],
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_internal_format_with_alignments(
|
||||
content: &str,
|
||||
mut alignments: Vec<TableAlignment>,
|
||||
) -> Self {
|
||||
let mut table = Self::from_internal_format(content);
|
||||
let col_count = table.headers.len();
|
||||
alignments.resize(col_count, TableAlignment::default());
|
||||
alignments.truncate(col_count);
|
||||
table.alignments = alignments;
|
||||
table
|
||||
}
|
||||
|
||||
/// Serialize to the internal tab-separated format used in `warp-markdown-table` code blocks.
|
||||
/// Inline formatting is preserved as markdown syntax so it survives the buffer round-trip.
|
||||
pub fn to_internal_format(&self) -> String {
|
||||
if self.headers.is_empty() && self.rows.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
let headers: Vec<String> = self.headers.iter().map(inline_to_markdown).collect();
|
||||
result.push_str(&headers.join("\t"));
|
||||
result.push('\n');
|
||||
for row in &self.rows {
|
||||
let cells: Vec<String> = row.iter().map(inline_to_markdown).collect();
|
||||
result.push_str(&cells.join("\t"));
|
||||
result.push('\n');
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Pad ragged rows/headers to a uniform column count.
|
||||
pub fn normalize_shape(&mut self) {
|
||||
let mut column_count = self
|
||||
.headers
|
||||
.len()
|
||||
.max(self.rows.iter().map(Vec::len).max().unwrap_or(0));
|
||||
if column_count == 0 {
|
||||
column_count = 1;
|
||||
}
|
||||
|
||||
self.headers.resize_with(column_count, Vec::new);
|
||||
self.alignments
|
||||
.resize(column_count, TableAlignment::default());
|
||||
for row in &mut self.rows {
|
||||
row.resize_with(column_count, Vec::new);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to GFM pipe-table markdown.
|
||||
pub fn to_plain_text(&self) -> String {
|
||||
fn inline_to_text(inline: &FormattedTextInline) -> String {
|
||||
inline.iter().map(|f| f.text.as_str()).collect()
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let headers: Vec<String> = self.headers.iter().map(inline_to_text).collect();
|
||||
lines.push(format!("| {} |", headers.join(" | ")));
|
||||
let separator: Vec<String> = self
|
||||
.alignments
|
||||
.iter()
|
||||
.map(|alignment| match alignment {
|
||||
TableAlignment::Left => "---".to_string(),
|
||||
TableAlignment::Center => ":---:".to_string(),
|
||||
TableAlignment::Right => "---:".to_string(),
|
||||
})
|
||||
.collect();
|
||||
lines.push(format!("| {} |", separator.join(" | ")));
|
||||
for row in &self.rows {
|
||||
let cells: Vec<String> = row.iter().map(inline_to_text).collect();
|
||||
lines.push(format!("| {} |", cells.join(" | ")));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `FormattedTextInline` back to markdown syntax.
|
||||
fn inline_to_markdown(inline: &FormattedTextInline) -> String {
|
||||
let mut result = String::new();
|
||||
for fragment in inline {
|
||||
let mut text = fragment.text.clone();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if fragment.styles.inline_code {
|
||||
result.push('`');
|
||||
result.push_str(&text);
|
||||
result.push('`');
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(Hyperlink::Url(url)) = &fragment.styles.hyperlink {
|
||||
text = format!("[{text}]({url})");
|
||||
}
|
||||
if fragment.styles.strikethrough {
|
||||
text = format!("~~{text}~~");
|
||||
}
|
||||
if fragment.styles.underline {
|
||||
text = format!("<u>{text}</u>");
|
||||
}
|
||||
let is_bold = fragment
|
||||
.styles
|
||||
.weight
|
||||
.is_some_and(|w| matches!(w, CustomWeight::Bold));
|
||||
if is_bold && fragment.styles.italic {
|
||||
text = format!("***{text}***");
|
||||
} else if is_bold {
|
||||
text = format!("**{text}**");
|
||||
} else if fragment.styles.italic {
|
||||
text = format!("*{text}*");
|
||||
}
|
||||
|
||||
result.push_str(&text);
|
||||
}
|
||||
result
|
||||
}
|
||||
pub type FormattedTableAlignment = TableAlignment;
|
||||
|
||||
pub type FormattedTextInline = Vec<FormattedTextFragment>;
|
||||
|
||||
/// A fragment of formatted text, containing the text itself and formatting flags/metadata.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Default)]
|
||||
pub struct FormattedTextFragment {
|
||||
pub text: String,
|
||||
pub styles: FormattedTextStyles,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Hyperlink {
|
||||
Url(String),
|
||||
Action(Arc<dyn Action>),
|
||||
}
|
||||
|
||||
impl Hyperlink {
|
||||
/// Returns the URL if this is a URL, or `None` otherwise.
|
||||
pub fn url(self) -> Option<String> {
|
||||
match self {
|
||||
Hyperlink::Url(url) => Some(url),
|
||||
Hyperlink::Action(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Hyperlink {
|
||||
// Stub implementation for [`Hyperlink`] that only compares URLs and not Actions.
|
||||
// This is an unfortunate byproduct of the fact that an [`Action`] does not implement [`PartialEq`]
|
||||
// but we require [`PartialEq`] to consolidate [`FormattedTextStyles`].
|
||||
// To get around this, we only compare URLs, which works for style consolidation since this is only
|
||||
// needed when generating formatted text from markdown, which provably does not support URLs that dispatch
|
||||
// actions
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Url(left), Self::Url(right)) => left == right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Hyperlink {}
|
||||
|
||||
/// Formatted text styling, with no attached content.
|
||||
#[derive(Clone, Default, Eq, PartialEq)]
|
||||
pub struct FormattedTextStyles {
|
||||
pub weight: Option<CustomWeight>,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub strikethrough: bool,
|
||||
pub inline_code: bool,
|
||||
pub hyperlink: Option<Hyperlink>,
|
||||
}
|
||||
|
||||
impl FormattedTextFragment {
|
||||
pub fn plain_text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn weighted(text: impl Into<String>, weight: Option<CustomWeight>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
weight,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_weight(&mut self, weight: Option<CustomWeight>) -> &Self {
|
||||
self.styles.weight = weight;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bold(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
weight: Some(CustomWeight::Bold),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn italic(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
italic: true,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bold_italic(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
weight: Some(CustomWeight::Bold),
|
||||
italic: true,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hyperlink(tag: impl Into<String>, url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: tag.into(),
|
||||
styles: FormattedTextStyles {
|
||||
hyperlink: Some(Hyperlink::Url(url.into())),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new hyperlink that dispatches an action when clicked.
|
||||
pub fn hyperlink_action<A: Action>(tag: impl Into<String>, action: A) -> Self {
|
||||
Self {
|
||||
text: tag.into(),
|
||||
styles: FormattedTextStyles {
|
||||
hyperlink: Some(Hyperlink::Action(Arc::new(action))),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inline_code(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
inline_code: true,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strikethrough(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
strikethrough: true,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn underline(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
underline: true,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn raw_text(&self) -> &String {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FormattedTextStyles {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// For readability, only show active styles.
|
||||
let mut first = true;
|
||||
|
||||
if let Some(weight) = self.weight {
|
||||
if !first {
|
||||
f.write_str(" | ")?;
|
||||
}
|
||||
write!(f, "{weight:?}")?;
|
||||
first = false;
|
||||
}
|
||||
|
||||
if self.italic {
|
||||
if !first {
|
||||
f.write_str(" | ")?;
|
||||
}
|
||||
f.write_str("Italic")?;
|
||||
first = false;
|
||||
}
|
||||
|
||||
if self.strikethrough {
|
||||
if !first {
|
||||
f.write_str(" | ")?;
|
||||
}
|
||||
f.write_str("Strikethrough")?;
|
||||
first = false;
|
||||
}
|
||||
|
||||
if self.inline_code {
|
||||
if !first {
|
||||
f.write_str(" | ")?;
|
||||
}
|
||||
f.write_str("InlineCode")?;
|
||||
first = false;
|
||||
}
|
||||
|
||||
if let Some(link) = &self.hyperlink {
|
||||
if !first {
|
||||
f.write_str(" | ")?;
|
||||
}
|
||||
|
||||
write!(f, "Hyperlink({link:?})")?;
|
||||
first = false;
|
||||
}
|
||||
|
||||
if first {
|
||||
// No styles are active, so this is plain text.
|
||||
f.write_str("PlainText")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
use enum_iterator::Sequence;
|
||||
|
||||
/// All [`Weight`]s that are not [`Weight::Normal`] are considered custom weights.
|
||||
/// Avoid importing `CustomWeight`, and prefer using [`Weight`] throughout the codebase,
|
||||
/// except in cases where you want to specifically track explicit weight overrides.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Sequence)]
|
||||
pub enum CustomWeight {
|
||||
Thin,
|
||||
ExtraLight,
|
||||
Light,
|
||||
Medium,
|
||||
Semibold,
|
||||
Bold,
|
||||
ExtraBold,
|
||||
Black,
|
||||
}
|
||||
|
||||
impl CustomWeight {
|
||||
/// Returns true if the weight is bold or heavier.
|
||||
pub fn is_at_least_bold(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
CustomWeight::Bold | CustomWeight::ExtraBold | CustomWeight::Black
|
||||
)
|
||||
}
|
||||
|
||||
/// We do not support nested weights at this time! The outer weight will
|
||||
/// be the only respected weight.
|
||||
pub fn merge_weights(
|
||||
first: Option<CustomWeight>,
|
||||
second: Option<CustomWeight>,
|
||||
) -> Option<CustomWeight> {
|
||||
// We don't currently support text containing text of varying weights.
|
||||
// We will just respect the outer weight if you specify a non-Normal weight.
|
||||
first.or(second)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user