From 3aee67fd95e5a5613c8f515bd28935ef4763433f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 20 Sep 2026 20:52:45 +0800 Subject: [PATCH] fix(html2md): preserve code boundaries across empty inline events --- moli-html2md/src/writer.rs | 31 +++--- moli-html2md/tests/conversion.rs | 23 +++++ moli-html2md/tests/regressions.rs | 98 ++++++++++++++++++- moli-html2md/tests/turndown/expectations.json | 4 +- 4 files changed, 137 insertions(+), 19 deletions(-) diff --git a/moli-html2md/src/writer.rs b/moli-html2md/src/writer.rs index 325da4bf4a..7417fdf72c 100644 --- a/moli-html2md/src/writer.rs +++ b/moli-html2md/src/writer.rs @@ -33,6 +33,9 @@ pub(crate) struct Writer<'a> { preserved_spaces: String, breaks: usize, code: Option, + // End of the last emitted Markdown code span in the local output. Only + // actual output after this position separates it from another code span. + markdown_code_end: Option, line_digits: Option, heading: bool, single_line_attributes: bool, @@ -176,22 +179,10 @@ impl<'a> Writer<'a> { if text.is_empty() { return; } - // Adjacent code elements have separate HTML nodes but no text between - // them. Markdown code spans would merge or need an invented space. - if self.code.is_some() { - self.flush_code_html(); - } + self.flush_code(); if preformatted { - if !text.is_empty() { - if self.code.is_none() { - self.prepare_inline('`'); - self.code = Some(String::new()); - } - self.code - .as_mut() - .expect("initialized code buffer") - .push_str(&text.replace("\r\n", " ").replace(['\n', '\r'], " ")); - } + self.prepare_inline('`'); + self.code = Some(text.replace("\r\n", " ").replace(['\n', '\r'], " ")); return; } let content = text.trim_matches(char::is_whitespace); @@ -265,6 +256,7 @@ impl<'a> Writer<'a> { self.flush_breaks(); self.prefix .push_text(std::mem::take(&mut self.output).into()); + self.markdown_code_end = None; self.prefix.append(output); self.line_digits = None; self.boundary(after); @@ -439,6 +431,13 @@ impl<'a> Writer<'a> { } fn flush_code(&mut self) { + // Empty text/style events can flush code without adding a separator. + // Check the emitted tail after spaces and style markers have been + // materialized, so adjacent backtick runs cannot merge code values. + if self.markdown_code_end == Some(self.output.len()) { + self.flush_code_html(); + return; + } if let Some(code) = self.code.take() { let fence = "`".repeat(longest_run(&code, '`') + 1); let padding = code.starts_with('`') @@ -453,6 +452,7 @@ impl<'a> Writer<'a> { self.output.push(' '); } self.output.push_str(&fence); + self.markdown_code_end = Some(self.output.len()); self.line_digits = None; } } @@ -476,6 +476,7 @@ impl<'a> Writer<'a> { } } self.output.push_str(""); + self.markdown_code_end = None; self.line_digits = None; } } diff --git a/moli-html2md/tests/conversion.rs b/moli-html2md/tests/conversion.rs index ad98e53ba2..4453a93c55 100644 --- a/moli-html2md/tests/conversion.rs +++ b/moli-html2md/tests/conversion.rs @@ -160,6 +160,29 @@ fn keeps_adjacent_code_elements_distinct_and_chooses_safe_delimiters() { ); } +#[test] +fn empty_text_nodes_do_not_separate_code_values() { + let mut dom = Tree::new(); + dom.leaf(0, "code", "name"); + dom.text(0, ""); + dom.leaf(0, "code", "string"); + dom.text(0, ""); + dom.text(0, ""); + dom.leaf(0, "code", "third"); + for preformatted_code in [false, true] { + let result = Converter::new(Options { + preformatted_code, + ..Options::default() + }) + .convert(&dom, 0); + assert_eq!( + rendered_html(&result), + "

namestringthird

