mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 05:47:00 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
48545479b0
commit
81833cfc33
@@ -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 => "**",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user