Skip to main content

quillmark_content/
export.rs

1//! Markdown export: content → markdown, per island loss class.
2//!
3//! The projection back to markdown. Marks become syntax; islands are emitted per
4//! their [`Loss`] class; identity ([`MarkKind::Anchor`]) marks are **omitted** —
5//! they survive across edits via diff-rebase, not the projection (issue #831
6//! § Codecs). Anchors carry no markdown encoding, so dropping them here is by
7//! design, not loss.
8//!
9//! The contract this crate pins is the **content fixed point**: for a content `rt`
10//! obtained from [`crate::import::from_markdown`],
11//! `from_markdown(to_markdown(rt)) == rt` modulo island loss class — markdown
12//! source is not canonical, but the content is, so round-trip is defined at the
13//! content, not the string.
14//!
15//! ## Documented codec limits (degenerate, non-authorable content values)
16//!
17//! The fixed point holds for the content a well-behaved producer emits. Two
18//! degenerate shapes markdown cannot represent do **not** round-trip, and are
19//! recorded here rather than hidden (see `tests::known_hard_break_limits`):
20//!
21//! - **A mark spanning a hard break** — per-line rendering splits it into two
22//!   per-line marks (they do not re-union across the `\n`).
23//! - **An empty first line in a hard-break block** — markdown has no
24//!   blank-then-forced-break syntax, so the leading empty line is dropped.
25//!
26//! Both arise only from adversarial delimiter/break placement, never from clean
27//! markdown or a form editor. Neither is hardened — no live editor yet defines
28//! what it can produce.
29
30use crate::island::KnownIslandType;
31use crate::model::{Container, Island, LineKind, MarkKind, Content, ISLAND_SLOT};
32
33/// Render a content to markdown. Lossless/degraded islands emit their markdown;
34/// unrepresentable islands emit a placeholder comment.
35pub fn to_markdown(rt: &Content) -> String {
36    // Per-line char ranges, so global marks can be clipped to a line.
37    let segments = line_segments(rt);
38    let ctx = Ctx {
39        rt,
40        segments: &segments,
41    };
42    let mut out = String::new();
43    emit_block(&ctx, 0..rt.lines.len(), 0, &mut out);
44    // Collapse any trailing blank lines. `to_markdown` projects a *value*, not a
45    // file: it emits no final newline, so `writer.set("subject", "Hello")` reads
46    // back as `"Hello"`, not `"Hello\n"` (the read-back-grows-a-newline footgun,
47    // issue #965). Document-file writers own the file-final newline
48    // (`Document::to_markdown`); the content fixed point is defined at the content,
49    // and import is newline-insensitive, so dropping it is round-trip-invisible.
50    while out.ends_with('\n') {
51        out.pop();
52    }
53    out
54}
55
56/// Render a content to plaintext: [`Content::text`] with island slots
57/// ([`ISLAND_SLOT`]) removed. The lossy sibling of [`to_markdown`] — it drops
58/// every mark and island (tables, images have no plaintext projection), keeping
59/// only literal text. Callers that want a non-empty result should check for the
60/// empty string themselves.
61///
62/// Tables (and images) having no plaintext form is a **decided limitation**, not
63/// an oversight (issue #880): the pdfform backend fills a form field from this
64/// projection, so a field bound to a table-bearing content renders the surrounding
65/// text and silently omits the table. A degraded row/tab dump was rejected — it
66/// would read as a faithful table and mislead — so the projection drops the
67/// island outright. Revisit only if a form field ever needs tabular fill.
68pub fn to_plaintext(rt: &Content) -> String {
69    rt.text.chars().filter(|&c| c != ISLAND_SLOT).collect()
70}
71
72struct Ctx<'a> {
73    rt: &'a Content,
74    segments: &'a [Segment],
75}
76
77/// One line's char range `[start, end)` into the content, with the matching byte
78/// range and the count of island slots before the line — so a caller indexes
79/// the content text and the island list in O(1) without rescanning.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct Segment {
82    /// USV index of the line's first char.
83    pub start: usize,
84    /// USV index one past the line's last char (its `\n`, or the content end).
85    pub end: usize,
86    /// Byte offset of `start` in the content text.
87    pub byte_start: usize,
88    /// Byte offset of `end` in the content text.
89    pub byte_end: usize,
90    /// Count of [`ISLAND_SLOT`] chars before `start`.
91    pub slots_before: usize,
92}
93
94/// Per-line char/byte ranges and slot prefixes over a content, in line order.
95pub fn line_segments(rt: &Content) -> Vec<Segment> {
96    let mut segs = Vec::with_capacity(rt.lines.len());
97    let mut start = 0usize;
98    let mut byte_start = 0usize;
99    let mut slots_before = 0usize;
100    let mut line_slots = 0usize;
101    let mut pos = 0usize;
102    for (b, c) in rt.text.char_indices() {
103        if c == '\n' {
104            segs.push(Segment {
105                start,
106                end: pos,
107                byte_start,
108                byte_end: b,
109                slots_before,
110            });
111            start = pos + 1;
112            byte_start = b + 1; // `\n` is one byte
113            slots_before += line_slots;
114            line_slots = 0;
115        } else if c == ISLAND_SLOT {
116            line_slots += 1;
117        }
118        pos += 1;
119    }
120    segs.push(Segment {
121        start,
122        end: pos,
123        byte_start,
124        byte_end: rt.text.len(),
125        slots_before,
126    });
127    // Defensive: a malformed content (lines.len() != segments) still gets one
128    // segment per line so indexing never panics.
129    let total_slots = slots_before + line_slots;
130    while segs.len() < rt.lines.len() {
131        segs.push(Segment {
132            start: pos,
133            end: pos,
134            byte_start: rt.text.len(),
135            byte_end: rt.text.len(),
136            slots_before: total_slots,
137        });
138    }
139    segs
140}
141
142/// Emit the lines in `range`, all sharing the container prefix of length
143/// `depth`. Leaf lines (containers.len() == depth) render at this level;
144/// deeper lines are grouped by their `depth`-th container and recursed into.
145fn emit_block(ctx: &Ctx, range: std::ops::Range<usize>, depth: usize, out: &mut String) {
146    let lines = &ctx.rt.lines;
147    let mut i = range.start;
148    let mut first_block = true;
149    while i < range.end {
150        let line = &lines[i];
151        if line.containers.len() > depth {
152            // A nested container starts here; gather its run and recurse.
153            let key = &line.containers[depth];
154            let mut j = i + 1;
155            while j < range.end
156                && lines[j].containers.len() > depth
157                && &lines[j].containers[depth] == key
158            {
159                j += 1;
160            }
161            block_separator(out, first_block);
162            emit_container(ctx, key, i..j, depth, out);
163            first_block = false;
164            i = j;
165        } else {
166            // A leaf block: this line (continues == false) plus every following
167            // line that continues it (a hard-break run, or a code fence's lines).
168            let mut j = i + 1;
169            while j < range.end && lines[j].containers.len() == depth && lines[j].continues {
170                j += 1;
171            }
172            block_separator(out, first_block);
173            emit_leaf_block(ctx, i..j, out);
174            first_block = false;
175            i = j;
176        }
177    }
178}
179
180fn block_separator(out: &mut String, first_block: bool) {
181    if !first_block {
182        if !out.ends_with('\n') {
183            out.push('\n');
184        }
185        out.push('\n');
186    }
187}
188
189/// Emit a container run (a list item's block, or a quote's block) by prefixing
190/// each produced line. The inner blocks are emitted into a scratch buffer, then
191/// each of its lines is prefixed.
192fn emit_container(
193    ctx: &Ctx,
194    key: &Container,
195    range: std::ops::Range<usize>,
196    depth: usize,
197    out: &mut String,
198) {
199    let mut inner = String::new();
200    emit_block(ctx, range, depth + 1, &mut inner);
201
202    match key {
203        Container::ListItem {
204            ordered,
205            start,
206            ordinal,
207        } => {
208            let marker = if *ordered {
209                // `start`/`ordinal` are unbounded `u64` and `validate` does not
210                // ceiling them, so a corrupt/adversarial content can drive the
211                // sum past `u64::MAX`; saturate rather than panic (or wrap under
212                // release overflow-checks) on the render path.
213                format!("{}. ", start.saturating_add(*ordinal))
214            } else {
215                "- ".to_string()
216            };
217            let indent = " ".repeat(marker.len());
218            prefix_lines(&inner, &marker, &indent, out);
219        }
220        Container::Quote => {
221            // `> ` on content lines, `>` on blank lines so paragraphs stay in
222            // one quote on re-import.
223            prefix_quote(&inner, out);
224        }
225    }
226}
227
228/// Prefix the first produced line with `first`, the rest with `cont`.
229fn prefix_lines(inner: &str, first: &str, cont: &str, out: &mut String) {
230    for (idx, line) in inner.split('\n').enumerate() {
231        if idx == 0 {
232            out.push_str(first);
233            out.push_str(line);
234        } else {
235            out.push('\n');
236            if line.is_empty() {
237                // blank continuation line: no trailing indent
238            } else {
239                out.push_str(cont);
240                out.push_str(line);
241            }
242        }
243    }
244}
245
246fn prefix_quote(inner: &str, out: &mut String) {
247    for (idx, line) in inner.split('\n').enumerate() {
248        if idx > 0 {
249            out.push('\n');
250        }
251        if line.is_empty() {
252            out.push('>');
253        } else {
254            out.push_str("> ");
255            out.push_str(line);
256        }
257    }
258}
259
260fn emit_code(ctx: &Ctx, range: std::ops::Range<usize>, lang: Option<&str>, out: &mut String) {
261    // Choose a fence long enough to not collide with backtick runs in content.
262    let mut max_ticks = 0usize;
263    for i in range.clone() {
264        max_ticks = max_ticks.max(longest_backtick_run(seg_str(ctx, i)));
265    }
266    let fence = "`".repeat(max_ticks.max(2) + 1);
267    out.push_str(&fence);
268    if let Some(l) = lang {
269        out.push_str(l);
270    }
271    out.push('\n');
272    for i in range {
273        out.push_str(seg_str(ctx, i));
274        out.push('\n');
275    }
276    out.push_str(&fence);
277}
278
279/// Emit one leaf block: the lines `range.start` (a block start) plus any
280/// continuation lines. A paragraph block joins its lines with a markdown hard
281/// break (`\` + newline); a code block renders one fence; a heading/island is a
282/// single line.
283fn emit_leaf_block(ctx: &Ctx, range: std::ops::Range<usize>, out: &mut String) {
284    let first = &ctx.rt.lines[range.start];
285    match &first.kind {
286        LineKind::Code { lang } => emit_code(ctx, range, lang.as_deref(), out),
287        LineKind::Island => {
288            // A block island: the segment is a single slot. Resolve and emit.
289            if let Some(isl) = slot_island(ctx, range.start) {
290                emit_island(isl, out);
291            }
292        }
293        LineKind::Heading { level } => {
294            for _ in 0..*level {
295                out.push('#');
296            }
297            out.push(' ');
298            // Headings never carry continuations (import maps a hard break in a
299            // heading to a space), so only the first line contributes.
300            let mut inline = render_inline(ctx, range.start, false);
301            // A trailing `#` run reads as an ATX closing sequence on re-import
302            // (`# a #` → heading text "a", dropping the `#`). Escape the last `#`
303            // so the run no longer reaches end-of-line as a bare hash sequence —
304            // one escaped hash defeats the whole closer, and `\#` re-imports as a
305            // literal `#`.
306            if inline.ends_with('#') {
307                inline.pop();
308                inline.push_str("\\#");
309            }
310            out.push_str(&inline);
311        }
312        LineKind::Para => {
313            // Join continuation lines with a backslash hard break.
314            let parts: Vec<String> = range.map(|i| render_inline(ctx, i, true)).collect();
315            out.push_str(&parts.join("\\\n"));
316        }
317        LineKind::Rule => out.push_str("---"),
318    }
319}
320
321fn seg_str<'a>(ctx: &'a Ctx, i: usize) -> &'a str {
322    let seg = &ctx.segments[i];
323    &ctx.rt.text[seg.byte_start..seg.byte_end]
324}
325
326/// The island backing the single slot on a block-island line `i`.
327fn slot_island<'a>(ctx: &'a Ctx, i: usize) -> Option<&'a Island> {
328    ctx.rt.islands.get(ctx.segments[i].slots_before)
329}
330
331fn emit_island(isl: &Island, out: &mut String) {
332    match KnownIslandType::parse(&isl.island_type) {
333        Some(KnownIslandType::Table) => emit_table(isl, out),
334        Some(KnownIslandType::Image) => emit_image(isl, out),
335        None => {
336            // Unknown island (the open set): a comment placeholder that survives
337            // round-trip as no content text (HTML comments are stripped on
338            // re-import). The island is preserved via storage, not the projection.
339            // A known type can't reach here — the match is exhaustive, so a new
340            // type is a compile error, not a silent placeholder.
341            out.push_str(&format!("<!-- island:{} -->", isl.island_type));
342        }
343    }
344}
345
346fn emit_table(isl: &Island, out: &mut String) {
347    let header = isl.props.get("header").and_then(|v| v.as_array());
348    let rows = isl.props.get("rows").and_then(|v| v.as_array());
349    let aligns = isl.props.get("aligns").and_then(|v| v.as_array());
350    let cols = header.map(|h| h.len()).unwrap_or(0);
351    if cols == 0 {
352        return;
353    }
354    // Cells are canonical `{text, marks}`; reconstruct each cell's markdown from
355    // its structure (the prose mark→syntax rendering, plus `|`→`\|`), so nothing
356    // re-parses markdown and `import(export(table))` is a fixed point.
357    // header row
358    out.push_str("| ");
359    if let Some(h) = header {
360        out.push_str(&h.iter().map(render_cell_md).collect::<Vec<_>>().join(" | "));
361    }
362    out.push_str(" |\n|");
363    for k in 0..cols {
364        let a = aligns
365            .and_then(|a| a.get(k))
366            .and_then(|v| v.as_str())
367            .unwrap_or("none");
368        out.push_str(match a {
369            "left" => " :--- |",
370            "center" => " :---: |",
371            "right" => " ---: |",
372            _ => " --- |",
373        });
374    }
375    if let Some(rs) = rows {
376        for row in rs {
377            if let Some(r) = row.as_array() {
378                // Pad/truncate to the header's column count so a ragged island
379                // (one that skipped normalization) still emits a rectangular
380                // table — the same count the Typst projection uses post-normalize.
381                let mut cells: Vec<String> = r.iter().map(render_cell_md).collect();
382                cells.resize(cols, String::new());
383                out.push_str("\n| ");
384                out.push_str(&cells.join(" | "));
385                out.push_str(" |");
386            }
387        }
388    }
389}
390
391fn emit_image(isl: &Island, out: &mut String) {
392    let url = isl.props.get("url").and_then(|v| v.as_str()).unwrap_or("");
393    let alt = isl.props.get("alt").and_then(|v| v.as_str()).unwrap_or("");
394    // Alt is inline content of `![…]`: escape it like a link's display text so a
395    // `]`/`\`/`&`/delimiter can't terminate the markup or decode on re-import.
396    // Url goes through `emit_url` (bare when safe, else angle-wrapped + escaped).
397    out.push_str("![");
398    out.push_str(&escape_run(&alt.chars().collect::<Vec<_>>(), false));
399    out.push_str("](");
400    emit_url(url, out);
401    out.push(')');
402}
403
404/// Emit a link/image destination that re-imports to `url` verbatim. A bare
405/// (unbracketed) destination round-trips only for a URL with no whitespace,
406/// control char, `<`, `>`, `\`, or `&`, and balanced parentheses — CommonMark's
407/// unbracketed form. Anything else is angle-wrapped, with `<`, `>`, `\`, and `&`
408/// backslash-escaped so the sequence re-parses to the exact URL: unescaped `<`/`>`
409/// are illegal even inside the wrap, and `\`/`&` would otherwise be consumed as an
410/// escape or entity reference on re-import (the same `&`-always-escaped rule
411/// [`escape_char_into`] applies to prose). Spaces and parentheses need no escape
412/// inside the wrap, so a spaced or unbalanced-paren URL wraps without further work.
413fn emit_url(url: &str, out: &mut String) {
414    if url_is_bare_safe(url) {
415        out.push_str(url);
416        return;
417    }
418    out.push('<');
419    for c in url.chars() {
420        if matches!(c, '<' | '>' | '\\' | '&') {
421            out.push('\\');
422        }
423        out.push(c);
424    }
425    out.push('>');
426}
427
428/// Whether `url` re-imports verbatim as a bare (unbracketed) link destination:
429/// no whitespace/control/`<`/`>`/`\`/`&`, and balanced parentheses. A `false`
430/// routes the URL through [`emit_url`]'s angle-wrapped, escaped form.
431fn url_is_bare_safe(url: &str) -> bool {
432    let mut depth: i32 = 0;
433    for c in url.chars() {
434        match c {
435            '(' => depth += 1,
436            ')' => {
437                depth -= 1;
438                if depth < 0 {
439                    return false;
440                }
441            }
442            '<' | '>' | '\\' | '&' => return false,
443            c if c.is_whitespace() || c.is_control() => return false,
444            _ => {}
445        }
446    }
447    depth == 0
448}
449
450// ---------------------------------------------------------------------------
451// Inline rendering: marks -> syntax, over one line's char range.
452// ---------------------------------------------------------------------------
453
454fn render_inline(ctx: &Ctx, i: usize, escape_leading_block: bool) -> String {
455    let seg = &ctx.segments[i];
456    let line_start = seg.start;
457    let text = seg_str(ctx, i);
458    let chars: Vec<char> = text.chars().collect();
459    let n = chars.len();
460
461    // Clip marks to this line, split into code (atomic) and formatting (nested).
462    let mut code_ranges: Vec<(usize, usize)> = Vec::new();
463    let mut fmt: Vec<(usize, usize, &MarkKind)> = Vec::new();
464    let mut links: Vec<(usize, usize, &str)> = Vec::new();
465    for m in &ctx.rt.marks {
466        let s = m.start.saturating_sub(line_start);
467        let e = m.end.saturating_sub(line_start);
468        if m.end <= line_start || m.start >= line_start + n {
469            continue; // outside this line
470        }
471        let s = s.min(n);
472        let e = e.min(n);
473        match &m.kind {
474            MarkKind::Anchor { .. } => {} // omitted from the projection
475            MarkKind::Code => code_ranges.push((s, e)),
476            MarkKind::Link { url } => links.push((s, e, url)),
477            _ => fmt.push((s, e, &m.kind)),
478        }
479    }
480
481    // Leading ordered-list marker: a line whose text starts `<digits>.` or
482    // `<digits>)` would re-import as an ordered list, so escape that punctuation.
483    let escape_punct_at = if escape_leading_block {
484        let lead_digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
485        if lead_digits > 0 && lead_digits < n && matches!(chars[lead_digits], '.' | ')') {
486            Some(lead_digits)
487        } else {
488            None
489        }
490    } else {
491        None
492    };
493
494    let slots_before_line = seg.slots_before;
495    render_marked_core(
496        &chars,
497        &code_ranges,
498        &fmt,
499        &links,
500        escape_punct_at,
501        escape_leading_block,
502        false, // prose text does not escape `|`
503        |pos_local| {
504            // Segment prefix + slots earlier on this line: the island index for
505            // the slot at `pos_local`, without rescanning the whole content.
506            let before = slots_before_line
507                + chars[..pos_local]
508                    .iter()
509                    .filter(|&&c| c == ISLAND_SLOT)
510                    .count();
511            ctx.rt.islands.get(before).map(|isl| {
512                let mut tmp = String::new();
513                emit_island(isl, &mut tmp);
514                tmp
515            })
516        },
517    )
518}
519
520/// Render marks over a standalone char slice to markdown: the projection's mark
521/// boundary sweep, shared by prose lines and table cells. `code_ranges`/`fmt`/
522/// `links` are the marks clipped to `chars` (local offsets); `escape_pipe` adds
523/// `|`→`\|` for cells; `island_markup_at` renders an island slot (prose) or
524/// yields `None` (cells carry no slot).
525///
526/// The model permits free (Peritext-style) overlap — an editor's `apply_mark_ops`
527/// can produce `strong[0,4)` + `strike[2,6)` — but markdown syntax nests. The
528/// sweep closes every mark ending at a boundary and reopens the deeper survivors,
529/// so a partial overlap lowers to balanced markdown (`**ab~~cd~~**~~ef~~`), which
530/// re-imports to the same content for marks with *distinct* delimiters. Two
531/// preconditions the sweep can't express in reopened markdown are clipped away
532/// first:
533///
534/// - **Atomic spans** (`code`/`link`) can't carry a partial wrap, and the sweep's
535///   cursor jumps their interior — a wrap edge hiding inside would be missed and
536///   left unbalanced. [`clip_fmt_to_atomic`] pulls such edges to the span's
537///   boundary (the #846 shape, in markdown).
538/// - **`*`/`**` (emph/strong) share a delimiter character**, so a reopened
539///   `*` abutting a `**` merges into an ambiguous `***` run that CommonMark
540///   re-segments wrong — this overlap is *unrepresentable*, so
541///   [`clip_asterisk_overlap`] nests the two by truncation (a documented codec
542///   limit: `strong`+`emph` overlap keeps the text but loses the crossing tail).
543#[allow(clippy::too_many_arguments)]
544fn render_marked_core(
545    chars: &[char],
546    code_ranges: &[(usize, usize)],
547    fmt: &[(usize, usize, &MarkKind)],
548    links: &[(usize, usize, &str)],
549    escape_punct_at: Option<usize>,
550    escape_leading_block: bool,
551    escape_pipe: bool,
552    island_markup_at: impl Fn(usize) -> Option<String>,
553) -> String {
554    let n = chars.len();
555
556    // Clip the wrapping marks so the sweep only ever sees a representable shape.
557    let mut fmt: Vec<(usize, usize, &MarkKind)> = fmt.to_vec();
558    let mut atomics: Vec<(usize, usize)> = code_ranges.to_vec();
559    atomics.extend(links.iter().map(|(s, e, _)| (*s, *e)));
560    clip_fmt_to_atomic(&mut fmt, &atomics);
561    clip_asterisk_overlap(&mut fmt);
562
563    // One mark sweep over `fmt` → inline markdown. Free (Peritext) overlap is
564    // lowered to nesting by closing every mark ending at `pos` and reopening the
565    // deeper survivors.
566    let sweep = |fmt: &[(usize, usize, &MarkKind)]| -> String {
567        let mut out = String::new();
568        // Indices into `fmt` for the marks currently open, outermost first.
569        // Storing the index (not `(end, kind)`) keeps each open mark's identity,
570        // so a reopened mark re-emits its OWN delimiter.
571        let mut stack: Vec<usize> = Vec::new();
572        let mut pos = 0usize;
573        while pos <= n {
574            if let Some(idx) = stack.iter().position(|&fi| fmt[fi].1 == pos) {
575                let mut reopen: Vec<usize> = Vec::new();
576                while stack.len() > idx {
577                    let fi = stack.pop().unwrap();
578                    out.push_str(&delim_close(fmt[fi].2));
579                    if fmt[fi].1 != pos {
580                        reopen.push(fi);
581                    }
582                }
583                for fi in reopen.into_iter().rev() {
584                    out.push_str(&delim_open(fmt[fi].2));
585                    stack.push(fi);
586                }
587            }
588            // Open formatting marks starting here, longest span (outer) first —
589            // BEFORE any atomic run, so a formatting mark that begins at the same
590            // position as inline code/link still wraps it (`**` + code →
591            // `**`code`…**`, not a dropped strong).
592            let mut opening: Vec<usize> = (0..fmt.len()).filter(|&fi| fmt[fi].0 == pos).collect();
593            opening.sort_by(|&a, &b| fmt[b].1.cmp(&fmt[a].1));
594            for fi in opening {
595                out.push_str(&delim_open(fmt[fi].2));
596                stack.push(fi);
597            }
598            // A link is emitted atomically as [text](url); its display text
599            // carries plain content (nested marks in link text are not supported).
600            if let Some(&(ls, le, url)) = links.iter().find(|(s, _, _)| *s == pos) {
601                out.push('[');
602                out.push_str(&escape_run(&chars[ls..le], escape_pipe));
603                out.push_str("](");
604                emit_url(url, &mut out);
605                out.push(')');
606                pos = le;
607                continue;
608            }
609            // A code range is atomic.
610            if let Some(&(cs, ce)) = code_ranges.iter().find(|(s, _)| *s == pos) {
611                let content: String = chars[cs..ce].iter().collect();
612                let ticks = longest_backtick_run(&content) + 1;
613                let fence = "`".repeat(ticks.max(1));
614                out.push_str(&fence);
615                out.push_str(&content);
616                out.push_str(&fence);
617                pos = ce;
618                continue;
619            }
620            if pos < n {
621                let c = chars[pos];
622                if c == ISLAND_SLOT {
623                    if let Some(markup) = island_markup_at(pos) {
624                        out.push_str(&markup);
625                    }
626                } else if Some(pos) == escape_punct_at {
627                    out.push('\\');
628                    out.push(c);
629                } else {
630                    escape_char_into(c, pos == 0 && escape_leading_block, escape_pipe, &mut out);
631                }
632            }
633            pos += 1;
634        }
635        // Drain any mark still open at end of sweep. Clipping keeps every wrap
636        // `end` reachable, so this normally drains nothing; the final close guard.
637        while let Some(fi) = stack.pop() {
638            out.push_str(&delim_close(fmt[fi].2));
639        }
640        out
641    };
642
643    // Verify-and-drop safety net. The clips above and the sweep round-trip every
644    // content `import` produces, but an editor's `apply_mark_ops` can build a mark
645    // over a span markdown can't represent — CommonMark's full emphasis algorithm
646    // (delimiter-run matching, the rule of 3, `\*`-escape adjacency) has corners
647    // no local rule captures — and it lowers to a `**`/`*`/`~~` run pulldown
648    // re-reads as literal text, leaking a delimiter into the content. Re-parse the
649    // rendered line and, if its plain text drifted, drop the last flanking mark
650    // and re-sweep until the text is preserved. Terminates: dropping only removes
651    // delimiters, and the mark-free render is always text-safe. Only marked lines
652    // pay the re-parse; a line markdown already round-trips passes on the first.
653    //
654    // The probe wraps the fragment in `,…,`: this is an *inline* fragment, but
655    // parsed standalone a leading `0. ` / `# ` / `> ` would read as a list/heading/
656    // quote marker (a false positive that would drop a good mark). A punctuation
657    // sentinel blocks every leading-block construct, preserves edge whitespace,
658    // and is flanking-equivalent to the line start/end it replaces (a run's
659    // open/close decision is identical whether the neighbor is line-boundary
660    // whitespace or a punctuation char), so it never masks or invents a leak.
661    // `expected` is the content text with islands as their slot char, which
662    // `import` restores from the emitted island markup.
663    let expected: String = chars.iter().collect();
664    let is_flanking = |k: &MarkKind| {
665        matches!(k, MarkKind::Strong | MarkKind::Emph | MarkKind::Strike)
666    };
667    let want = format!(",{expected},");
668    let text_safe = |md: &str| {
669        crate::import::from_markdown(&format!(",{md},"))
670            .map(|rt| rt.text == want)
671            .unwrap_or(false)
672    };
673    let mut out = sweep(&fmt);
674    while fmt.iter().any(|m| is_flanking(m.2)) && !text_safe(&out) {
675        let Some(i) = fmt.iter().rposition(|m| is_flanking(m.2)) else {
676            break;
677        };
678        fmt.remove(i);
679        out = sweep(&fmt);
680    }
681    out
682}
683
684/// Clip wrapping marks so none crosses the interior of an atomic span (`code` or
685/// a `link`'s text). A wrap edge landing strictly inside a `[cs, ce)` span is
686/// pulled to that span's boundary (`start`→`ce`, `end`→`cs`); a wrap swallowed
687/// whole collapses and drops. An atomic span can't carry partial styling, and
688/// the sweep's cursor jumps its interior start→end, so a wrap `end` hiding inside
689/// would be missed and left unbalanced (the #846 shape). A wrap that strictly
690/// *contains* a span keeps both edges, so the span still nests inside it.
691fn clip_fmt_to_atomic(fmt: &mut Vec<(usize, usize, &MarkKind)>, atomics: &[(usize, usize)]) {
692    for m in fmt.iter_mut() {
693        clip_range_to_atomic(&mut m.0, &mut m.1, atomics);
694    }
695    fmt.retain(|m| m.0 < m.1);
696}
697
698/// The #846 balance rule for a single `[*start, *end)` range against a set of
699/// atomic spans. A range edge landing strictly inside an atomic `[cs, ce)` span
700/// is pulled to that span's boundary (`start`→`ce`, `end`→`cs`); an edge outside
701/// every span is untouched. An atomic span can't carry partial styling, and a
702/// mark-sweep cursor jumps a span's interior start→end, so an edge left hiding
703/// inside would be missed and render unbalanced. A range that strictly *contains*
704/// a span keeps both edges, so the span still nests inside it. Applying the spans
705/// in sequence is order-independent across ranges, so a caller may loop ranges
706/// or spans on the outside — a range whose edges cross after clipping (swallowed
707/// whole) is left empty for the caller to drop. Shared by this crate's export
708/// and the Typst backend's inline emitter, the two sites that enforce #846.
709pub fn clip_range_to_atomic(start: &mut usize, end: &mut usize, atomics: &[(usize, usize)]) {
710    for &(cs, ce) in atomics {
711        if cs < *start && *start < ce {
712            *start = ce;
713        }
714        if cs < *end && *end < ce {
715            *end = cs;
716        }
717    }
718}
719
720/// Nest `strong`/`emph` marks that partially overlap, by truncating the
721/// later-opening one to its enclosing sibling's end. Both render as runs of the
722/// same character (`**`/`*`), so a reopened `*` abutting a `**` would merge into
723/// an ambiguous `***` — this overlap is unrepresentable in CommonMark. Truncation
724/// keeps the text and the nested portion of both marks, dropping only the
725/// crossing tail (a documented codec limit). Marks with distinct delimiters
726/// (`strike`, `underline`, `link`) are left to the sweep's close-and-reopen,
727/// which round-trips them exactly. No-op when the marks already nest.
728fn clip_asterisk_overlap(fmt: &mut [(usize, usize, &MarkKind)]) {
729    let is_ast = |k: &MarkKind| matches!(k, MarkKind::Strong | MarkKind::Emph);
730    // Asterisk-family marks, outermost first (start asc, then longer span first).
731    let mut idx: Vec<usize> = (0..fmt.len()).filter(|&i| is_ast(fmt[i].2)).collect();
732    idx.sort_by(|&a, &b| fmt[a].0.cmp(&fmt[b].0).then(fmt[b].1.cmp(&fmt[a].1)));
733    // Ends of the enclosing ancestors still open at the current mark's start.
734    let mut open_ends: Vec<usize> = Vec::new();
735    for &i in &idx {
736        let (s, mut e, _) = fmt[i];
737        while open_ends.last().is_some_and(|&end| end <= s) {
738            open_ends.pop();
739        }
740        if let Some(&parent_end) = open_ends.last() {
741            if parent_end < e {
742                e = parent_end;
743                fmt[i].1 = e;
744            }
745        }
746        open_ends.push(e);
747    }
748}
749
750/// Reconstruct a table cell's markdown from its `{text, marks}`: the same mark
751/// sweep as prose (`render_marked_core`) with `|`→`\|` escaping so the cell
752/// survives re-import through `pulldown`'s pipe splitting. A cell is flat inline
753/// — no islands, no leading-block escape — so `import(export(table))` is a fixed
754/// point.
755fn render_cell_md(v: &serde_json::Value) -> String {
756    let (text, marks) = crate::serial::parse_cell(v);
757    let chars: Vec<char> = text.chars().collect();
758    let n = chars.len();
759    let mut code_ranges: Vec<(usize, usize)> = Vec::new();
760    let mut fmt: Vec<(usize, usize, &MarkKind)> = Vec::new();
761    let mut links: Vec<(usize, usize, &str)> = Vec::new();
762    for m in &marks {
763        if m.start >= n {
764            continue;
765        }
766        let s = m.start;
767        let e = m.end.min(n);
768        if s >= e {
769            continue;
770        }
771        match &m.kind {
772            MarkKind::Anchor { .. } => {}
773            MarkKind::Code => code_ranges.push((s, e)),
774            MarkKind::Link { url } => links.push((s, e, url)),
775            _ => fmt.push((s, e, &m.kind)),
776        }
777    }
778    render_marked_core(
779        &chars,
780        &code_ranges,
781        &fmt,
782        &links,
783        None,
784        false,
785        true,
786        |_| None,
787    )
788}
789
790fn delim_open(kind: &MarkKind) -> String {
791    match kind {
792        MarkKind::Strong => "**".into(),
793        // `*`, not `_`: `_` cannot do intraword emphasis (CommonMark flanking),
794        // so `_a_你` re-imports as literal text; `*a*你` emphasizes correctly.
795        MarkKind::Emph => "*".into(),
796        MarkKind::Underline => "<u>".into(),
797        MarkKind::Strike => "~~".into(),
798        // Code/Link/Anchor handled elsewhere.
799        _ => String::new(),
800    }
801}
802
803fn delim_close(kind: &MarkKind) -> String {
804    match kind {
805        MarkKind::Strong => "**".into(),
806        MarkKind::Emph => "*".into(),
807        MarkKind::Underline => "</u>".into(),
808        MarkKind::Strike => "~~".into(),
809        _ => String::new(),
810    }
811}
812
813fn escape_run(chars: &[char], escape_pipe: bool) -> String {
814    let mut s = String::new();
815    for (i, c) in chars.iter().enumerate() {
816        escape_char_into(*c, i == 0, escape_pipe, &mut s);
817    }
818    s
819}
820
821/// Push `c` into `out` escaped so it re-imports as literal text: the char
822/// verbatim, or a `&'static str` escape. `leading` also escapes block-starter
823/// chars that would otherwise open a heading/list/quote; `escape_pipe` adds
824/// `|`→`\|`, so a table cell survives `pulldown`'s pipe split.
825fn escape_char_into(c: char, leading: bool, escape_pipe: bool, out: &mut String) {
826    let esc: &str = match c {
827        '\\' => "\\\\",
828        '*' => "\\*",
829        '_' => "\\_",
830        '`' => "\\`",
831        '[' => "\\[",
832        ']' => "\\]",
833        '<' => "\\<",
834        '~' => "\\~",
835        // `&` starts a CommonMark entity/numeric reference (`&amp;`, `&#38;`),
836        // decoded on re-import — an unescaped `&` in `&word;`-shaped text would
837        // silently collapse to the entity's character. Always escaped (a bare `&`
838        // is harmless, but detecting "would form an entity" is not worth the
839        // fragility); `\&` re-imports as a literal `&`.
840        '&' => "\\&",
841        '|' if escape_pipe => "\\|",
842        '#' if leading => "\\#",
843        '>' if leading => "\\>",
844        '-' if leading => "\\-",
845        '+' if leading => "\\+",
846        other => {
847            out.push(other);
848            return;
849        }
850    };
851    out.push_str(esc);
852}
853
854fn longest_backtick_run(s: &str) -> usize {
855    let mut max = 0;
856    let mut run = 0;
857    for c in s.chars() {
858        if c == '`' {
859            run += 1;
860            max = max.max(run);
861        } else {
862            run = 0;
863        }
864    }
865    max
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::import::from_markdown;
872    use crate::model::{Line, Loss, Mark};
873
874    /// The contract: export∘import is the identity on the content (modulo
875    /// island loss class, which our test islands don't trigger).
876    fn round_trips(md: &str) {
877        let rt = from_markdown(md).unwrap();
878        let md2 = to_markdown(&rt);
879        let rt2 = from_markdown(&md2).unwrap();
880        assert_eq!(
881            rt, rt2,
882            "content not a fixed point.\n  in:  {md:?}\n  mid: {md2:?}"
883        );
884    }
885
886    /// [`to_plaintext`] keeps literal text, drops marks, and strips island
887    /// slots — the lossy projection pdfform binds to non-content fields.
888    #[test]
889    fn plaintext_drops_marks_and_islands() {
890        // Marks contribute no delimiters to plaintext.
891        let rt = marked(
892            "bold text",
893            vec![Mark { start: 0, end: 4, kind: MarkKind::Strong }],
894        );
895        assert_eq!(to_plaintext(&rt), "bold text");
896        // An island slot in the text is removed.
897        let mut rt = Content {
898            text: format!("see {ISLAND_SLOT} here"),
899            lines: vec![Line { kind: LineKind::Para, containers: vec![], continues: false }],
900            marks: vec![],
901            islands: vec![Island {
902                id: String::new(),
903                island_type: "image".into(),
904                props: serde_json::Value::Null,
905                loss: Loss::Unrepresentable,
906            }],
907        };
908        rt.normalize();
909        assert_eq!(to_plaintext(&rt), "see  here");
910    }
911
912    /// A single-paragraph content over `text` with hand-placed `marks` — the
913    /// free-overlap shapes an editor's `apply_mark_ops` produces but markdown
914    /// import never does. Normalized + validated before use.
915    fn marked(text: &str, marks: Vec<Mark>) -> Content {
916        let mut rt = Content {
917            text: text.to_string(),
918            lines: vec![Line {
919                kind: LineKind::Para,
920                containers: vec![],
921                continues: false,
922            }],
923            marks,
924            islands: vec![],
925        };
926        rt.normalize();
927        assert_eq!(rt.validate(), Ok(()), "content invariants");
928        rt
929    }
930
931    /// One deterministic fixed-point smoke per block/inline construct. Each is
932    /// a `document()` generator arm that `properties.rs` fuzzes; kept as a
933    /// labeled table so a break localizes to the exact construct without a
934    /// proptest seed. Constructs with NO generator coverage (nested_marks,
935    /// code_block, thematic_break, hard breaks) stay as their own tests below.
936    #[test]
937    fn single_constructs_round_trip() {
938        for (label, md) in [
939            ("paragraph", "Hello world"),
940            ("two_paragraphs", "one\n\ntwo"),
941            ("marks", "a **b** _c_ ~~d~~ <u>e</u>"),
942            ("heading", "## Title here"),
943            ("inline_code", "run `cargo test` now"),
944            ("bullet_list", "- a\n- b\n- c"),
945            ("ordered_list", "3. a\n4. b"),
946            ("multi_paragraph_item", "- first\n\n  second"),
947            ("blockquote", "> quoted text"),
948            ("link", "see [our site](https://example.com) now"),
949            ("table", "| a | b |\n| --- | --- |\n| 1 | 2 |"),
950            ("image", "see ![a cat](cat.png) here"),
951        ] {
952            println!("construct: {label}");
953            round_trips(md);
954        }
955    }
956
957    #[test]
958    fn nested_marks() {
959        round_trips("**bold _and italic_**");
960    }
961
962    #[test]
963    fn code_block() {
964        round_trips("```rust\nfn a() {}\nfn b() {}\n```");
965    }
966
967    #[test]
968    fn thematic_break() {
969        round_trips("one\n\n***\n\ntwo");
970    }
971
972    #[test]
973    fn thematic_break_canonicalizes_to_dashes() {
974        // `***`/`___` and `---` all import to the same `Rule` line, so export
975        // re-emits the canonical `---` whatever the source delimiter was.
976        for src in ["***", "___", "- - -"] {
977            let rt = from_markdown(&format!("one\n\n{src}\n\ntwo")).unwrap();
978            let md = to_markdown(&rt);
979            assert!(md.contains("\n\n---\n\n"), "source: {src}, got: {md:?}");
980        }
981    }
982
983    #[test]
984    fn literal_asterisks_escaped() {
985        round_trips("2 * 3 = 6 and a_b_c");
986    }
987
988    #[test]
989    fn hard_break_round_trips() {
990        round_trips("line one\\\nline two");
991    }
992
993    #[test]
994    fn hard_break_in_list_item() {
995        round_trips("- one\\\ntwo\n- three");
996    }
997
998    #[test]
999    fn leading_ordered_marker_escaped() {
1000        // Content prose that begins `N.` must not re-import as an ordered list.
1001        let mut rt = from_markdown("x").unwrap();
1002        rt.text = "1. not a list".into();
1003        let md = to_markdown(&rt);
1004        let back = from_markdown(&md).unwrap();
1005        assert_eq!(back.lines[0].kind, LineKind::Para);
1006        assert!(back.lines[0].containers.is_empty());
1007        assert_eq!(back, rt);
1008    }
1009
1010    #[test]
1011    fn table_with_formatted_cells_round_trips() {
1012        // Option A: cells carry {text, marks}; export reconstructs their markdown
1013        // from structure, so the content is a fixed point across formatted cells.
1014        round_trips("| Name | Note |\n| --- | --- |\n| **bold** | _italic_ |");
1015        round_trips("| A |\n| --- |\n| **b** and _i_ `c` [d](https://e.com) ~~e~~ |");
1016        round_trips("| A |\n| --- |\n| <u>under</u> |");
1017        // A literal pipe inside a cell survives via `\|` re-escaping on export.
1018        round_trips("| A |\n| --- |\n| a \\| b |");
1019    }
1020
1021    #[test]
1022    fn formatted_cell_marks_are_structured_not_reparsed() {
1023        // The cell stores marks, not a markdown slice: a strong cell's island
1024        // props carry a `strong` mark over the cell-local range, and export
1025        // renders it back to `**bold**` from that structure.
1026        let rt = from_markdown("| H |\n| --- |\n| **bold** |").unwrap();
1027        let cell = &rt.islands[0].props["rows"][0][0];
1028        assert_eq!(cell["text"], "bold");
1029        assert_eq!(cell["marks"][0]["type"], "strong");
1030        assert_eq!(cell["marks"][0]["start"], 0);
1031        assert_eq!(cell["marks"][0]["end"], 4);
1032        assert!(to_markdown(&rt).contains("**bold**"));
1033    }
1034
1035    #[test]
1036    fn known_hard_break_limits() {
1037        // Recorded, not hidden: a mark spanning a hard break splits per line.
1038        let rt = from_markdown("**one\\\ntwo**").unwrap();
1039        let rt2 = from_markdown(&to_markdown(&rt)).unwrap();
1040        // The spanning strong becomes two per-line strongs on round-trip.
1041        assert!(
1042            rt != rt2,
1043            "if this ever round-trips, promote it out of the known-limits list"
1044        );
1045        assert_eq!(rt2.marks.len(), 2, "mark split across the hard break");
1046    }
1047
1048    #[test]
1049    fn anchor_marks_omitted_but_text_survives() {
1050        let mut rt = from_markdown("comment target here").unwrap();
1051        rt.marks.push(Mark {
1052            start: 8,
1053            end: 14,
1054            kind: MarkKind::Anchor { id: "c1".into() },
1055        });
1056        rt.normalize();
1057        let md = to_markdown(&rt);
1058        // No anchor syntax in the projection, but the text round-trips.
1059        let rt2 = from_markdown(&md).unwrap();
1060        assert_eq!(rt2.text, "comment target here");
1061        assert!(!md.contains("c1"));
1062    }
1063
1064    // ---------------------------------------------------------------------
1065    // Issue #848: markdown export fixed-point violations.
1066    // ---------------------------------------------------------------------
1067
1068    /// The exact #848 repro: `strong[0,4)` + `emph[2,6)` over "abcdef" must
1069    /// export balanced markdown that preserves the text, not `**ab*cdef*`,
1070    /// which re-imports as LITERAL `**abcdef` text (a silent content change).
1071    /// The two marks share the `*` delimiter character, so their
1072    /// overlap is unrepresentable in CommonMark (a reopened `*` abutting `**`
1073    /// merges into `***`); the export nests them by truncation, a documented
1074    /// codec limit — text intact, the crossing tail of the inner mark dropped.
1075    #[test]
1076    fn overlapping_asterisk_marks_stay_text_safe() {
1077        let rt = marked(
1078            "abcdef",
1079            vec![
1080                Mark {
1081                    start: 0,
1082                    end: 4,
1083                    kind: MarkKind::Strong,
1084                },
1085                Mark {
1086                    start: 2,
1087                    end: 6,
1088                    kind: MarkKind::Emph,
1089                },
1090            ],
1091        );
1092        let md = to_markdown(&rt);
1093        assert_eq!(md, "**ab*cd***ef", "balanced, no literal `**` leak");
1094        let rt2 = from_markdown(&md).unwrap();
1095        // Text is preserved exactly — the corruption the issue reported is gone.
1096        assert_eq!(rt2.text, "abcdef");
1097        // Documented limit: same-delimiter overlap degrades to its nested subset.
1098        assert_eq!(
1099            rt2.marks,
1100            vec![
1101                Mark {
1102                    start: 0,
1103                    end: 4,
1104                    kind: MarkKind::Strong
1105                },
1106                Mark {
1107                    start: 2,
1108                    end: 4,
1109                    kind: MarkKind::Emph
1110                },
1111            ]
1112        );
1113    }
1114
1115    /// Overlap between marks with *distinct* delimiters round-trips exactly:
1116    /// the close-and-reopen sweep lowers `strong[0,4)` + `strike[2,6)` to
1117    /// `**ab~~cd~~**~~ef~~`, which re-imports to the same overlapping content.
1118    #[test]
1119    fn overlapping_distinct_delim_marks_round_trip_exactly() {
1120        for (k1, k2) in [
1121            (MarkKind::Strong, MarkKind::Strike),
1122            (MarkKind::Strike, MarkKind::Strong),
1123            (MarkKind::Emph, MarkKind::Strike),
1124            (MarkKind::Underline, MarkKind::Emph),
1125            (MarkKind::Strong, MarkKind::Underline),
1126        ] {
1127            let rt = marked(
1128                "abcdef",
1129                vec![
1130                    Mark {
1131                        start: 0,
1132                        end: 4,
1133                        kind: k1.clone(),
1134                    },
1135                    Mark {
1136                        start: 2,
1137                        end: 6,
1138                        kind: k2.clone(),
1139                    },
1140                ],
1141            );
1142            let md = to_markdown(&rt);
1143            let rt2 = from_markdown(&md).unwrap();
1144            assert_eq!(rt, rt2, "{k1:?}+{k2:?} overlap not a fixed point: {md:?}");
1145        }
1146    }
1147
1148    /// A formatting mark partially overlapping an atomic `code` span can't wrap
1149    /// the code's interior; the wrap clips to the text outside so the markdown
1150    /// stays balanced (the #846 shape, here in the markdown emitter).
1151    #[test]
1152    fn wrap_over_code_stays_balanced() {
1153        let rt = marked(
1154            "abcdef",
1155            vec![
1156                Mark {
1157                    start: 0,
1158                    end: 4,
1159                    kind: MarkKind::Strong,
1160                },
1161                Mark {
1162                    start: 2,
1163                    end: 6,
1164                    kind: MarkKind::Code,
1165                },
1166            ],
1167        );
1168        let md = to_markdown(&rt);
1169        assert_eq!(md, "**ab**`cdef`");
1170        let rt2 = from_markdown(&md).unwrap();
1171        assert_eq!(rt2.text, "abcdef");
1172    }
1173
1174    /// Issue #848 part 2: a literal `&` (or an entity-shaped `&amp;`) must not
1175    /// re-import as the decoded entity. `from_markdown("\\&amp;")` yields content
1176    /// text "&amp;"; exporting it unescaped as `&amp;` would re-import as "&".
1177    #[test]
1178    fn ampersand_and_entities_round_trip() {
1179        // A bare `&` and an entity-shaped run both survive.
1180        round_trips("a & b");
1181        round_trips("copyright \\&copy; sign");
1182        // The pinned repro: literal "&amp;" text.
1183        let rt = from_markdown("\\&amp;").unwrap();
1184        assert_eq!(rt.text, "&amp;");
1185        let md = to_markdown(&rt);
1186        assert!(md.contains("\\&"), "the `&` must be escaped, got {md:?}");
1187        let rt2 = from_markdown(&md).unwrap();
1188        assert_eq!(rt2.text, "&amp;", "entity-shaped text must not decode");
1189        assert_eq!(rt, rt2);
1190    }
1191
1192    /// Issue #848 part 3: heading text ending in a `#` run must not re-import as
1193    /// an ATX closing sequence. `from_markdown("# a \\#")` yields heading text
1194    /// "a #"; exporting it as `# a #` would re-import as "a", dropping the `#`.
1195    #[test]
1196    fn heading_trailing_hash_round_trips() {
1197        let rt = from_markdown("# a \\#").unwrap();
1198        assert_eq!(rt.text, "a #");
1199        let md = to_markdown(&rt);
1200        assert!(md.contains("\\#"), "trailing `#` must be escaped, got {md:?}");
1201        let rt2 = from_markdown(&md).unwrap();
1202        assert_eq!(rt2.text, "a #", "trailing `#` must survive");
1203        assert_eq!(rt, rt2);
1204        // A multi-`#` trailing run and a no-space `#` both round-trip.
1205        round_trips("# heading \\#\\#");
1206        round_trips("## title\\#");
1207    }
1208
1209    // ---------------------------------------------------------------------
1210    // Issue #900: unescaped image alt/URL and link URL cause silent content
1211    // loss on round-trip (a special char terminates the markup early).
1212    // ---------------------------------------------------------------------
1213
1214    /// The exact #900 image repro: an alt with `\]` imports to alt text "a]b";
1215    /// exporting it unescaped as `![a]b](x.png)` re-imports as prose with the
1216    /// image gone. The alt must be escaped so the island survives.
1217    #[test]
1218    fn image_alt_specials_round_trip() {
1219        // `]`, `\`, `&`, and emphasis delimiters in alt all survive.
1220        round_trips("see ![a\\]b](x.png) here");
1221        round_trips("see ![a\\\\b](x.png) here");
1222        round_trips("see ![a&b](x.png) here");
1223        round_trips("see ![a\\*b\\_c](x.png) here");
1224        // The pinned repro: the image is not lost.
1225        let rt = from_markdown("see ![a\\]b](x.png) here").unwrap();
1226        assert_eq!(rt.islands.len(), 1, "one image island");
1227        assert_eq!(rt.islands[0].props["alt"], "a]b");
1228        let md = to_markdown(&rt);
1229        let rt2 = from_markdown(&md).unwrap();
1230        assert_eq!(rt2.islands.len(), 1, "image survived, got md {md:?}");
1231        assert_eq!(rt2.islands[0].props["alt"], "a]b");
1232    }
1233
1234    /// The exact #900 link repro: a URL with a space imports to link url
1235    /// "foo bar"; exporting it unescaped as `[t](foo bar)` re-imports as prose
1236    /// with the link gone. The URL must be angle-wrapped.
1237    #[test]
1238    fn link_url_with_space_round_trips() {
1239        round_trips("a [t](<foo bar>) b");
1240        let rt = from_markdown("a [t](<foo bar>) b").unwrap();
1241        assert!(
1242            rt.marks
1243                .iter()
1244                .any(|m| matches!(&m.kind, MarkKind::Link { url } if url == "foo bar")),
1245            "link url with space imported"
1246        );
1247        let md = to_markdown(&rt);
1248        assert!(md.contains("<foo bar>"), "url angle-wrapped, got {md:?}");
1249        let rt2 = from_markdown(&md).unwrap();
1250        assert_eq!(rt, rt2);
1251    }
1252
1253    /// Image and link URLs carrying the destination-terminating specials —
1254    /// unbalanced parens, `&`, `<`/`>`, backslash — all round-trip.
1255    #[test]
1256    fn url_specials_round_trip() {
1257        // Balanced parens stay bare (CommonMark permits them); the others wrap.
1258        round_trips("see [t](https://en.wikipedia.org/wiki/Rust_(programming_language)) x");
1259        round_trips("see ![a](<x y.png>) here");
1260        round_trips("see [t](<a )b>) x");
1261        round_trips("see [t](<a&b>) x");
1262        round_trips("see [t](<a\\<b\\>c>) x");
1263        round_trips("see [t](<a\\\\b>) x");
1264    }
1265
1266    /// `emit_url` chooses bare for a clean URL and angle-wraps only when a
1267    /// special forces it — the aesthetic contract (common URLs stay unbracketed).
1268    #[test]
1269    fn emit_url_bare_when_safe() {
1270        let mut bare = String::new();
1271        emit_url("https://ex.com/a(b)c", &mut bare);
1272        assert_eq!(bare, "https://ex.com/a(b)c", "balanced parens stay bare");
1273        let mut wrapped = String::new();
1274        emit_url("a b", &mut wrapped);
1275        assert_eq!(wrapped, "<a b>", "space forces the wrap");
1276        let mut esc = String::new();
1277        emit_url("a&<\\b", &mut esc);
1278        assert_eq!(esc, "<a\\&\\<\\\\b>", "specials escaped inside the wrap");
1279    }
1280
1281    #[test]
1282    fn ordered_list_marker_saturates_on_overflow() {
1283        // `validate` does not ceiling `start`/`ordinal`, so a corrupt content can
1284        // carry `start == u64::MAX`. Export must not panic (or wrap silently) on
1285        // the `start + ordinal` marker; it saturates instead.
1286        let json = format!(
1287            r#"{{"text":"x","lines":[{{"kind":"para","containers":[{{"container":"list_item","ordered":true,"start":{},"ordinal":5}}]}}],"marks":[],"islands":[]}}"#,
1288            u64::MAX
1289        );
1290        let rt = Content::from_canonical_json(&json).unwrap();
1291        let md = to_markdown(&rt);
1292        assert!(
1293            md.contains(&format!("{}. ", u64::MAX)),
1294            "marker saturates to u64::MAX: {md:?}"
1295        );
1296    }
1297}