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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
NetterSilver
2026-10-29 23:43:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 48545479b0
commit 81833cfc33
3 changed files with 92 additions and 13 deletions
+28 -12
View File
@@ -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<Mark> =
[(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<char>| 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 => "**",
+1 -1
View File
@@ -16,7 +16,7 @@ enum Node {
Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec<Emph>, closes: Vec<Emph> },
}
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())
}
+63
View File
@@ -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");