From 223feae801183fb3507a1380f6de263692f1c5e5 Mon Sep 17 00:00:00 2001 From: Asherah Connor Date: Thu, 19 Feb 2026 12:20:24 +1100 Subject: [PATCH 1/5] tests/commonmark: actually use the created options?! --- src/tests/commonmark.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/commonmark.rs b/src/tests/commonmark.rs index 1669a240..bdc78ee6 100644 --- a/src/tests/commonmark.rs +++ b/src/tests/commonmark.rs @@ -64,7 +64,7 @@ fn commonmark_math(markdown: &str, cm: &str) { options.extension.math_dollars = true; options.extension.math_code = true; - commonmark(markdown, cm, None); + commonmark(markdown, cm, Some(&options)); } #[test_case("\\(x^2\\) and \\[y^2\\]", "\\(x^2\\) and \\[y^2\\]\n")] From bddb702b781bde3c99276df7d05bded38b5b6a4e Mon Sep 17 00:00:00 2001 From: Asherah Connor Date: Thu, 19 Feb 2026 12:20:24 +1100 Subject: [PATCH 2/5] failing test case for #551 --- src/tests/commonmark.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/tests/commonmark.rs b/src/tests/commonmark.rs index bdc78ee6..f055ef55 100644 --- a/src/tests/commonmark.rs +++ b/src/tests/commonmark.rs @@ -1,7 +1,9 @@ -use self::nodes::{Ast, LineColumn, ListType, NodeList}; +use ntest::test_case; +use pretty_assertions::assert_eq; + +use crate::nodes::{Ast, LineColumn, ListType, NodeList}; use super::*; -use ntest::test_case; #[test] fn commonmark_removes_redundant_strong() { @@ -181,3 +183,26 @@ fn dont_wrap_table_cell() { fn ol_marker_wonk() { commonmark(">9)\r\u{b}", "> 9) \n\n \n", None); } + +#[test_case("**Hello **")] +#[test_case("* Hello*")] +#[test_case("* Hello *")] +#[test_case("~~Hello ~~")] +fn entity_roundtrips_fooled_by_whitespace(markdown: &str) { + let arena = Arena::new(); + let mut options = Options::default(); + if markdown.contains("~") { + options.extension.strikethrough = true; + } + let root = parse_document(&arena, markdown, &options); + let mut original_html = String::new(); + html::format_document(root, &options, &mut original_html).unwrap(); + + let mut roundtripped = String::new(); + cm::format_document(root, &options, &mut roundtripped).unwrap(); + let roundtripped_root = parse_document(&arena, &roundtripped, &options); + let mut roundtripped_html = String::new(); + cm::format_document(roundtripped_root, &options, &mut roundtripped_html).unwrap(); + + assert_eq!(original_html, roundtripped_html); +} From ca5c29bd753a2a9010afe976977a45abd6592ba3 Mon Sep 17 00:00:00 2001 From: Asherah Connor Date: Thu, 19 Feb 2026 12:39:14 +1100 Subject: [PATCH 3/5] add multi-space cases We only need to encode the very outer characters so the run is recognised; some of these will roundtrip differently to CommonMark but as long as they're re-parsed the same (i.e. become the same HTML), it's correct. --- src/tests/commonmark.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tests/commonmark.rs b/src/tests/commonmark.rs index f055ef55..149d670f 100644 --- a/src/tests/commonmark.rs +++ b/src/tests/commonmark.rs @@ -185,8 +185,11 @@ fn ol_marker_wonk() { } #[test_case("**Hello **")] +#[test_case("**Hello **")] #[test_case("* Hello*")] +#[test_case("* Hello*")] #[test_case("* Hello *")] +#[test_case("* Hello *")] #[test_case("~~Hello ~~")] fn entity_roundtrips_fooled_by_whitespace(markdown: &str) { let arena = Arena::new(); From 37a996d94e4434634706a1b1c3727d6e5f43b031 Mon Sep 17 00:00:00 2001 From: Asherah Connor Date: Thu, 19 Feb 2026 12:39:14 +1100 Subject: [PATCH 4/5] entity encode leading/trailing ws inside emphasis. --- src/cm.rs | 72 +++++++++++++++++++++++++++++++++++++++-- src/tests/commonmark.rs | 2 +- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/cm.rs b/src/cm.rs index b1ae4ac0..4e954918 100644 --- a/src/cm.rs +++ b/src/cm.rs @@ -465,7 +465,9 @@ impl<'a, 'o, 'c, 'w> CommonMarkFormatter<'a, 'o, 'c, 'w> { NodeValue::HeexBlock(ref nhb) => self.format_heex_block(nhb, entering)?, NodeValue::ThematicBreak => self.format_thematic_break(entering)?, NodeValue::Paragraph => self.format_paragraph(entering), - NodeValue::Text(ref literal) => self.format_text(literal, entering, !text_in_cell)?, + NodeValue::Text(ref literal) => { + self.format_text(literal, node, entering, !text_in_cell)? + } NodeValue::LineBreak => self.format_line_break(entering, next_is_block)?, NodeValue::SoftBreak => self.format_soft_break(entering)?, NodeValue::Code(ref code) => self.format_code(&code.literal, entering)?, @@ -755,9 +757,58 @@ impl<'a, 'o, 'c, 'w> CommonMarkFormatter<'a, 'o, 'c, 'w> { } } - fn format_text(&mut self, literal: &str, entering: bool, wrap: bool) -> fmt::Result { + fn format_text( + &mut self, + literal: &str, + node: Node<'a>, + entering: bool, + wrap: bool, + ) -> fmt::Result { if entering { - self.output(literal, wrap, Escaping::Normal)?; + // Entity encode a leading or trailing whitespace character if this + // is the first or last child of an emphasis node respectively, + // otherwise flankingness rules will cause the emph to not be + // parsed! (Note that both is possible.) + let (leading, trailing) = if node + .parent() + .is_some_and(|n| is_emphasis_delimiter_node(&n.data().value)) + { + ( + if node.previous_sibling().is_none() { + literal + .chars() + .next() + .and_then(|c| if c.is_whitespace() { Some(c) } else { None }) + } else { + None + }, + if node.next_sibling().is_none() { + literal + .chars() + .next_back() + .and_then(|c| if c.is_whitespace() { Some(c) } else { None }) + } else { + None + }, + ) + } else { + (None, None) + }; + + if let Some(leading) = leading { + write!(self, "&#{};", leading as u32)?; + } + + self.output( + &literal[leading.map_or(0, char::len_utf8) + ..literal.len() - trailing.map_or(0, char::len_utf8)], + wrap, + Escaping::Normal, + )?; + + if let Some(trailing) = trailing { + write!(self, "&#{};", trailing as u32)?; + } } Ok(()) } @@ -1317,3 +1368,18 @@ pub fn escape_link_destination(url: &str) -> String { result } + +#[inline] +fn is_emphasis_delimiter_node(value: &NodeValue) -> bool { + matches!( + value, + NodeValue::Emph + | NodeValue::Strong + | NodeValue::Strikethrough + | NodeValue::Highlight + | NodeValue::Superscript + | NodeValue::Subscript + | NodeValue::SpoileredText + | NodeValue::Underline + ) +} diff --git a/src/tests/commonmark.rs b/src/tests/commonmark.rs index 149d670f..00d5236b 100644 --- a/src/tests/commonmark.rs +++ b/src/tests/commonmark.rs @@ -205,7 +205,7 @@ fn entity_roundtrips_fooled_by_whitespace(markdown: &str) { cm::format_document(root, &options, &mut roundtripped).unwrap(); let roundtripped_root = parse_document(&arena, &roundtripped, &options); let mut roundtripped_html = String::new(); - cm::format_document(roundtripped_root, &options, &mut roundtripped_html).unwrap(); + html::format_document(roundtripped_root, &options, &mut roundtripped_html).unwrap(); assert_eq!(original_html, roundtripped_html); } From 1fbd635af969ed3b385847cde6bb391840bb6f57 Mon Sep 17 00:00:00 2001 From: Asherah Connor Date: Mon, 6 Jul 2026 18:54:54 +1000 Subject: [PATCH 5/5] deal with emphasis that contain only a whitespace. --- src/cm.rs | 9 +++++++++ src/tests/commonmark.rs | 1 + 2 files changed, 10 insertions(+) diff --git a/src/cm.rs b/src/cm.rs index 4e954918..d713db21 100644 --- a/src/cm.rs +++ b/src/cm.rs @@ -797,6 +797,15 @@ impl<'a, 'o, 'c, 'w> CommonMarkFormatter<'a, 'o, 'c, 'w> { if let Some(leading) = leading { write!(self, "&#{};", leading as u32)?; + + if leading.len_utf8() == literal.len() { + // Deleterious case: the emphasis contains a text node which + // is a single whitespace character. Then,leading and trailing + // are actually the same (only) character, and the string slice + // below will fail. + // We can just stop! + return Ok(()); + } } self.output( diff --git a/src/tests/commonmark.rs b/src/tests/commonmark.rs index 00d5236b..380f44c5 100644 --- a/src/tests/commonmark.rs +++ b/src/tests/commonmark.rs @@ -191,6 +191,7 @@ fn ol_marker_wonk() { #[test_case("* Hello *")] #[test_case("* Hello *")] #[test_case("~~Hello ~~")] +#[test_case("_ _")] fn entity_roundtrips_fooled_by_whitespace(markdown: &str) { let arena = Arena::new(); let mut options = Options::default();