Skip to content

Commit 7f527e9

Browse files
RemcoSmitsDevbennetbo
authored andcommitted
markdown: Add support for HTML styling attributes (zed-industries#42143)
Second take on zed-industries#37765. This PR adds support for styling elements (**b**, **strong**, **em**, **i**, **ins**, **del**), but also allow you to show the styling text inline with the current text. This is done by appending all the up-following text into one text chunk and merge the highlights from both of them into the already existing chunk. If there does not exist a text chunk, we will create one and the next iteration we will use that one to store all the information on. **Before** <img width="483" height="692" alt="Screenshot 2025-11-06 at 22 08 09" src="https://github.com/user-attachments/assets/6158fd3b-066c-4abe-9f8e-bcafae85392e" /> **After** <img width="868" height="300" alt="Screenshot 2025-11-06 at 22 08 21" src="https://github.com/user-attachments/assets/4d5a7a33-d31c-4514-91c8-2b2a2ff43e0e" /> **Code example** ```html <p>some text <b>bold text</b></p> <p>some text <strong>strong text</strong></p> <p>some text <i>italic text</i></p> <p>some text <em>emphasized text</em></p> <p>some text <del>delete text</del></p> <p>some text <ins>insert text</ins></p> <p>Some text <strong>strong text</strong> more text <b>bold text</b> more text <i>italic text</i> more text <em>emphasized text</em> more text <del>deleted text</del> more text <ins>inserted text</ins></p> <p><a href="https://example.com">Link Text</a></p> <p style="text-decoration: underline;">text styled from style attribute</p> ``` cc @bennetbo **TODO** - [x] add tests for styling nested text that should result in one merge Release Notes: - Markdown Preview: Added support for `HTML` styling elements --------- Co-authored-by: Bennet Bo Fenner <[email protected]>
1 parent 0e8de76 commit 7f527e9

File tree

2 files changed

+229
-13
lines changed

2 files changed

+229
-13
lines changed

crates/markdown_preview/src/markdown_elements.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ impl MarkdownHighlight {
222222
});
223223
}
224224

225+
if style.oblique {
226+
highlight.font_style = Some(FontStyle::Oblique)
227+
}
228+
225229
Some(highlight)
226230
}
227231

@@ -243,6 +247,8 @@ pub struct MarkdownHighlightStyle {
243247
pub weight: FontWeight,
244248
/// Whether the text should be stylized as link.
245249
pub link: bool,
250+
// Whether the text should be obliqued.
251+
pub oblique: bool,
246252
}
247253

248254
/// A parsed region in a Markdown document.

crates/markdown_preview/src/markdown_parser.rs

Lines changed: 223 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
1212
use std::{
1313
cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec,
1414
};
15+
use ui::SharedString;
1516