\n", + "preformatted={preformatted_code}: {result}" + ); + } +} + #[test] fn preserves_preformatted_text_including_blank_lines_and_nested_elements() { let mut dom = Tree::new(); diff --git a/moli-html2md/tests/regressions.rs b/moli-html2md/tests/regressions.rs index 8fbf1a0578..f5e12486a8 100644 --- a/moli-html2md/tests/regressions.rs +++ b/moli-html2md/tests/regressions.rs @@ -158,8 +158,16 @@ fn adjacent_code_elements_keep_distinct_values() { } #[test] -fn empty_code_between_values_does_not_join_or_invent_code_text() { - for empty in ["", ""] { +fn empty_inline_elements_between_code_do_not_join_or_invent_text() { + for empty in [ + "", + "", + "", + "", + "", + "", + "", + ] { let source = format!("name{empty}string"); for preformatted in [false, true] { let result = markdown(&source, preformatted); @@ -172,6 +180,82 @@ fn empty_code_between_values_does_not_join_or_invent_code_text() { } } +#[test] +fn code_remains_distinct_when_surrounding_styles_coalesce() { + for tag in ["em", "strong", "del"] { + let source = format!("<{tag}>name<{tag}>string"); + for preformatted in [false, true] { + let result = markdown(&source, preformatted); + assert_eq!( + rendered_html(&result), + format!("

<{tag}>namestring

\n"), + "{source}, preformatted={preformatted}: {result}" + ); + } + } +} + +#[test] +fn actual_output_separates_markdown_code_spans() { + for (source, expected) in [ + ( + "name string", + "`name` `string`", + ), + ( + "name string", + "`name`\u{a0}`string`", + ), + ( + "nameandstring", + "`name`*and*`string`", + ), + ( + "namestring", + "*`name`*`string`", + ), + ( + "namestring", + "`name`*`string`*", + ), + ( + "namestring", + "`name`[](/a)`string`", + ), + ( + "namestring", + "`name`![](/i)`string`", + ), + ( + "name
string", + "`name` \n`string`", + ), + ( + "name

string", + "`name`\n\n`string`", + ), + ( + "a
b
xc", + "`a`\n\n> `b`\n\nx`c`", + ), + ] { + for preformatted in [false, true] { + assert_eq!( + markdown(source, preformatted), + expected, + "{source}, preformatted={preformatted}" + ); + } + } + for source in [ + "name string", + "name string", + "name string", + ] { + assert_eq!(markdown(source, false), "`name` `string`", "{source}"); + } +} + #[test] fn neighboring_code_values_keep_their_text_and_node_boundaries() { for (source, preformatted, expected) in [ @@ -195,6 +279,16 @@ fn neighboring_code_values_keep_their_text_and_node_boundaries() { true, "

firstsecond

\n", ), + ( + "first*[x]|<&>`thirdfourth", + false, + "

first*[x]|<&>`thirdfourth

\n", + ), + ( + "word a b ", + true, + "

word a b

\n", + ), ] { let result = markdown(source, preformatted); assert_eq!(rendered_html(&result), expected, "{source}: {result}"); diff --git a/moli-html2md/tests/turndown/expectations.json b/moli-html2md/tests/turndown/expectations.json index 451003d61c..9a9593aad9 100644 --- a/moli-html2md/tests/turndown/expectations.json +++ b/moli-html2md/tests/turndown/expectations.json @@ -4,8 +4,8 @@ "reason": "Moli fences bare pre elements as well as pre/code, preserving preformatted text. Turndown only recognizes a pre/code pair." }, "extra_adjacent_code": { - "markdown": "a`b`", - "reason": "Adjacent code elements retain their semantic boundary without an invented space. Inline HTML preserves the first boundary because adjacent Markdown code spans would merge. Turndown joins their delimiters into one code span whose text contains two extra backticks." + "markdown": "`a`b", + "reason": "Adjacent code elements retain their semantic boundary without an invented space. Inline HTML preserves the second code element when it immediately follows an emitted Markdown code span. Turndown joins their delimiters into one code span whose text contains two extra backticks." }, "extra_code_nested_format": { "markdown": "`ab`",