From 3ba63915ce0a6abaf48d4d6dcc331e8992ba4ec2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 26 Feb 2025 17:52:18 +0800 Subject: [PATCH] text_view: Fix some case may lose spaces, `
` in HTML and Markdown render. (#667) --- Cargo.lock | 11 +- crates/story/examples/html.html | 10 +- crates/story/examples/markdown.md | 92 +++++++-------- crates/ui/Cargo.toml | 14 ++- crates/ui/src/text/element.rs | 187 ++++++++++++++++++++++++++++-- crates/ui/src/text/html.rs | 78 ++++++++++++- crates/ui/src/text/markdown.rs | 50 +++++--- crates/ui/src/text/text_view.rs | 15 +++ 8 files changed, 366 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 775efe39..db6eefc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -527,7 +527,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -2546,6 +2546,7 @@ dependencies = [ "gpui", "html5ever 0.27.0", "image", + "indoc", "itertools 0.13.0", "markdown", "markup5ever_rcdom", @@ -3025,6 +3026,12 @@ dependencies = [ "serde", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inout" version = "0.1.3" @@ -3915,7 +3922,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.96", diff --git a/crates/story/examples/html.html b/crates/story/examples/html.html index c636cca9..7452b52a 100644 --- a/crates/story/examples/html.html +++ b/crates/story/examples/html.html @@ -72,8 +72,7 @@ - Cell 1 - Cell 2 + This Cell have 2 span Cell 3 @@ -94,21 +93,18 @@ (A Tesla Model X on display at the June 2024 Shanghai new energy vehicle show. Image credit: CnEVPost)

-

SVG

Text before the image. Rust Text after the image. Rust diff --git a/crates/story/examples/markdown.md b/crates/story/examples/markdown.md index 7f8239b9..fcce51b8 100644 --- a/crates/story/examples/markdown.md +++ b/crates/story/examples/markdown.md @@ -9,10 +9,10 @@ This is an additional demonstration paragraph in English demonstrating more cont ![Img](https://miro.medium.com/v2/resize:fit:1400/format:webp/1*WgEz5f3n3lD7MfC7NeQGOA.jpeg) ---- - 这是一个中文演示段落,用于展示更多的 [Markdown GFM](https://github.github.com/gfm/) 内容。您可以在此尝试使用使用**粗体**、*斜体*和`代码`等样式。これは日本語のデモ段落です。Markdown の多言語サポートを示すためのテキストが含まれています。例えば、、**ボールド**、_イタリック_、および`コード`のスタイルなどを試すことができます。 +--- + ## Heading for [Links](https://www.google.com) Here is a link to [Google](https://www.google.com), and another to [Rust](https://www.rust-lang.org). @@ -25,6 +25,50 @@ Here is a link to [Google](https://www.google.com), and another to [Rust](https: ![Rust](https://www.rust-lang.org/logos/rust-logo-blk.svg) +### Table + +| Header 1 | Centered | Header 3 | Align Right | +| -------- | :------: | ------------------------------------ | ----------: | +| Cell 0 | Cell 1 | This is a long cell with line break. | Cell 3 | +| Row 2 | Row 2 | Row 2
[Link](https://github.com) | Row 2 | +| Row 3 | **Bold** | Row 3 | Row 3 | + +#### Lists + +##### Bulleted List + +- Bullet 1 +- Bullet 2 + - Bullet 2.1 + - Bullet 2.1.1 + - Bullet 2.1.1.1 + - Bullet 2.1.2 + - Bullet 2.2 +- Bullet 3 + +##### Numbered List + +1. Numbered item 1 + 1. Numbered item 1.1 + 1. Numbered item 1.1.1 + 1. Numbered item 1.2 +2. Numbered item 2 +3. Numbered item 3 + +##### To-Do List + +- [x] Task 1 +- [ ] Task 2 +- [ ] Task 3 + +#### Heading for Code + +```rust +fn main() { + println!("Hello, World!"); +} +``` + ### HTML #### Paragraph and Text @@ -77,50 +121,6 @@ Here is a link to [Google](https://www.google.com), and another to [Rust](https: Rust Rust -### Table - -| Header 1 | Centered | Header 3 | Align Right | -| -------- | :------: | ------------------------------------ | ----------: | -| Cell 0 | Cell 1 | This is a long cell with line break. | Cell 3 | -| Row 2 | Row 2 | Row 2
[Link](https://github.com) | Row 2 | -| Row 3 | **Bold** | Row 3 | Row 3 | - -#### Lists - -##### Bulleted List - -- Bullet 1 -- Bullet 2 - - Bullet 2.1 - - Bullet 2.1.1 - - Bullet 2.1.1.1 - - Bullet 2.1.2 - - Bullet 2.2 -- Bullet 3 - -##### Numbered List - -1. Numbered item 1 - 1. Numbered item 1.1 - 1. Numbered item 1.1.1 - 1. Numbered item 1.2 -2. Numbered item 2 -3. Numbered item 3 - -##### To-Do List - -- [x] Task 1 -- [ ] Task 2 -- [ ] Task 3 - -#### Heading for Code - -```rust -fn main() { - println!("Hello, World!"); -} -``` - ## Unsupported ### HTML diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index a45fefa8..7f1c5397 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -23,8 +23,8 @@ once_cell = "1.19.0" paste = "1" regex = "1" resvg = { version = "0.44.0", default-features = false, features = [ - "system-fonts", - "text", + "system-fonts", + "text", ] } rust-embed.workspace = true rust-i18n = "3" @@ -34,20 +34,22 @@ smallvec = "1.13.2" smol = "1" unicode-segmentation = "1.12.0" usvg = { version = "0.44.0", default-features = false, features = [ - "system-fonts", - "text", + "system-fonts", + "text", ] } uuid = "1.10" wry = { version = "0.48.0", optional = true } # Markdown Parser markdown = "1.0.0-alpha.22" # HTML Parser -html5ever = "0.27" +html5ever = "0.27" markup5ever_rcdom = "0.3.0" minify-html = "0.15.0" - # Calendar chrono = "0.4.38" +[dev-dependencies] +indoc = "2" + [lints] workspace = true diff --git a/crates/ui/src/text/element.rs b/crates/ui/src/text/element.rs index 88573f4d..8abbed2a 100644 --- a/crates/ui/src/text/element.rs +++ b/crates/ui/src/text/element.rs @@ -229,22 +229,27 @@ pub enum Node { lang: Option, }, Table(Table), - //
- Break, + Break { + html: bool, + }, Divider, Ignore, Unknown, } impl Node { - fn is_ignore(&self) -> bool { + pub(super) fn is_ignore(&self) -> bool { matches!(self, Self::Ignore) } - fn is_list_item(&self) -> bool { + pub(super) fn is_list_item(&self) -> bool { matches!(self, Self::ListItem { .. }) } + pub(super) fn is_break(&self) -> bool { + matches!(self, Self::Break { .. }) + } + /// Combine all children, omitting the empt parent nodes. pub(super) fn compact(&self) -> Node { match self { @@ -276,7 +281,14 @@ impl RenderOnce for Paragraph { for text_node in children.into_iter() { let text_len = text_node.text.len(); - text.push_str(&text_node.text); + let part = if text.len() == 0 { + // trim start for first text + text_node.text.trim_start() + } else { + text_node.text.as_str() + }; + + text.push_str(part); let mut node_highlights = vec![]; for (range, style) in text_node.marks { @@ -581,7 +593,6 @@ impl Node { .border_l_3() .border_color(cx.theme().secondary_active) .px_4() - .py_1() .child(children) .into_any_element(), Node::List { children, ordered } => v_flex() @@ -628,7 +639,7 @@ impl Node { .h(px(2.)) .mb(mb) .into_any_element(), - Node::Break => div().into_any_element(), + Node::Break { .. } => div().into_any_element(), Node::Ignore => div().into_any_element(), _ => { if cfg!(debug_assertions) { @@ -640,3 +651,165 @@ impl Node { } } } + +impl Paragraph { + fn to_markdown(&self) -> String { + let mut text = match self { + Paragraph::Texts { children, .. } => children + .iter() + .map(|text_node| { + let mut text = text_node.text.clone(); + for (range, style) in &text_node.marks { + if style.bold { + text = format!("**{}**", &text_node.text[range.clone()]); + } + if style.italic { + text = format!("*{}*", &text_node.text[range.clone()]); + } + if style.strikethrough { + text = format!("~~{}~~", &text_node.text[range.clone()]); + } + if style.code { + text = format!("`{}`", &text_node.text[range.clone()]); + } + if let Some(link) = &style.link { + text = format!("[{}]({})", &text_node.text[range.clone()], link.url); + } + } + text + }) + .collect::>() + .join(""), + Paragraph::Image { image, .. } => { + let alt = image.alt.clone().unwrap_or_default(); + let title = image + .title + .clone() + .map_or(String::new(), |t| format!(" \"{}\"", t)); + format!("![{}]({}{})", alt, image.url, title) + } + }; + + text.push_str("\n\n"); + text + } +} + +impl Node { + /// Converts the node to markdown format. + /// + /// This is used to generate markdown for test. + #[allow(dead_code)] + pub(crate) fn to_markdown(&self) -> String { + match self { + Node::Root { children } => children + .iter() + .map(|child| child.to_markdown()) + .collect::>() + .join("\n\n"), + Node::Paragraph(paragraph) => paragraph.to_markdown(), + Node::Heading { level, children } => { + let hashes = "#".repeat(*level as usize); + format!("{} {}", hashes, children.to_markdown()) + } + Node::Blockquote(paragraph) => { + let content = paragraph.to_markdown(); + content + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n") + } + Node::List { children, ordered } => children + .iter() + .enumerate() + .map(|(i, child)| { + let prefix = if *ordered { + format!("{}. ", i + 1) + } else { + "- ".to_string() + }; + format!("{}{}", prefix, child.to_markdown()) + }) + .collect::>() + .join("\n"), + Node::ListItem { + children, checked, .. + } => { + let checkbox = if let Some(checked) = checked { + if *checked { + "[x] " + } else { + "[ ] " + } + } else { + "" + }; + format!( + "{}{}", + checkbox, + children + .iter() + .map(|child| child.to_markdown()) + .collect::>() + .join("\n") + ) + } + Node::CodeBlock { code, lang } => { + format!("```{}\n{}\n```", lang.clone().unwrap_or_default(), code) + } + Node::Table(table) => { + let header = table + .children + .first() + .map(|row| { + row.children + .iter() + .map(|cell| cell.children.to_markdown()) + .collect::>() + .join(" | ") + }) + .unwrap_or_default(); + let alignments = table + .column_aligns + .iter() + .map(|align| { + match align { + TableColumnAlign::Left => ":--", + TableColumnAlign::Center => ":-:", + TableColumnAlign::Right => "--:", + } + .to_string() + }) + .collect::>() + .join(" | "); + let rows = table + .children + .iter() + .skip(1) + .map(|row| { + row.children + .iter() + .map(|cell| cell.children.to_markdown()) + .collect::>() + .join(" | ") + }) + .collect::>() + .join("\n"); + format!("{}\n{}\n{}", header, alignments, rows) + } + Node::Break { html } => { + if *html { + "
".to_string() + } else { + "\n".to_string() + } + } + Node::Divider => "---".to_string(), + Node::Ignore => "".to_string(), + Node::Unknown => "".to_string(), + } + .trim() + .to_string() + } +} diff --git a/crates/ui/src/text/html.rs b/crates/ui/src/text/html.rs index c43be79a..bd97eabc 100644 --- a/crates/ui/src/text/html.rs +++ b/crates/ui/src/text/html.rs @@ -62,7 +62,7 @@ pub(super) fn parse_html(source: &str) -> Result { ..Default::default() }; - let bytes = minify_html::minify(source.as_bytes(), &minify_html::Cfg::default()); + let bytes = cleanup_html(&source); let mut cursor = std::io::Cursor::new(bytes); // Ref // https://github.com/servo/html5ever/blob/main/rcdom/examples/print-rcdom.rs @@ -79,6 +79,12 @@ pub(super) fn parse_html(source: &str) -> Result { Ok(node) } +fn cleanup_html(source: &str) -> Vec { + let mut cfg = minify_html::Cfg::default(); + cfg.keep_closing_tags = true; + minify_html::minify(source.as_bytes(), &cfg) +} + #[derive(Clone)] pub(super) struct HtmlElement { id: ElementId, @@ -334,6 +340,7 @@ fn parse_table_cell( /// /// - Before: " \r\n Hello world \t " /// - After: " Hello world " +#[allow(dead_code)] fn trim_text(text: &str) -> String { let mut out = String::with_capacity(text.len()); @@ -372,7 +379,7 @@ fn parse_paragraph( match &node.data { NodeData::Text { ref contents } => { - let part = trim_text(&contents.borrow()); + let part = &contents.borrow(); text.push_str(&part); paragraph.push_str(&text); } @@ -524,7 +531,7 @@ fn parse_paragraph( fn parse_node(node: &Rc, paragraph: &mut Paragraph) -> element::Node { match node.data { NodeData::Text { ref contents } => { - let text = contents.borrow().trim_start().to_string(); + let text = contents.borrow().to_string(); if text.len() > 0 { paragraph.push_str(&text); } @@ -536,7 +543,7 @@ fn parse_node(node: &Rc, paragraph: &mut Paragraph) -> element::Node { ref attrs, .. } => match name.local { - local_name!("br") => element::Node::Break, + local_name!("br") => element::Node::Break { html: true }, local_name!("h1") | local_name!("h2") | local_name!("h3") @@ -760,11 +767,74 @@ mod tests { use super::trim_text; + #[test] + fn test_cleanup_html() { + let html = r#"

+ and + code + text +

"#; + let cleaned = super::cleanup_html(html); + assert_eq!( + String::from_utf8(cleaned).unwrap(), + "

and code text

" + ); + + let html = r#"

+ and + code italic + text +

"#; + let cleaned = super::cleanup_html(html); + assert_eq!( + String::from_utf8(cleaned).unwrap(), + "

and code italic text

" + ); + } + #[test] fn test_trim_text() { assert_eq!(trim_text(" \n\tHello world \t\r "), " Hello world ",); } + #[test] + fn test_keep_spaces() { + let html = r#"

and code text

"#; + let node = super::parse_html(html).unwrap(); + assert_eq!(node.to_markdown(), "and `code` text"); + + let html = r#" +
+

+ and + code italic + text +

+

+ Example +

+
    +
  • Item 1
  • +
  • Item 2 +
  • +
+
+ "#; + let node = super::parse_html(html).unwrap(); + assert_eq!( + node.to_markdown(), + indoc::indoc! {r#" + and * code italic * text + + ![Example](https://example.com/image.png "Example Image") + + - Item 1 + - Item 2 + "#} + .trim() + ); + } + #[test] fn test_value_to_length() { assert_eq!(super::value_to_length("100px"), Some(px(100.).into())); diff --git a/crates/ui/src/text/markdown.rs b/crates/ui/src/text/markdown.rs index 984db6b0..9cecb398 100644 --- a/crates/ui/src/text/markdown.rs +++ b/crates/ui/src/text/markdown.rs @@ -20,17 +20,7 @@ use super::{ /// This is design goal is to be able to most common Markdown (GFM) features /// to let us to display rich text in our application. /// -/// The goal: -/// -/// - For used to help message. -/// - For used to display like about page. -/// - Some general style customization (Like base text size, line-height...). -/// -/// Not in goal: -/// -/// - As a markdown editor. -/// - Add custom markdown syntax. -/// - Complex styles cumstomization. +/// See also [`super::TextView`] #[derive(Clone)] pub(super) struct MarkdownElement { id: ElementId, @@ -75,11 +65,7 @@ impl MarkdownState { } self.raw = new_text; - self.root = Some( - markdown::to_mdast(&self.raw, &ParseOptions::gfm()) - .map(|n| n.into()) - .map_err(|e| e.to_string().into()), - ); + self.root = Some(parse_markdown(&self.raw)); } } @@ -156,6 +142,13 @@ impl Element for MarkdownElement { } } +/// Parse Markdown into a tree of nodes. +fn parse_markdown(raw: &str) -> Result { + markdown::to_mdast(&raw, &ParseOptions::gfm()) + .map(|n| n.into()) + .map_err(|e| e.to_string().into()) +} + fn parse_table_row(table: &mut Table, node: &mdast::TableRow) { let mut row = TableRow::default(); node.children.iter().for_each(|c| { @@ -312,8 +305,12 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { } Node::Html(val) => match parse_html(&val.value) { Ok(el) => { - if el == element::Node::Break { - text.push_str("\n"); + if el.is_break() { + text = "\n".to_owned(); + paragraph.push(element::TextNode { + text: text.clone(), + marks: vec![(0..text.len(), InlineTextStyle::default())], + }); } else { if cfg!(debug_assertions) { eprintln!("[markdown] unsupported inline html tag: {:#?}", el); @@ -376,7 +373,7 @@ impl From for element::Node { checked: val.checked, } } - Node::Break(_) => element::Node::Break, + Node::Break(_) => element::Node::Break { html: false }, Node::Code(raw) => element::Node::CodeBlock { code: raw.value.into(), lang: raw.lang.map(|s| s.into()), @@ -460,3 +457,18 @@ impl From for element::Node { } } } + +#[cfg(test)] +mod tests { + use super::parse_markdown; + + #[test] + fn test_parse_br() { + let raw = "Row 1
Row 2
[Link](https://github.com)"; + let node = parse_markdown(&raw).unwrap(); + assert_eq!( + node.to_markdown(), + "Row 1\nRow 2\n[Link](https://github.com)" + ); + } +} diff --git a/crates/ui/src/text/text_view.rs b/crates/ui/src/text/text_view.rs index d841f6e4..4b413629 100644 --- a/crates/ui/src/text/text_view.rs +++ b/crates/ui/src/text/text_view.rs @@ -3,6 +3,21 @@ use gpui::{rems, App, ElementId, IntoElement, Rems, RenderOnce, SharedString, Wi use super::{html::HtmlElement, markdown::MarkdownElement}; /// A text view that can render Markdown or HTML. +/// +/// ## Goals +/// +/// - Provide a rich text rendering component for such as Markdown or HTML, +/// used to display rich text in GPUI application (e.g., Help messages, Release notes) +/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups. +/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ... +/// +/// ## Not Goals +/// +/// - Customization of the complex style (some simple styles will be supported) +/// - As a Markdown editor or viewer (If you want to like this, you must fork your version). +/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader. +/// +/// See also [`MarkdownElement`], [`HtmlElement`] #[allow(private_interfaces)] #[derive(IntoElement, Clone)] pub enum TextView {