quillmark_content/import.rs
1//! Markdown import (cold): `normalize → pulldown → content`.
2//!
3//! The one place the `<u>` allowlist runs — once, at the
4//! boundary (issue #831 § Codecs). Input is normalized by
5//! [`crate::normalize::normalize_markdown`] (CRLF→LF, bidi strip, HTML
6//! comment-fence repair) so the content invariants hold by construction, then
7//! parsed with `pulldown_cmark` (CommonMark + strikethrough + pipe tables) and
8//! walked into a [`Content`].
9//!
10//! ## Canonicalizations (documented, not bugs)
11//!
12//! Import maps some distinct markdown to one canonical content. All of them, in
13//! one place:
14//!
15//! - **Soft breaks → space; hard breaks → a `continues` line.** A soft break is
16//! a space (CommonMark rendering); a hard break (two trailing spaces or `\`)
17//! is a within-block continuation line ([`crate::model::Line::continues`]),
18//! kept distinct from a paragraph boundary. A hard break inside a heading is a
19//! space (ATX headings can't carry one).
20//! - **Adjacent sibling lists of the same shape merge.** Two consecutive lists
21//! of the same kind whose items share an `ordinal` (`* a` then `+ b`, or two
22//! ordered lists both starting at 1) are indistinguishable from one list /
23//! one multi-paragraph item — item identity is positional `ordinal`, not a
24//! minted list instance. Adjacent block quotes likewise merge into one.
25//! - **Empty blocks and containers keep their line.** An empty heading (`#`),
26//! empty paragraph, empty `- ` item, or empty `>` quote each yields one empty
27//! line so the structure survives, rather than vanishing.
28//! - **Island ids are minted sequentially** (`isl-0`, `isl-1`, …) so import is a
29//! pure, deterministic function of its markdown. This positional scheme is
30//! normative: ids are hash input, so a producer must derive them
31//! deterministically and never from an ambient source (`DOCUMENT_STORAGE.md`
32//! § Island-id determinism). Sequential ids round-trip — export drops them,
33//! re-import re-mints the same sequence.
34//! - **Tables and images are islands.** Tables are block islands (their own
35//! `Island` line); images are inline island slots. Both `Lossless` — pipe
36//! tables and `` carry them faithfully.
37//! - **Thematic breaks are `Rule` lines.** `---`/`***`/`___` in prose (never
38//! the root-block frontmatter alias, resolved before this layer runs) map
39//! to a `LineKind::Rule` line carrying no text — the break is the line
40//! itself.
41
42use crate::model::{
43 Container, Island, Line, LineKind, Loss, Mark, MarkKind, Content, ISLAND_SLOT,
44};
45use crate::island::KnownIslandType;
46use crate::normalize::normalize_markdown;
47use crate::MAX_NESTING_DEPTH;
48use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
49
50/// What `event` contributes to the alt text of an image being collected, or
51/// `None` when it contributes nothing. One rule, two accumulators: a `String`
52/// at top level and the table cell inside a table (which additionally flags the
53/// cell `degraded` and drops the URL).
54fn image_alt_text<'e>(event: &'e Event<'e>) -> Option<&'e str> {
55 match event {
56 Event::Text(t) | Event::Code(t) => Some(t),
57 Event::SoftBreak | Event::HardBreak => Some(" "),
58 _ => None,
59 }
60}
61use serde_json::json;
62use std::cell::RefCell;
63use std::collections::HashSet;
64use std::ops::Range;
65use std::rc::Rc;
66
67/// Byte offsets at which the [`MarkdownFixer`] converted a `<u>` open tag into a
68/// `Tag::Strong` event. The fixer is the one place that classifies `<u>` (via
69/// [`is_u_open_tag`]); it records the fact here so the [`Builder`] can tell a
70/// `<u>`-derived strong from a real `**` one without re-sniffing source bytes.
71type UnderlineOpens = Rc<RefCell<HashSet<usize>>>;
72
73/// Import errors: just the nesting guard (mirrors the typst backend's
74/// `ConversionError::NestingTooDeep`).
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum ImportError {
77 /// Container nesting exceeded [`MAX_NESTING_DEPTH`].
78 NestingTooDeep { depth: usize, max: usize },
79}
80
81impl std::fmt::Display for ImportError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 ImportError::NestingTooDeep { depth, max } => {
85 write!(f, "nesting too deep: {depth} (max {max})")
86 }
87 }
88 }
89}
90impl std::error::Error for ImportError {}
91
92/// Import markdown into a normalized, validated [`Content`] content.
93pub fn from_markdown(markdown: &str) -> Result<Content, ImportError> {
94 let normalized = normalize_markdown(markdown);
95 let mut options = Options::empty();
96 options.insert(Options::ENABLE_STRIKETHROUGH);
97 options.insert(Options::ENABLE_TABLES);
98 let parser = Parser::new_ext(&normalized, options);
99 let underline_opens: UnderlineOpens = Rc::new(RefCell::new(HashSet::new()));
100 let fixer = MarkdownFixer::new(parser.into_offset_iter(), Rc::clone(&underline_opens));
101
102 let mut b = Builder::new(underline_opens);
103 b.run(fixer)?;
104 let mut rt = b.finish();
105 rt.normalize();
106 Ok(rt)
107}
108
109/// Import plain text (literal) into a [`Content`] content — the literal-codec
110/// sibling of [`from_markdown`]. Every character is content, never syntax:
111/// `*hi*` is four literal chars, not emphasis, and nothing is escaped. Paired
112/// with [`crate::export::to_plaintext`] as its exporter, it pins the literal
113/// fixed point `to_plaintext(from_plaintext(s)) == s` for any `s` free of `\r`,
114/// bidi controls, and the reserved island slot ([`ISLAND_SLOT`]) — the same
115/// boundary cleanup [`from_markdown`] performs, so the fixed point holds for
116/// clean plaintext and the content invariants hold by construction.
117///
118/// Line structure is **derived, not stored**: a lone `\n` between two non-empty
119/// segments is a within-paragraph break ([`Line::continues`] `true`, lowered as
120/// a backend hard break); a blank line (`\n\n`) is a paragraph boundary (its own
121/// empty line resets `continues`). The text is stored verbatim, so the round
122/// trip is byte-exact and idempotent regardless of how structure is later
123/// re-derived.
124pub fn from_plaintext(s: &str) -> Content {
125 // Boundary cleanup so the content invariants hold: CRLF→LF (drop `\r`), strip
126 // bidi controls, drop the reserved island slot. Clean plaintext passes
127 // through untouched, so the literal fixed point holds for it.
128 let text: String = s
129 .chars()
130 .filter(|&c| c != '\r' && c != ISLAND_SLOT && !crate::normalize::is_bidi_char(c))
131 .collect();
132 // One line per `\n`-separated segment. `continues` marks a within-paragraph
133 // break — a lone `\n` joining two non-empty segments; any empty segment is a
134 // paragraph boundary and resets it. A single streaming pass carries the prior
135 // segment's non-emptiness, so line 0 is `false` (the flag starts `false`) and
136 // no intermediate segment vector is allocated.
137 let mut prev_nonempty = false;
138 let lines = text
139 .split('\n')
140 .map(|seg| {
141 let continues = prev_nonempty && !seg.is_empty();
142 prev_nonempty = !seg.is_empty();
143 Line {
144 kind: LineKind::Para,
145 containers: Vec::new(),
146 continues,
147 }
148 })
149 .collect();
150 Content {
151 text,
152 lines,
153 marks: Vec::new(),
154 islands: Vec::new(),
155 }
156}
157
158// ---------------------------------------------------------------------------
159// Content builder
160// ---------------------------------------------------------------------------
161
162/// A flat inline accumulator: `text` plus `marks` over local USV offsets, with
163/// the content char-filtering baked in. One implementation serves both a prose
164/// line's inline content (embedded in the [`Builder`], which layers the
165/// line/block scaffolding on top) and a table cell's isolated content (offsets
166/// `0..cell_len`) — the mark-building logic is written once, not copied per site.
167#[derive(Default)]
168struct Inline {
169 /// The accumulated text (a whole content for the [`Builder`]; one cell's text
170 /// for a table cell). USV length is tracked in [`Self::pos`].
171 text: String,
172 /// USV position = char count of [`Self::text`].
173 pos: usize,
174 marks: Vec<Mark>,
175 /// `(kind, start)` for each mark opened but not yet closed.
176 open: Vec<(MarkKind, usize)>,
177}
178
179impl Inline {
180 /// Append inline text, stripping characters the content forbids: `\r` and a
181 /// stray [`ISLAND_SLOT`] are dropped; a stray `\n` becomes a space (inline
182 /// text carries no line boundary — real ones go through [`Self::push_raw`]).
183 ///
184 /// A literal [`ISLAND_SLOT`] (U+FFFC) in source markdown is dropped
185 /// *silently and by design*: the slot char is the reserved island sentinel,
186 /// so admitting a bare one would break the slot-count invariant. Such a char
187 /// in prose is a paste/render artifact, never authored content, so its loss
188 /// carries no signal — this is a fixed point, not lossy round-tripping.
189 fn push_text(&mut self, s: &str) {
190 for c in s.chars() {
191 let c = match c {
192 '\r' => continue,
193 ISLAND_SLOT => continue,
194 '\n' => ' ',
195 other => other,
196 };
197 self.text.push(c);
198 self.pos += 1;
199 }
200 }
201
202 /// Append one char verbatim (a line-boundary `\n`, an island slot), bypassing
203 /// the [`Self::push_text`] filtering.
204 fn push_raw(&mut self, c: char) {
205 self.text.push(c);
206 self.pos += 1;
207 }
208
209 /// Open a mark at the current position.
210 fn open_mark(&mut self, kind: MarkKind) {
211 self.open.push((kind, self.pos));
212 }
213
214 /// Close the innermost open mark (pulldown nests them well).
215 fn close_mark(&mut self) {
216 if let Some((kind, start)) = self.open.pop() {
217 self.marks.push(Mark {
218 start,
219 end: self.pos,
220 kind,
221 });
222 }
223 }
224
225 /// Append inline code text and record its [`MarkKind::Code`] mark over it.
226 fn push_code(&mut self, s: &str) {
227 let start = self.pos;
228 self.push_text(s);
229 self.marks.push(Mark {
230 start,
231 end: self.pos,
232 kind: MarkKind::Code,
233 });
234 }
235}
236
237struct Builder {
238 /// A `Tag::Strong` whose `range.start` is here was a `<u>` in source (the
239 /// fixer recorded it); the [`Builder`] opens [`MarkKind::Underline`] for it
240 /// instead of [`MarkKind::Strong`] — the carried form of the distinction the
241 /// fixer would otherwise erase.
242 underline_opens: UnderlineOpens,
243 /// The content text + marks; the [`Builder`] adds line/block structure around
244 /// it (a `\n` boundary is [`Inline::push_raw`], inline content is the mark
245 /// machinery). A table cell reuses the same [`Inline`] in isolation.
246 inline: Inline,
247 lines: Vec<Line>,
248 cur: Option<Line>, // the line currently open (kind + containers fixed at open)
249 /// A block start records `(kind, continues)` the next inline content should
250 /// open a fresh line with. Set at Paragraph/Heading/Item (tight lists emit no
251 /// Paragraph wrapper, so Item must force a line) with `continues = false`; a
252 /// hard break sets `continues = true`. Cleared when a block that owns its own
253 /// lines (List/Quote/CodeBlock/Table) takes over.
254 pending: Option<(LineKind, bool)>,
255 islands: Vec<Island>,
256 island_seq: usize,
257 containers: Vec<Container>,
258 /// Parallel to `containers`: the [`Self::emitted`] count when each container
259 /// opened, so a container that closes having emitted no line (an empty `>`
260 /// quote, an empty `- ` item) can still get one.
261 container_marks: Vec<usize>,
262 list_stack: Vec<ListInfo>,
263 // code block
264 code_lang: Option<String>,
265 in_code: bool,
266 code_opened: bool, // whether the current code block has opened its first line
267 // image collection
268 image_depth: usize,
269 image_url: String,
270 image_alt: String,
271 // table collection
272 table: Option<TableAcc>,
273}
274
275#[derive(Clone)]
276struct ListInfo {
277 ordered: bool,
278 start: u64,
279 /// 0-based index of the next item — becomes the item's `ordinal`.
280 count: u64,
281}
282
283struct TableAcc {
284 aligns: Vec<&'static str>,
285 /// Cells as canonical `{text, marks}` JSON (via `serial::cell_to_value`), so
286 /// nothing downstream re-parses markdown to render a formatted cell.
287 header: Vec<serde_json::Value>,
288 rows: Vec<Vec<serde_json::Value>>,
289 cur_row: Vec<serde_json::Value>,
290 in_head: bool,
291 /// The cell currently open (between `Tag::TableCell` start/end), building its
292 /// inline text + marks with the same [`Inline`] machinery prose uses.
293 cell: Option<Inline>,
294 /// Open-image nesting inside the current cell. GFM permits inline images in
295 /// cells, but a cell has no island slot to carry one; while `> 0` the image's
296 /// alt flows into the cell as plain text (the degraded projection) and its
297 /// url is dropped. Mirrors the top-level `image_depth` interception.
298 img_depth: usize,
299 /// Whether any cell dropped an image's url — the island is then minted
300 /// [`Loss::Degraded`], not `Lossless`: the markdown/Typst projection carries
301 /// the alt text but not the image.
302 degraded: bool,
303}
304
305fn align_str(a: &pulldown_cmark::Alignment) -> &'static str {
306 match a {
307 pulldown_cmark::Alignment::None => "none",
308 pulldown_cmark::Alignment::Left => "left",
309 pulldown_cmark::Alignment::Center => "center",
310 pulldown_cmark::Alignment::Right => "right",
311 }
312}
313
314impl Builder {
315 fn new(underline_opens: UnderlineOpens) -> Self {
316 Builder {
317 underline_opens,
318 inline: Inline::default(),
319 lines: Vec::new(),
320 cur: None,
321 pending: None,
322 islands: Vec::new(),
323 island_seq: 0,
324 containers: Vec::new(),
325 container_marks: Vec::new(),
326 list_stack: Vec::new(),
327 code_lang: None,
328 in_code: false,
329 code_opened: false,
330 image_depth: 0,
331 image_url: String::new(),
332 image_alt: String::new(),
333 table: None,
334 }
335 }
336
337 /// Open a fresh line with `kind` and the current container path. The first
338 /// open sets the line directly; each later one first closes the previous
339 /// line with a single `\n` boundary — so `lines.len()` always equals the
340 /// `\n`-segment count.
341 fn open_line(&mut self, kind: LineKind, continues: bool) {
342 // The first line (no line yet open) can never continue anything.
343 let continues = continues && self.cur.is_some();
344 if let Some(prev) = self.cur.take() {
345 self.inline.push_raw('\n');
346 self.lines.push(prev);
347 }
348 self.cur = Some(Line {
349 kind,
350 containers: self.containers.clone(),
351 continues,
352 });
353 }
354
355 /// Open a fresh line for a `pending_kind` set at the last block start, or
356 /// (defensively) a `default` line if inline content arrives with none
357 /// pending and no line open. A no-op when a line is already open and no new
358 /// one is pending — inline content flows onto the current line.
359 fn ensure_open(&mut self, default: LineKind) {
360 if let Some((k, cont)) = self.pending.take() {
361 self.open_line(k, cont);
362 } else if self.cur.is_none() {
363 self.open_line(default, false);
364 }
365 }
366
367 /// Append inline text to the current line, stripping any characters the
368 /// content invariants forbid (stray `\r`, stray island slots; stray `\n`
369 /// becomes a space — inline text should carry none).
370 fn push_inline(&mut self, s: &str) {
371 self.ensure_open(LineKind::Para);
372 self.inline.push_text(s);
373 }
374
375 /// Lines emitted so far, counting the line currently open. A container that
376 /// closes with this unchanged from when it opened produced nothing.
377 fn emitted(&self) -> usize {
378 self.lines.len() + usize::from(self.cur.is_some())
379 }
380
381 /// Open a line for a block that ended with no inline content (an empty
382 /// heading `#`, an empty paragraph) — otherwise the block, and any content
383 /// model it carries, is silently lost.
384 fn flush_empty_block(&mut self) {
385 if let Some((k, cont)) = self.pending.take() {
386 self.open_line(k, cont);
387 }
388 }
389
390 /// Close a container: if it emitted no line, give it one empty `Para` line
391 /// (an empty `- ` item, an empty `>` quote) so the structure survives; then
392 /// pop it. `mark` is the [`Self::emitted`] snapshot from when it opened.
393 fn close_container(&mut self, mark: usize) {
394 if self.emitted() == mark {
395 self.pending = None;
396 self.open_line(LineKind::Para, false);
397 }
398 self.containers.pop();
399 }
400
401 fn open_mark(&mut self, kind: MarkKind) {
402 // Resolve any armed line first, so a mark that begins a block records
403 // the position *after* the block's line boundary — not the `\n` before
404 // it. Without this the mark swallows the separator and equal content
405 // from an editor vs from import serializes to different canonical bytes.
406 self.ensure_open(LineKind::Para);
407 self.inline.open_mark(kind);
408 }
409
410 fn close_mark(&mut self) {
411 // Well-nested by pulldown: close the innermost open mark.
412 self.inline.close_mark();
413 }
414
415 /// Mint an island of a *known* type — the importer can only produce the
416 /// closed set, so an unknown type can enter the system through storage
417 /// deserialization but never through import. The `isl-{seq}` id is the
418 /// normative deterministic scheme (`DOCUMENT_STORAGE.md` § Island-id
419 /// determinism); minting by position keeps import a pure function.
420 fn mint_island(&mut self, kind: KnownIslandType, props: serde_json::Value, loss: Loss) {
421 let id = format!("isl-{}", self.island_seq);
422 self.island_seq += 1;
423 self.islands.push(Island {
424 id,
425 island_type: kind.as_str().to_string(),
426 props,
427 loss,
428 });
429 }
430
431 fn check_depth(&self) -> Result<(), ImportError> {
432 // Container path plus open marks approximates the structural depth the
433 // typst backend caps; bound it identically for parity.
434 let depth = self.containers.len() + self.inline.open.len();
435 if depth > MAX_NESTING_DEPTH {
436 return Err(ImportError::NestingTooDeep {
437 depth,
438 max: MAX_NESTING_DEPTH,
439 });
440 }
441 Ok(())
442 }
443
444 /// [`MarkKind::Underline`] if the fixer converted a `<u>` open at `start`,
445 /// else [`MarkKind::Strong`] — reads the classification the fixer carried,
446 /// no source re-sniff.
447 fn strong_kind(&self, start: usize) -> MarkKind {
448 if self.underline_opens.borrow().contains(&start) {
449 MarkKind::Underline
450 } else {
451 MarkKind::Strong
452 }
453 }
454
455 fn run<'a, I>(&mut self, iter: I) -> Result<(), ImportError>
456 where
457 I: Iterator<Item = (Event<'a>, Range<usize>)>,
458 {
459 for (event, range) in iter {
460 // Image alt collection intercepts everything until the image closes.
461 if self.image_depth > 0 {
462 match &event {
463 Event::Start(Tag::Image { .. }) => self.image_depth += 1,
464 Event::End(TagEnd::Image) => {
465 self.image_depth -= 1;
466 if self.image_depth == 0 {
467 self.emit_image();
468 }
469 }
470 other => {
471 if let Some(s) = image_alt_text(other) {
472 self.image_alt.push_str(s);
473 }
474 }
475 }
476 continue;
477 }
478
479 // Table collection routes both structural events (head/row/cell) and
480 // a cell's inline content (text/marks) to the accumulator, so each
481 // cell is stored as canonical `{text, marks}` — no markdown re-parse
482 // downstream.
483 if self.table.is_some() {
484 self.table_event(&event, &range);
485 if matches!(event, Event::End(TagEnd::Table)) {
486 self.emit_table();
487 }
488 continue;
489 }
490
491 match event {
492 Event::Start(tag) => self.start_tag(tag, range)?,
493 Event::End(tag) => self.end_tag(tag),
494 Event::Text(t) => {
495 if self.in_code {
496 self.push_code_content(&t);
497 } else {
498 self.push_inline(&t);
499 }
500 }
501 Event::Code(t) => {
502 self.ensure_open(LineKind::Para);
503 self.inline.push_code(&t);
504 }
505 Event::Rule => self.open_line(LineKind::Rule, false),
506 Event::SoftBreak => self.push_inline(" "),
507 Event::HardBreak => {
508 match self.cur.as_ref().map(|l| &l.kind) {
509 // ATX headings can't carry a hard break in markdown, so
510 // one inside a heading canonicalizes to a space (a
511 // documented, representable choice).
512 Some(LineKind::Heading { .. }) => self.push_inline(" "),
513 // Elsewhere: a within-block line break — arm a pending
514 // continuation line (same kind, continues = true) so it
515 // stays one block and export re-emits a hard break, not a
516 // paragraph split.
517 _ => {
518 let kind = self
519 .cur
520 .as_ref()
521 .map(|l| l.kind.clone())
522 .unwrap_or(LineKind::Para);
523 self.pending = Some((kind, true));
524 }
525 }
526 }
527 // Html/InlineHtml already stripped or rewritten by the fixer;
528 // math/footnotes/etc. produce no content.
529 _ => {}
530 }
531 }
532 Ok(())
533 }
534
535 fn start_tag<'a>(&mut self, tag: Tag<'a>, range: Range<usize>) -> Result<(), ImportError> {
536 match tag {
537 // Block starts arm a pending line (new block, continues = false);
538 // the next inline content opens it.
539 Tag::Paragraph => self.pending = Some((LineKind::Para, false)),
540 Tag::Heading { level, .. } => {
541 self.pending = Some((
542 LineKind::Heading {
543 level: heading_level(level),
544 },
545 false,
546 ))
547 }
548 Tag::CodeBlock(kind) => {
549 self.pending = None; // code opens its own lines
550 self.in_code = true;
551 self.code_lang = match kind {
552 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
553 let l = sanitize_lang(&lang);
554 if l.is_empty() {
555 None
556 } else {
557 Some(l)
558 }
559 }
560 pulldown_cmark::CodeBlockKind::Indented => None,
561 };
562 // First code line opens on the first content chunk; nothing to
563 // open yet (a code block with no content still yields one line,
564 // handled in push_code_content / end).
565 self.code_opened = false;
566 }
567 Tag::List(start) => {
568 self.pending = None; // nested list content sets its own
569 self.list_stack.push(ListInfo {
570 ordered: start.is_some(),
571 start: start.unwrap_or(1),
572 count: 0,
573 });
574 }
575 Tag::Item => {
576 // Tight-list items carry no Paragraph wrapper, so the item start
577 // is what forces a new line for the item's first inline content.
578 self.pending = Some((LineKind::Para, false));
579 self.container_marks.push(self.emitted());
580 let container = match self.list_stack.last_mut() {
581 Some(info) => {
582 let ordinal = info.count;
583 info.count += 1;
584 Container::ListItem {
585 ordered: info.ordered,
586 start: info.start,
587 ordinal,
588 }
589 }
590 None => Container::ListItem {
591 ordered: false,
592 start: 1,
593 ordinal: 0,
594 },
595 };
596 self.containers.push(container);
597 self.check_depth()?;
598 }
599 Tag::BlockQuote(_) => {
600 self.pending = None; // quote content sets its own
601 self.container_marks.push(self.emitted());
602 self.containers.push(Container::Quote);
603 self.check_depth()?;
604 }
605 Tag::Table(aligns) => {
606 self.pending = None;
607 self.open_line(LineKind::Island, false);
608 self.inline.push_raw(ISLAND_SLOT);
609 self.table = Some(TableAcc {
610 aligns: aligns.iter().map(align_str).collect(),
611 header: Vec::new(),
612 rows: Vec::new(),
613 cur_row: Vec::new(),
614 in_head: false,
615 cell: None,
616 img_depth: 0,
617 degraded: false,
618 });
619 }
620 Tag::Emphasis => {
621 self.open_mark(MarkKind::Emph);
622 self.check_depth()?;
623 }
624 Tag::Strong => {
625 let kind = self.strong_kind(range.start);
626 self.open_mark(kind);
627 self.check_depth()?;
628 }
629 Tag::Strikethrough => {
630 self.open_mark(MarkKind::Strike);
631 self.check_depth()?;
632 }
633 Tag::Link { dest_url, .. } => {
634 self.open_mark(MarkKind::Link {
635 url: dest_url.to_string(),
636 });
637 self.check_depth()?;
638 }
639 Tag::Image { dest_url, .. } => {
640 self.image_url = dest_url.to_string();
641 self.image_alt.clear();
642 self.image_depth = 1;
643 }
644 _ => {}
645 }
646 Ok(())
647 }
648
649 fn end_tag(&mut self, tag: TagEnd) {
650 match tag {
651 TagEnd::CodeBlock => {
652 if !self.code_opened {
653 // Empty code block: one empty Code line.
654 let lang = self.code_lang.take();
655 self.open_line(LineKind::Code { lang }, false);
656 }
657 self.in_code = false;
658 self.code_lang = None;
659 }
660 TagEnd::List(_) => {
661 self.list_stack.pop();
662 }
663 TagEnd::Item => {
664 let mark = self.container_marks.pop().unwrap_or(0);
665 self.close_container(mark);
666 }
667 TagEnd::BlockQuote(_) => {
668 let mark = self.container_marks.pop().unwrap_or(0);
669 self.close_container(mark);
670 }
671 TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
672 self.close_mark()
673 }
674 // A block that produced no inline content still gets its line.
675 TagEnd::Heading(_) | TagEnd::Paragraph => self.flush_empty_block(),
676 _ => {}
677 }
678 }
679
680 fn push_code_content(&mut self, content: &str) {
681 // pulldown appends a trailing newline as the last line's terminator, not
682 // content; drop exactly one so an N-line block yields N lines.
683 let content = content.strip_suffix('\n').unwrap_or(content);
684 for seg in content.split('\n') {
685 // First line of the block starts it (continues = false); every later
686 // line is a within-block continuation, so the fence stays one block.
687 let continues = self.code_opened;
688 self.open_line(
689 LineKind::Code {
690 lang: self.code_lang.clone(),
691 },
692 continues,
693 );
694 self.code_opened = true;
695 // Code text is literal; still enforce content invariants.
696 self.push_code_line(seg);
697 }
698 }
699
700 fn push_code_line(&mut self, seg: &str) {
701 for c in seg.chars() {
702 match c {
703 '\r' | '\n' => continue,
704 ISLAND_SLOT => continue,
705 other => self.inline.push_raw(other),
706 }
707 }
708 }
709
710 // ---- table ----
711
712 /// The open table cell's inline accumulator, if one is open.
713 fn cell_mut(&mut self) -> Option<&mut Inline> {
714 self.table.as_mut()?.cell.as_mut()
715 }
716
717 /// Route one table event: structural events (head/row/cell boundaries) shape
718 /// the accumulator; inline events (text/code/marks) build the open cell with
719 /// the SAME [`Inline`] machinery prose uses — a cell is flat inline (no lines,
720 /// no nested islands), so its marks are USV offsets into its own text.
721 fn table_event(&mut self, event: &Event, range: &Range<usize>) {
722 // An image open inside the current cell intercepts everything until it
723 // closes: the alt text lands in the cell as plain text (marks flattened,
724 // like the top-level image path), the url is dropped, and the island is
725 // flagged degraded. A cell has no island slot to carry a real image.
726 if self.table.as_ref().is_some_and(|a| a.img_depth > 0) {
727 match event {
728 Event::Start(Tag::Image { .. }) => {
729 if let Some(a) = self.table.as_mut() {
730 a.img_depth += 1;
731 }
732 }
733 Event::End(TagEnd::Image) => {
734 if let Some(a) = self.table.as_mut() {
735 a.img_depth -= 1;
736 }
737 }
738 other => {
739 if let Some(s) = image_alt_text(other) {
740 if let Some(c) = self.cell_mut() {
741 c.push_text(s);
742 }
743 }
744 }
745 }
746 return;
747 }
748 match event {
749 Event::Start(Tag::Image { .. }) => {
750 if let Some(a) = self.table.as_mut() {
751 a.img_depth += 1;
752 a.degraded = true;
753 }
754 }
755 Event::Start(Tag::TableHead) => {
756 if let Some(a) = self.table.as_mut() {
757 a.in_head = true;
758 }
759 }
760 Event::End(TagEnd::TableHead) => {
761 if let Some(a) = self.table.as_mut() {
762 a.header = std::mem::take(&mut a.cur_row);
763 a.in_head = false;
764 }
765 }
766 Event::Start(Tag::TableRow) => {
767 if let Some(a) = self.table.as_mut() {
768 a.cur_row.clear();
769 }
770 }
771 Event::End(TagEnd::TableRow) => {
772 if let Some(a) = self.table.as_mut() {
773 if !a.in_head {
774 let row = std::mem::take(&mut a.cur_row);
775 a.rows.push(row);
776 }
777 }
778 }
779 Event::Start(Tag::TableCell) => {
780 if let Some(a) = self.table.as_mut() {
781 a.cell = Some(Inline::default());
782 }
783 }
784 Event::End(TagEnd::TableCell) => {
785 if let Some(a) = self.table.as_mut() {
786 if let Some(mut cell) = a.cell.take() {
787 // Close any marks pulldown left open (malformed input).
788 while !cell.open.is_empty() {
789 cell.close_mark();
790 }
791 a.cur_row
792 .push(crate::serial::cell_to_value(&cell.text, &cell.marks));
793 }
794 }
795 }
796 // Inline content of the open cell (pulldown already trimmed the cell's
797 // surrounding whitespace; the fixer already stripped non-`<u>` HTML).
798 // A soft/hard break in a single-line cell is a space.
799 Event::Text(t) => {
800 if let Some(c) = self.cell_mut() {
801 c.push_text(t);
802 }
803 }
804 Event::Code(t) => {
805 if let Some(c) = self.cell_mut() {
806 c.push_code(t);
807 }
808 }
809 Event::SoftBreak | Event::HardBreak => {
810 if let Some(c) = self.cell_mut() {
811 c.push_text(" ");
812 }
813 }
814 Event::Start(Tag::Emphasis) => {
815 if let Some(c) = self.cell_mut() {
816 c.open_mark(MarkKind::Emph);
817 }
818 }
819 Event::Start(Tag::Strong) => {
820 let kind = self.strong_kind(range.start);
821 if let Some(c) = self.cell_mut() {
822 c.open_mark(kind);
823 }
824 }
825 Event::Start(Tag::Strikethrough) => {
826 if let Some(c) = self.cell_mut() {
827 c.open_mark(MarkKind::Strike);
828 }
829 }
830 Event::Start(Tag::Link { dest_url, .. }) => {
831 let url = dest_url.to_string();
832 if let Some(c) = self.cell_mut() {
833 c.open_mark(MarkKind::Link { url });
834 }
835 }
836 Event::End(TagEnd::Emphasis)
837 | Event::End(TagEnd::Strong)
838 | Event::End(TagEnd::Strikethrough)
839 | Event::End(TagEnd::Link) => {
840 if let Some(c) = self.cell_mut() {
841 c.close_mark();
842 }
843 }
844 _ => {}
845 }
846 }
847
848 fn emit_table(&mut self) {
849 if let Some(acc) = self.table.take() {
850 let props = json!({
851 "aligns": acc.aligns,
852 "header": acc.header,
853 "rows": acc.rows,
854 });
855 // Degraded when a cell dropped an inline image's url — the projection
856 // then carries the alt text but not the image (not a fixed point);
857 // otherwise the type's ceiling. Recorded, not acted on: `Loss`
858 // describes fidelity for a consumer to surface (issue #1043); no
859 // projection branches on it.
860 let loss = if acc.degraded {
861 Loss::Degraded
862 } else {
863 KnownIslandType::Table.default_loss()
864 };
865 self.mint_island(KnownIslandType::Table, props, loss);
866 }
867 }
868
869 fn emit_image(&mut self) {
870 self.ensure_open(LineKind::Para);
871 self.inline.push_raw(ISLAND_SLOT);
872 let props = json!({
873 "url": self.image_url,
874 "alt": self.image_alt.trim(),
875 });
876 self.mint_island(KnownIslandType::Image, props, KnownIslandType::Image.default_loss());
877 }
878
879 fn finish(mut self) -> Content {
880 if let Some(last) = self.cur.take() {
881 self.lines.push(last);
882 }
883 if self.lines.is_empty() {
884 // Empty document: one empty Para line.
885 self.lines.push(Line {
886 kind: LineKind::Para,
887 containers: Vec::new(),
888 continues: false,
889 });
890 }
891 // Close any marks left open (unterminated `<u>`, malformed input).
892 while !self.inline.open.is_empty() {
893 self.close_mark();
894 }
895 Content {
896 text: self.inline.text,
897 lines: self.lines,
898 marks: self.inline.marks,
899 islands: self.islands,
900 }
901 }
902}
903
904fn heading_level(level: pulldown_cmark::HeadingLevel) -> u8 {
905 use pulldown_cmark::HeadingLevel::*;
906 match level {
907 H1 => 1,
908 H2 => 2,
909 H3 => 3,
910 H4 => 4,
911 H5 => 5,
912 H6 => 6,
913 }
914}
915
916/// Sanitize a code-block info string to a language identifier (parity with the
917/// typst backend's `sanitize_lang_tag`).
918fn sanitize_lang(lang: &str) -> String {
919 lang.chars()
920 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
921 .collect()
922}
923
924// ---------------------------------------------------------------------------
925// MarkdownFixer — the raw-HTML filter between pulldown and the builder.
926//
927// One job: allowlist `<u>…</u>` as underline (rewritten to Strong start/end,
928// the classification carried to the builder in `underline_opens`) and drop
929// every other raw HTML event. Delimiter arithmetic is pulldown's — a fixer that
930// re-segments `***` runs can only disagree with CommonMark, and disagreeing
931// means deleting an asterisk the author typed (`***a**` is a literal `*` then
932// strong `a`; `***bold italic***` parses natively).
933// ---------------------------------------------------------------------------
934
935fn is_u_open_tag(html: &str) -> bool {
936 let s = html.trim();
937 if s.starts_with('<') && s.ends_with('>') {
938 s[1..s.len() - 1].trim().eq_ignore_ascii_case("u")
939 } else {
940 false
941 }
942}
943
944fn is_u_close_tag(html: &str) -> bool {
945 let s = html.trim();
946 if s.starts_with("</") && s.ends_with('>') {
947 s[2..s.len() - 1].trim().eq_ignore_ascii_case("u")
948 } else {
949 false
950 }
951}
952
953struct MarkdownFixer<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>> {
954 inner: I,
955 /// Shared with the [`Builder`]: each `<u>` open this fixer converts to
956 /// `Tag::Strong` records its `range.start` here, so the builder recovers the
957 /// underline without a second source-byte test (see [`UnderlineOpens`]).
958 underline_opens: UnderlineOpens,
959 _marker: std::marker::PhantomData<&'a ()>,
960}
961
962impl<'a, I> MarkdownFixer<'a, I>
963where
964 I: Iterator<Item = (Event<'a>, Range<usize>)>,
965{
966 fn new(inner: I, underline_opens: UnderlineOpens) -> Self {
967 Self {
968 inner,
969 underline_opens,
970 _marker: std::marker::PhantomData,
971 }
972 }
973}
974
975impl<'a, I> Iterator for MarkdownFixer<'a, I>
976where
977 I: Iterator<Item = (Event<'a>, Range<usize>)>,
978{
979 type Item = (Event<'a>, Range<usize>);
980
981 fn next(&mut self) -> Option<Self::Item> {
982 loop {
983 let (event, range) = self.inner.next()?;
984 return Some(match event {
985 Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_open_tag(html) => {
986 // Carry the `<u>` classification to the builder keyed on the
987 // tag's start offset, so it need not re-sniff the source.
988 self.underline_opens.borrow_mut().insert(range.start);
989 (Event::Start(Tag::Strong), range)
990 }
991 Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_close_tag(html) => {
992 (Event::End(TagEnd::Strong), range)
993 }
994 Event::Html(_) | Event::InlineHtml(_) => continue,
995 other => (other, range),
996 });
997 }
998 }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004 use crate::model::LineKind;
1005
1006 fn imp(md: &str) -> Content {
1007 let rt = from_markdown(md).unwrap();
1008 assert_eq!(rt.validate(), Ok(()), "invariants for {md:?}");
1009 rt
1010 }
1011
1012 fn imp_plain(s: &str) -> Content {
1013 let rt = from_plaintext(s);
1014 assert_eq!(rt.validate(), Ok(()), "invariants for {s:?}");
1015 rt
1016 }
1017
1018 /// Plaintext is literal: markdown delimiters are content, not syntax, and
1019 /// nothing is escaped or marked. The content is mark- and island-free.
1020 #[test]
1021 fn plaintext_is_literal_and_plain() {
1022 let rt = imp_plain("a *star* and _under_ #hash");
1023 assert_eq!(rt.text, "a *star* and _under_ #hash");
1024 assert!(rt.marks.is_empty());
1025 assert!(rt.islands.is_empty());
1026 assert!(rt.is_plain());
1027 assert!(rt.is_inline(), "one line with no formatting is also inline");
1028 }
1029
1030 /// The literal fixed point: `to_plaintext(from_plaintext(s)) == s` for clean
1031 /// text, and re-import is idempotent.
1032 #[test]
1033 fn plaintext_round_trip_is_verbatim_and_idempotent() {
1034 for s in ["", "one line", "a\nb", "a\n\nb", "trailing\n", "*not bold*"] {
1035 let rt = imp_plain(s);
1036 assert_eq!(crate::export::to_plaintext(&rt), s, "verbatim for {s:?}");
1037 let rt2 = from_plaintext(&crate::export::to_plaintext(&rt));
1038 assert_eq!(rt2.text, rt.text, "idempotent for {s:?}");
1039 assert_eq!(rt2.lines, rt.lines, "idempotent structure for {s:?}");
1040 }
1041 }
1042
1043 /// Lone `\n` between non-empty segments is a within-paragraph break
1044 /// (`continues: true`); a blank line resets it to a paragraph boundary.
1045 #[test]
1046 fn plaintext_derives_continues_from_line_structure() {
1047 let rt = imp_plain("a\nb");
1048 assert_eq!(rt.lines.len(), 2);
1049 assert!(!rt.lines[0].continues);
1050 assert!(rt.lines[1].continues, "lone \\n is a within-paragraph break");
1051
1052 let rt = imp_plain("a\n\nb");
1053 assert_eq!(rt.lines.len(), 3);
1054 assert!(!rt.lines[0].continues);
1055 assert!(!rt.lines[1].continues, "the blank line is a paragraph boundary");
1056 assert!(!rt.lines[2].continues, "text after a blank line starts a new block");
1057 }
1058
1059 /// Boundary cleanup keeps the content invariants: CRLF collapses to LF, bidi
1060 /// controls and the reserved island slot are dropped. Clean text is
1061 /// unaffected, so the fixed point still holds for it.
1062 #[test]
1063 fn plaintext_strips_invariant_breakers() {
1064 let rt = imp_plain("a\r\nb");
1065 assert_eq!(rt.text, "a\nb", "CRLF collapses to LF");
1066 let rt = imp_plain(&format!("a{ISLAND_SLOT}b"));
1067 assert_eq!(rt.text, "ab", "the reserved island slot is dropped");
1068 assert_eq!(rt.islands.len(), 0);
1069 }
1070
1071 #[test]
1072 fn plain_paragraph() {
1073 let rt = imp("Hello world");
1074 assert_eq!(rt.text, "Hello world");
1075 assert_eq!(rt.lines.len(), 1);
1076 assert_eq!(rt.lines[0].kind, LineKind::Para);
1077 assert!(rt.marks.is_empty());
1078 }
1079
1080 #[test]
1081 fn bold_and_italic_marks() {
1082 let rt = imp("a **b** _c_");
1083 assert_eq!(rt.text, "a b c");
1084 // "b" at 2..3 strong, "c" at 4..5 emph
1085 assert!(rt.marks.contains(&Mark {
1086 start: 2,
1087 end: 3,
1088 kind: MarkKind::Strong
1089 }));
1090 assert!(rt.marks.contains(&Mark {
1091 start: 4,
1092 end: 5,
1093 kind: MarkKind::Emph
1094 }));
1095 }
1096
1097 #[test]
1098 fn underline_from_u_tag() {
1099 let rt = imp("x <u>y</u> z");
1100 assert_eq!(rt.text, "x y z");
1101 assert!(rt
1102 .marks
1103 .iter()
1104 .any(|m| m.kind == MarkKind::Underline && m.start == 2 && m.end == 3));
1105 }
1106
1107 #[test]
1108 fn other_html_stripped() {
1109 let rt = imp("a <span>b</span> c");
1110 assert_eq!(rt.text, "a b c");
1111 }
1112
1113 /// Issue #1053: an asterisk run the author typed reaches the content.
1114 /// `***a**` is a literal `*` followed by strong `a` (CommonMark's rule of
1115 /// three: the closing run matches two of the three), and every shape here
1116 /// keeps its stars — a fixer re-segmenting the run deleted one.
1117 #[test]
1118 fn odd_asterisk_runs_keep_their_literal_star() {
1119 for (src, text) in [
1120 ("***a**", "*a"),
1121 ("***aa**", "*aa"),
1122 ("****a**", "**a"),
1123 ("a***a**", "a*a"),
1124 ] {
1125 assert_eq!(imp(src).text, text, "literal star dropped from {src:?}");
1126 }
1127 // The shape the fixup read as its reason for existing: pulldown nests
1128 // strong+emph natively, with no star left over.
1129 let rt = imp("***bold italic***");
1130 assert_eq!(rt.text, "bold italic");
1131 assert!(rt.marks.iter().any(|m| m.kind == MarkKind::Strong));
1132 assert!(rt.marks.iter().any(|m| m.kind == MarkKind::Emph));
1133 }
1134
1135 /// A `<u>`-lookalike must not be read as underline. The fixer's single
1136 /// `is_u_open_tag` classifier rejects `<ul>` (inner != "u"), so it is
1137 /// stripped like any other HTML — no underline, no strong. Regression for
1138 /// the old split where a separate 2-byte `<u` prefix peek would have
1139 /// mis-classified it had the fixer ever converted it.
1140 #[test]
1141 fn ul_lookalike_is_not_underline() {
1142 let rt = imp("x <ul>y</ul> z");
1143 assert_eq!(rt.text, "x y z");
1144 assert!(rt
1145 .marks
1146 .iter()
1147 .all(|m| m.kind != MarkKind::Underline && m.kind != MarkKind::Strong));
1148 }
1149
1150 #[test]
1151 fn two_paragraphs_two_lines() {
1152 let rt = imp("one\n\ntwo");
1153 assert_eq!(rt.text, "one\ntwo");
1154 assert_eq!(rt.lines.len(), 2);
1155 assert!(rt.lines.iter().all(|l| l.kind == LineKind::Para));
1156 }
1157
1158 #[test]
1159 fn heading_line_kind() {
1160 let rt = imp("## Title");
1161 assert_eq!(rt.text, "Title");
1162 assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
1163 }
1164
1165 #[test]
1166 fn inline_code_mark() {
1167 let rt = imp("run `cargo test` now");
1168 assert_eq!(rt.text, "run cargo test now");
1169 assert!(rt
1170 .marks
1171 .iter()
1172 .any(|m| m.kind == MarkKind::Code && m.start == 4 && m.end == 14));
1173 }
1174
1175 #[test]
1176 fn code_block_lines() {
1177 let rt = imp("```rust\nfn a() {}\nfn b() {}\n```");
1178 assert_eq!(rt.text, "fn a() {}\nfn b() {}");
1179 assert_eq!(rt.lines.len(), 2);
1180 assert!(rt.lines.iter().all(|l| l.kind
1181 == LineKind::Code {
1182 lang: Some("rust".into())
1183 }));
1184 }
1185
1186 #[test]
1187 fn bullet_list_containers() {
1188 let rt = imp("- a\n- b");
1189 assert_eq!(rt.text, "a\nb");
1190 assert_eq!(rt.lines.len(), 2);
1191 // Two items: same list (ordered=false, start=1), distinct ordinals.
1192 assert_eq!(
1193 rt.lines[0].containers,
1194 vec![Container::ListItem {
1195 ordered: false,
1196 start: 1,
1197 ordinal: 0
1198 }]
1199 );
1200 assert_eq!(
1201 rt.lines[1].containers,
1202 vec![Container::ListItem {
1203 ordered: false,
1204 start: 1,
1205 ordinal: 1
1206 }]
1207 );
1208 }
1209
1210 #[test]
1211 fn ordered_list_custom_start() {
1212 let rt = imp("3. a\n4. b");
1213 assert_eq!(
1214 rt.lines[0].containers,
1215 vec![Container::ListItem {
1216 ordered: true,
1217 start: 3,
1218 ordinal: 0
1219 }]
1220 );
1221 assert_eq!(
1222 rt.lines[1].containers,
1223 vec![Container::ListItem {
1224 ordered: true,
1225 start: 3,
1226 ordinal: 1
1227 }]
1228 );
1229 }
1230
1231 #[test]
1232 fn multi_paragraph_list_item_shares_container() {
1233 // One item with two paragraphs -> two Para lines sharing one ListItem.
1234 let rt = imp("- first\n\n second");
1235 assert_eq!(rt.lines.len(), 2);
1236 assert_eq!(rt.lines[0].containers, rt.lines[1].containers);
1237 assert_eq!(
1238 rt.lines[0].containers,
1239 vec![Container::ListItem {
1240 ordered: false,
1241 start: 1,
1242 ordinal: 0
1243 }]
1244 );
1245 }
1246
1247 #[test]
1248 fn blockquote_container() {
1249 let rt = imp("> quoted");
1250 assert_eq!(rt.text, "quoted");
1251 assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
1252 }
1253
1254 #[test]
1255 fn thematic_break_is_rule_line() {
1256 for src in ["---", "***", "___"] {
1257 let md = format!("one\n\n{src}\n\ntwo");
1258 let rt = imp(&md);
1259 assert_eq!(rt.lines.len(), 3, "source: {src}");
1260 assert_eq!(rt.lines[0].kind, LineKind::Para);
1261 assert_eq!(rt.lines[1].kind, LineKind::Rule, "source: {src}");
1262 assert_eq!(rt.lines[2].kind, LineKind::Para);
1263 // The rule line carries no text of its own.
1264 assert_eq!(rt.text, "one\n\ntwo");
1265 }
1266 }
1267
1268 #[test]
1269 fn table_is_block_island() {
1270 let rt = imp("| a | b |\n|---|---|\n| 1 | 2 |");
1271 assert_eq!(rt.text, "\u{FFFC}");
1272 assert_eq!(rt.lines[0].kind, LineKind::Island);
1273 assert_eq!(rt.islands.len(), 1);
1274 assert_eq!(rt.islands[0].island_type, "table");
1275 assert_eq!(rt.islands[0].loss, Loss::Lossless);
1276 }
1277
1278 #[test]
1279 fn island_ids_are_deterministic_and_positional() {
1280 // Island-id determinism (DOCUMENT_STORAGE.md § Island-id determinism):
1281 // ids derive from mint position, so the same markdown imports to
1282 // byte-identical canonical JSON — ids included — and the ids are exactly
1283 // the `isl-{n}` sequence. This is the contract that keeps content-hashes
1284 // stable across producers; a random/ambient id would break it.
1285 let md = "\n\n| h |\n|---|\n| c |";
1286 let a = imp(md);
1287 let b = imp(md);
1288 assert_eq!(a.to_canonical_json(), b.to_canonical_json());
1289 // Image slot then table island, minted in slot order.
1290 let ids: Vec<&str> = a.islands.iter().map(|i| i.id.as_str()).collect();
1291 assert_eq!(ids, ["isl-0", "isl-1"]);
1292 }
1293
1294 #[test]
1295 fn table_with_cell_image_degrades() {
1296 // GFM permits an inline image in a cell; the cell has no island slot to
1297 // carry it, so the alt text lands as plain cell text, the url is dropped,
1298 // and the island is Degraded (not the silent-Lossless lie).
1299 let rt = imp("| a | b |\n|---|---|\n|  | 2 |");
1300 assert_eq!(rt.islands.len(), 1);
1301 assert_eq!(rt.islands[0].island_type, "table");
1302 assert_eq!(rt.islands[0].loss, Loss::Degraded);
1303 // The dropped image left no nested island; alt survived as cell text.
1304 assert_eq!(rt.islands[0].props["rows"][0][0]["text"], "a cat");
1305 // A table with no cell image stays Lossless (regression guard).
1306 let plain = imp("| a | b |\n|---|---|\n| 1 | 2 |");
1307 assert_eq!(plain.islands[0].loss, Loss::Lossless);
1308 }
1309
1310 #[test]
1311 fn image_is_inline_island() {
1312 let rt = imp("see  here");
1313 assert_eq!(rt.text, "see \u{FFFC} here");
1314 assert_eq!(rt.islands.len(), 1);
1315 assert_eq!(rt.islands[0].island_type, "image");
1316 assert_eq!(rt.islands[0].props["url"], "cat.png");
1317 assert_eq!(rt.islands[0].props["alt"], "a cat");
1318 }
1319
1320 #[test]
1321 fn empty_list_item_keeps_its_line() {
1322 // An empty `- ` item (here an empty bullet nested in an ordered item)
1323 // must not vanish (regression for the container-flush fix).
1324 let rt = imp("- a\n-\n- b");
1325 assert_eq!(rt.lines.len(), 3, "empty middle item preserved");
1326 }
1327
1328 #[test]
1329 fn empty_blockquote_keeps_its_line() {
1330 let rt = imp("> ");
1331 assert_eq!(rt.lines.len(), 1);
1332 assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
1333 }
1334
1335 #[test]
1336 fn adjacent_sibling_lists_merge_is_stable() {
1337 // Documented canonicalization: two sibling bullet lists collapse to one.
1338 // Distinct markdown, one content — but the content is a fixed point.
1339 let rt = imp("* a\n\n+ b");
1340 let rt2 = from_markdown(&crate::export::to_markdown(&rt)).unwrap();
1341 assert_eq!(rt, rt2, "merged sibling lists still round-trip");
1342 }
1343
1344 #[test]
1345 fn empty_input_one_empty_line() {
1346 let rt = imp("");
1347 assert_eq!(rt.text, "");
1348 assert_eq!(rt.lines.len(), 1);
1349 }
1350
1351 #[test]
1352 fn mark_does_not_swallow_leading_newline() {
1353 // Regression (review finding 1): a mark starting a block must begin at
1354 // the content, not on the preceding line boundary.
1355 let rt = imp("a\n\n**b**");
1356 assert_eq!(rt.text, "a\nb");
1357 let m = &rt.marks[0];
1358 assert_eq!((m.start, m.end), (2, 3));
1359 assert_eq!(rt.text.chars().nth(m.start), Some('b'));
1360 }
1361
1362 #[test]
1363 fn import_and_editor_content_same_canonical_bytes() {
1364 // The freeze's central promise: equal content → equal bytes, whatever
1365 // the producer. Import of "a\n\n**b**" must byte-match a hand-built
1366 // editor content of the same content.
1367 let imported = imp("a\n\n**b**");
1368 let editor = Content {
1369 text: "a\nb".into(),
1370 lines: vec![
1371 Line {
1372 kind: LineKind::Para,
1373 containers: vec![],
1374 continues: false,
1375 },
1376 Line {
1377 kind: LineKind::Para,
1378 containers: vec![],
1379 continues: false,
1380 },
1381 ],
1382 marks: vec![Mark {
1383 start: 2,
1384 end: 3,
1385 kind: MarkKind::Strong,
1386 }],
1387 islands: vec![],
1388 };
1389 assert_eq!(imported.to_canonical_json(), editor.to_canonical_json());
1390 }
1391
1392 #[test]
1393 fn hard_break_is_a_continuation_line() {
1394 let rt = imp("line one\\\nline two");
1395 assert_eq!(rt.text, "line one\nline two");
1396 assert_eq!(rt.lines.len(), 2);
1397 assert!(!rt.lines[0].continues);
1398 assert!(rt.lines[1].continues, "hard break -> continuation line");
1399 }
1400
1401 #[test]
1402 fn heading_cannot_carry_hard_break() {
1403 // ATX headings are single-line: `## a \nb` is a heading plus a separate
1404 // paragraph, never a heading with a continuation. (The heading→space
1405 // canonicalization in HardBreak handling is defensive for editor-built
1406 // content, unreachable via markdown import.)
1407 let rt = imp("## a \nb");
1408 assert_eq!(rt.text, "a\nb");
1409 assert_eq!(rt.lines.len(), 2);
1410 assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
1411 assert_eq!(rt.lines[1].kind, LineKind::Para);
1412 assert!(!rt.lines[1].continues, "separate block, not a continuation");
1413 }
1414
1415 #[test]
1416 fn astral_positions_are_usv() {
1417 let rt = imp("a😀**b**");
1418 // 'a'(0) '😀'(1) 'b'(2) — strong over "b" is 2..3 in USV.
1419 assert_eq!(rt.text, "a😀b");
1420 assert!(rt
1421 .marks
1422 .iter()
1423 .any(|m| m.start == 2 && m.end == 3 && m.kind == MarkKind::Strong));
1424 }
1425}