1617
pub async fn parse_markdown(
1718
markdown_input: &str,
@@ -876,13 +877,21 @@ impl<'a> MarkdownParser<'a> {
876877
}
877878
markup5ever_rcdom::NodeData::Comment { .. } => {}
878879
markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
880+
let mut styles = if let Some(styles) = Self::markdown_style_from_html_styles(
881+
Self::extract_styles_from_attributes(attrs),
882+
) {
883+
vec![MarkdownHighlight::Style(styles)]
884+
} else {
885+
Vec::default()
886+
};
887+
879888
if local_name!("img") == name.local {
880889
if let Some(image) = self.extract_image(source_range, attrs) {
881890
elements.push(ParsedMarkdownElement::Image(image));
882891
}
883892
} else if local_name!("p") == name.local {
884893
let mut paragraph = MarkdownParagraph::new();
885-
self.parse_paragraph(source_range, node, &mut paragraph);
894+
self.parse_paragraph(source_range, node, &mut paragraph, &mut styles);
886895

887896
if !paragraph.is_empty() {
888897
elements.push(ParsedMarkdownElement::Paragraph(paragraph));
@@ -897,7 +906,7 @@ impl<'a> MarkdownParser<'a> {
897906
| local_name!("h6")
898907
) {
899908
let mut paragraph = MarkdownParagraph::new();
900-
self.consume_paragraph(source_range.clone(), node, &mut paragraph);
909+
self.consume_paragraph(source_range.clone(), node, &mut paragraph, &mut styles);
901910

902911
if !paragraph.is_empty() {
903912
elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
@@ -944,24 +953,90 @@ impl<'a> MarkdownParser<'a> {
944953
source_range: Range<usize>,
945954
node: &Rc<markup5ever_rcdom::Node>,
946955
paragraph: &mut MarkdownParagraph,
956+
highlights: &mut Vec<MarkdownHighlight>,
947957
) {
958+
fn add_highlight_range(
959+
text: &String,
960+
start: usize,
961+
highlights: Vec<MarkdownHighlight>,
962+
) -> Vec<(Range<usize>, MarkdownHighlight)> {
963+
highlights
964+
.into_iter()
965+
.map(|style| (start..text.len(), style))
966+
.collect()
967+
}
968+
948969
match &node.data {
949970
markup5ever_rcdom::NodeData::Text { contents } => {
950-
paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
951-
source_range,
952-
regions: Vec::default(),
953-
region_ranges: Vec::default(),
954-
highlights: Vec::default(),
955-
contents: contents.borrow().to_string().into(),
956-
}));
971+
// append the text to the last chunk, so we can have a hacky version
972+
// of inline text with highlighting
973+
if let Some(text) = paragraph.iter_mut().last().and_then(|p| match p {
974+
MarkdownParagraphChunk::Text(text) => Some(text),
975+
_ => None,
976+
}) {
977+
let mut new_text = text.contents.to_string();
978+
new_text.push_str(&contents.borrow());
979+
let highlights = add_highlight_range(
980+
&new_text,
981+
text.contents.len(),
982+
std::mem::take(highlights),
983+
);
984+
985+
text.contents = SharedString::from(new_text);
986+
text.highlights.extend(highlights);
987+
} else {
988+
let contents = contents.borrow().to_string();
989+
paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
990+
source_range,
991+
highlights: add_highlight_range(&contents, 0, std::mem::take(highlights)),
992+
regions: Vec::default(),
993+
contents: contents.into(),
994+
region_ranges: Vec::default(),
995+
}));
996+
}
957997
}
958998
markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
959999
if local_name!("img") == name.local {
9601000
if let Some(image) = self.extract_image(source_range, attrs) {
9611001
paragraph.push(MarkdownParagraphChunk::Image(image));
9621002
}
1003+
} else if local_name!("b") == name.local || local_name!("strong") == name.local {
1004+
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1005+
weight: FontWeight::BOLD,
1006+
..Default::default()
1007+
}));
1008+
1009+
self.consume_paragraph(source_range, node, paragraph, highlights);
1010+
} else if local_name!("i") == name.local {
1011+
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1012+
italic: true,
1013+
..Default::default()
1014+
}));
1015+
1016+
self.consume_paragraph(source_range, node, paragraph, highlights);
1017+
} else if local_name!("em") == name.local {
1018+
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1019+
oblique: true,
1020+
..Default::default()
1021+
}));
1022+
1023+
self.consume_paragraph(source_range, node, paragraph, highlights);
1024+
} else if local_name!("del") == name.local {
1025+
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1026+
strikethrough: true,
1027+
..Default::default()
1028+
}));
1029+
1030+
self.consume_paragraph(source_range, node, paragraph, highlights);
1031+
} else if local_name!("ins") == name.local {
1032+
highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1033+
underline: true,
1034+
..Default::default()
1035+
}));
1036+
1037+
self.consume_paragraph(source_range, node, paragraph, highlights);
9631038
} else {
964-
self.consume_paragraph(source_range, node, paragraph);
1039+
self.consume_paragraph(source_range, node, paragraph, highlights);
9651040
}
9661041
}
9671042
_ => {}
@@ -973,9 +1048,10 @@ impl<'a> MarkdownParser<'a> {
9731048
source_range: Range<usize>,
9741049
node: &Rc<markup5ever_rcdom::Node>,
9751050
paragraph: &mut MarkdownParagraph,
1051+
highlights: &mut Vec<MarkdownHighlight>,
9761052
) {
9771053
for node in node.children.borrow().iter() {
978-
self.parse_paragraph(source_range.clone(), node, paragraph);
1054+
self.parse_paragraph(source_range.clone(), node, paragraph, highlights);
9791055
}
9801056
}
9811057

@@ -1020,7 +1096,7 @@ impl<'a> MarkdownParser<'a> {
10201096
}
10211097

10221098
let mut children = MarkdownParagraph::new();
1023-
self.consume_paragraph(source_range, node, &mut children);
1099+
self.consume_paragraph(source_range, node, &mut children, &mut Vec::new());
10241100

10251101
let is_header = matches!(name.local, local_name!("th"));
10261102

@@ -1084,6 +1160,58 @@ impl<'a> MarkdownParser<'a> {
10841160
})
10851161
}
10861162

