diff --git a/src/render/markdown/escape.rs b/src/render/markdown/escape.rs index 5bd9a82..cacf820 100644 --- a/src/render/markdown/escape.rs +++ b/src/render/markdown/escape.rs @@ -23,6 +23,11 @@ pub(crate) struct EscapeOpts { /// The character following the run is unknown or active markup; pairable /// delimiters must assume the worst. pub trailing_active: bool, + /// More of the same paragraph follows this run. A delimiter is left + /// unescaped when nothing in the run pairs with it, but Markdown pairs + /// across the whole paragraph, not one run: the partner can arrive in a + /// later run, with a line break or an anchor in between. + pub pairs_ahead: bool, /// Inside a link label / image alt, where an unmatched `]` (or `[`) /// would terminate the label early. pub in_label: bool, @@ -30,7 +35,7 @@ pub(crate) struct EscapeOpts { /// Escape Markdown syntax in document text. pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> String { - let EscapeOpts { at_line_start, styled, trailing_active, in_label } = opts; + let EscapeOpts { at_line_start, styled, trailing_active, pairs_ahead, in_label } = opts; let chars: Vec = text.chars().collect(); // Last position of each pairable delimiter; a lone one is inert. let mut last: [Option; 5] = [None; 5]; // * _ ~ ` ] @@ -64,7 +69,8 @@ pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> S let next = chars.get(i + 1).copied(); // At the run's end the next character is unknown; trailing_active assumes the worst. let next_nonspace = next.map_or(trailing_active, |n| !n.is_whitespace()); - let paired = |slot: usize| trailing_active || last[slot].is_some_and(|j| j > i); + let paired = + |slot: usize| trailing_active || pairs_ahead || last[slot].is_some_and(|j| j > i); let escape = match c { '\\' => true, ']' if in_label => true, diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index 6c66951..58d1f96 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -79,13 +79,23 @@ pub(crate) fn normalize<'a>(inlines: &'a [Inline], rc: &Ctx) -> Vec> { } pub(crate) fn render_inlines(inlines: &[Inline], ctx: InlineContext, rc: &Ctx) -> String { - render_inlines_mode(inlines, ctx, false, rc) + render_inlines_mode(inlines, ctx, false, false, rc) } -fn render_inlines_mode(inlines: &[Inline], ctx: InlineContext, in_label: bool, rc: &Ctx) -> String { +/// `more_follows` marks a nested render whose output lands mid-paragraph (a +/// link label, or link content that degraded to plain text), so its last run +/// still has content after it even though its own run list has ended. +fn render_inlines_mode( + inlines: &[Inline], + ctx: InlineContext, + in_label: bool, + more_follows: bool, + rc: &Ctx, +) -> String { let runs = normalize(inlines, rc); let mut out = String::new(); for (idx, run) in runs.iter().enumerate() { + let pairs_ahead = more_follows || idx + 1 < runs.len(); match run { Norm::Text { text, style } => { let next_active = matches!( @@ -95,15 +105,19 @@ fn render_inlines_mode(inlines: &[Inline], ctx: InlineContext, in_label: bool, r runs.get(idx + 1), Some(Norm::Text { style, .. }) if *style != Style::PLAIN ); - render_text_run(text, *style, ctx, next_active, in_label, &mut out) + render_text_run(text, *style, ctx, next_active, pairs_ahead, in_label, &mut out) } Norm::NoteRef(id) => { if let Some(num) = rc.nums.get(*id) { let _ = write!(out, "[^{num}]"); } } - Norm::Link { content, target } => render_link(content, target, ctx, rc, &mut out), - Norm::Image { alt, source } => render_image(alt, source, ctx, in_label, &mut out), + Norm::Link { content, target } => { + render_link(content, target, ctx, pairs_ahead, rc, &mut out) + } + Norm::Image { alt, source } => { + render_image(alt, source, ctx, in_label, pairs_ahead, &mut out) + } Norm::Anchor(id) => { if let Some(html_id) = rc.anchors.html_id(id) { let _ = write!(out, ""); @@ -123,10 +137,13 @@ fn render_link( content: &[Inline], target: &LinkTarget, ctx: InlineContext, + pairs_ahead: bool, rc: &Ctx, out: &mut String, ) { - let label = render_inlines_mode(content, ctx, true, rc); + // The label is always followed by `](url)`, so its runs never end the + // paragraph. + let label = render_inlines_mode(content, ctx, true, true, rc); let url = match target { LinkTarget::External(url) | LinkTarget::Relative(url) => url.clone(), LinkTarget::Anchor(id) => match rc.anchors.fragment(id) { @@ -134,7 +151,7 @@ fn render_link( None => { // Target exists nowhere in the document: degrade to plain text. log::debug!("unresolved internal link target: {id}"); - out.push_str(&render_inlines_mode(content, ctx, false, rc)); + out.push_str(&render_inlines_mode(content, ctx, false, pairs_ahead, rc)); return; } }, @@ -156,12 +173,17 @@ fn render_image( source: &ImageSource, ctx: InlineContext, in_label: bool, + pairs_ahead: bool, out: &mut String, ) { match source { ImageSource::External(url) => { - let alt = - escape_text(alt.trim(), ctx, EscapeOpts { in_label: true, ..Default::default() }); + // The alt text is always followed by `](url)`. + let alt = escape_text( + alt.trim(), + ctx, + EscapeOpts { in_label: true, pairs_ahead: true, ..Default::default() }, + ); let _ = write!(out, "![{}]({})", alt, format_url(url)); } // Embedded assets render as their alt text: Markdown cannot embed @@ -172,7 +194,7 @@ fn render_image( out.push_str(&escape_text( alt.trim(), ctx, - EscapeOpts { in_label, ..Default::default() }, + EscapeOpts { in_label, pairs_ahead, ..Default::default() }, )); } } @@ -185,6 +207,7 @@ fn render_text_run( style: Style, ctx: InlineContext, trailing_active: bool, + pairs_ahead: bool, in_label: bool, out: &mut String, ) { @@ -193,7 +216,13 @@ fn render_text_run( out.push_str(&escape_text( text, ctx, - EscapeOpts { at_line_start, trailing_active, in_label, ..Default::default() }, + EscapeOpts { + at_line_start, + trailing_active, + pairs_ahead, + in_label, + ..Default::default() + }, )); return; } diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index 463bbfa..e288550 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -337,6 +337,57 @@ fn hard_break() { assert_eq!(md, "line one\\\nline two\n"); } +#[test] +fn delimiters_cannot_pair_across_a_hard_break() { + // A delimiter is inert only while nothing pairs with it. Each run here + // holds one, and a hard break does not end the paragraph, so the pair + // would form a code span, emphasis, or a link out of document text. + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("a `"), + Inline::LineBreak, + Inline::plain("b `"), + ])]); + assert_eq!(md, "a \\`\\\nb `\n"); + + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("a *x"), + Inline::LineBreak, + Inline::plain("y* b"), + ])]); + assert_eq!(md, "a \\*x\\\ny* b\n"); + + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("see ["), + Inline::LineBreak, + Inline::plain("note](http://example.com)"), + ])]); + assert_eq!(md, "see \\[\\\nnote](http://example.com)\n"); +} + +#[test] +fn a_pasted_code_fence_stays_literal() { + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("```"), + Inline::LineBreak, + Inline::plain("let x = 1;"), + Inline::LineBreak, + Inline::plain("```"), + ])]); + assert_eq!(md, "\\`\\`\\`\\\nlet x = 1;\\\n\\`\\``\n"); +} + +#[test] +fn a_delimiter_with_nothing_after_it_stays_bare() { + // The paragraph's last run has no partner ahead of it, so minimal + // escaping still applies. + let md = doc(vec![Block::Paragraph(vec![ + Inline::plain("intro"), + Inline::LineBreak, + Inline::plain("only one `"), + ])]); + assert_eq!(md, "intro\\\nonly one `\n"); +} + #[test] fn line_start_escape_after_hard_break() { let md = doc(vec![Block::Paragraph(vec![