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(";
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 (`&`, `&`),
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  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 `&`) must not
1175 /// re-import as the decoded entity. `from_markdown("\\&")` yields content
1176 /// text "&"; exporting it unescaped as `&` 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 \\© sign");
1182 // The pinned repro: literal "&" text.
1183 let rt = from_markdown("\\&").unwrap();
1184 assert_eq!(rt.text, "&");
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, "&", "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  here");
1222 round_trips("see  here");
1223 round_trips("see  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  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}