1163+
fn markdown_style_from_html_styles(
1164+
styles: HashMap<String, String>,
1165+
) -> Option<MarkdownHighlightStyle> {
1166+
let mut markdown_style = MarkdownHighlightStyle::default();
1167+
1168+
if let Some(text_decoration) = styles.get("text-decoration") {
1169+
match text_decoration.to_lowercase().as_str() {
1170+
"underline" => {
1171+
markdown_style.underline = true;
1172+
}
1173+
"line-through" => {
1174+
markdown_style.strikethrough = true;
1175+
}
1176+
_ => {}
1177+
}
1178+
}
1179+
1180+
if let Some(font_style) = styles.get("font-style") {
1181+
match font_style.to_lowercase().as_str() {
1182+
"italic" => {
1183+
markdown_style.italic = true;
1184+
}
1185+
"oblique" => {
1186+
markdown_style.oblique = true;
1187+
}
1188+
_ => {}
1189+
}
1190+
}
1191+
1192+
if let Some(font_weight) = styles.get("font-weight") {
1193+
match font_weight.to_lowercase().as_str() {
1194+
"bold" => {
1195+
markdown_style.weight = FontWeight::BOLD;
1196+
}
1197+
"lighter" => {
1198+
markdown_style.weight = FontWeight::THIN;
1199+
}
1200+
_ => {
1201+
if let Some(weight) = font_weight.parse::<f32>().ok() {
1202+
markdown_style.weight = FontWeight(weight);
1203+
}
1204+
}
1205+
}
1206+
}
1207+
1208+
if markdown_style != MarkdownHighlightStyle::default() {
1209+
Some(markdown_style)
1210+
} else {
1211+
None
1212+
}
1213+
}
1214+
10871215
fn extract_styles_from_attributes(
10881216
attrs: &RefCell<Vec<html5ever::Attribute>>,
10891217
) -> HashMap<String, String> {
@@ -1241,7 +1369,12 @@ impl<'a> MarkdownParser<'a> {
12411369
markup5ever_rcdom::NodeData::Element { name, .. } => {
12421370
if local_name!("caption") == name.local {
12431371
let mut paragraph = MarkdownParagraph::new();
1244-
self.parse_paragraph(source_range.clone(), node, &mut paragraph);
1372+
self.parse_paragraph(
1373+
source_range.clone(),
1374+
node,
1375+
&mut paragraph,
1376+
&mut Vec::new(),
1377+
);
12451378
caption = Some(paragraph);
12461379
}
12471380
if local_name!("thead") == name.local {
@@ -1408,6 +1541,83 @@ mod tests {
14081541
);
14091542
}
14101543

1544+
#[gpui::test]
1545+
async fn test_html_inline_style_elements() {
1546+
let parsed =
1547+
parse("<p>Some text <strong>strong text</strong> more text <b>bold text</b> more text <i>italic text</i> more text <em>emphasized text</em> more text <del>deleted text</del> more text <ins>inserted text</ins></p>").await;
1548+
1549+
assert_eq!(1, parsed.children.len());
1550+
let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
1551+
chunks
1552+
} else {
1553+
panic!("Expected a paragraph");
1554+
};
1555+
1556+
assert_eq!(1, chunks.len());
1557+
let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
1558+
text
1559+
} else {
1560+
panic!("Expected a paragraph");
1561+
};
1562+
1563+
assert_eq!(0..205, text.source_range);
1564+
assert_eq!(
1565+
"Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text",
1566+
text.contents.as_str(),
1567+
);
1568+
assert_eq!(
1569+
vec![
1570+
(
1571+
10..21,
1572+
MarkdownHighlight::Style(MarkdownHighlightStyle {
1573+
weight: FontWeight(700.0),
1574+
..Default::default()
1575+
},),
1576+
),
1577+
(
1578+
32..41,
1579+
MarkdownHighlight::Style(MarkdownHighlightStyle {
1580+
weight: FontWeight(700.0),
1581+
..Default::default()
1582+
},),
1583+
),
1584+
(
1585+
52..63,
1586+
MarkdownHighlight::Style(MarkdownHighlightStyle {
1587+
italic: true,
1588+
weight: FontWeight(400.0),
1589+
..Default::default()
1590+
},),
1591+
),
1592+
(
1593+
74..89,
1594+
MarkdownHighlight::Style(MarkdownHighlightStyle {
1595+
weight: FontWeight(400.0),
1596+
oblique: true,
1597+
..Default::default()
1598+
},),
1599+
),
1600+
(
1601+
100..112,
1602+
MarkdownHighlight::Style(MarkdownHighlightStyle {
1603+
strikethrough: true,
1604+
weight: FontWeight(400.0),
1605+
..Default::default()
1606+
},),
1607+
),
1608+
(
1609+
123..136,
1610+
MarkdownHighlight::Style(MarkdownHighlightStyle {
1611+
underline: true,
1612+
weight: FontWeight(400.0,),
1613+
..Default::default()
1614+
},),
1615+
),
1616+
],
1617+
text.highlights
1618+
);
1619+
}
1620+
14111621
#[gpui::test]
14121622
async fn test_text_with_inline_html() {
14131623
let parsed = parse("This is a paragraph with an inline HTML <sometag>tag</sometag>.").await;

0 commit comments

Comments
 (0)