Skip to main content

common/parser_tools/
content_parser.rs

1use crate::entities::{Alignment, ListStyle, MarkerType, SemanticRole, TextDirection};
2use crate::parser_tools::djot_options::DjotImportOptions;
3
4/// An inline image recovered by a parser, before it becomes an `ImageAnchor`.
5///
6/// `src` is whatever the source document pointed at — a relative path, a bare
7/// name, a URL. Parsers do not resolve it; that is the embedding application's
8/// job, and this crate never touches the filesystem.
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct ParsedImage {
11    pub src: String,
12    pub alt: String,
13    /// Display size in pixels, `0` when the source did not state one.
14    ///
15    /// Djot and HTML both carry these as attributes (`{width=800}`,
16    /// `<img width=…>`); Markdown has no syntax for them, so a Markdown import
17    /// leaves both zero and the caller supplies intrinsic dimensions.
18    pub width: i64,
19    pub height: i64,
20}
21
22/// A parsed inline span with formatting info.
23///
24/// A span carries *either* text or an image, never both: an image span's `text`
25/// is empty and its description lives in [`ParsedImage::alt`], so alt text
26/// cannot leak into the block's prose (and therefore cannot be counted as
27/// manuscript words or matched by a search).
28#[derive(Debug, Clone, Default)]
29pub struct ParsedSpan {
30    pub text: String,
31    pub bold: bool,
32    pub italic: bool,
33    pub underline: bool,
34    pub strikeout: bool,
35    pub code: bool,
36    /// Superscript (djot `^x^`). Maps to `CharVerticalAlignment::SuperScript`.
37    pub superscript: bool,
38    /// Subscript (djot `~x~`). Maps to `CharVerticalAlignment::SubScript`.
39    pub subscript: bool,
40    pub link_href: Option<String>,
41    /// Set when this span is an inline image rather than text.
42    pub image: Option<ParsedImage>,
43    /// Set when this span is a footnote *reference* rather than text — the
44    /// label naming the note, never the number a reader sees.
45    pub footnote_ref: Option<String>,
46}
47
48/// A parsed table cell containing inline spans.
49#[derive(Debug, Clone)]
50pub struct ParsedTableCell {
51    pub spans: Vec<ParsedSpan>,
52}
53
54/// A parsed table extracted from markdown or HTML.
55#[derive(Debug, Clone)]
56pub struct ParsedTable {
57    /// Number of header rows (typically 1 for markdown tables).
58    pub header_rows: usize,
59    /// All rows (header + body), each containing cells with their inline spans.
60    pub rows: Vec<Vec<ParsedTableCell>>,
61    /// Blockquote nesting depth at the point the table appeared
62    /// (0 = not inside a blockquote), mirroring `ParsedBlock::blockquote_depth`.
63    pub blockquote_depth: u32,
64}
65
66/// A parsed element: either a block or a table.
67#[derive(Debug, Clone)]
68pub enum ParsedElement {
69    Block(ParsedBlock),
70    Table(ParsedTable),
71    /// A footnote definition: the label its references name, and the blocks
72    /// making up its body.
73    ///
74    /// Separate from `Block` because a definition is not part of the flow it was
75    /// written in — it belongs wherever the output format puts notes, which the
76    /// importer expresses by giving it a detached frame of its own.
77    FootnoteDefinition {
78        label: String,
79        blocks: Vec<ParsedBlock>,
80    },
81}
82
83impl ParsedElement {
84    /// Extract blocks, flattening tables into one block per cell.
85    /// Use when table structure is not needed.
86    pub fn flatten_to_blocks(elements: Vec<ParsedElement>) -> Vec<ParsedBlock> {
87        let mut blocks = Vec::new();
88        for elem in elements {
89            match elem {
90                ParsedElement::Block(b) => blocks.push(b),
91                // A definition is not part of the flow it was written in — it
92                // belongs wherever the format puts notes. Flattening it in
93                // would splice a note's body into the prose at the point the
94                // definition happened to be typed.
95                ParsedElement::FootnoteDefinition { .. } => {}
96                ParsedElement::Table(t) => {
97                    for row in t.rows {
98                        for cell in row {
99                            blocks.push(ParsedBlock {
100                                spans: cell.spans,
101                                heading_level: None,
102                                list_style: None,
103                                list_indent: 0,
104                                list_prefix: String::new(),
105                                list_suffix: String::new(),
106                                marker: None,
107                                is_code_block: false,
108                                code_language: None,
109                                blockquote_depth: t.blockquote_depth,
110                                line_height: None,
111                                non_breakable_lines: None,
112                                page_break_before: None,
113                                direction: None,
114                                background_color: None,
115                                alignment: None,
116                                top_margin: None,
117                                text_indent: None,
118                                semantic_role: None,
119                            });
120                        }
121                    }
122                }
123            }
124        }
125        if blocks.is_empty() {
126            blocks.push(ParsedBlock {
127                spans: vec![ParsedSpan {
128                    text: String::new(),
129                    ..Default::default()
130                }],
131                heading_level: None,
132                list_style: None,
133                list_indent: 0,
134                list_prefix: String::new(),
135                list_suffix: String::new(),
136                marker: None,
137                is_code_block: false,
138                code_language: None,
139                blockquote_depth: 0,
140                line_height: None,
141                non_breakable_lines: None,
142                page_break_before: None,
143                direction: None,
144                background_color: None,
145                alignment: None,
146                top_margin: None,
147                text_indent: None,
148                semantic_role: None,
149            });
150        }
151        blocks
152    }
153}
154
155/// A parsed block (paragraph, heading, list item, code block)
156///
157/// `Default` is an unformatted, empty paragraph — the shape a caller wants when
158/// it has prose and no structure to give it. Deriving it also means a field
159/// added later reaches those callers as "absent" rather than as a compile error
160/// they would fix by guessing a value.
161#[derive(Debug, Clone, Default)]
162pub struct ParsedBlock {
163    pub spans: Vec<ParsedSpan>,
164    pub heading_level: Option<i64>,
165    pub list_style: Option<ListStyle>,
166    pub list_indent: u32,
167    /// Ordered-list delimiter prefix (e.g. `"("` for djot `(1)` lists; empty
168    /// otherwise). Stored on the `List` entity for round-trip fidelity.
169    pub list_prefix: String,
170    /// Ordered-list delimiter suffix (`"."` for `1.`, `")"` for `1)`/`(1)`;
171    /// empty for unordered lists).
172    pub list_suffix: String,
173    /// Task-list checkbox marker (djot `- [ ]` / `- [x]`). Maps to
174    /// `Block.fmt_marker`. `None` for non-task blocks.
175    pub marker: Option<MarkerType>,
176    pub is_code_block: bool,
177    pub code_language: Option<String>,
178    pub blockquote_depth: u32,
179    pub line_height: Option<i64>,
180    pub non_breakable_lines: Option<bool>,
181    /// Start this block on a new page (djot `{page_break_before=true}`). Maps to
182    /// `Block.fmt_page_break_before`. `None` when absent.
183    pub page_break_before: Option<bool>,
184    pub direction: Option<TextDirection>,
185    pub background_color: Option<String>,
186    /// Paragraph alignment (djot `{alignment=left|right|center|justify}`). Maps
187    /// to `Block.fmt_alignment`. `None` when no alignment attribute is present.
188    pub alignment: Option<Alignment>,
189    /// This block's own space-above (djot `{top_margin=<int>}`). Maps to
190    /// `Block.fmt_top_margin` and overrides the document-wide paragraph
191    /// spacing for this block alone. `None` when absent.
192    pub top_margin: Option<i64>,
193    /// This block's own first-line indent (djot `{text_indent=<int>}`). Maps to
194    /// `Block.fmt_text_indent` and overrides the document-wide first-line
195    /// indent for this block alone. `None` when absent.
196    pub text_indent: Option<i64>,
197    /// The enclosing blockquote's semantic role (djot `{semantic_role=epigraph}`),
198    /// written on the quote's first block because block attributes are the only channel
199    /// djot offers. The importer lifts it onto the `Frame`, where it belongs.
200    pub semantic_role: Option<SemanticRole>,
201}
202
203impl ParsedBlock {
204    /// Returns `true` when this block carries no block-level formatting,
205    /// meaning its content is purely inline.
206    pub fn is_inline_only(&self) -> bool {
207        self.heading_level.is_none()
208            && self.list_style.is_none()
209            && !self.is_code_block
210            && self.blockquote_depth == 0
211            && self.line_height.is_none()
212            && self.non_breakable_lines.is_none()
213            && self.page_break_before.is_none()
214            && self.direction.is_none()
215            && self.background_color.is_none()
216            && self.alignment.is_none()
217            && self.top_margin.is_none()
218            && self.text_indent.is_none()
219    }
220}
221
222// ─── Markdown parsing ────────────────────────────────────────────────
223
224/// Labels referenced as `[^label]` in `markdown` with no `[^label]: ...`
225/// definition anywhere in the document.
226///
227/// pulldown-cmark's default footnote mode (`Options::ENABLE_FOOTNOTES`, i.e.
228/// GitHub's syntax) only emits `Event::FootnoteReference` for a label some
229/// `Tag::FootnoteDefinition` actually defines (verified against 0.13) — an
230/// undefined reference silently decomposes into three ordinary `Text` events
231/// (`"["`, `"^label"`, `"]"`) instead, indistinguishable from a reader having
232/// typed literal brackets. `Options::ENABLE_OLD_FOOTNOTES` recognises it, but
233/// as `parse_markdown`'s doc comment explains, trades away multi-paragraph
234/// definition bodies to get there. Neither flag alone gives both, so
235/// `parse_markdown` hands pulldown-cmark a synthetic empty definition for
236/// every such label instead — real enough for it to recognise the reference,
237/// thrown away again before `elements` is returned.
238///
239/// Finding "every such label" is two parts: which labels pulldown-cmark's own
240/// block scanner (a first pass, at the caller's `options`) already recognises
241/// as truly defined, and which `[^…]`-shaped spans exist in the raw text at
242/// all. The second part is a plain scan, not a parser — it can over-match
243/// (inside a code span, inside a fenced block, a coincidental `[^x]:` in the
244/// middle of a sentence that real block parsing would never treat as a
245/// definition), but over-matching only ever produces an unused synthetic
246/// definition: pulldown-cmark's real, correct parse of the augmented text is
247/// what decides whether any span actually becomes a reference, and
248/// `parse_markdown` drops every synthesized definition regardless of whether
249/// that happened.
250fn dangling_footnote_labels(
251    markdown: &str,
252    options: pulldown_cmark::Options,
253) -> std::collections::BTreeSet<String> {
254    use pulldown_cmark::{Event, Parser, Tag};
255
256    let mut defined: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
257    for event in Parser::new_ext(markdown, options) {
258        if let Event::Start(Tag::FootnoteDefinition(label)) = event {
259            defined.insert(label.to_string());
260        }
261    }
262
263    let mut referenced: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
264    let bytes = markdown.as_bytes();
265    let mut search_from = 0usize;
266    while let Some(rel) = markdown[search_from..].find("[^") {
267        let open = search_from + rel;
268        let label_start = open + 2;
269        let Some(close_rel) = markdown[label_start..].find(']') else {
270            break;
271        };
272        let close = label_start + close_rel;
273        let label = &markdown[label_start..close];
274        // `]:` immediately after is definition syntax, not a reference —
275        // pulldown-cmark's own scan above already supplied the ground truth
276        // for which labels are truly defined; this only needs to avoid
277        // counting a definition's own label as a "reference" candidate.
278        let looks_like_definition = bytes.get(close + 1) == Some(&b':');
279        if !label.is_empty() && !looks_like_definition && !label.chars().any(char::is_whitespace) {
280            referenced.insert(label.to_string());
281        }
282        search_from = close + 1;
283    }
284
285    referenced.difference(&defined).cloned().collect()
286}
287
288pub fn parse_markdown(markdown: &str) -> Vec<ParsedElement> {
289    use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
290
291    // ENABLE_FOOTNOTES is required for pulldown-cmark to parse `[^label]`
292    // references and `[^label]: body` definitions at all — without it both
293    // fall through as plain link-reference-shaped text. Deliberately NOT
294    // `ENABLE_OLD_FOOTNOTES` too: that variant makes a reference with no
295    // definition survive (see `dangling_footnote_labels`), but in exchange
296    // breaks multi-paragraph definition bodies — a blank line before an
297    // indented continuation escapes the definition and becomes a sibling
298    // code block, which is exactly the shape this crate's own Markdown
299    // exporter writes for a note with more than one paragraph. Getting both
300    // is `dangling_footnote_labels`'s job, not an `Options` flag's.
301    let options = Options::ENABLE_STRIKETHROUGH
302        | Options::ENABLE_TABLES
303        | Options::ENABLE_TASKLISTS
304        | Options::ENABLE_FOOTNOTES;
305
306    // See `dangling_footnote_labels`: pulldown-cmark's GFM-style footnotes
307    // only recognise `[^label]` as a reference when some definition exists
308    // for it anywhere in the document, so an undefined one — the normal
309    // state for a host that owns note bodies itself — has to be given a
310    // throwaway definition before parsing, or it silently becomes the
311    // literal text "[^label]" instead of surviving as a reference.
312    let dangling = dangling_footnote_labels(markdown, options);
313    let augmented_owner;
314    let source: &str = if dangling.is_empty() {
315        markdown
316    } else {
317        augmented_owner = dangling
318            .iter()
319            .fold(markdown.to_string(), |mut acc, label| {
320                acc.push_str("\n\n[^");
321                acc.push_str(label);
322                acc.push_str("]:\n");
323                acc
324            });
325        &augmented_owner
326    };
327    let parser = Parser::new_ext(source, options);
328
329    let mut elements: Vec<ParsedElement> = Vec::new();
330    let mut current_spans: Vec<ParsedSpan> = Vec::new();
331    let mut current_heading: Option<i64> = None;
332    let mut current_list_style: Option<ListStyle> = None;
333    let mut is_code_block = false;
334    let mut code_language: Option<String> = None;
335    let mut blockquote_depth: u32 = 0;
336    let mut in_block = false;
337
338    // Formatting state stack
339    let mut bold = false;
340    let mut italic = false;
341    let mut strikeout = false;
342    let mut link_href: Option<String> = None;
343    // Set between an image's Start and End; its alt text arrives as Text events.
344    let mut pending_image: Option<ParsedImage> = None;
345
346    // The label and element index of the footnote definition currently open,
347    // if any. Mirrors `parse_djot`'s `footnote_open`: a definition's body is
348    // ordinary block content, parsed by the same machinery as everything
349    // else, then lifted back out at `End` into its own top-level element so
350    // it never becomes part of the flow it was written in. CommonMark
351    // footnote definitions cannot nest, so one slot suffices.
352    let mut footnote_open: Option<(String, usize)> = None;
353
354    // List style stack for nested lists (also tracks nesting depth)
355    let mut list_stack: Vec<Option<ListStyle>> = Vec::new();
356    let mut current_list_indent: u32 = 0;
357
358    // Table tracking state
359    let mut in_table = false;
360    let mut in_table_head = false;
361    let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
362    let mut current_row_cells: Vec<ParsedTableCell> = Vec::new();
363    let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
364    let mut table_header_rows: usize = 0;
365
366    for event in parser {
367        match event {
368            Event::Start(Tag::Paragraph) => {
369                in_block = true;
370                current_heading = None;
371                is_code_block = false;
372            }
373            Event::End(TagEnd::Paragraph) => {
374                if !current_spans.is_empty() || in_block {
375                    elements.push(ParsedElement::Block(ParsedBlock {
376                        spans: std::mem::take(&mut current_spans),
377                        heading_level: current_heading.take(),
378                        list_style: current_list_style.clone(),
379                        list_indent: current_list_indent,
380                        list_prefix: String::new(),
381                        list_suffix: String::new(),
382                        marker: None,
383                        is_code_block: false,
384                        code_language: None,
385                        blockquote_depth,
386                        line_height: None,
387                        non_breakable_lines: None,
388                        page_break_before: None,
389                        direction: None,
390                        background_color: None,
391                        alignment: None,
392                        top_margin: None,
393                        text_indent: None,
394                        semantic_role: None,
395                    }));
396                }
397                in_block = false;
398                current_list_style = None;
399            }
400            Event::Start(Tag::Heading { level, .. }) => {
401                in_block = true;
402                current_heading = Some(heading_level_to_i64(level));
403                is_code_block = false;
404            }
405            Event::End(TagEnd::Heading(_)) => {
406                elements.push(ParsedElement::Block(ParsedBlock {
407                    spans: std::mem::take(&mut current_spans),
408                    heading_level: current_heading.take(),
409                    list_style: None,
410                    list_indent: 0,
411                    list_prefix: String::new(),
412                    list_suffix: String::new(),
413                    marker: None,
414                    is_code_block: false,
415                    code_language: None,
416                    blockquote_depth,
417                    line_height: None,
418                    non_breakable_lines: None,
419                    page_break_before: None,
420                    direction: None,
421                    background_color: None,
422                    alignment: None,
423                    top_margin: None,
424                    text_indent: None,
425                    semantic_role: None,
426                }));
427                in_block = false;
428            }
429            Event::Start(Tag::List(ordered)) => {
430                let style = if ordered.is_some() {
431                    Some(ListStyle::Decimal)
432                } else {
433                    Some(ListStyle::Disc)
434                };
435                list_stack.push(style);
436            }
437            Event::End(TagEnd::List(_)) => {
438                list_stack.pop();
439            }
440            Event::Start(Tag::Item) => {
441                // Flush any accumulated spans from the parent item before
442                // starting a child item in a tight list
443                if !current_spans.is_empty() {
444                    elements.push(ParsedElement::Block(ParsedBlock {
445                        spans: std::mem::take(&mut current_spans),
446                        heading_level: None,
447                        list_style: current_list_style.clone(),
448                        list_indent: current_list_indent,
449                        list_prefix: String::new(),
450                        list_suffix: String::new(),
451                        marker: None,
452                        is_code_block: false,
453                        code_language: None,
454                        blockquote_depth,
455                        line_height: None,
456                        non_breakable_lines: None,
457                        page_break_before: None,
458                        direction: None,
459                        background_color: None,
460                        alignment: None,
461                        top_margin: None,
462                        text_indent: None,
463                        semantic_role: None,
464                    }));
465                }
466                in_block = true;
467                current_list_style = list_stack.last().cloned().flatten();
468                current_list_indent = if list_stack.is_empty() {
469                    0
470                } else {
471                    (list_stack.len() - 1) as u32
472                };
473            }
474            Event::End(TagEnd::Item) => {
475                // The paragraph inside the item will have already been flushed,
476                // but if there was no inner paragraph (tight list), flush now.
477                if !current_spans.is_empty() {
478                    elements.push(ParsedElement::Block(ParsedBlock {
479                        spans: std::mem::take(&mut current_spans),
480                        heading_level: None,
481                        list_style: current_list_style.clone(),
482                        list_indent: current_list_indent,
483                        list_prefix: String::new(),
484                        list_suffix: String::new(),
485                        marker: None,
486                        is_code_block: false,
487                        code_language: None,
488                        blockquote_depth,
489                        line_height: None,
490                        non_breakable_lines: None,
491                        page_break_before: None,
492                        direction: None,
493                        background_color: None,
494                        alignment: None,
495                        top_margin: None,
496                        text_indent: None,
497                        semantic_role: None,
498                    }));
499                }
500                in_block = false;
501                current_list_style = None;
502            }
503            Event::Start(Tag::CodeBlock(kind)) => {
504                in_block = true;
505                is_code_block = true;
506                code_language = match &kind {
507                    pulldown_cmark::CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
508                        Some(lang.to_string())
509                    }
510                    _ => None,
511                };
512            }
513            Event::End(TagEnd::CodeBlock) => {
514                // pulldown-cmark appends a trailing '\n' to code block text — strip it
515                if let Some(last) = current_spans.last_mut()
516                    && last.text.ends_with('\n')
517                {
518                    last.text.truncate(last.text.len() - 1);
519                }
520                elements.push(ParsedElement::Block(ParsedBlock {
521                    spans: std::mem::take(&mut current_spans),
522                    heading_level: None,
523                    list_style: None,
524                    list_indent: 0,
525                    list_prefix: String::new(),
526                    list_suffix: String::new(),
527                    marker: None,
528                    is_code_block: true,
529                    code_language: code_language.take(),
530                    blockquote_depth,
531                    line_height: None,
532                    non_breakable_lines: None,
533                    page_break_before: None,
534                    direction: None,
535                    background_color: None,
536                    alignment: None,
537                    top_margin: None,
538                    text_indent: None,
539                    semantic_role: None,
540                }));
541                in_block = false;
542                is_code_block = false;
543            }
544            // ─── Table events ───────────────────────────────────────
545            Event::Start(Tag::Table(_)) => {
546                in_table = true;
547                in_table_head = false;
548                table_rows.clear();
549                current_row_cells.clear();
550                current_cell_spans.clear();
551                table_header_rows = 0;
552            }
553            Event::End(TagEnd::Table) => {
554                elements.push(ParsedElement::Table(ParsedTable {
555                    header_rows: table_header_rows,
556                    rows: std::mem::take(&mut table_rows),
557                    blockquote_depth,
558                }));
559                in_table = false;
560            }
561            Event::Start(Tag::TableHead) => {
562                in_table_head = true;
563                current_row_cells.clear();
564            }
565            Event::End(TagEnd::TableHead) => {
566                // Flush the header row
567                table_rows.push(std::mem::take(&mut current_row_cells));
568                table_header_rows += 1;
569                in_table_head = false;
570            }
571            Event::Start(Tag::TableRow) => {
572                current_row_cells.clear();
573            }
574            Event::End(TagEnd::TableRow) if !in_table_head => {
575                // Body rows only — header row is flushed in End(TableHead)
576                table_rows.push(std::mem::take(&mut current_row_cells));
577            }
578            Event::Start(Tag::TableCell) => {
579                current_cell_spans.clear();
580            }
581            Event::End(TagEnd::TableCell) => {
582                current_row_cells.push(ParsedTableCell {
583                    spans: std::mem::take(&mut current_cell_spans),
584                });
585            }
586            // ─── Inline formatting ──────────────────────────────────
587            Event::Start(Tag::Emphasis) => {
588                italic = true;
589            }
590            Event::End(TagEnd::Emphasis) => {
591                italic = false;
592            }
593            Event::Start(Tag::Strong) => {
594                bold = true;
595            }
596            Event::End(TagEnd::Strong) => {
597                bold = false;
598            }
599            Event::Start(Tag::Strikethrough) => {
600                strikeout = true;
601            }
602            Event::End(TagEnd::Strikethrough) => {
603                strikeout = false;
604            }
605            Event::Start(Tag::Link { dest_url, .. }) => {
606                link_href = Some(dest_url.to_string());
607            }
608            Event::End(TagEnd::Link) => {
609                link_href = None;
610            }
611            // Markdown has no syntax for display size, so width/height stay 0
612            // and the caller supplies the image's intrinsic dimensions.
613            Event::Start(Tag::Image { dest_url, .. }) => {
614                pending_image = Some(ParsedImage {
615                    src: dest_url.to_string(),
616                    alt: String::new(),
617                    width: 0,
618                    height: 0,
619                });
620            }
621            Event::End(TagEnd::Image) => {
622                if let Some(image) = pending_image.take() {
623                    let span = ParsedSpan {
624                        text: String::new(),
625                        bold,
626                        italic,
627                        underline: false,
628                        strikeout,
629                        code: false,
630                        superscript: false,
631                        subscript: false,
632                        link_href: link_href.clone(),
633                        image: Some(image),
634                        footnote_ref: None,
635                    };
636                    if in_table {
637                        current_cell_spans.push(span);
638                    } else {
639                        if !in_block {
640                            in_block = true;
641                        }
642                        current_spans.push(span);
643                    }
644                }
645            }
646            Event::Text(text) => {
647                // Inside an image this is its alt text, which pulldown-cmark
648                // emits as an ordinary Text event. Without this guard it fell
649                // through and landed in the paragraph as prose — the image was
650                // dropped and its description silently became manuscript text.
651                if let Some(img) = pending_image.as_mut() {
652                    img.alt.push_str(&text);
653                    continue;
654                }
655                let span = ParsedSpan {
656                    text: text.to_string(),
657                    bold,
658                    italic,
659                    underline: false,
660                    strikeout,
661                    code: is_code_block,
662                    superscript: false,
663                    subscript: false,
664                    link_href: link_href.clone(),
665                    image: None,
666                    footnote_ref: None,
667                };
668                if in_table {
669                    current_cell_spans.push(span);
670                } else {
671                    if !in_block {
672                        in_block = true;
673                    }
674                    current_spans.push(span);
675                }
676            }
677            Event::Code(text) => {
678                let span = ParsedSpan {
679                    text: text.to_string(),
680                    bold,
681                    italic,
682                    underline: false,
683                    strikeout,
684                    code: true,
685                    superscript: false,
686                    subscript: false,
687                    link_href: link_href.clone(),
688                    image: None,
689                    footnote_ref: None,
690                };
691                if in_table {
692                    current_cell_spans.push(span);
693                } else {
694                    if !in_block {
695                        in_block = true;
696                    }
697                    current_spans.push(span);
698                }
699            }
700            Event::SoftBreak => {
701                let span = ParsedSpan {
702                    text: " ".to_string(),
703                    bold,
704                    italic,
705                    underline: false,
706                    strikeout,
707                    code: false,
708                    superscript: false,
709                    subscript: false,
710                    link_href: link_href.clone(),
711                    image: None,
712                    footnote_ref: None,
713                };
714                if in_table {
715                    current_cell_spans.push(span);
716                } else {
717                    current_spans.push(span);
718                }
719            }
720            Event::HardBreak if !current_spans.is_empty() || in_block => {
721                // Finalize current block
722                elements.push(ParsedElement::Block(ParsedBlock {
723                    spans: std::mem::take(&mut current_spans),
724                    heading_level: current_heading.take(),
725                    list_style: current_list_style.clone(),
726                    list_indent: current_list_indent,
727                    list_prefix: String::new(),
728                    list_suffix: String::new(),
729                    marker: None,
730                    is_code_block,
731                    code_language: code_language.clone(),
732                    blockquote_depth,
733                    line_height: None,
734                    non_breakable_lines: None,
735                    page_break_before: None,
736                    direction: None,
737                    background_color: None,
738                    alignment: None,
739                    top_margin: None,
740                    text_indent: None,
741                    semantic_role: None,
742                }));
743            }
744            Event::Start(Tag::BlockQuote(_)) => {
745                blockquote_depth += 1;
746            }
747            Event::End(TagEnd::BlockQuote(_)) => {
748                blockquote_depth = blockquote_depth.saturating_sub(1);
749            }
750            // ── Footnote definitions ──
751            //
752            // `[^label]: body` opens a block-level container; its body is
753            // ordinary paragraphs/lists/etc., pushed onto `elements` by the
754            // ordinary machinery above. `End` drains everything pushed since
755            // `Start` back out into one `FootnoteDefinition`, exactly as
756            // `parse_djot` does for the same `[^label]: body` syntax.
757            // Flushing any spans left open by an interrupted block first
758            // matches every other container boundary in this parser
759            // (List/Item/BlockQuote).
760            Event::Start(Tag::FootnoteDefinition(label)) => {
761                if !current_spans.is_empty() {
762                    elements.push(ParsedElement::Block(ParsedBlock {
763                        spans: std::mem::take(&mut current_spans),
764                        heading_level: current_heading.take(),
765                        list_style: current_list_style.clone(),
766                        list_indent: current_list_indent,
767                        list_prefix: String::new(),
768                        list_suffix: String::new(),
769                        marker: None,
770                        is_code_block: false,
771                        code_language: None,
772                        blockquote_depth,
773                        line_height: None,
774                        non_breakable_lines: None,
775                        page_break_before: None,
776                        direction: None,
777                        background_color: None,
778                        alignment: None,
779                        top_margin: None,
780                        text_indent: None,
781                        semantic_role: None,
782                    }));
783                }
784                footnote_open = Some((label.to_string(), elements.len()));
785            }
786            Event::End(TagEnd::FootnoteDefinition) => {
787                if !current_spans.is_empty() {
788                    elements.push(ParsedElement::Block(ParsedBlock {
789                        spans: std::mem::take(&mut current_spans),
790                        heading_level: current_heading.take(),
791                        list_style: current_list_style.clone(),
792                        list_indent: current_list_indent,
793                        list_prefix: String::new(),
794                        list_suffix: String::new(),
795                        marker: None,
796                        is_code_block: false,
797                        code_language: None,
798                        blockquote_depth,
799                        line_height: None,
800                        non_breakable_lines: None,
801                        page_break_before: None,
802                        direction: None,
803                        background_color: None,
804                        alignment: None,
805                        top_margin: None,
806                        text_indent: None,
807                        semantic_role: None,
808                    }));
809                }
810                if let Some((label, start)) = footnote_open.take() {
811                    let blocks: Vec<ParsedBlock> = elements
812                        .drain(start..)
813                        .filter_map(|e| match e {
814                            ParsedElement::Block(b) => Some(b),
815                            // A table inside a footnote is not representable
816                            // as note content, same limitation as djot.
817                            _ => None,
818                        })
819                        .collect();
820                    elements.push(ParsedElement::FootnoteDefinition { label, blocks });
821                }
822            }
823            // A footnote reference. pulldown-cmark emits this whether or not
824            // a matching `[^label]:` definition exists anywhere in the
825            // document — a dangling reference (the normal state for a host
826            // that owns note bodies itself) must survive just the same,
827            // mirroring `parse_djot`'s `E::FootnoteReference` handling.
828            Event::FootnoteReference(label) => {
829                let span = ParsedSpan {
830                    text: String::new(),
831                    bold,
832                    italic,
833                    underline: false,
834                    strikeout,
835                    code: false,
836                    superscript: false,
837                    subscript: false,
838                    link_href: link_href.clone(),
839                    image: None,
840                    footnote_ref: Some(label.to_string()),
841                };
842                if in_table {
843                    current_cell_spans.push(span);
844                } else {
845                    if !in_block {
846                        in_block = true;
847                    }
848                    current_spans.push(span);
849                }
850            }
851            _ => {}
852        }
853    }
854
855    // Flush any remaining content
856    if !current_spans.is_empty() {
857        elements.push(ParsedElement::Block(ParsedBlock {
858            spans: std::mem::take(&mut current_spans),
859            heading_level: current_heading,
860            list_style: current_list_style,
861            list_indent: current_list_indent,
862            list_prefix: String::new(),
863            list_suffix: String::new(),
864            marker: None,
865            is_code_block,
866            code_language: code_language.take(),
867            blockquote_depth,
868            line_height: None,
869            non_breakable_lines: None,
870            page_break_before: None,
871            direction: None,
872            background_color: None,
873            alignment: None,
874            top_margin: None,
875            text_indent: None,
876            semantic_role: None,
877        }));
878    }
879
880    // Drop the throwaway definitions `dangling_footnote_labels` asked for —
881    // they exist only to make pulldown-cmark recognise the reference as
882    // real, never to become a note the document owns. `elements` cannot
883    // become empty from this: a label reached `dangling` only via a
884    // reference actually present in `markdown`, so its surrounding block
885    // survives even with the synthetic definition gone.
886    if !dangling.is_empty() {
887        elements.retain(
888            |e| !matches!(e, ParsedElement::FootnoteDefinition { label, .. } if dangling.contains(label)),
889        );
890    }
891
892    // If no elements were parsed, create a single empty paragraph
893    if elements.is_empty() {
894        elements.push(ParsedElement::Block(ParsedBlock {
895            spans: vec![ParsedSpan {
896                text: String::new(),
897                ..Default::default()
898            }],
899            heading_level: None,
900            list_style: None,
901            list_indent: 0,
902            list_prefix: String::new(),
903            list_suffix: String::new(),
904            marker: None,
905            is_code_block: false,
906            code_language: None,
907            blockquote_depth: 0,
908            line_height: None,
909            non_breakable_lines: None,
910            page_break_before: None,
911            direction: None,
912            background_color: None,
913            alignment: None,
914            top_margin: None,
915            text_indent: None,
916            semantic_role: None,
917        }));
918    }
919
920    elements
921}
922
923fn heading_level_to_i64(level: pulldown_cmark::HeadingLevel) -> i64 {
924    use pulldown_cmark::HeadingLevel;
925    match level {
926        HeadingLevel::H1 => 1,
927        HeadingLevel::H2 => 2,
928        HeadingLevel::H3 => 3,
929        HeadingLevel::H4 => 4,
930        HeadingLevel::H5 => 5,
931        HeadingLevel::H6 => 6,
932    }
933}
934
935// ─── HTML parsing ────────────────────────────────────────────────────
936
937use scraper::Node;
938
939/// Parsed CSS block-level styles from an inline `style` attribute.
940#[derive(Debug, Clone, Default)]
941struct BlockStyles {
942    line_height: Option<i64>,
943    non_breakable_lines: Option<bool>,
944    page_break_before: Option<bool>,
945    direction: Option<TextDirection>,
946    background_color: Option<String>,
947    /// `white-space` asks for the source's own spacing to be kept verbatim.
948    /// Separate from `non_breakable_lines` because the two do not coincide:
949    /// `nowrap` forbids wrapping but still collapses runs of spaces, so a
950    /// block carrying it must be normalised like any other.
951    preserve_whitespace: Option<bool>,
952}
953
954/// Parse relevant CSS properties from an inline style string.
955/// Handles: line-height, white-space, break-before/page-break-before, direction,
956/// background-color.
957fn parse_block_styles(style: &str) -> BlockStyles {
958    let mut result = BlockStyles::default();
959    for part in style.split(';') {
960        let part = part.trim();
961        if let Some((prop, val)) = part.split_once(':') {
962            let prop = prop.trim().to_ascii_lowercase();
963            let val = val.trim();
964            match prop.as_str() {
965                "line-height" => {
966                    // Try parsing as a plain number (multiplier)
967                    if let Ok(v) = val.parse::<f64>() {
968                        result.line_height = Some((v * 1000.0) as i64);
969                    }
970                }
971                "white-space" if val == "pre" || val == "nowrap" || val == "pre-wrap" => {
972                    result.non_breakable_lines = Some(true);
973                    if val != "nowrap" {
974                        result.preserve_whitespace = Some(true);
975                    }
976                }
977                // CSS3 `break-before` and its CSS2 predecessor `page-break-before` mean
978                // the same thing; both are read because both are written (browsers still
979                // want the legacy spelling, and so do most EPUB engines).
980                "break-before" | "page-break-before" => {
981                    result.page_break_before = match val.to_ascii_lowercase().as_str() {
982                        "page" | "always" | "left" | "right" | "recto" | "verso" => Some(true),
983                        "avoid" | "auto" => Some(false),
984                        _ => None,
985                    };
986                }
987                "direction" => {
988                    if val.eq_ignore_ascii_case("rtl") {
989                        result.direction = Some(TextDirection::RightToLeft);
990                    } else if val.eq_ignore_ascii_case("ltr") {
991                        result.direction = Some(TextDirection::LeftToRight);
992                    }
993                }
994                "background-color" | "background" => {
995                    result.background_color = Some(val.to_string());
996                }
997                _ => {}
998            }
999        }
1000    }
1001    result
1002}
1003
1004/// The five characters HTML calls whitespace.
1005///
1006/// Deliberately not `char::is_whitespace`, which is `true` for U+00A0: a
1007/// no-break space is a character the writer typed — the one French typography
1008/// puts before a colon, holding "Attention&nbsp;:" together — and collapsing it
1009/// away is a silent edit of the text, not of its layout.
1010fn is_html_space(ch: char) -> bool {
1011    matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0C}')
1012}
1013
1014/// Whether a span run holds anything a reader would see.
1015fn spans_carry_content(spans: &[ParsedSpan]) -> bool {
1016    spans
1017        .iter()
1018        .any(|s| !s.text.is_empty() || s.image.is_some() || s.footnote_ref.is_some())
1019}
1020
1021/// Apply the CSS `white-space: normal` rules to one block's inline spans: every
1022/// run of HTML whitespace collapses to a single space, and the runs at the
1023/// block's two ends disappear entirely.
1024///
1025/// Per block rather than per text node, because a run crosses span boundaries —
1026/// `<b>word</b>\n<i>next</i>` is one collapsible run split over three nodes —
1027/// and because only the block knows where its own ends are.
1028///
1029/// Without this, an exporter's own source formatting reaches the document as
1030/// text. LibreOffice writes a newline after every `<p …>` and hard-wraps the
1031/// prose inside it at about seventy columns, so pasting a whole book put a
1032/// blank line before every paragraph and a break through the middle of every
1033/// sentence.
1034fn collapse_inline_whitespace(spans: &mut [ParsedSpan]) {
1035    fn is_replaced(span: &ParsedSpan) -> bool {
1036        span.image.is_some() || span.footnote_ref.is_some()
1037    }
1038
1039    // Collapse within each span first. Nothing non-empty is emptied here, which
1040    // is what lets the second pass read `is_empty()` as "this span held no text
1041    // to begin with" — the marker a `<br>` leaves behind.
1042    for span in spans.iter_mut() {
1043        if is_replaced(span) {
1044            continue;
1045        }
1046        let mut out = String::with_capacity(span.text.len());
1047        let mut in_run = false;
1048        for ch in span.text.chars() {
1049            if is_html_space(ch) {
1050                if !in_run {
1051                    out.push(' ');
1052                    in_run = true;
1053                }
1054            } else {
1055                out.push(ch);
1056                in_run = false;
1057            }
1058        }
1059        span.text = out;
1060    }
1061
1062    // The block's start counts as "already ended on a space", so a run opening
1063    // the block is dropped outright.
1064    let mut prev_ends_with_space = true;
1065    for span in spans.iter_mut() {
1066        if is_replaced(span) {
1067            prev_ends_with_space = false;
1068            continue;
1069        }
1070        if span.text.is_empty() {
1071            // The empty span a `<br>` leaves. It contributes no text, but it
1072            // still ends the collapsible run either side of it — without that,
1073            // `x <br> y` closes up into "xy".
1074            prev_ends_with_space = false;
1075            continue;
1076        }
1077        if prev_ends_with_space && span.text.starts_with(' ') {
1078            span.text.remove(0);
1079        }
1080        // A span emptied by that drop leaves the run open rather than closing
1081        // it: the space it carried belongs to the run its neighbours share.
1082        if !span.text.is_empty() {
1083            prev_ends_with_space = span.text.ends_with(' ');
1084        }
1085    }
1086
1087    // The block's trailing run, in whichever span still holds it. Replaced
1088    // content stops the walk: a space before a trailing image is between two
1089    // pieces of content, not after the last one.
1090    for span in spans.iter_mut().rev() {
1091        if is_replaced(span) {
1092            break;
1093        }
1094        if span.text.is_empty() {
1095            continue;
1096        }
1097        if span.text.ends_with(' ') {
1098            span.text.pop();
1099        }
1100        break;
1101    }
1102}
1103
1104pub fn parse_html(html: &str) -> Vec<ParsedBlock> {
1105    ParsedElement::flatten_to_blocks(parse_html_elements(html))
1106}
1107
1108/// The attribute an HTML producer uses to say "this element *is* a footnote
1109/// reference", and the label it names.
1110///
1111/// A data attribute on an otherwise-ordinary element, rather than a literal
1112/// `[^label]` in the text, because the text a producer emits is *escaped* on the
1113/// way to Djot — [`escape_djot_inline`](crate::parser_tools::djot_escape::escape_djot_inline)
1114/// neutralises `[`, `]` and `^`, so a reference smuggled through as characters
1115/// arrives as `\[\^1\]` and is prose rather than a reference. It has to be a
1116/// node before it is serialised, which is what this makes it.
1117///
1118/// [`ParsedSpan::footnote_ref`] is the same field the Markdown/Djot reader
1119/// already fills, so everything downstream — the document model, the Djot
1120/// writer, numbering — needs no change at all.
1121pub const HTML_FOOTNOTE_ATTR: &str = "data-footnote-ref";
1122
1123/// Build a footnote-reference span from any element carrying
1124/// [`HTML_FOOTNOTE_ATTR`].
1125fn html_footnote_span(
1126    el: &scraper::node::Element,
1127    link_href: Option<String>,
1128) -> Option<ParsedSpan> {
1129    let label = el.attr(HTML_FOOTNOTE_ATTR)?.trim();
1130    if label.is_empty() {
1131        return None;
1132    }
1133    Some(ParsedSpan {
1134        text: String::new(),
1135        link_href,
1136        footnote_ref: Some(label.to_string()),
1137        ..Default::default()
1138    })
1139}
1140
1141/// Build an inline-image span from an `<img>` element, if it has a usable
1142/// source.
1143///
1144/// `<img>` was matched by none of the HTML walker's three tag dispatches, so it
1145/// fell into their wildcard arms and was dropped whole — silently, and unlike
1146/// Markdown not even leaving its alt text behind. That is the path a browser or
1147/// Word paste travels.
1148fn html_img_span(el: &scraper::node::Element, link_href: Option<String>) -> Option<ParsedSpan> {
1149    let src = el.attr("src")?;
1150    if src.is_empty() {
1151        return None;
1152    }
1153    let dim = |name: &str| -> i64 {
1154        el.attr(name)
1155            .and_then(|v| v.trim().trim_end_matches("px").parse::<i64>().ok())
1156            .filter(|n| *n > 0)
1157            .unwrap_or(0)
1158    };
1159    Some(ParsedSpan {
1160        text: String::new(),
1161        link_href,
1162        image: Some(ParsedImage {
1163            src: src.to_string(),
1164            alt: el.attr("alt").unwrap_or_default().to_string(),
1165            width: dim("width"),
1166            height: dim("height"),
1167        }),
1168        ..Default::default()
1169    })
1170}
1171
1172pub fn parse_html_elements(html: &str) -> Vec<ParsedElement> {
1173    use scraper::Html;
1174
1175    let fragment = Html::parse_fragment(html);
1176    let mut elements: Vec<ParsedElement> = Vec::new();
1177
1178    // Walk the DOM tree starting from the root
1179    let root = fragment.root_element();
1180
1181    #[derive(Clone, Default)]
1182    struct FmtState {
1183        bold: bool,
1184        italic: bool,
1185        underline: bool,
1186        strikeout: bool,
1187        code: bool,
1188        superscript: bool,
1189        subscript: bool,
1190        link_href: Option<String>,
1191    }
1192
1193    const MAX_RECURSION_DEPTH: usize = 256;
1194
1195    /// Elements whose text content is machinery, not prose.
1196    ///
1197    /// `<style>` and `<script>` hold raw text the HTML spec never renders, and
1198    /// `<head>` and its metadata children carry none a reader is meant to see.
1199    /// Every walker below recurses into unknown tags on the assumption that
1200    /// they are inline wrappers, which turns a stylesheet into paragraphs —
1201    /// and Word, Google Docs and Chrome all put a `<style>` block in the
1202    /// `text/html` flavour they publish to the clipboard, so this is the
1203    /// ordinary paste, not an exotic one.
1204    fn is_metadata_tag(tag: &str) -> bool {
1205        matches!(
1206            tag,
1207            "head"
1208                | "style"
1209                | "script"
1210                | "title"
1211                | "meta"
1212                | "link"
1213                | "base"
1214                | "noscript"
1215                | "template"
1216        )
1217    }
1218
1219    /// Collect inline spans from a `<td>` or `<th>` cell element.
1220    fn collect_cell_spans(
1221        node: ego_tree::NodeRef<Node>,
1222        state: &FmtState,
1223        spans: &mut Vec<ParsedSpan>,
1224        depth: usize,
1225    ) {
1226        if depth > MAX_RECURSION_DEPTH {
1227            return;
1228        }
1229        for child in node.children() {
1230            match child.value() {
1231                Node::Text(text) => {
1232                    let t = text.text.to_string();
1233                    if !t.is_empty() {
1234                        spans.push(ParsedSpan {
1235                            text: t,
1236                            bold: state.bold,
1237                            italic: state.italic,
1238                            underline: state.underline,
1239                            strikeout: state.strikeout,
1240                            code: state.code,
1241                            superscript: state.superscript,
1242                            subscript: state.subscript,
1243                            link_href: state.link_href.clone(),
1244                            image: None,
1245                            footnote_ref: None,
1246                        });
1247                    }
1248                }
1249                Node::Element(el) => {
1250                    let tag = el.name();
1251                    if is_metadata_tag(tag) {
1252                        continue;
1253                    }
1254                    let mut new_state = state.clone();
1255                    match tag {
1256                        // Checked by attribute rather than by tag name: the
1257                        // producer picks the element (a `<sup>` renders sensibly
1258                        // in a browser), and only the attribute is a contract.
1259                        _ if el.attr(HTML_FOOTNOTE_ATTR).is_some() => {
1260                            if let Some(span) = html_footnote_span(el, new_state.link_href.clone())
1261                            {
1262                                spans.push(span);
1263                            }
1264                            continue;
1265                        }
1266                        "b" | "strong" => new_state.bold = true,
1267                        "i" | "em" => new_state.italic = true,
1268                        "u" | "ins" => new_state.underline = true,
1269                        "s" | "del" | "strike" => new_state.strikeout = true,
1270                        "code" => new_state.code = true,
1271                        "sup" => new_state.superscript = true,
1272                        "sub" => new_state.subscript = true,
1273                        "a" => {
1274                            if let Some(href) = el.attr("href") {
1275                                new_state.link_href = Some(href.to_string());
1276                            }
1277                        }
1278                        "img" => {
1279                            if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1280                                spans.push(span);
1281                            }
1282                            continue;
1283                        }
1284                        _ => {}
1285                    }
1286                    collect_cell_spans(child, &new_state, spans, depth + 1);
1287                }
1288                _ => {}
1289            }
1290        }
1291    }
1292
1293    /// Parse a `<table>` element into a ParsedTable.
1294    fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
1295        let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1296        let mut header_rows: usize = 0;
1297
1298        fn collect_rows(
1299            node: ego_tree::NodeRef<Node>,
1300            rows: &mut Vec<Vec<ParsedTableCell>>,
1301            header_rows: &mut usize,
1302            in_thead: bool,
1303        ) {
1304            for child in node.children() {
1305                if let Node::Element(el) = child.value() {
1306                    match el.name() {
1307                        "thead" => collect_rows(child, rows, header_rows, true),
1308                        "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
1309                        "tr" => {
1310                            let mut cells: Vec<ParsedTableCell> = Vec::new();
1311                            for td in child.children() {
1312                                if let Node::Element(td_el) = td.value()
1313                                    && matches!(td_el.name(), "td" | "th")
1314                                {
1315                                    let mut spans = Vec::new();
1316                                    let state = FmtState::default();
1317                                    collect_cell_spans(td, &state, &mut spans, 0);
1318                                    collapse_inline_whitespace(&mut spans);
1319                                    if spans.is_empty() {
1320                                        spans.push(ParsedSpan::default());
1321                                    }
1322                                    cells.push(ParsedTableCell { spans });
1323                                }
1324                            }
1325                            if !cells.is_empty() {
1326                                rows.push(cells);
1327                                if in_thead {
1328                                    *header_rows += 1;
1329                                }
1330                            }
1331                        }
1332                        _ => {}
1333                    }
1334                }
1335            }
1336        }
1337
1338        collect_rows(table_node, &mut rows, &mut header_rows, false);
1339
1340        // Tables without explicit <thead> but with <th> cells: treat first row as header
1341        if header_rows == 0 && !rows.is_empty() {
1342            header_rows = 1;
1343        }
1344
1345        ParsedTable {
1346            header_rows,
1347            rows,
1348            // The caller (`walk_node`) sets the real depth — this helper has
1349            // no visibility into the surrounding blockquote nesting.
1350            blockquote_depth: 0,
1351        }
1352    }
1353
1354    fn walk_node(
1355        node: ego_tree::NodeRef<Node>,
1356        state: &FmtState,
1357        elements: &mut Vec<ParsedElement>,
1358        current_list_style: &Option<ListStyle>,
1359        blockquote_depth: u32,
1360        list_depth: u32,
1361        depth: usize,
1362    ) {
1363        if depth > MAX_RECURSION_DEPTH {
1364            return;
1365        }
1366        match node.value() {
1367            Node::Element(el) => {
1368                let tag = el.name();
1369                if is_metadata_tag(tag) {
1370                    return;
1371                }
1372                let mut new_state = state.clone();
1373                let mut new_list_style = current_list_style.clone();
1374                let mut bq_depth = blockquote_depth;
1375                let mut new_list_depth = list_depth;
1376
1377                // Determine if this is a block-level element
1378                let is_block_tag = matches!(
1379                    tag,
1380                    "p" | "div"
1381                        | "h1"
1382                        | "h2"
1383                        | "h3"
1384                        | "h4"
1385                        | "h5"
1386                        | "h6"
1387                        | "li"
1388                        | "pre"
1389                        | "br"
1390                        | "blockquote"
1391                        | "body"
1392                        | "html"
1393                );
1394
1395                // Update formatting state
1396                match tag {
1397                    "b" | "strong" => new_state.bold = true,
1398                    "i" | "em" => new_state.italic = true,
1399                    "u" | "ins" => new_state.underline = true,
1400                    "s" | "del" | "strike" => new_state.strikeout = true,
1401                    "code" => new_state.code = true,
1402                    "sup" => new_state.superscript = true,
1403                    "sub" => new_state.subscript = true,
1404                    "a" => {
1405                        if let Some(href) = el.attr("href") {
1406                            new_state.link_href = Some(href.to_string());
1407                        }
1408                    }
1409                    "ul" => {
1410                        new_list_style = Some(ListStyle::Disc);
1411                        new_list_depth = list_depth + 1;
1412                    }
1413                    "ol" => {
1414                        new_list_style = Some(ListStyle::Decimal);
1415                        new_list_depth = list_depth + 1;
1416                    }
1417                    "blockquote" => {
1418                        bq_depth += 1;
1419                    }
1420                    _ => {}
1421                }
1422
1423                // Determine heading level
1424                let heading_level = match tag {
1425                    "h1" => Some(1),
1426                    "h2" => Some(2),
1427                    "h3" => Some(3),
1428                    "h4" => Some(4),
1429                    "h5" => Some(5),
1430                    "h6" => Some(6),
1431                    _ => None,
1432                };
1433
1434                let is_code_block = tag == "pre";
1435
1436                // Extract code language from <pre><code class="language-xxx">
1437                let code_language = if is_code_block {
1438                    node.children().find_map(|child| {
1439                        if let Node::Element(cel) = child.value()
1440                            && cel.name() == "code"
1441                            && let Some(cls) = cel.attr("class")
1442                        {
1443                            return cls
1444                                .split_whitespace()
1445                                .find_map(|c| c.strip_prefix("language-"))
1446                                .map(|l| l.to_string());
1447                        }
1448                        None
1449                    })
1450                } else {
1451                    None
1452                };
1453
1454                // Extract CSS styles from block-level elements
1455                let css = if is_block_tag {
1456                    el.attr("style").map(parse_block_styles).unwrap_or_default()
1457                } else {
1458                    BlockStyles::default()
1459                };
1460
1461                if tag == "table" {
1462                    // Parse table structure into a ParsedTable
1463                    let mut parsed_table = parse_table_element(node);
1464                    if !parsed_table.rows.is_empty() {
1465                        parsed_table.blockquote_depth = bq_depth;
1466                        elements.push(ParsedElement::Table(parsed_table));
1467                    }
1468                    return;
1469                }
1470
1471                if tag == "br" {
1472                    // <br> creates a new block
1473                    elements.push(ParsedElement::Block(ParsedBlock {
1474                        spans: vec![ParsedSpan {
1475                            text: String::new(),
1476                            ..Default::default()
1477                        }],
1478                        heading_level: None,
1479                        list_style: None,
1480                        list_indent: 0,
1481                        list_prefix: String::new(),
1482                        list_suffix: String::new(),
1483                        marker: None,
1484                        is_code_block: false,
1485                        code_language: None,
1486                        blockquote_depth: bq_depth,
1487                        line_height: None,
1488                        non_breakable_lines: None,
1489                        page_break_before: None,
1490                        direction: None,
1491                        background_color: None,
1492                        alignment: None,
1493                        top_margin: None,
1494                        text_indent: None,
1495                        semantic_role: None,
1496                    }));
1497                    return;
1498                }
1499
1500                if tag == "blockquote" {
1501                    // Blockquote is a container — recurse into children with increased depth
1502                    for child in node.children() {
1503                        walk_node(
1504                            child,
1505                            &new_state,
1506                            elements,
1507                            &new_list_style,
1508                            bq_depth,
1509                            new_list_depth,
1510                            depth + 1,
1511                        );
1512                    }
1513                } else if is_block_tag && tag != "br" {
1514                    // Start collecting spans for a new block.
1515                    // Use a temporary buffer so that nested block-level
1516                    // elements (e.g. sub-lists inside <li>) are collected
1517                    // separately and appended *after* the parent block.
1518                    let mut spans: Vec<ParsedSpan> = Vec::new();
1519                    let mut nested_elements: Vec<ParsedElement> = Vec::new();
1520                    collect_inline_spans(
1521                        node,
1522                        &new_state,
1523                        &mut spans,
1524                        &new_list_style,
1525                        &mut nested_elements,
1526                        bq_depth,
1527                        new_list_depth,
1528                        depth + 1,
1529                    );
1530
1531                    let list_style_for_block = if tag == "li" {
1532                        new_list_style.clone()
1533                    } else {
1534                        None
1535                    };
1536
1537                    let list_indent_for_block = if tag == "li" {
1538                        new_list_depth.saturating_sub(1)
1539                    } else {
1540                        0
1541                    };
1542
1543                    if !(is_code_block || css.preserve_whitespace == Some(true)) {
1544                        collapse_inline_whitespace(&mut spans);
1545                    }
1546
1547                    // Whitespace between block-level children is layout, not
1548                    // content. `<body>` holds a newline after every `</p>` an
1549                    // exporter writes, and keeping those as the container's own
1550                    // inline run is what closed a pasted book with a paragraph
1551                    // of four thousand blank lines. A block that produced no
1552                    // children keeps its empty run instead: `<p> </p>` is an
1553                    // empty paragraph the writer meant to be there.
1554                    let own_run_is_content =
1555                        spans_carry_content(&spans) || nested_elements.is_empty();
1556
1557                    if (!spans.is_empty() && own_run_is_content) || heading_level.is_some() {
1558                        elements.push(ParsedElement::Block(ParsedBlock {
1559                            spans,
1560                            heading_level,
1561                            list_style: list_style_for_block,
1562                            list_indent: list_indent_for_block,
1563                            list_prefix: String::new(),
1564                            list_suffix: String::new(),
1565                            marker: None,
1566                            is_code_block,
1567                            code_language,
1568                            blockquote_depth: bq_depth,
1569                            line_height: css.line_height,
1570                            non_breakable_lines: css.non_breakable_lines,
1571                            page_break_before: css.page_break_before,
1572                            direction: css.direction,
1573                            background_color: css.background_color,
1574                            alignment: None,
1575                            top_margin: None,
1576                            text_indent: None,
1577                            semantic_role: None,
1578                        }));
1579                    }
1580                    // Append nested block elements after the parent block
1581                    elements.append(&mut nested_elements);
1582                } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
1583                    // Container elements: recurse into children
1584                    for child in node.children() {
1585                        walk_node(
1586                            child,
1587                            &new_state,
1588                            elements,
1589                            &new_list_style,
1590                            bq_depth,
1591                            new_list_depth,
1592                            depth + 1,
1593                        );
1594                    }
1595                } else {
1596                    // Inline element or unknown: recurse
1597                    for child in node.children() {
1598                        walk_node(
1599                            child,
1600                            &new_state,
1601                            elements,
1602                            current_list_style,
1603                            bq_depth,
1604                            list_depth,
1605                            depth + 1,
1606                        );
1607                    }
1608                }
1609            }
1610            Node::Text(text) => {
1611                let t = text.text.to_string();
1612                let trimmed = t.trim();
1613                if !trimmed.is_empty() {
1614                    // Bare text not in a block — create a paragraph
1615                    elements.push(ParsedElement::Block(ParsedBlock {
1616                        spans: vec![ParsedSpan {
1617                            text: trimmed.to_string(),
1618                            bold: state.bold,
1619                            italic: state.italic,
1620                            underline: state.underline,
1621                            strikeout: state.strikeout,
1622                            code: state.code,
1623                            superscript: state.superscript,
1624                            subscript: state.subscript,
1625                            link_href: state.link_href.clone(),
1626                            image: None,
1627                            footnote_ref: None,
1628                        }],
1629                        heading_level: None,
1630                        list_style: None,
1631                        list_indent: 0,
1632                        list_prefix: String::new(),
1633                        list_suffix: String::new(),
1634                        marker: None,
1635                        is_code_block: false,
1636                        code_language: None,
1637                        blockquote_depth,
1638                        line_height: None,
1639                        non_breakable_lines: None,
1640                        page_break_before: None,
1641                        direction: None,
1642                        background_color: None,
1643                        alignment: None,
1644                        top_margin: None,
1645                        text_indent: None,
1646                        semantic_role: None,
1647                    }));
1648                }
1649            }
1650            _ => {
1651                // Document, Comment, etc. — recurse children
1652                for child in node.children() {
1653                    walk_node(
1654                        child,
1655                        state,
1656                        elements,
1657                        current_list_style,
1658                        blockquote_depth,
1659                        list_depth,
1660                        depth + 1,
1661                    );
1662                }
1663            }
1664        }
1665    }
1666
1667    /// Collect inline spans from a block-level element's children.
1668    /// If a nested block-level element is encountered, it is flushed as a
1669    /// separate block.
1670    #[allow(clippy::too_many_arguments)]
1671    fn collect_inline_spans(
1672        node: ego_tree::NodeRef<Node>,
1673        state: &FmtState,
1674        spans: &mut Vec<ParsedSpan>,
1675        current_list_style: &Option<ListStyle>,
1676        elements: &mut Vec<ParsedElement>,
1677        blockquote_depth: u32,
1678        list_depth: u32,
1679        depth: usize,
1680    ) {
1681        if depth > MAX_RECURSION_DEPTH {
1682            return;
1683        }
1684        for child in node.children() {
1685            match child.value() {
1686                Node::Text(text) => {
1687                    let t = text.text.to_string();
1688                    if !t.is_empty() {
1689                        spans.push(ParsedSpan {
1690                            text: t,
1691                            bold: state.bold,
1692                            italic: state.italic,
1693                            underline: state.underline,
1694                            strikeout: state.strikeout,
1695                            code: state.code,
1696                            superscript: state.superscript,
1697                            subscript: state.subscript,
1698                            link_href: state.link_href.clone(),
1699                            image: None,
1700                            footnote_ref: None,
1701                        });
1702                    }
1703                }
1704                Node::Element(el) => {
1705                    let tag = el.name();
1706                    if is_metadata_tag(tag) {
1707                        continue;
1708                    }
1709                    let mut new_state = state.clone();
1710
1711                    match tag {
1712                        // Checked by attribute rather than by tag name: the
1713                        // producer picks the element (a `<sup>` renders sensibly
1714                        // in a browser), and only the attribute is a contract.
1715                        _ if el.attr(HTML_FOOTNOTE_ATTR).is_some() => {
1716                            if let Some(span) = html_footnote_span(el, new_state.link_href.clone())
1717                            {
1718                                spans.push(span);
1719                            }
1720                            continue;
1721                        }
1722                        "b" | "strong" => new_state.bold = true,
1723                        "i" | "em" => new_state.italic = true,
1724                        "u" | "ins" => new_state.underline = true,
1725                        "s" | "del" | "strike" => new_state.strikeout = true,
1726                        "code" => new_state.code = true,
1727                        "sup" => new_state.superscript = true,
1728                        "sub" => new_state.subscript = true,
1729                        "a" => {
1730                            if let Some(href) = el.attr("href") {
1731                                new_state.link_href = Some(href.to_string());
1732                            }
1733                        }
1734                        "img" => {
1735                            if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1736                                spans.push(span);
1737                            }
1738                            continue;
1739                        }
1740                        _ => {}
1741                    }
1742
1743                    // Check for nested block elements
1744                    let nested_block = matches!(
1745                        tag,
1746                        "p" | "div"
1747                            | "h1"
1748                            | "h2"
1749                            | "h3"
1750                            | "h4"
1751                            | "h5"
1752                            | "h6"
1753                            | "li"
1754                            | "pre"
1755                            | "blockquote"
1756                            | "ul"
1757                            | "ol"
1758                    );
1759
1760                    if tag == "br" {
1761                        // br within a block: treat as splitting into new block
1762                        // For simplicity, just add a newline to current span
1763                        spans.push(ParsedSpan {
1764                            text: String::new(),
1765                            ..Default::default()
1766                        });
1767                    } else if nested_block || tag == "table" {
1768                        // Flush as separate element
1769                        walk_node(
1770                            child,
1771                            &new_state,
1772                            elements,
1773                            current_list_style,
1774                            blockquote_depth,
1775                            list_depth,
1776                            depth + 1,
1777                        );
1778                    } else {
1779                        // Inline element: recurse
1780                        collect_inline_spans(
1781                            child,
1782                            &new_state,
1783                            spans,
1784                            current_list_style,
1785                            elements,
1786                            blockquote_depth,
1787                            list_depth,
1788                            depth + 1,
1789                        );
1790                    }
1791                }
1792                _ => {}
1793            }
1794        }
1795    }
1796
1797    let initial_state = FmtState::default();
1798    // Treat the root element as a block-level container so that
1799    // top-level inline elements (e.g. `<b>Bold</b> <em>Italic</em>`)
1800    // are grouped into a single block instead of becoming separate blocks.
1801    let mut root_spans: Vec<ParsedSpan> = Vec::new();
1802    collect_inline_spans(
1803        *root,
1804        &initial_state,
1805        &mut root_spans,
1806        &None,
1807        &mut elements,
1808        0,
1809        0,
1810        0,
1811    );
1812    collapse_inline_whitespace(&mut root_spans);
1813    // Only when something survived: the root's direct text children are the
1814    // newlines between top-level blocks in every document an exporter writes.
1815    if spans_carry_content(&root_spans) {
1816        elements.push(ParsedElement::Block(ParsedBlock {
1817            spans: root_spans,
1818            heading_level: None,
1819            list_style: None,
1820            list_indent: 0,
1821            list_prefix: String::new(),
1822            list_suffix: String::new(),
1823            marker: None,
1824            is_code_block: false,
1825            code_language: None,
1826            blockquote_depth: 0,
1827            line_height: None,
1828            non_breakable_lines: None,
1829            page_break_before: None,
1830            direction: None,
1831            background_color: None,
1832            alignment: None,
1833            top_margin: None,
1834            text_indent: None,
1835            semantic_role: None,
1836        }));
1837    }
1838
1839    // If no elements were parsed, create a single empty paragraph
1840    if elements.is_empty() {
1841        elements.push(ParsedElement::Block(ParsedBlock {
1842            spans: vec![ParsedSpan {
1843                text: String::new(),
1844                ..Default::default()
1845            }],
1846            heading_level: None,
1847            list_style: None,
1848            list_indent: 0,
1849            list_prefix: String::new(),
1850            list_suffix: String::new(),
1851            marker: None,
1852            is_code_block: false,
1853            code_language: None,
1854            blockquote_depth: 0,
1855            line_height: None,
1856            non_breakable_lines: None,
1857            page_break_before: None,
1858            direction: None,
1859            background_color: None,
1860            alignment: None,
1861            top_margin: None,
1862            text_indent: None,
1863            semantic_role: None,
1864        }));
1865    }
1866
1867    elements
1868}
1869
1870/// Convert a `ParsedSpan` (parser output) into the `CharacterFormat` used by
1871/// `FormatRun`. `is_code_block` forces `monospace` as the font family for
1872/// every span inside a code block.
1873pub fn character_format_from_span(
1874    span: &ParsedSpan,
1875    is_code_block: bool,
1876) -> crate::format_runs::CharacterFormat {
1877    use crate::entities::CharVerticalAlignment;
1878    crate::format_runs::CharacterFormat {
1879        font_bold: if span.bold { Some(true) } else { None },
1880        font_italic: if span.italic { Some(true) } else { None },
1881        font_underline: if span.underline { Some(true) } else { None },
1882        font_strikeout: if span.strikeout { Some(true) } else { None },
1883        font_family: if span.code || is_code_block {
1884            Some("monospace".to_string())
1885        } else {
1886            None
1887        },
1888        anchor_href: span.link_href.clone(),
1889        is_anchor: if span.link_href.is_some() {
1890            Some(true)
1891        } else {
1892            None
1893        },
1894        vertical_alignment: if span.superscript {
1895            Some(CharVerticalAlignment::SuperScript)
1896        } else if span.subscript {
1897            Some(CharVerticalAlignment::SubScript)
1898        } else {
1899            None
1900        },
1901        ..Default::default()
1902    }
1903}
1904
1905/// Translate a slice of parsed spans into `(plain_text, format_runs)`.
1906///
1907/// One non-default span yields one `FormatRun`; spans with empty
1908/// `CharacterFormat` (no decoration, no link, no code) emit no run, since an
1909/// absent run means "inherit default formatting" in the new model. Adjacent
1910/// runs with identical formats are coalesced via `coalesce_in_place` so the
1911/// resulting vector satisfies `debug_assert_well_formed`.
1912///
1913/// Returns the concatenated `plain_text` of all spans and a sorted,
1914/// non-overlapping, coalesced `Vec<FormatRun>`. Both safe to feed straight
1915/// into the store under the dual-write bridge.
1916pub fn format_runs_from_spans(spans: &[ParsedSpan], is_code_block: bool) -> ParsedInline {
1917    use crate::format_runs::{
1918        CharacterFormat, FootnoteRefAnchor, FormatRun, ImageAnchor, coalesce_in_place,
1919    };
1920
1921    let mut plain_text = String::new();
1922    let mut runs: Vec<FormatRun> = Vec::new();
1923    let mut images: Vec<ImageAnchor> = Vec::new();
1924    let mut footnote_refs: Vec<FootnoteRefAnchor> = Vec::new();
1925    let default = CharacterFormat::default();
1926
1927    for span in spans {
1928        let byte_start = plain_text.len() as u32;
1929
1930        if let Some(label) = &span.footnote_ref {
1931            // A reference occupies one U+FFFC, exactly as an image does, so
1932            // every downstream offset treats the two alike.
1933            plain_text.push('\u{FFFC}');
1934            // Raised, always — whatever the surrounding run is doing.
1935            //
1936            // A footnote marker is superscript by definition, in every
1937            // typographic tradition and in every reader that renders djot. The
1938            // ambient `superscript` flag the span carries is the *prose's*, and
1939            // prose is not superscript, so taking it verbatim sets a note's
1940            // number on the baseline in the middle of a sentence — which reads
1941            // as a stray digit the writer typed rather than as a reference.
1942            //
1943            // It is set here, on the anchor, rather than at render time so that
1944            // every consumer agrees: the editor raises it, the exporters that
1945            // carry character formatting carry it, and the djot writer knows to
1946            // emit `[^label]` *without* wrapping it in `^…^` (see
1947            // `a_reference_is_not_wrapped_in_superscript_markers`).
1948            let mut format = character_format_from_span(span, is_code_block);
1949            format.vertical_alignment = Some(crate::entities::CharVerticalAlignment::SuperScript);
1950            footnote_refs.push(FootnoteRefAnchor {
1951                byte_offset: byte_start,
1952                label: label.clone(),
1953                format,
1954            });
1955            continue;
1956        }
1957
1958        if let Some(image) = &span.image {
1959            // An image occupies one U+FFFC in the text, exactly as
1960            // `insert_image` mirrors into the rope, so every downstream offset
1961            // calculation treats a parsed image and an inserted one alike.
1962            plain_text.push('\u{FFFC}');
1963            images.push(ImageAnchor {
1964                byte_offset: byte_start,
1965                name: image.src.clone(),
1966                alt: image.alt.clone(),
1967                width: image.width,
1968                height: image.height,
1969                quality: 100,
1970                format: character_format_from_span(span, is_code_block),
1971            });
1972            continue;
1973        }
1974
1975        plain_text.push_str(&span.text);
1976        let byte_end = plain_text.len() as u32;
1977        if byte_start == byte_end {
1978            continue;
1979        }
1980        let format = character_format_from_span(span, is_code_block);
1981        if format == default {
1982            continue;
1983        }
1984        runs.push(FormatRun {
1985            byte_start,
1986            byte_end,
1987            format,
1988        });
1989    }
1990    coalesce_in_place(&mut runs);
1991    ParsedInline {
1992        plain_text,
1993        runs,
1994        images,
1995        footnote_refs,
1996    }
1997}
1998
1999/// The three parallel things a block stores, as recovered from parsed spans.
2000///
2001/// Returned as a struct rather than a tuple because it grew a third member
2002/// (images) after nine call sites already destructured a pair — and every one
2003/// of those sites has to decide what to do with images, so a silent
2004/// tuple-arity change would have been the wrong kind of easy.
2005#[derive(Debug, Clone, Default)]
2006pub struct ParsedInline {
2007    pub plain_text: String,
2008    pub runs: Vec<crate::format_runs::FormatRun>,
2009    pub images: Vec<crate::format_runs::ImageAnchor>,
2010    pub footnote_refs: Vec<crate::format_runs::FootnoteRefAnchor>,
2011}
2012
2013// ─── Djot parsing ────────────────────────────────────────────────────
2014
2015/// Map a jotdown unordered/task bullet marker to a model `ListStyle`.
2016///
2017/// The mapping is a stable bijection (`-`↔Disc, `*`↔Circle, `+`↔Square) so the
2018/// djot exporter can recover the exact bullet character for a lossless
2019/// round-trip.
2020fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
2021    use jotdown::ListBulletType as B;
2022    match b {
2023        B::Dash => ListStyle::Disc,
2024        B::Star => ListStyle::Circle,
2025        B::Plus => ListStyle::Square,
2026    }
2027}
2028
2029/// Map a jotdown ordered-list numbering scheme to a model `ListStyle`.
2030fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
2031    use jotdown::OrderedListNumbering as N;
2032    match n {
2033        N::Decimal => ListStyle::Decimal,
2034        N::AlphaLower => ListStyle::LowerAlpha,
2035        N::AlphaUpper => ListStyle::UpperAlpha,
2036        N::RomanLower => ListStyle::LowerRoman,
2037        N::RomanUpper => ListStyle::UpperRoman,
2038    }
2039}
2040
2041/// Map a jotdown ordered-list delimiter to the `(prefix, suffix)` affixes
2042/// stored on the `List` entity (`1.` → `("", ".")`, `1)` → `("", ")")`,
2043/// `(1)` → `("(", ")")`).
2044fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
2045    use jotdown::OrderedListStyle as S;
2046    match style {
2047        S::Period => (String::new(), ".".to_string()),
2048        S::Paren => (String::new(), ")".to_string()),
2049        S::ParenParen => ("(".to_string(), ")".to_string()),
2050    }
2051}
2052
2053/// Optional block-level style attributes carried on a djot block through its
2054/// `{key=value}` block attributes. All `None` when the block has no such
2055/// attributes (or they were filtered out by [`DjotImportOptions`]).
2056#[derive(Debug, Clone, Default)]
2057struct DjotBlockStyle {
2058    alignment: Option<Alignment>,
2059    line_height: Option<i64>,
2060    non_breakable_lines: Option<bool>,
2061    page_break_before: Option<bool>,
2062    direction: Option<TextDirection>,
2063    background_color: Option<String>,
2064    top_margin: Option<i64>,
2065    text_indent: Option<i64>,
2066    semantic_role: Option<SemanticRole>,
2067}
2068
2069impl DjotBlockStyle {
2070    /// Overlay the `Some` fields of `other` onto `self`, leaving `self`'s
2071    /// existing values for any field `other` does not set. Used to combine a
2072    /// heading's enclosing-`Section` attributes with any on the heading itself.
2073    fn merge_from(&mut self, other: DjotBlockStyle) {
2074        if other.alignment.is_some() {
2075            self.alignment = other.alignment;
2076        }
2077        if other.line_height.is_some() {
2078            self.line_height = other.line_height;
2079        }
2080        if other.non_breakable_lines.is_some() {
2081            self.non_breakable_lines = other.non_breakable_lines;
2082        }
2083        if other.page_break_before.is_some() {
2084            self.page_break_before = other.page_break_before;
2085        }
2086        if other.direction.is_some() {
2087            self.direction = other.direction;
2088        }
2089        if other.background_color.is_some() {
2090            self.background_color = other.background_color;
2091        }
2092        if other.top_margin.is_some() {
2093            self.top_margin = other.top_margin;
2094        }
2095        if other.text_indent.is_some() {
2096            self.text_indent = other.text_indent;
2097        }
2098        if other.semantic_role.is_some() {
2099            self.semantic_role = other.semantic_role.clone();
2100        }
2101    }
2102}
2103
2104/// Read the round-tripped block-style attributes off a djot block's
2105/// [`jotdown::Attributes`], honouring the import [`DjotImportOptions`]. Keys are
2106/// the model field names (`alignment`, `line_height`, `direction`,
2107/// `non_breakable_lines`, `page_break_before`, `background_color`, `top_margin`,
2108/// `text_indent`, `semantic_role`); unrecognised values are ignored.
2109fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
2110    let mut style = DjotBlockStyle::default();
2111
2112    if opts.alignment
2113        && let Some(v) = attrs.get_value("alignment")
2114    {
2115        style.alignment = match v.to_string().as_str() {
2116            "left" => Some(Alignment::Left),
2117            "right" => Some(Alignment::Right),
2118            "center" => Some(Alignment::Center),
2119            "justify" => Some(Alignment::Justify),
2120            _ => None,
2121        };
2122    }
2123    if opts.line_height
2124        && let Some(v) = attrs.get_value("line_height")
2125    {
2126        style.line_height = v.to_string().parse::<i64>().ok();
2127    }
2128    if opts.direction
2129        && let Some(v) = attrs.get_value("direction")
2130    {
2131        style.direction = match v.to_string().as_str() {
2132            "ltr" => Some(TextDirection::LeftToRight),
2133            "rtl" => Some(TextDirection::RightToLeft),
2134            _ => None,
2135        };
2136    }
2137    if opts.non_breakable_lines
2138        && let Some(v) = attrs.get_value("non_breakable_lines")
2139    {
2140        style.non_breakable_lines = match v.to_string().as_str() {
2141            "true" => Some(true),
2142            "false" => Some(false),
2143            _ => None,
2144        };
2145    }
2146    if opts.page_break_before
2147        && let Some(v) = attrs.get_value("page_break_before")
2148    {
2149        style.page_break_before = match v.to_string().as_str() {
2150            "true" => Some(true),
2151            "false" => Some(false),
2152            _ => None,
2153        };
2154    }
2155    if opts.background_color
2156        && let Some(v) = attrs.get_value("background_color")
2157    {
2158        style.background_color = Some(v.to_string());
2159    }
2160    if opts.top_margin
2161        && let Some(v) = attrs.get_value("top_margin")
2162    {
2163        style.top_margin = v.to_string().parse::<i64>().ok();
2164    }
2165    if opts.text_indent
2166        && let Some(v) = attrs.get_value("text_indent")
2167    {
2168        style.text_indent = v.to_string().parse::<i64>().ok();
2169    }
2170    if opts.semantic_role
2171        && let Some(v) = attrs.get_value("semantic_role")
2172    {
2173        style.semantic_role = match v.to_string().as_str() {
2174            "epigraph" => Some(SemanticRole::Epigraph),
2175            // An unknown role is dropped, not guessed at — the same way an unknown
2176            // alignment value above is. A future role read by an older build then
2177            // degrades to a plain blockquote, which is what it looks like anyway.
2178            _ => None,
2179        };
2180    }
2181
2182    style
2183}
2184
2185/// Push a finished block into `elements`, applying the djot block-level fields
2186/// plus any round-tripped block-style attributes carried in `style`.
2187#[allow(clippy::too_many_arguments)]
2188fn djot_push_block(
2189    elements: &mut Vec<ParsedElement>,
2190    spans: Vec<ParsedSpan>,
2191    heading_level: Option<i64>,
2192    list_style: Option<ListStyle>,
2193    list_indent: u32,
2194    list_prefix: String,
2195    list_suffix: String,
2196    marker: Option<MarkerType>,
2197    is_code_block: bool,
2198    code_language: Option<String>,
2199    blockquote_depth: u32,
2200    style: DjotBlockStyle,
2201) {
2202    elements.push(ParsedElement::Block(ParsedBlock {
2203        spans,
2204        heading_level,
2205        list_style,
2206        list_indent,
2207        list_prefix,
2208        list_suffix,
2209        marker,
2210        is_code_block,
2211        code_language,
2212        blockquote_depth,
2213        line_height: style.line_height,
2214        non_breakable_lines: style.non_breakable_lines,
2215        page_break_before: style.page_break_before,
2216        direction: style.direction,
2217        background_color: style.background_color,
2218        alignment: style.alignment,
2219        top_margin: style.top_margin,
2220        text_indent: style.text_indent,
2221        semantic_role: style.semantic_role.clone(),
2222    }));
2223}
2224
2225/// Parse djot source into the shared [`ParsedElement`] intermediate, mirroring
2226/// [`parse_markdown`]. Uses the [`jotdown`] pull parser.
2227///
2228/// Constructs the document model cannot represent are dropped, and their text
2229/// content is discarded so it never leaks into the document: footnotes, math,
2230/// fenced divs, raw blocks/inline, thematic breaks, description lists,
2231/// captions, symbols, link-reference definitions, and highlight/`mark`. Inline
2232/// images keep their alt text as plain text (the image itself is not modelled),
2233/// matching the Markdown importer. Smart-punctuation events are normalised to
2234/// their canonical Unicode characters so the model→djot→model round-trip is a
2235/// fixpoint.
2236///
2237/// Standalone paragraphs and headings additionally carry the optional
2238/// block-style attributes selected by `options` — paragraph alignment, line
2239/// height, text direction, non-breakable lines and background color — read from
2240/// djot `{key=value}` block attributes (see [`DjotImportOptions`]). List items,
2241/// code blocks and table cells normalise their block styling away.
2242///
2243/// Known model limitations (normalised, not preserved on round-trip):
2244/// ordered-list start number, table column alignment, and list tight/loose.
2245pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
2246    use jotdown::{Container as C, Event as E, ListKind, Parser};
2247
2248    // `jotdown` descends once per nested container with no depth limit, and a
2249    // stack overflow **aborts the process** — it cannot be caught by
2250    // `catch_unwind`, so there is no recovering from it after the fact and the
2251    // only safe move is not to start. Input is not always the author's own: a
2252    // project bundle is mailed and shared, and an imported document comes from
2253    // whoever sent it.
2254    //
2255    // Degrade rather than refuse, so the signature stays the same and nothing is
2256    // lost: the source comes back as one plain paragraph. Its structure is not
2257    // computed, which is the honest answer for a document whose structure cannot
2258    // be computed without ending the process — and every character is still
2259    // there for the writer to see and repair.
2260    if crate::parser_tools::djot_depth::is_too_deep(djot) {
2261        return vec![ParsedElement::Block(ParsedBlock {
2262            spans: vec![ParsedSpan {
2263                text: djot.to_string(),
2264                ..Default::default()
2265            }],
2266            ..Default::default()
2267        })];
2268    }
2269
2270    let mut elements: Vec<ParsedElement> = Vec::new();
2271    let mut current_spans: Vec<ParsedSpan> = Vec::new();
2272    let mut current_heading: Option<i64> = None;
2273    let mut is_code_block = false;
2274    let mut code_language: Option<String> = None;
2275    let mut blockquote_depth: u32 = 0;
2276    // Block-style attributes captured from a standalone paragraph/heading's djot
2277    // `{…}` block attributes, consumed when that block is flushed.
2278    let mut pending_style = DjotBlockStyle::default();
2279
2280    // Inline formatting state.
2281    let mut bold = false;
2282    let mut italic = false;
2283    let mut underline = false;
2284    let mut strikeout = false;
2285    let mut code = false;
2286    let mut superscript = false;
2287    let mut subscript = false;
2288    let mut link_href: Option<String> = None;
2289    // Set between an image's Start and End. Its alt text arrives as ordinary
2290    // `Str` events in between, so it has to be captured rather than emitted.
2291    let mut pending_image: Option<ParsedImage> = None;
2292
2293    // List nesting: each entry is (style, prefix, suffix); depth = indent + 1.
2294    let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
2295    // Context applied to the next flushed block while inside a list item.
2296    let mut cur_list_style: Option<ListStyle> = None;
2297    let mut cur_list_prefix = String::new();
2298    let mut cur_list_suffix = String::new();
2299    let mut cur_list_indent: u32 = 0;
2300    let mut cur_marker: Option<MarkerType> = None;
2301
2302    // Table accumulation.
2303    let mut in_table_cell = false;
2304    let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
2305    let mut current_row: Vec<ParsedTableCell> = Vec::new();
2306    let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
2307    let mut table_header_rows: usize = 0;
2308    let mut row_is_head = false;
2309
2310    // Subtree-skip depth for unrepresentable containers (their entire content
2311    // is dropped). Incremented on the dropped container's `Start` and on every
2312    // nested `Start`; decremented on every `End`.
2313    let mut skip_depth: u32 = 0;
2314
2315    // The label and element index of the footnote definition currently open,
2316    // if any. Djot has no footnote inside a footnote, so one slot is enough
2317    // where the dropped containers need a depth counter.
2318    let mut footnote_open: Option<(String, usize)> = None;
2319
2320    // Push one inline span carrying the current formatting state into the
2321    // active sink (table cell or block). A macro (not a closure) to avoid
2322    // borrowing `current_spans`/`current_cell_spans` across the formatting
2323    // state reads.
2324    macro_rules! push_text {
2325        ($t:expr) => {{
2326            // Alt text belongs to the image, not to the paragraph. While an
2327            // image is open every text event is diverted into its description,
2328            // which is what keeps a photo's caption out of the manuscript's
2329            // word count and out of the search corpus.
2330            if let Some(img) = pending_image.as_mut() {
2331                img.alt.push_str(($t).as_ref());
2332            } else {
2333                let sp = ParsedSpan {
2334                    text: ($t).to_string(),
2335                    bold,
2336                    italic,
2337                    underline,
2338                    strikeout,
2339                    code,
2340                    superscript,
2341                    subscript,
2342                    link_href: link_href.clone(),
2343                    image: None,
2344                    footnote_ref: None,
2345                };
2346                if in_table_cell {
2347                    current_cell_spans.push(sp);
2348                } else {
2349                    current_spans.push(sp);
2350                }
2351            }
2352        }};
2353    }
2354
2355    // Push a completed inline image span into the active sink.
2356    macro_rules! push_image {
2357        ($img:expr) => {{
2358            let sp = ParsedSpan {
2359                text: String::new(),
2360                bold,
2361                italic,
2362                underline,
2363                strikeout,
2364                code,
2365                superscript,
2366                subscript,
2367                link_href: link_href.clone(),
2368                image: Some($img),
2369                footnote_ref: None,
2370            };
2371            if in_table_cell {
2372                current_cell_spans.push(sp);
2373            } else {
2374                current_spans.push(sp);
2375            }
2376        }};
2377    }
2378
2379    // Enter a list item, flushing any unterminated inline content first and
2380    // capturing the list context + task marker for the item's block.
2381    macro_rules! enter_item {
2382        ($marker:expr) => {{
2383            if !current_spans.is_empty() {
2384                djot_push_block(
2385                    &mut elements,
2386                    std::mem::take(&mut current_spans),
2387                    None,
2388                    cur_list_style.clone(),
2389                    cur_list_indent,
2390                    cur_list_prefix.clone(),
2391                    cur_list_suffix.clone(),
2392                    cur_marker.clone(),
2393                    false,
2394                    None,
2395                    blockquote_depth,
2396                    DjotBlockStyle::default(),
2397                );
2398            }
2399            let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
2400                ListStyle::Disc,
2401                String::new(),
2402                String::new(),
2403            ));
2404            cur_list_style = Some(style);
2405            cur_list_prefix = prefix;
2406            cur_list_suffix = suffix;
2407            cur_list_indent = list_stack.len().saturating_sub(1) as u32;
2408            cur_marker = $marker;
2409        }};
2410    }
2411
2412    for event in Parser::new(djot) {
2413        if skip_depth > 0 {
2414            match event {
2415                E::Start(..) => skip_depth += 1,
2416                E::End(_) => skip_depth -= 1,
2417                _ => {}
2418            }
2419            continue;
2420        }
2421
2422        match event {
2423            // ── Transparent wrappers (unwrap, keep content) ──
2424            E::Start(C::Document, _) | E::End(C::Document) => {}
2425            E::Start(C::Section { .. }, attrs) => {
2426                // A heading's block attributes attach to its enclosing Section,
2427                // not the heading itself; capture them for the heading's flush.
2428                if list_stack.is_empty() {
2429                    pending_style.merge_from(block_attrs_to_style(&attrs, options));
2430                }
2431            }
2432            E::End(C::Section { .. }) => {}
2433            E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
2434
2435            // ── Blockquote ──
2436            E::Start(C::Blockquote, _) => blockquote_depth += 1,
2437            E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
2438
2439            // ── Lists ──
2440            E::Start(C::List { kind, .. }, _) => {
2441                let (style, prefix, suffix) = match kind {
2442                    ListKind::Unordered(b) | ListKind::Task(b) => {
2443                        (djot_bullet_style(b), String::new(), String::new())
2444                    }
2445                    ListKind::Ordered {
2446                        numbering, style, ..
2447                    } => {
2448                        let (p, s) = djot_ordered_affixes(style);
2449                        (djot_ordered_style(numbering), p, s)
2450                    }
2451                };
2452                list_stack.push((style, prefix, suffix));
2453            }
2454            E::End(C::List { .. }) => {
2455                list_stack.pop();
2456                cur_list_style = None;
2457                cur_marker = None;
2458            }
2459            E::Start(C::ListItem, _) => enter_item!(None),
2460            E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
2461                MarkerType::Checked
2462            } else {
2463                MarkerType::Unchecked
2464            })),
2465            E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
2466                // Tight item without a wrapping paragraph (defensive flush).
2467                if !current_spans.is_empty() {
2468                    djot_push_block(
2469                        &mut elements,
2470                        std::mem::take(&mut current_spans),
2471                        None,
2472                        cur_list_style.clone(),
2473                        cur_list_indent,
2474                        cur_list_prefix.clone(),
2475                        cur_list_suffix.clone(),
2476                        cur_marker.clone(),
2477                        false,
2478                        None,
2479                        blockquote_depth,
2480                        DjotBlockStyle::default(),
2481                    );
2482                }
2483                cur_list_style = None;
2484                cur_marker = None;
2485            }
2486
2487            // ── Headings, paragraphs, code blocks ──
2488            E::Start(C::Heading { level, .. }, attrs) => {
2489                current_heading = Some(level as i64);
2490                // The block-style attributes live on the enclosing Section;
2491                // merge any placed directly on the heading without clearing them.
2492                pending_style.merge_from(block_attrs_to_style(&attrs, options));
2493            }
2494            E::End(C::Heading { .. }) => {
2495                djot_push_block(
2496                    &mut elements,
2497                    std::mem::take(&mut current_spans),
2498                    current_heading.take(),
2499                    None,
2500                    0,
2501                    String::new(),
2502                    String::new(),
2503                    None,
2504                    false,
2505                    None,
2506                    blockquote_depth,
2507                    std::mem::take(&mut pending_style),
2508                );
2509            }
2510            E::Start(C::Paragraph, attrs) => {
2511                current_heading = None;
2512                // Block attributes only apply to standalone paragraphs;
2513                // list-item paragraphs normalise their styling away (matching
2514                // the exporter).
2515                pending_style = if list_stack.is_empty() {
2516                    block_attrs_to_style(&attrs, options)
2517                } else {
2518                    DjotBlockStyle::default()
2519                };
2520            }
2521            E::End(C::Paragraph) => {
2522                if !current_spans.is_empty() {
2523                    djot_push_block(
2524                        &mut elements,
2525                        std::mem::take(&mut current_spans),
2526                        None,
2527                        cur_list_style.clone(),
2528                        cur_list_indent,
2529                        cur_list_prefix.clone(),
2530                        cur_list_suffix.clone(),
2531                        cur_marker.clone(),
2532                        false,
2533                        None,
2534                        blockquote_depth,
2535                        std::mem::take(&mut pending_style),
2536                    );
2537                }
2538                cur_list_style = None;
2539                cur_marker = None;
2540            }
2541            E::Start(C::CodeBlock { language }, _) => {
2542                is_code_block = true;
2543                code_language = if language.is_empty() {
2544                    None
2545                } else {
2546                    Some(language.to_string())
2547                };
2548            }
2549            E::End(C::CodeBlock { .. }) => {
2550                // Strip the single trailing newline jotdown appends.
2551                if let Some(last) = current_spans.last_mut()
2552                    && last.text.ends_with('\n')
2553                {
2554                    last.text.pop();
2555                }
2556                djot_push_block(
2557                    &mut elements,
2558                    std::mem::take(&mut current_spans),
2559                    None,
2560                    None,
2561                    0,
2562                    String::new(),
2563                    String::new(),
2564                    None,
2565                    true,
2566                    code_language.take(),
2567                    blockquote_depth,
2568                    DjotBlockStyle::default(),
2569                );
2570                is_code_block = false;
2571            }
2572
2573            // ── Tables ──
2574            E::Start(C::Table, _) => {
2575                table_rows.clear();
2576                current_row.clear();
2577                current_cell_spans.clear();
2578                table_header_rows = 0;
2579            }
2580            E::End(C::Table) => {
2581                elements.push(ParsedElement::Table(ParsedTable {
2582                    header_rows: table_header_rows,
2583                    rows: std::mem::take(&mut table_rows),
2584                    blockquote_depth,
2585                }));
2586            }
2587            E::Start(C::TableRow { head }, _) => {
2588                row_is_head = head;
2589                current_row.clear();
2590            }
2591            E::End(C::TableRow { .. }) => {
2592                if row_is_head {
2593                    table_header_rows += 1;
2594                }
2595                table_rows.push(std::mem::take(&mut current_row));
2596            }
2597            E::Start(C::TableCell { .. }, _) => {
2598                in_table_cell = true;
2599                current_cell_spans.clear();
2600            }
2601            E::End(C::TableCell { .. }) => {
2602                in_table_cell = false;
2603                current_row.push(ParsedTableCell {
2604                    spans: std::mem::take(&mut current_cell_spans),
2605                });
2606            }
2607
2608            // ── Inline formatting ──
2609            E::Start(C::Strong, _) => bold = true,
2610            E::End(C::Strong) => bold = false,
2611            E::Start(C::Emphasis, _) => italic = true,
2612            E::End(C::Emphasis) => italic = false,
2613            E::Start(C::Verbatim, _) => code = true,
2614            E::End(C::Verbatim) => code = false,
2615            E::Start(C::Superscript, _) => superscript = true,
2616            E::End(C::Superscript) => superscript = false,
2617            E::Start(C::Subscript, _) => subscript = true,
2618            E::End(C::Subscript) => subscript = false,
2619            E::Start(C::Insert, _) => underline = true,
2620            E::End(C::Insert) => underline = false,
2621            E::Start(C::Delete, _) => strikeout = true,
2622            E::End(C::Delete) => strikeout = false,
2623            // Highlight/mark and bare spans have no model field — keep the text.
2624            E::Start(C::Mark, _) | E::End(C::Mark) => {}
2625            E::Start(C::Span, _) | E::End(C::Span) => {}
2626            E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
2627            E::End(C::Link(..)) => link_href = None,
2628            // Inline images. Djot writes display size as inline attributes
2629            // (`![alt](src){width=800 height=600}`), which jotdown hands over
2630            // on the `Start` event — verified against jotdown 0.10, including
2631            // quoted values and images mid-sentence.
2632            E::Start(C::Image(src, _), attrs) => {
2633                let attr_num = |key: &str| -> i64 {
2634                    attrs
2635                        .get_value(key)
2636                        .map(|v| v.to_string())
2637                        .and_then(|v| v.trim().parse::<i64>().ok())
2638                        .filter(|n| *n > 0)
2639                        .unwrap_or(0)
2640                };
2641                pending_image = Some(ParsedImage {
2642                    src: src.to_string(),
2643                    alt: String::new(),
2644                    width: attr_num("width"),
2645                    height: attr_num("height"),
2646                });
2647            }
2648            E::End(C::Image(..)) => {
2649                if let Some(img) = pending_image.take() {
2650                    push_image!(img);
2651                }
2652            }
2653
2654            // ── Footnote definitions ──
2655            //
2656            // The body is ordinary block content, so it is parsed by the
2657            // ordinary machinery: flush whatever inline run is open, note where
2658            // this definition's blocks start, and let them accumulate. `End`
2659            // lifts them back out. Nesting cannot occur — djot has no footnote
2660            // inside a footnote — so one mark suffices where the dropped
2661            // containers below need a depth counter.
2662            E::Start(C::Footnote { label }, _) => {
2663                if !current_spans.is_empty() {
2664                    djot_push_block(
2665                        &mut elements,
2666                        std::mem::take(&mut current_spans),
2667                        None,
2668                        cur_list_style.clone(),
2669                        cur_list_indent,
2670                        cur_list_prefix.clone(),
2671                        cur_list_suffix.clone(),
2672                        cur_marker.clone(),
2673                        false,
2674                        None,
2675                        blockquote_depth,
2676                        DjotBlockStyle::default(),
2677                    );
2678                }
2679                footnote_open = Some((label.to_string(), elements.len()));
2680            }
2681            E::End(C::Footnote { .. }) => {
2682                if !current_spans.is_empty() {
2683                    djot_push_block(
2684                        &mut elements,
2685                        std::mem::take(&mut current_spans),
2686                        None,
2687                        cur_list_style.clone(),
2688                        cur_list_indent,
2689                        cur_list_prefix.clone(),
2690                        cur_list_suffix.clone(),
2691                        cur_marker.clone(),
2692                        false,
2693                        None,
2694                        blockquote_depth,
2695                        DjotBlockStyle::default(),
2696                    );
2697                }
2698                if let Some((label, start)) = footnote_open.take() {
2699                    let blocks: Vec<ParsedBlock> = elements
2700                        .drain(start..)
2701                        .filter_map(|e| match e {
2702                            ParsedElement::Block(b) => Some(b),
2703                            // A table inside a footnote is not representable as
2704                            // note content; its cells would have to become
2705                            // blocks and lose their structure either way.
2706                            _ => None,
2707                        })
2708                        .collect();
2709                    elements.push(ParsedElement::FootnoteDefinition { label, blocks });
2710                }
2711            }
2712
2713            // ── Unrepresentable containers: drop the entire subtree ──
2714            E::Start(
2715                C::Math { .. }
2716                | C::RawBlock { .. }
2717                | C::RawInline { .. }
2718                | C::DescriptionList
2719                | C::DescriptionDetails
2720                | C::DescriptionTerm
2721                | C::Caption
2722                | C::LinkDefinition { .. },
2723                _,
2724            ) => skip_depth = 1,
2725
2726            // ── Text + atoms ──
2727            E::Str(s) => push_text!(s.as_ref()),
2728            E::Softbreak => push_text!(" "),
2729            E::LeftSingleQuote => push_text!("\u{2018}"),
2730            E::RightSingleQuote => push_text!("\u{2019}"),
2731            E::LeftDoubleQuote => push_text!("\u{201C}"),
2732            E::RightDoubleQuote => push_text!("\u{201D}"),
2733            E::Ellipsis => push_text!("\u{2026}"),
2734            E::EnDash => push_text!("\u{2013}"),
2735            E::EmDash => push_text!("\u{2014}"),
2736            E::NonBreakingSpace => push_text!("\u{00A0}"),
2737            E::Hardbreak => {
2738                if in_table_cell {
2739                    push_text!(" ");
2740                } else if !current_spans.is_empty() {
2741                    // Mirrors the Markdown importer: a hard break splits the
2742                    // paragraph into a new block.
2743                    djot_push_block(
2744                        &mut elements,
2745                        std::mem::take(&mut current_spans),
2746                        None,
2747                        cur_list_style.clone(),
2748                        cur_list_indent,
2749                        cur_list_prefix.clone(),
2750                        cur_list_suffix.clone(),
2751                        cur_marker.clone(),
2752                        is_code_block,
2753                        code_language.clone(),
2754                        blockquote_depth,
2755                        pending_style.clone(),
2756                    );
2757                }
2758            }
2759            // A footnote reference. jotdown emits this purely syntactically —
2760            // it never checks that a matching `[^label]:` exists anywhere — so a
2761            // reference whose definition lives outside this document (the normal
2762            // state for a host that owns note bodies itself) arrives here just
2763            // the same, and must survive.
2764            E::FootnoteReference(label) => {
2765                let sp = ParsedSpan {
2766                    text: String::new(),
2767                    bold,
2768                    italic,
2769                    underline,
2770                    strikeout,
2771                    code,
2772                    superscript,
2773                    subscript,
2774                    link_href: link_href.clone(),
2775                    image: None,
2776                    footnote_ref: Some(label.to_string()),
2777                };
2778                if in_table_cell {
2779                    current_cell_spans.push(sp);
2780                } else {
2781                    current_spans.push(sp);
2782                }
2783            }
2784            // Symbols, escapes, blanklines, thematic breaks and dangling block
2785            // attributes carry no representable content.
2786            E::Symbol(_) => {}
2787            E::Escape | E::Blankline => {}
2788            E::ThematicBreak(_) | E::Attributes(_) => {}
2789
2790            // Ends of dropped containers (never reached at skip_depth 0) and any
2791            // future variants.
2792            _ => {}
2793        }
2794    }
2795
2796    // Flush any trailing inline content (defensive — Document End closes blocks).
2797    if !current_spans.is_empty() {
2798        djot_push_block(
2799            &mut elements,
2800            std::mem::take(&mut current_spans),
2801            current_heading.take(),
2802            cur_list_style.clone(),
2803            cur_list_indent,
2804            cur_list_prefix.clone(),
2805            cur_list_suffix.clone(),
2806            cur_marker.clone(),
2807            is_code_block,
2808            code_language.take(),
2809            blockquote_depth,
2810            std::mem::take(&mut pending_style),
2811        );
2812    }
2813
2814    // An empty document still yields a single empty paragraph (matches
2815    // `parse_markdown`).
2816    if elements.is_empty() {
2817        djot_push_block(
2818            &mut elements,
2819            vec![ParsedSpan {
2820                text: String::new(),
2821                ..Default::default()
2822            }],
2823            None,
2824            None,
2825            0,
2826            String::new(),
2827            String::new(),
2828            None,
2829            false,
2830            None,
2831            0,
2832            DjotBlockStyle::default(),
2833        );
2834    }
2835
2836    elements
2837}
2838
2839#[cfg(test)]
2840mod tests {
2841    use super::*;
2842
2843    /// Helper: flatten parse_markdown output to blocks for tests that don't care about tables.
2844    fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
2845        ParsedElement::flatten_to_blocks(parse_markdown(md))
2846    }
2847
2848    #[test]
2849    fn test_parse_markdown_simple_paragraph() {
2850        let blocks = parse_markdown_blocks("Hello **world**");
2851        assert_eq!(blocks.len(), 1);
2852        assert!(blocks[0].spans.len() >= 2);
2853        // "Hello " is plain, "world" is bold
2854        let plain_span = blocks[0]
2855            .spans
2856            .iter()
2857            .find(|s| s.text.contains("Hello"))
2858            .unwrap();
2859        assert!(!plain_span.bold);
2860        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2861        assert!(bold_span.bold);
2862    }
2863
2864    #[test]
2865    fn test_parse_markdown_heading() {
2866        let blocks = parse_markdown_blocks("# Title");
2867        assert_eq!(blocks.len(), 1);
2868        assert_eq!(blocks[0].heading_level, Some(1));
2869        assert_eq!(blocks[0].spans[0].text, "Title");
2870    }
2871
2872    #[test]
2873    fn test_parse_markdown_list() {
2874        let blocks = parse_markdown_blocks("- item1\n- item2");
2875        assert!(blocks.len() >= 2);
2876        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2877        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2878    }
2879
2880    /// Helper: extract (is_table, blockquote_depth) per element for nesting assertions.
2881    fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
2882        elements
2883            .iter()
2884            .map(|e| match e {
2885                ParsedElement::Block(b) => (false, b.blockquote_depth),
2886                ParsedElement::Table(t) => (true, t.blockquote_depth),
2887                // Definitions carry no blockquote nesting of their own; this
2888                // helper exists for the nesting assertions and never sees one.
2889                ParsedElement::FootnoteDefinition { .. } => (false, 0),
2890            })
2891            .collect()
2892    }
2893
2894    #[test]
2895    fn test_parse_markdown_table_in_blockquote_records_depth() {
2896        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2897        assert_eq!(element_depths(&elements), vec![(true, 1)]);
2898    }
2899
2900    #[test]
2901    fn test_parse_markdown_text_then_table_in_blockquote() {
2902        let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
2903        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
2904    }
2905
2906    #[test]
2907    fn test_parse_markdown_table_after_blockquote_closes() {
2908        let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
2909        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
2910    }
2911
2912    #[test]
2913    fn test_parse_markdown_table_in_nested_blockquote() {
2914        let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
2915        assert_eq!(element_depths(&elements), vec![(true, 2)]);
2916    }
2917
2918    #[test]
2919    fn test_parse_markdown_list_in_blockquote_records_depth() {
2920        let elements = parse_markdown("> - item1\n> - item2");
2921        let depths = element_depths(&elements);
2922        assert_eq!(depths, vec![(false, 1), (false, 1)]);
2923        for e in &elements {
2924            if let ParsedElement::Block(b) = e {
2925                assert_eq!(b.list_style, Some(ListStyle::Disc));
2926            }
2927        }
2928    }
2929
2930    #[test]
2931    fn test_parse_html_table_in_blockquote_records_depth() {
2932        let elements = parse_html_elements(
2933            "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
2934        );
2935        assert_eq!(element_depths(&elements), vec![(true, 1)]);
2936    }
2937
2938    #[test]
2939    fn test_parse_html_table_after_blockquote() {
2940        let elements = parse_html_elements(
2941            "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
2942        );
2943        let depths = element_depths(&elements);
2944        // The blockquote paragraph carries depth 1; the table is outside (depth 0).
2945        assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
2946        assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
2947    }
2948
2949    #[test]
2950    fn test_flatten_to_blocks_propagates_blockquote_depth() {
2951        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2952        let blocks = ParsedElement::flatten_to_blocks(elements);
2953        assert!(!blocks.is_empty());
2954        for b in &blocks {
2955            assert_eq!(b.blockquote_depth, 1);
2956        }
2957    }
2958
2959    #[test]
2960    fn test_parse_html_simple() {
2961        let blocks = parse_html("<p>Hello <b>world</b></p>");
2962        assert_eq!(blocks.len(), 1);
2963        assert!(blocks[0].spans.len() >= 2);
2964        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2965        assert!(bold_span.bold);
2966    }
2967
2968    #[test]
2969    fn test_parse_html_multiple_paragraphs() {
2970        let blocks = parse_html("<p>A</p><p>B</p>");
2971        assert_eq!(blocks.len(), 2);
2972    }
2973
2974    #[test]
2975    fn test_parse_html_heading() {
2976        let blocks = parse_html("<h2>Subtitle</h2>");
2977        assert_eq!(blocks.len(), 1);
2978        assert_eq!(blocks[0].heading_level, Some(2));
2979    }
2980
2981    #[test]
2982    fn test_parse_html_list() {
2983        let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2984        assert!(blocks.len() >= 2);
2985        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2986    }
2987
2988    #[test]
2989    fn test_parse_markdown_code_block() {
2990        let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2991        assert_eq!(blocks.len(), 1);
2992        assert!(blocks[0].is_code_block);
2993        assert!(blocks[0].spans[0].code);
2994        // pulldown-cmark appends a trailing \n to code block text — verify it's stripped
2995        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2996        assert_eq!(
2997            text, "fn main() {}",
2998            "code block text should not have trailing newline"
2999        );
3000    }
3001
3002    #[test]
3003    fn test_parse_markdown_nested_formatting() {
3004        let blocks = parse_markdown_blocks("***bold italic***");
3005        assert_eq!(blocks.len(), 1);
3006        let span = &blocks[0].spans[0];
3007        assert!(span.bold);
3008        assert!(span.italic);
3009    }
3010
3011    #[test]
3012    fn test_parse_markdown_link() {
3013        let blocks = parse_markdown_blocks("[click](http://example.com)");
3014        assert_eq!(blocks.len(), 1);
3015        let span = &blocks[0].spans[0];
3016        assert_eq!(span.text, "click");
3017        assert_eq!(span.link_href, Some("http://example.com".to_string()));
3018    }
3019
3020    #[test]
3021    fn test_parse_markdown_empty() {
3022        let blocks = parse_markdown_blocks("");
3023        assert_eq!(blocks.len(), 1);
3024        assert!(blocks[0].spans[0].text.is_empty());
3025    }
3026
3027    #[test]
3028    fn test_parse_html_empty() {
3029        let blocks = parse_html("");
3030        assert_eq!(blocks.len(), 1);
3031        assert!(blocks[0].spans[0].text.is_empty());
3032    }
3033
3034    #[test]
3035    fn test_parse_html_nested_formatting() {
3036        let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
3037        assert_eq!(blocks.len(), 1);
3038        let span = &blocks[0].spans[0];
3039        assert!(span.bold);
3040        assert!(span.italic);
3041    }
3042
3043    #[test]
3044    fn test_parse_html_link() {
3045        let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
3046        assert_eq!(blocks.len(), 1);
3047        let span = &blocks[0].spans[0];
3048        assert_eq!(span.text, "click");
3049        assert_eq!(span.link_href, Some("http://example.com".to_string()));
3050    }
3051
3052    #[test]
3053    fn test_parse_html_ordered_list() {
3054        let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
3055        assert!(blocks.len() >= 2);
3056        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
3057    }
3058
3059    #[test]
3060    fn test_parse_markdown_ordered_list() {
3061        let blocks = parse_markdown_blocks("1. first\n2. second");
3062        assert!(blocks.len() >= 2);
3063        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
3064    }
3065
3066    #[test]
3067    fn test_parse_html_blockquote_nested() {
3068        let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
3069        assert!(blocks.len() >= 3);
3070    }
3071
3072    #[test]
3073    fn test_parse_block_styles_line_height() {
3074        let styles = parse_block_styles("line-height: 1.5");
3075        assert_eq!(styles.line_height, Some(1500));
3076    }
3077
3078    #[test]
3079    fn test_parse_block_styles_direction_rtl() {
3080        let styles = parse_block_styles("direction: rtl");
3081        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
3082    }
3083
3084    #[test]
3085    fn test_parse_block_styles_background_color() {
3086        let styles = parse_block_styles("background-color: #ff0000");
3087        assert_eq!(styles.background_color, Some("#ff0000".to_string()));
3088    }
3089
3090    #[test]
3091    fn test_parse_block_styles_white_space_pre() {
3092        let styles = parse_block_styles("white-space: pre");
3093        assert_eq!(styles.non_breakable_lines, Some(true));
3094    }
3095
3096    #[test]
3097    fn test_parse_block_styles_multiple() {
3098        let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
3099        assert_eq!(styles.line_height, Some(2000));
3100        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
3101        assert_eq!(styles.background_color, Some("blue".to_string()));
3102    }
3103
3104    #[test]
3105    fn test_parse_html_block_styles_extracted() {
3106        let blocks = parse_html(
3107            r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
3108        );
3109        assert_eq!(blocks.len(), 1);
3110        assert_eq!(blocks[0].line_height, Some(1500));
3111        assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
3112        assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
3113    }
3114
3115    /// The shape every word processor publishes on the clipboard: a newline
3116    /// after the opening tag, the prose hard-wrapped inside it, and a newline
3117    /// between every pair of blocks. None of it is text.
3118    #[test]
3119    fn test_parse_html_collapses_exporter_line_wrapping() {
3120        let blocks = parse_html(
3121            "<body>\n<p style=\"line-height: 200%\">\nfirst line wrapped\nhere</p>\n\
3122             <p>\nsecond para</p>\n</body>",
3123        );
3124        let texts: Vec<String> = blocks
3125            .iter()
3126            .map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
3127            .collect();
3128        assert_eq!(texts, vec!["first line wrapped here", "second para"]);
3129    }
3130
3131    /// The reported bug: pasting a whole LibreOffice book ended in one block
3132    /// holding every newline the exporter had written between its paragraphs —
3133    /// four thousand blank lines under the last sentence.
3134    #[test]
3135    fn test_parse_html_drops_whitespace_between_blocks() {
3136        let blocks = parse_html("<html>\n<body>\n<p>a</p>\n<p>b</p>\n</body>\n</html>");
3137        assert_eq!(blocks.len(), 2, "no block for the whitespace between them");
3138        assert!(
3139            blocks
3140                .iter()
3141                .all(|b| b.spans.iter().all(|s| !s.text.contains('\n'))),
3142            "no block keeps a literal newline"
3143        );
3144    }
3145
3146    /// A run split across inline elements is still one run, and it collapses to
3147    /// one space rather than disappearing between them.
3148    #[test]
3149    fn test_parse_html_collapses_across_span_boundaries() {
3150        let blocks = parse_html("<p>  <b>bold</b>\n  <i>italic</i>  </p>");
3151        assert_eq!(blocks.len(), 1);
3152        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3153        assert_eq!(text, "bold italic");
3154    }
3155
3156    /// U+00A0 is content, not layout — the space holding "Attention&nbsp;:"
3157    /// together in French must survive a paste.
3158    #[test]
3159    fn test_parse_html_keeps_no_break_space() {
3160        let blocks = parse_html("<p>\nAttention&nbsp;: ici</p>");
3161        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3162        assert_eq!(text, "Attention\u{a0}: ici");
3163    }
3164
3165    /// An empty paragraph is a paragraph. Only the whitespace *between* blocks
3166    /// goes; a block the writer opened stays, however little is in it.
3167    #[test]
3168    fn test_parse_html_keeps_empty_paragraph() {
3169        let blocks = parse_html("<body>\n<p>a</p>\n<p> </p>\n<p>b</p>\n</body>");
3170        assert_eq!(blocks.len(), 3);
3171        let text: String = blocks[1].spans.iter().map(|s| s.text.as_str()).collect();
3172        assert_eq!(text, "");
3173    }
3174
3175    /// `<pre>` and `white-space: pre` mean the source's spacing *is* the
3176    /// content. `nowrap` does not: it forbids wrapping and collapses runs like
3177    /// any other block, which is why it is not in the same test.
3178    #[test]
3179    fn test_parse_html_preserves_whitespace_in_pre() {
3180        // The newline straight after `<pre>` is the one the HTML parser itself
3181        // drops, per spec; every newline after that is content.
3182        let blocks = parse_html("<pre>\nfn main() {\n    let x = 1;\n}</pre>");
3183        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3184        assert_eq!(text, "fn main() {\n    let x = 1;\n}");
3185
3186        let styled = parse_html("<p style=\"white-space: pre-wrap\">a\n  b</p>");
3187        let text: String = styled[0].spans.iter().map(|s| s.text.as_str()).collect();
3188        assert_eq!(text, "a\n  b");
3189
3190        let nowrap = parse_html("<p style=\"white-space: nowrap\">a\n  b</p>");
3191        let text: String = nowrap[0].spans.iter().map(|s| s.text.as_str()).collect();
3192        assert_eq!(text, "a b");
3193    }
3194
3195    /// Table cells are blocks too, and an exporter indents them just as
3196    /// generously as it indents paragraphs.
3197    #[test]
3198    fn test_parse_html_collapses_table_cell_whitespace() {
3199        let elements = parse_html_elements(
3200            "<table>\n<tr>\n<td>\n  one\n</td>\n<td>\n  two\n</td>\n</tr>\n</table>",
3201        );
3202        let table = elements
3203            .iter()
3204            .find_map(|e| match e {
3205                ParsedElement::Table(t) => Some(t),
3206                _ => None,
3207            })
3208            .expect("a table");
3209        let cells: Vec<String> = table.rows[0]
3210            .iter()
3211            .map(|c| c.spans.iter().map(|s| s.text.as_str()).collect())
3212            .collect();
3213        assert_eq!(cells, vec!["one", "two"]);
3214    }
3215
3216    #[test]
3217    fn test_parse_html_white_space_pre() {
3218        let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
3219        assert_eq!(blocks.len(), 1);
3220        assert_eq!(blocks[0].non_breakable_lines, Some(true));
3221    }
3222
3223    #[test]
3224    fn test_parse_html_no_styles_returns_none() {
3225        let blocks = parse_html("<p>plain</p>");
3226        assert_eq!(blocks.len(), 1);
3227        assert_eq!(blocks[0].line_height, None);
3228        assert_eq!(blocks[0].direction, None);
3229        assert_eq!(blocks[0].background_color, None);
3230        assert_eq!(blocks[0].non_breakable_lines, None);
3231    }
3232
3233    #[test]
3234    fn test_parse_markdown_nested_list_indent() {
3235        let md = "- top\n  - nested\n    - deep";
3236        let blocks = parse_markdown_blocks(md);
3237        assert_eq!(blocks.len(), 3);
3238        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
3239        assert_eq!(blocks[0].list_indent, 0);
3240        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
3241        assert_eq!(blocks[1].list_indent, 1);
3242        assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
3243        assert_eq!(blocks[2].list_indent, 2);
3244    }
3245
3246    #[test]
3247    fn test_parse_markdown_nested_ordered_list_indent() {
3248        let md = "1. first\n   1. nested\n   2. nested2";
3249        let blocks = parse_markdown_blocks(md);
3250        assert_eq!(blocks.len(), 3);
3251        assert_eq!(blocks[0].list_indent, 0);
3252        assert_eq!(blocks[1].list_indent, 1);
3253        assert_eq!(blocks[2].list_indent, 1);
3254    }
3255
3256    #[test]
3257    fn test_parse_html_nested_list_indent() {
3258        let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
3259        let blocks = parse_html(html);
3260        assert!(blocks.len() >= 2);
3261        assert_eq!(blocks[0].list_indent, 0);
3262        assert_eq!(blocks[1].list_indent, 1);
3263    }
3264
3265    #[test]
3266    fn test_parse_markdown_table() {
3267        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
3268        let elements = parse_markdown(md);
3269        assert_eq!(elements.len(), 1);
3270        match &elements[0] {
3271            ParsedElement::Table(table) => {
3272                assert_eq!(table.header_rows, 1);
3273                assert_eq!(table.rows.len(), 2); // 1 header + 1 body
3274                // Header row
3275                assert_eq!(table.rows[0].len(), 2);
3276                assert_eq!(table.rows[0][0].spans[0].text, "A");
3277                assert_eq!(table.rows[0][1].spans[0].text, "B");
3278                // Body row
3279                assert_eq!(table.rows[1].len(), 2);
3280                assert_eq!(table.rows[1][0].spans[0].text, "1");
3281                assert_eq!(table.rows[1][1].spans[0].text, "2");
3282            }
3283            _ => panic!("Expected ParsedElement::Table"),
3284        }
3285    }
3286
3287    #[test]
3288    fn test_parse_markdown_table_with_formatting() {
3289        let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
3290        let elements = parse_markdown(md);
3291        assert_eq!(elements.len(), 1);
3292        match &elements[0] {
3293            ParsedElement::Table(table) => {
3294                assert_eq!(table.rows.len(), 2);
3295                // Header: bold cell
3296                assert!(table.rows[0][0].spans[0].bold);
3297                // Header: code cell
3298                assert!(table.rows[0][1].spans[0].code);
3299                // Header: italic cell
3300                assert!(table.rows[0][2].spans[0].italic);
3301                // Body: strikeout cell
3302                assert!(table.rows[1][0].spans[0].strikeout);
3303                // Body: link cell
3304                assert_eq!(
3305                    table.rows[1][2].spans[0].link_href,
3306                    Some("http://x.com".to_string())
3307                );
3308            }
3309            _ => panic!("Expected ParsedElement::Table"),
3310        }
3311    }
3312
3313    #[test]
3314    fn test_parse_markdown_mixed_content_with_table() {
3315        let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
3316        let elements = parse_markdown(md);
3317        assert_eq!(elements.len(), 3);
3318        assert!(matches!(&elements[0], ParsedElement::Block(_)));
3319        assert!(matches!(&elements[1], ParsedElement::Table(_)));
3320        assert!(matches!(&elements[2], ParsedElement::Block(_)));
3321    }
3322}
3323
3324#[cfg(test)]
3325mod djot_tests {
3326    use super::*;
3327    use crate::entities::MarkerType;
3328
3329    fn blocks(d: &str) -> Vec<ParsedBlock> {
3330        ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
3331    }
3332
3333    fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
3334        b.spans.iter().find(|s| pred(s)).expect("span not found")
3335    }
3336
3337    #[test]
3338    fn paragraph_bold_italic() {
3339        let b = blocks("normal *bold* _italic_");
3340        assert_eq!(b.len(), 1);
3341        assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
3342        assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
3343    }
3344
3345    #[test]
3346    fn heading_levels() {
3347        assert_eq!(blocks("# H1")[0].heading_level, Some(1));
3348        assert_eq!(blocks("### H3")[0].heading_level, Some(3));
3349        assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
3350    }
3351
3352    #[test]
3353    fn unordered_bullet_styles_are_distinct() {
3354        assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
3355        assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
3356        assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
3357    }
3358
3359    #[test]
3360    fn ordered_delimiters() {
3361        let period = blocks("1. a");
3362        assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
3363        assert_eq!(period[0].list_prefix, "");
3364        assert_eq!(period[0].list_suffix, ".");
3365
3366        let paren = blocks("1) a");
3367        assert_eq!(paren[0].list_suffix, ")");
3368        assert_eq!(paren[0].list_prefix, "");
3369
3370        let paren_paren = blocks("(1) a");
3371        assert_eq!(paren_paren[0].list_prefix, "(");
3372        assert_eq!(paren_paren[0].list_suffix, ")");
3373    }
3374
3375    #[test]
3376    fn task_list_markers() {
3377        let b = blocks("- [ ] a\n- [x] b");
3378        assert_eq!(b.len(), 2);
3379        assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
3380        assert_eq!(b[1].marker, Some(MarkerType::Checked));
3381    }
3382
3383    #[test]
3384    fn code_block_with_language() {
3385        let b = blocks("```rust\nfn main() {}\n```");
3386        assert_eq!(b.len(), 1);
3387        assert!(b[0].is_code_block);
3388        assert_eq!(b[0].code_language.as_deref(), Some("rust"));
3389        let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
3390        assert_eq!(text, "fn main() {}");
3391    }
3392
3393    #[test]
3394    fn link_href() {
3395        let b = blocks("[text](http://example.com)");
3396        let s = first_span_with(&b[0], |s| s.text == "text");
3397        assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
3398    }
3399
3400    #[test]
3401    fn superscript_subscript() {
3402        assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
3403        assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
3404    }
3405
3406    #[test]
3407    fn delete_insert_verbatim() {
3408        assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
3409        assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
3410        assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
3411    }
3412
3413    #[test]
3414    fn blockquote_depth() {
3415        let els = parse_djot("> quoted", &DjotImportOptions::default());
3416        match &els[0] {
3417            ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
3418            _ => panic!("expected block"),
3419        }
3420    }
3421
3422    #[test]
3423    fn nested_list_indent() {
3424        // Djot nests a sub-list only when a blank line separates it from the
3425        // parent item and it is indented to the parent's content column
3426        // (2 spaces per level). Without the blank line the markers fold into
3427        // the paragraph as lazy continuation.
3428        let b = blocks("- a\n\n  - b\n\n    - c");
3429        assert_eq!(b.len(), 3);
3430        assert_eq!(b[0].list_indent, 0);
3431        assert_eq!(b[1].list_indent, 1);
3432        assert_eq!(b[2].list_indent, 2);
3433    }
3434
3435    #[test]
3436    fn table_parsed_as_table() {
3437        let els = parse_djot(
3438            "| a | b |\n|---|---|\n| c | d |",
3439            &DjotImportOptions::default(),
3440        );
3441        assert_eq!(els.len(), 1);
3442        match &els[0] {
3443            ParsedElement::Table(t) => {
3444                assert_eq!(t.header_rows, 1);
3445                assert_eq!(t.rows.len(), 2);
3446                assert_eq!(t.rows[0][0].spans[0].text, "a");
3447                assert_eq!(t.rows[1][1].spans[0].text, "d");
3448            }
3449            _ => panic!("expected table"),
3450        }
3451    }
3452
3453    #[test]
3454    fn smart_punctuation_normalised_to_unicode() {
3455        let text: String = blocks("a... b---c")[0]
3456            .spans
3457            .iter()
3458            .map(|s| s.text.as_str())
3459            .collect();
3460        assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
3461        assert!(text.contains('\u{2014}'), "em dash: {text:?}");
3462    }
3463
3464    #[test]
3465    fn unrepresentable_constructs_dropped_without_leaking_text() {
3466        // Thematic break between two paragraphs: no extra block, no stray text.
3467        let b = blocks("para1\n\n---\n\npara2");
3468        assert_eq!(b.len(), 2);
3469        assert_eq!(
3470            b[0].spans
3471                .iter()
3472                .map(|s| s.text.as_str())
3473                .collect::<String>(),
3474            "para1"
3475        );
3476        assert_eq!(
3477            b[1].spans
3478                .iter()
3479                .map(|s| s.text.as_str())
3480                .collect::<String>(),
3481            "para2"
3482        );
3483
3484        // Fenced div is unwrapped: its content survives, the fence does not.
3485        let d = blocks(":::\ninside\n:::");
3486        let joined: String = d
3487            .iter()
3488            .flat_map(|b| b.spans.iter())
3489            .map(|s| s.text.as_str())
3490            .collect();
3491        assert_eq!(joined, "inside");
3492
3493        // Inline math content is dropped, surrounding text kept.
3494        let m = blocks("before $`E=mc^2` after");
3495        let joined: String = m
3496            .iter()
3497            .flat_map(|b| b.spans.iter())
3498            .map(|s| s.text.as_str())
3499            .collect();
3500        assert!(joined.contains("before"), "{joined:?}");
3501        assert!(joined.contains("after"), "{joined:?}");
3502        assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
3503    }
3504
3505    #[test]
3506    fn empty_document_yields_one_empty_block() {
3507        let b = blocks("");
3508        assert_eq!(b.len(), 1);
3509        assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
3510    }
3511
3512    #[test]
3513    fn block_attributes_parse_into_block() {
3514        let b = blocks(
3515            "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
3516        );
3517        assert_eq!(b.len(), 1);
3518        assert_eq!(b[0].alignment, Some(Alignment::Center));
3519        assert_eq!(b[0].line_height, Some(1500));
3520        assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
3521        assert_eq!(b[0].non_breakable_lines, Some(true));
3522        assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
3523    }
3524
3525    #[test]
3526    fn spacing_block_attributes_parse_into_block() {
3527        // `top_margin` / `text_indent` let one block override the document-wide
3528        // paragraph spacing and first-line indent — what a scene break needs for
3529        // the paragraph that follows it.
3530        let b = blocks("{top_margin=24 text_indent=0}\nhello");
3531        assert_eq!(b.len(), 1);
3532        assert_eq!(b[0].top_margin, Some(24));
3533        assert_eq!(b[0].text_indent, Some(0));
3534    }
3535
3536    #[test]
3537    fn a_zero_text_indent_is_distinct_from_an_absent_one() {
3538        // The whole point of the attribute: `Some(0)` means "explicitly no
3539        // indent", which must not collapse to `None` ("use the document
3540        // default") — otherwise a scene break could not suppress the indent.
3541        let explicit = blocks("{text_indent=0}\nhello");
3542        let absent = blocks("hello");
3543        assert_eq!(explicit[0].text_indent, Some(0));
3544        assert_eq!(absent[0].text_indent, None);
3545        assert!(!explicit[0].is_inline_only());
3546        assert!(absent[0].is_inline_only());
3547    }
3548
3549    #[test]
3550    fn spacing_block_attributes_respect_import_options() {
3551        let src = "{top_margin=24 text_indent=0}\nhello";
3552        let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3553        assert_eq!(b[0].top_margin, None);
3554        assert_eq!(b[0].text_indent, None);
3555    }
3556
3557    #[test]
3558    fn block_attributes_on_heading() {
3559        let b = blocks("{alignment=right}\n# Title");
3560        assert_eq!(b[0].heading_level, Some(1));
3561        assert_eq!(b[0].alignment, Some(Alignment::Right));
3562    }
3563
3564    #[test]
3565    fn block_attributes_respect_import_options() {
3566        // With every optional attribute disabled, the `{…}` block attributes are
3567        // parsed and discarded — only the core paragraph survives.
3568        let src = "{alignment=center line_height=1500}\nhello";
3569        let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3570        assert_eq!(b[0].alignment, None);
3571        assert_eq!(b[0].line_height, None);
3572        assert_eq!(
3573            b[0].spans
3574                .iter()
3575                .map(|s| s.text.as_str())
3576                .collect::<String>(),
3577            "hello"
3578        );
3579    }
3580
3581    #[test]
3582    fn list_item_block_attributes_are_dropped() {
3583        // Block attributes only bind to standalone paragraphs/headings; a list
3584        // item normalises them away (symmetric with the exporter).
3585        let b = blocks("{alignment=center}\n- item");
3586        assert!(b.iter().all(|blk| blk.alignment.is_none()));
3587    }
3588
3589    #[test]
3590    fn unknown_alignment_value_is_ignored() {
3591        let b = blocks("{alignment=sideways}\nhello");
3592        assert_eq!(b[0].alignment, None);
3593    }
3594}
3595
3596// ─── Cheap plain-text extraction ─────────────────────────────────────
3597
3598/// The `U+FFFC OBJECT REPLACEMENT CHARACTER` that stands for a table in the document's
3599/// text.
3600///
3601/// A table is not prose, but it *occupies a position* in the flow: the import mirrors this
3602/// single sentinel into the rope where the table sits
3603/// (`rope_helpers::rope_append_table_anchor`), then the cells as ordinary blocks. Anything
3604/// reconstructing the text a search runs against has to reproduce it, or every offset after
3605/// the first table is short by the two characters (the sentinel and its separator) that the
3606/// document really holds there.
3607pub const TABLE_ANCHOR: &str = "\u{FFFC}";
3608
3609/// One span's contribution to the addressable text.
3610///
3611/// An inline object carries no prose but **does** occupy one `U+FFFC` in the
3612/// document (`format_runs_from_spans` mirrors it there), so it has to occupy one
3613/// here too. Leaving it out makes this string shorter than the document it
3614/// claims to be byte-identical to, and every offset past the object — every
3615/// search hit, every comment anchor — lands a character early.
3616fn span_prose(span: &ParsedSpan, out: &mut String) {
3617    if span.image.is_some() || span.footnote_ref.is_some() {
3618        out.push('\u{FFFC}');
3619        return;
3620    }
3621    out.push_str(&span.text);
3622}
3623
3624fn block_prose(block: &ParsedBlock) -> String {
3625    let mut prose = String::new();
3626    for span in &block.spans {
3627        span_prose(span, &mut prose);
3628    }
3629    prose
3630}
3631
3632fn cell_prose(cell: &ParsedTableCell) -> String {
3633    let mut prose = String::new();
3634    for span in &cell.spans {
3635        span_prose(span, &mut prose);
3636    }
3637    prose
3638}
3639
3640/// The prose of a Djot document, with no entities, no store, and no threads.
3641///
3642/// [`parse_djot`] and [`ParsedElement::flatten_to_blocks`] were both already `pub`;
3643/// nothing chained them. This does, and that is the whole trick: it stops at the
3644/// *parse*, where a full import goes on to create a `Block` entity per paragraph, list
3645/// item and table cell, mirror each into the rope, and write its format runs.
3646///
3647/// # Why a project-wide search needs this
3648///
3649/// A host app searching a manuscript must ask "does this scene contain that word" of
3650/// **thousands** of Djot rows, on every keystroke. Doing that by importing each one into
3651/// a document is not a slow feature, it is a frozen app.
3652///
3653/// And searching the Djot *source* instead — the tempting shortcut — is simply wrong:
3654/// the source is markup. `http` matches inside a link's URL, `*` matches an emphasis
3655/// marker, and an occurrence count taken from the source does not agree with what a
3656/// replace re-derives inside the parsed document. Where a replace guards itself with
3657/// "the text moved under me, skip this field", a count taken from markup makes that
3658/// guard fire on perfectly good rows.
3659///
3660/// # The contract
3661///
3662/// The result is **byte-identical to the text the document searches** for the same Djot:
3663/// each block's spans concatenated, blocks joined by a single `\n`, and a table announced
3664/// by its [`TABLE_ANCHOR`] sentinel — exactly the string the import mirrors into the rope
3665/// and that `build_full_text_via_store` recomposes. So an offset found here is an offset
3666/// the document agrees with. On a live document the same string is served by
3667/// `TextDocument::to_addressable_text()`, which reads it off the search plumbing itself.
3668///
3669/// A property in `djot_roundtrip_tests` pins that across the whole generated feature set;
3670/// without it this would be a second, silently-diverging definition of "the text".
3671///
3672/// **Known exception: a footnote *definition*'s body.** This function treats it as out of
3673/// flow and omits it — matching `character_count()`, which does not count it — but the
3674/// live document mirrors its blocks into the rope, so today an in-document search DOES
3675/// run over the note's body and `to_addressable_text()` includes it. On a document with
3676/// footnote definitions the two strings differ by exactly those bodies; which side is
3677/// wrong is an open product question (should search see notes?), pinned as current
3678/// behaviour in `addressable_text_tests::footnote_bodies_are_searched_in_the_live_document`.
3679///
3680/// ⚠ It is **not** the same as `TextDocument::to_plain_text()`. That is the human-readable
3681/// *export* — same prose, same order, but with every object anchor omitted, so its offsets
3682/// drift by two characters per preceding table. The authority is what a search sees,
3683/// because that is what a replace edits.
3684/// (`to_plain_text` once also *ordered* a blockquote's prose differently; that was a bug,
3685/// fixed and pinned by `plain_text_order_tests` — see
3686/// `claude_reviews/text-document-plain-text-ordering.md`. The anchors are now the two
3687/// views' only difference.)
3688pub fn djot_to_plain_text(djot: &str, options: &DjotImportOptions) -> String {
3689    // Deliberately NOT `ParsedElement::flatten_to_blocks`: that helper drops a table's
3690    // anchor and yields only its cells, which would silently shift every offset in a
3691    // document containing a table by the two characters the document actually holds there.
3692    let elements = parse_djot(djot, options);
3693
3694    // Sized from the source: the prose is always shorter than the markup that carries it,
3695    // so this allocates once and never grows.
3696    let mut out = String::with_capacity(djot.len());
3697
3698    // The separator is decided by "is this the first block", NOT by "is the output still
3699    // empty". An EMPTY block (an empty code fence, say) is still a block: the document
3700    // holds an empty line for it, and an emptiness test would swallow both the block and
3701    // its separator, shifting every offset after it by one.
3702    let mut first = true;
3703    let push = |text: &str, out: &mut String, first: &mut bool| {
3704        if *first {
3705            *first = false;
3706        } else {
3707            out.push('\n');
3708        }
3709        out.push_str(text);
3710    };
3711
3712    for element in &elements {
3713        match element {
3714            ParsedElement::Block(block) => {
3715                push(&block_prose(block), &mut out, &mut first);
3716            }
3717            // A note's body is **out of flow**: it is not laid out where its
3718            // definition was written, it is not part of a copied fragment, and
3719            // the document does not count its characters. So it is not part of
3720            // the addressable text either — which is the one thing that has to
3721            // stay true here, since `character_count()` and this string are
3722            // compared directly.
3723            //
3724            // Its blocks do live in the rope, because they have to live
3725            // somewhere; they are simply not addressable prose.
3726            ParsedElement::FootnoteDefinition { .. } => {}
3727            ParsedElement::Table(table) => {
3728                // The import mirrors a table into the rope as a lone `U+FFFC` sentinel
3729                // followed by its cells, one per block (`rope_append_table_anchor`). The
3730                // sentinel occupies a real position in the text the document searches, so
3731                // it has to occupy one here too — otherwise every offset after a table is
3732                // short by two characters, and a snippet taken from this string would be
3733                // sliced in the wrong place.
3734                push(TABLE_ANCHOR, &mut out, &mut first);
3735                for row in &table.rows {
3736                    for cell in row {
3737                        push(&cell_prose(cell), &mut out, &mut first);
3738                    }
3739                }
3740            }
3741        }
3742    }
3743    out
3744}
3745
3746#[cfg(test)]
3747mod html_footnote_tests {
3748    use super::*;
3749
3750    /// The reason this exists at all: a producer cannot smuggle a reference
3751    /// through as text, because the Djot escaper neutralises `[`, `^` and `]`.
3752    /// Pinned here so the escaping rule and this workaround cannot drift apart.
3753    #[test]
3754    fn a_literal_reference_in_text_would_be_escaped_into_prose() {
3755        let blocks = parse_html("<p>The ferry.[^1]</p>");
3756        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3757        assert!(
3758            text.contains("[^1]"),
3759            "the parser keeps it as characters: {text:?}"
3760        );
3761        assert!(
3762            blocks[0].spans.iter().all(|s| s.footnote_ref.is_none()),
3763            "text is text — it must not become a reference by accident"
3764        );
3765    }
3766
3767    #[test]
3768    fn an_attributed_element_becomes_a_real_footnote_reference() {
3769        let blocks = parse_html(
3770            r#"<p>The ferry was late.<sup data-footnote-ref="3"></sup> She waited.</p>"#,
3771        );
3772        let refs: Vec<&str> = blocks
3773            .iter()
3774            .flat_map(|b| b.spans.iter())
3775            .filter_map(|s| s.footnote_ref.as_deref())
3776            .collect();
3777        assert_eq!(refs, vec!["3"]);
3778
3779        // And it carries no text of its own, so it cannot be counted as words or
3780        // matched by a search.
3781        let marker = blocks[0]
3782            .spans
3783            .iter()
3784            .find(|s| s.footnote_ref.is_some())
3785            .expect("the reference span");
3786        assert!(marker.text.is_empty(), "{marker:?}");
3787    }
3788
3789    /// The attribute is the contract, not the tag: a producer may use whatever
3790    /// element renders sensibly in a browser.
3791    #[test]
3792    fn any_element_carrying_the_attribute_works() {
3793        for html in [
3794            r#"<p>a<sup data-footnote-ref="1"></sup></p>"#,
3795            r#"<p>a<span data-footnote-ref="1"></span></p>"#,
3796            r##"<p>a<a data-footnote-ref="1" href="#fn1"></a></p>"##,
3797        ] {
3798            let blocks = parse_html(html);
3799            assert_eq!(
3800                blocks
3801                    .iter()
3802                    .flat_map(|b| b.spans.iter())
3803                    .filter_map(|s| s.footnote_ref.as_deref())
3804                    .collect::<Vec<_>>(),
3805                vec!["1"],
3806                "failed for {html}"
3807            );
3808        }
3809    }
3810
3811    #[test]
3812    fn an_empty_or_absent_label_is_not_a_reference() {
3813        for html in [
3814            r#"<p>a<sup data-footnote-ref=""></sup></p>"#,
3815            r#"<p>a<sup></sup></p>"#,
3816        ] {
3817            let blocks = parse_html(html);
3818            assert!(
3819                blocks
3820                    .iter()
3821                    .flat_map(|b| b.spans.iter())
3822                    .all(|s| s.footnote_ref.is_none()),
3823                "failed for {html}"
3824            );
3825        }
3826    }
3827}