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