text_view: Fix some case may lose spaces, <br> in HTML and Markdown render. (#667)

This commit is contained in:
Jason Lee 2025-02-26 17:52:18 +08:00 committed by GitHub
parent ffa848fb26
commit 3ba63915ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 366 additions and 91 deletions

11
Cargo.lock generated
View file

@ -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",

View file

@ -72,8 +72,7 @@
</thead>
<tbody>
<tr>
<td><strong>Cell</strong> 1</td>
<td>Cell 2</td>
<td span="2">This <strong>Cell</strong> have 2 span</td>
</tr>
<tr>
<td>Cell 3</td>
@ -94,21 +93,18 @@
(A Tesla Model X on display at the June 2024 Shanghai new energy
vehicle show. Image credit: CnEVPost)
</p>
<h3>SVG</h3>
<img
src="https://miro.medium.com/v2/resize:fit:1400/format:webp/1*-Y9ozbNWSViiCmal1TT32w.jpeg"
width="100%"
/>
Text before the image.
<img
src="https://www.rust-lang.org/logos/rust-logo-blk.svg"
alt="Rust"
src="https://miro.medium.com/v2/resize:fit:1400/format:webp/0*u4La03Nh6E4zIc9-.jpeg"
width="100%"
/>
Text after the image.
<img
src="https://www.rust-lang.org/logos/rust-logo-blk.svg"
alt="Rust"
src="https://miro.medium.com/v2/resize:fit:1400/format:webp/0*Q_JiltniByWLWoUv"
style="width: 100%"
/>
</section>

View file

@ -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<br>[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:
<img src="https://www.rust-lang.org/logos/rust-logo-blk.svg" alt="Rust" width="100%" />
<img src="https://www.rust-lang.org/logos/rust-logo-blk.svg" alt="Rust" style="width:100%" />
### 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<br>[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

View file

@ -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

View file

@ -229,22 +229,27 @@ pub enum Node {
lang: Option<SharedString>,
},
Table(Table),
// <br>
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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join(" | ")
})
.unwrap_or_default();
let alignments = table
.column_aligns
.iter()
.map(|align| {
match align {
TableColumnAlign::Left => ":--",
TableColumnAlign::Center => ":-:",
TableColumnAlign::Right => "--:",
}
.to_string()
})
.collect::<Vec<_>>()
.join(" | ");
let rows = table
.children
.iter()
.skip(1)
.map(|row| {
row.children
.iter()
.map(|cell| cell.children.to_markdown())
.collect::<Vec<_>>()
.join(" | ")
})
.collect::<Vec<_>>()
.join("\n");
format!("{}\n{}\n{}", header, alignments, rows)
}
Node::Break { html } => {
if *html {
"<br>".to_string()
} else {
"\n".to_string()
}
}
Node::Divider => "---".to_string(),
Node::Ignore => "".to_string(),
Node::Unknown => "".to_string(),
}
.trim()
.to_string()
}
}

View file

@ -62,7 +62,7 @@ pub(super) fn parse_html(source: &str) -> Result<element::Node, SharedString> {
..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<element::Node, SharedString> {
Ok(node)
}
fn cleanup_html(source: &str) -> Vec<u8> {
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<Node>, 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<Node>, 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#"<p>
and
<code>code</code>
text
</p>"#;
let cleaned = super::cleanup_html(html);
assert_eq!(
String::from_utf8(cleaned).unwrap(),
"<p>and <code>code</code> text</p>"
);
let html = r#"<p>
and
<em> <code>code</code> <i>italic</i> </em>
text
</p>"#;
let cleaned = super::cleanup_html(html);
assert_eq!(
String::from_utf8(cleaned).unwrap(),
"<p>and <em> <code>code</code> <i>italic</i> </em> text</p>"
);
}
#[test]
fn test_trim_text() {
assert_eq!(trim_text(" \n\tHello world \t\r "), " Hello world ",);
}
#[test]
fn test_keep_spaces() {
let html = r#"<p>and <code>code</code> text</p>"#;
let node = super::parse_html(html).unwrap();
assert_eq!(node.to_markdown(), "and `code` text");
let html = r#"
<div>
<p>
and
<em> <code>code</code> <i>italic</i> </em>
text
</p>
<p>
<img src="https://example.com/image.png" alt="Example" width="100" height="200" title="Example Image" />
</p>
<ul>
<li>Item 1</li>
<li>Item 2
</li>
</ul>
</div>
"#;
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()));

View file

@ -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<element::Node, SharedString> {
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<mdast::Node> 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<mdast::Node> for element::Node {
}
}
}
#[cfg(test)]
mod tests {
use super::parse_markdown;
#[test]
fn test_parse_br() {
let raw = "Row 1<br/>Row 2<br>[Link](https://github.com)";
let node = parse_markdown(&raw).unwrap();
assert_eq!(
node.to_markdown(),
"Row 1\nRow 2\n[Link](https://github.com)"
);
}
}

View file

@ -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 {