From 81833cfc334790d352b3d7adeecb2ed00ace266c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:43:16 +0100 Subject: [PATCH] Keep Markdown emphasis markers flanking next to punctuation (#4801) Found while testing crates/formats/src/markdown/export.rs:167-182 (inlines_md_impl, emphasis marker emission) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/formats/src/markdown/export.rs | 40 +++++++++++----- crates/formats/src/markdown/inlines.rs | 2 +- crates/formats/src/markdown/tests.rs | 63 ++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index d4f21533c..c1dbe5267 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -1,5 +1,5 @@ use super::blocks::thematic_break; -use super::inlines::within; +use super::inlines::{is_punct, within}; use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, Inline, Kind, model}; // --------------------------------------------------------------------------------------------- @@ -166,30 +166,43 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { } let want: Vec = [(f.bold, Mark::Bold), (f.italic, Mark::Italic), (f.strike, Mark::Strike)].iter().filter(|x| x.0).map(|x| x.1).collect(); + let body = if f.code { + if in_table { code_span(&text.replace('|', "\\|")) } else { code_span(&text) } + } else { + escape(&text).replace('\n', "\\\n").replace('\t', " ") + }; // Close until the stack is a prefix of what we want. + let mut close = String::new(); while let Some(pos) = stack.iter().position(|m| !want.contains(m)) { while stack.len() > pos { if let Some(m) = stack.pop() { - out.push_str(marker(m)); + close.push_str(marker(m)); } } } + let mut open = String::new(); for m in want { if !stack.contains(&m) { - out.push_str(marker(m)); + open.push_str(marker(m)); stack.push(m); } } - if f.code { - if in_table { - let t = text.replace('|', "\\|"); - out.push_str(&code_span(&t)); - } else { - out.push_str(&code_span(&text)); - } - } else { - out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " ")); + // CommonMark only reads a closing run after punctuation as closing when whitespace or + // punctuation follows it, and an opening run before punctuation as opening when whitespace + // or punctuation precedes it. Where a letter sits there instead, or an opening run would + // directly follow a closing run (the two would merge into one run), an empty HTML comment (which readers drop) supplies + // the punctuation. + let prev = out.chars().next_back(); + let next = body.chars().next(); + let word = |c: Option| c.is_some_and(|c| !c.is_whitespace() && !is_punct(c)); + out.push_str(&close); + let close_needs = !close.is_empty() && prev.is_some_and(is_punct) && (!open.is_empty() || word(next)); + let open_needs = !open.is_empty() && next.is_some_and(is_punct) && (!close.is_empty() || word(prev)); + if close_needs || open_needs { + out.push_str(EMPTY_COMMENT); } + out.push_str(&open); + out.push_str(&body); } close_all(&mut out, &mut stack); if let Some(l) = &link { @@ -229,6 +242,9 @@ fn entity_like(rest: &str) -> bool { !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric()) } +/// Inline HTML that Markdown readers drop, used to separate emphasis markers from letters. +const EMPTY_COMMENT: &str = ""; + fn marker(m: Mark) -> &'static str { match m { Mark::Bold => "**", diff --git a/crates/formats/src/markdown/inlines.rs b/crates/formats/src/markdown/inlines.rs index 8cc6b376e..99c397246 100644 --- a/crates/formats/src/markdown/inlines.rs +++ b/crates/formats/src/markdown/inlines.rs @@ -16,7 +16,7 @@ enum Node { Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec, closes: Vec }, } -fn is_punct(c: char) -> bool { +pub(super) fn is_punct(c: char) -> bool { c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control()) } diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index 11d591258..32767eeee 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -201,6 +201,69 @@ fn export_escapes_round_trip() { assert!(q.list.is_none()); } +#[test] +fn export_emphasis_next_to_punctuation_round_trips() { + let marks: [(&str, fn(&mut Fmt)); 3] = [("bold", |f| f.bold = true), ("italic", |f| f.italic = true), ("strike", |f| f.strike = true)]; + let cases: [&[(&str, bool)]; 4] = [ + &[("注意:", true), ("请阅读", false)], + &[("a.", true), ("b", false)], + &[("a", false), ("!", true), ("b", false)], + &[("a", false), ("(x)", true), ("b", false)], + ]; + for (name, set) in marks { + for runs in cases { + let mut p = Para::default(); + for (t, on) in runs { + let mut f = Fmt::default(); + if *on { + set(&mut f); + } + p.push_text(t, &f); + } + let md = inlines_md(&p.inlines); + let back = parse(&md); + let FBlock::Para(q) = &back.blocks[0] else { panic!() }; + let want: String = runs.iter().map(|r| r.0).collect(); + assert_eq!(q.text(), want, "{name} exported as {md:?}"); + let got: Vec<(String, bool)> = q + .inlines + .iter() + .filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.bold || f.italic || f.strike)) } else { None }) + .collect(); + let expect: Vec<(String, bool)> = runs.iter().map(|(t, on)| (t.to_string(), *on)).collect(); + assert_eq!(got, expect, "{name} exported as {md:?}"); + } + } +} + +#[test] +fn export_adjacent_emphasis_next_to_punctuation_round_trips() { + let b = Fmt { bold: true, ..Fmt::default() }; + let i = Fmt { italic: true, ..Fmt::default() }; + let s = Fmt { strike: true, ..Fmt::default() }; + let cases: [&[(&str, &Fmt)]; 6] = [ + &[("ab", &b), ("!c", &i)], + &[("ab", &i), ("!c", &b)], + &[("ab", &b), ("!c", &s)], + &[("ab", &s), ("(c)", &b)], + &[("a.", &b), ("b", &i)], + &[("a.", &b), ("!b", &i)], + ]; + for runs in cases { + let mut p = Para::default(); + for (t, f) in runs { + p.push_text(t, f); + } + let md = inlines_md(&p.inlines); + let back = parse(&md); + let FBlock::Para(q) = &back.blocks[0] else { panic!() }; + let got: Vec<(String, bool, bool, bool)> = + q.inlines.iter().filter_map(|x| if let Inline::Text(t, f) = x { Some((t.clone(), f.bold, f.italic, f.strike)) } else { None }).collect(); + let expect: Vec<(String, bool, bool, bool)> = runs.iter().map(|(t, f)| (t.to_string(), f.bold, f.italic, f.strike)).collect(); + assert_eq!(got, expect, "exported as {md:?}"); + } +} + #[test] fn escape_line_start_preserves_indentation() { assert_eq!(escape_line_start(" indented text".to_string()), " indented text");