Skip to main content

docling_core/
pandoc.rs

1//! Pandoc AST output (`--to pandoc`, #515): the document as the JSON
2//! serialization of Pandoc's `Pandoc` type (`pandoc -f json`), which hands
3//! docling.rs's parsing to every Pandoc writer — DOCX, ODT, EPUB, RST, Org,
4//! Typst, AsciiDoc, … — through `docling-rs in.pdf --to pandoc | pandoc -f
5//! json -t docx -o out.docx`.
6//!
7//! Like [`crate::html`] and [`crate::latex`] this walks the *JSON* document
8//! model ([`export_to_json_value`](crate::DoclingDocument::export_to_json_value))
9//! rather than the flat [`Node`](crate::Node) stream: the JSON export already
10//! carries docling's item structure for every backend — the backend-built
11//! item tree where one exists (HTML, DOCX: heading nesting, inline groups of
12//! formatted runs, rich table cells), docling's generic grouping rules
13//! otherwise — so the AST inherits those decisions instead of re-deriving
14//! them. The walk is the HTML serializer's (`_iterate_items` with groups,
15//! pictures not traversed, captions rendered by the item that owns them,
16//! content layers filtered with their children still walked); only the
17//! node it emits differs.
18//!
19//! Mapping (Pandoc constructor ← docling item):
20//!
21//! | docling | Pandoc |
22//! |---|---|
23//! | `title` | `Header 1` |
24//! | `section_header` (level *n*) | `Header (n+1)`, capped at 6 — docling's title-reserving depth; under [`HeadingDepth::Source`] (#848) `Header n`, the depth the DOCX / ODT author wrote |
25//! | `text`, `paragraph` | `Para` (`Plain` inside lists and table cells) |
26//! | `inline` group | the runs' inlines joined by `Space` |
27//! | formatting / hyperlink | `Strong`, `Emph`, `Underline`, `Strikeout`, `Subscript`, `Superscript`, then `Link` around it all — an internal link's target is `#id` (#849); a Markdown `[label](target)` inside an item's text, the flat node chain's hyperlink (`.doc`, AbiWord, …), is a `Link` too (#852) |
28//! | a numbered heading (#850: a DOCX heading numbered through `w:numPr`, docling's `1.2 Title` text with `_number`) | the `Header` holds the authored text alone, `Title` — Pandoc numbers sections itself (`--number-sections`), and its docx reader writes no number, so a numbered AST would show `1.2 1.2 Title`; the Markdown and JSON keep docling's numbered text |
29//! | a link destination (#849: a DOCX bookmark, `_anchors`) | on a heading, its `Header` identifier (Pandoc's own auto-identifier of the heading text, `target-section`, so `#target-section` resolves as it does from `pandoc -f docx`); anywhere else an empty `Span` with the identifier and the class `anchor`, Pandoc's docx reader's shape, at the start of the item's inlines |
30//! | `list` group | `OrderedList` (first item enumerated; start, number style and delimiter from the items' markers — `a)` LowerAlpha OneParen, `iii.` LowerRoman from 3, `(3)` Decimal TwoParens, #835) / `BulletList`, nested lists inside their item |
31//! | `code` | `CodeBlock` with the language as its class (`Code` inline) |
32//! | `formula` | `Para [Math DisplayMath]` (`Math InlineMath` inline) |
33//! | `checkbox_selected` / `_unselected` | the text after `☒` / `☐` — Pandoc's own task-list convention |
34//! | `table` | `Table`: leading all-header rows as `TableHead`, `rowspan` / `colspan`, rich cells as their blocks, captions as the caption |
35//! | `picture` | `Para [Image]` (Pandoc's readers' shape), or a `Figure` holding it when there is a caption or a tabular chart's data `Table`; the `Image`'s alt text is the picture's description — the alt text a DOCX / PPTX / XLSX / ODT author typed (#834) — else its caption; its `width` / `height` attributes the size the document displays it at; the target per [`ImageMode`] |
36//! | footnotes of a table / picture | `Note` at the end of its caption (the float is the call site) |
37//! | key-value / form graph | `Div .key-value-region` / `.form-container` holding a `DefinitionList` (or the nested `BulletList` of a hierarchical graph) |
38//! | form `field_region` | `Div .field-region` holding a `DefinitionList`: `marker` + `field_key` as the term, each `field_value` a definition |
39//! | any other text label (`caption` without an owner, `footnote` without one, `reference`, `handwritten_text`, `page_header`, …) | `Div .docling-<label> [Para]` |
40//! | the document's metadata (#836) | `meta`: `title`, `subject`, `description`, `date` (the document's own, else its creation date), `publisher`, `rights`, `identifier` as `MetaInlines`, `author` and `keywords` as `MetaList`s of `MetaInlines`, `lang` as `MetaString` |
41//! | a tracked change kept under `track_changes=all` (#851: a DOCX `w:ins` / `w:del` / `w:moveTo` / `w:moveFrom` run, `_revision`) | `Span .insertion` / `Span .deletion` with `author` and `date` around the run's inlines — Pandoc's docx reader's `--track-changes=all` shape; under an explicit `accept` / `reject` the document holds that view's text alone and the comments below are left out, as Pandoc leaves them |
42//! | a reviewer comment on an item (#836) | Pandoc's docx reader's `--track-changes=all` shape: a `Span .comment-start` with `comment-id`, `author` and `date` holding the comment's text before the item's inlines, and an empty `Span .comment-end` with the `comment-id` after them; a thread's replies follow as further pairs |
43//!
44//! No Pandoc equivalent, so not written: provenance (pages, bounding boxes),
45//! confidence and classification meta, form field geometry; furniture
46//! (headers/footers) is off unless its [`ContentLayers`] are asked for,
47//! exactly as in the HTML export, and the notes layer's comment sections
48//! are written as the spans above, never as blocks. Images
49//! are targets, not bytes, in Pandoc: the default [`ImageMode::Embedded`]
50//! writes `data:` URIs, so the AST is self-contained and `pandoc -f json -t
51//! docx` embeds every picture (#537); [`ImageMode::Referenced`] links files
52//! under the artifacts directory (relative paths, resolved from where
53//! `pandoc` runs). A picture without a target — [`ImageMode::Placeholder`],
54//! or one whose payload docling cannot decode (EMF/WMF) — is still an
55//! `Image`, classed `docling-placeholder` with an empty target: it survives
56//! into every writer (which reports the missing resource and prints the alt
57//! text) instead of vanishing as raw HTML.
58//!
59//! Version: the output is stamped [`PANDOC_API_VERSION`] — the latest
60//! `pandoc-types` (Pandoc 3.x). Pandoc accepts a document whose
61//! `pandoc-api-version` agrees on the first two components; a caller asking
62//! for another API ([`PandocExportOptions::api_version`]) gets
63//! [`PandocError::UnsupportedApiVersion`] rather than a document Pandoc would
64//! reject. Every node is built through the [`ast`] constructors, the one
65//! place a future API change touches.
66
67use std::collections::{BTreeMap, HashMap, HashSet};
68
69use serde_json::Value;
70
71use crate::document::{ContentLayers, DoclingDocument};
72use crate::json::{TableData, TableView};
73use crate::markdown::ImageMode;
74use crate::TrackChanges;
75
76/// The `pandoc-api-version` written — `pandoc-types` 1.23.1.1 (Pandoc 3.x).
77pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
78
79/// A Pandoc export: the JSON AST, and — for [`ImageMode::Referenced`] — the
80/// image files it links to, as `(path under artifacts_dir, bytes)`.
81pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
82
83/// How a `section_header`'s `level` becomes a Pandoc `Header` depth (#848).
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85pub enum HeadingDepth {
86    /// docling's convention, the default: `Header 1` is the document title
87    /// and a `section_header` of level *n* is `Header (n+1)` (capped at 6),
88    /// the depth docling's Markdown and HTML serializers give it (`##` for
89    /// level 1). The flat `Node::Heading` round trip relies on it — a
90    /// level-2 heading is stored as a level-1 `section_header` and comes
91    /// back as `Header 2` — as do consumers of docling's JSON, so it is not
92    /// changed under them.
93    #[default]
94    Docling,
95    /// The source's own depth: a `section_header` of level *n* is `Header n`
96    /// (clamped to 1–6; a missing or zero level is 1) and the `title` stays
97    /// `Header 1` — what Pandoc's own docx / odt readers give (a DOCX
98    /// `Heading 1` is `Header 1`), so `--number-sections`, TOC depths and
99    /// `--shift-heading-level-by` behave as on `pandoc -f docx`. A document
100    /// whose backend reserves level 1 for the title (HTML, Markdown: an
101    /// `<h2>` is a level-1 `section_header`) comes out one depth shallower
102    /// under it.
103    Source,
104}
105
106impl HeadingDepth {
107    /// The spellings every surface accepts (`docling` = the default).
108    pub const NAMES: [&'static str; 2] = ["docling", "source"];
109
110    pub fn as_str(self) -> &'static str {
111        match self {
112            HeadingDepth::Docling => "docling",
113            HeadingDepth::Source => "source",
114        }
115    }
116}
117
118impl std::str::FromStr for HeadingDepth {
119    type Err = String;
120
121    fn from_str(s: &str) -> Result<Self, String> {
122        match s.trim().to_ascii_lowercase().as_str() {
123            "docling" | "" => Ok(HeadingDepth::Docling),
124            "source" => Ok(HeadingDepth::Source),
125            other => Err(format!(
126                "unknown heading depth {other:?} (expected docling or source)"
127            )),
128        }
129    }
130}
131
132/// Options for [`to_pandoc`].
133#[derive(Debug, Clone)]
134pub struct PandocExportOptions {
135    /// How pictures carry their image (see the module docs); `Embedded` by
136    /// default, so the AST alone rebuilds a document with its pictures.
137    pub image_mode: ImageMode,
138    /// The directory referenced images are written under
139    /// (`<artifacts_dir>/image_NNNNNN.<ext>`, the Markdown export's names).
140    pub artifacts_dir: String,
141    /// The content layers written; body only by default.
142    pub layers: ContentLayers,
143    /// The Pandoc API version the caller needs (`"1.23"`, `"1.23.1.1"`, …);
144    /// `None` = [`PANDOC_API_VERSION`]. Only that API is supported.
145    pub api_version: Option<String>,
146    /// The `Header` depth of a `section_header` (#848): docling's
147    /// title-reserving `n + 1` by default, or the source's own `n`.
148    pub heading_depth: HeadingDepth,
149    /// The tracked-changes policy the document was read under (#851).
150    /// `None` (no policy asked for) and `Some(All)` write the reviewer
151    /// comments as `Span .comment-start` / `.comment-end` pairs; an
152    /// explicit `Accept` / `Reject` leaves them out, as Pandoc's
153    /// `--track-changes=accept|reject` do.
154    pub track_changes: Option<TrackChanges>,
155}
156
157impl Default for PandocExportOptions {
158    fn default() -> Self {
159        Self {
160            image_mode: ImageMode::Embedded,
161            artifacts_dir: "artifacts".to_string(),
162            layers: ContentLayers::BODY,
163            api_version: None,
164            heading_depth: HeadingDepth::Docling,
165            track_changes: None,
166        }
167    }
168}
169
170/// Why a Pandoc export was refused.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum PandocError {
173    /// The requested `pandoc-api-version` is not the one supported.
174    UnsupportedApiVersion { requested: String },
175}
176
177impl std::fmt::Display for PandocError {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        match self {
180            PandocError::UnsupportedApiVersion { requested } => write!(
181                f,
182                "unsupported Pandoc API version '{requested}': only {} (pandoc-types {}, Pandoc 3.x) is supported",
183                version_string(&PANDOC_API_VERSION[..2]),
184                version_string(&PANDOC_API_VERSION),
185            ),
186        }
187    }
188}
189
190impl std::error::Error for PandocError {}
191
192fn version_string(parts: &[u32]) -> String {
193    parts
194        .iter()
195        .map(u32::to_string)
196        .collect::<Vec<_>>()
197        .join(".")
198}
199
200/// Check a requested API version: Pandoc reads a document whose version
201/// shares its first two components (major API, minor API), so `1.23`,
202/// `1.23.1` and `1.23.1.1` are all this export's; anything else is refused.
203pub fn check_api_version(requested: &str) -> Result<(), PandocError> {
204    let err = || PandocError::UnsupportedApiVersion {
205        requested: requested.to_string(),
206    };
207    let parts: Vec<u32> = requested
208        .trim()
209        .split('.')
210        .map(|p| p.parse::<u32>().map_err(|_| err()))
211        .collect::<Result<_, _>>()?;
212    if parts.len() < 2 || parts[..2] != PANDOC_API_VERSION[..2] {
213        return Err(err());
214    }
215    if parts.len() > PANDOC_API_VERSION.len()
216        || parts
217            .iter()
218            .zip(PANDOC_API_VERSION.iter())
219            .skip(2)
220            .any(|(a, b)| a > b)
221    {
222        // A newer patch than this export knows.
223        return Err(err());
224    }
225    Ok(())
226}
227
228/// The document as Pandoc JSON (compact, one line, like `pandoc -t json`),
229/// and for [`ImageMode::Referenced`] the `(path, bytes)` image files to
230/// write.
231pub fn to_pandoc(
232    doc: &DoclingDocument,
233    options: &PandocExportOptions,
234) -> Result<PandocOutput, PandocError> {
235    // The JSON with the note calls docling's model has no field for (#538),
236    // its tables typed (#875).
237    let (json, tables) = crate::json::to_json_with_notes(doc);
238    render_json(&json, &tables, options)
239}
240
241/// [`to_pandoc`] for a document already in docling's JSON wire format —
242/// e.g. one produced by Python docling, or `DoclingDocument.export_to_dict()`
243/// in the Python bindings.
244pub fn from_docling_json(
245    json: &Value,
246    options: &PandocExportOptions,
247) -> Result<PandocOutput, PandocError> {
248    render_json(json, &[], options)
249}
250
251/// [`from_docling_json`] with the typed tables the JSON's table items name
252/// ([`crate::json::to_json_typed_tables`]).
253fn render_json(
254    json: &Value,
255    tables: &[TableData],
256    options: &PandocExportOptions,
257) -> Result<PandocOutput, PandocError> {
258    if let Some(v) = &options.api_version {
259        check_api_version(v)?;
260    }
261    // The walk recurses per nesting level; an XBRL instance nests thousands
262    // deep, so it runs on a big-stack thread like the HTML serializer.
263    let render = || {
264        let mut ser = Serializer::new(json, options);
265        ser.tables = tables;
266        let blocks = ser.body();
267        let doc = ast::document_with_meta(meta_of(json), blocks);
268        (
269            serde_json::to_string(&doc).expect("Pandoc JSON is always serializable"),
270            ser.artifacts,
271        )
272    };
273    #[cfg(not(target_arch = "wasm32"))]
274    {
275        Ok(std::thread::scope(|scope| {
276            std::thread::Builder::new()
277                .name("docling-pandoc".into())
278                .stack_size(256 << 20)
279                .spawn_scoped(scope, render)
280                .expect("spawn the pandoc serializer thread")
281                .join()
282                .expect("pandoc serializer thread panicked")
283        }))
284    }
285    #[cfg(target_arch = "wasm32")]
286    {
287        Ok(render())
288    }
289}
290
291/// Pandoc's auto-identifier of a heading (`Text.Pandoc.Shared.
292/// stringToIdentifier`, the default `auto_identifiers` rule, #849): the
293/// text lower-cased, everything but letters, digits, `_`, `-`, `.` and
294/// spaces removed, the words joined by `-`, anything up to the first letter
295/// dropped; `section` when nothing is left. `Target section` →
296/// `target-section`, `2026 Results` → `results`.
297pub fn auto_identifier(text: &str) -> String {
298    let kept: String = text
299        .chars()
300        .filter(|c| c.is_alphanumeric() || c.is_whitespace() || matches!(c, '_' | '-' | '.'))
301        .flat_map(char::to_lowercase)
302        .collect();
303    let joined = kept.split_whitespace().collect::<Vec<_>>().join("-");
304    let id = joined.trim_start_matches(|c: char| !c.is_alphabetic());
305    if id.is_empty() {
306        "section".to_string()
307    } else {
308        id.to_string()
309    }
310}
311
312/// The Pandoc AST constructors (`Text.Pandoc.Definition`, JSON per
313/// `Text.Pandoc.JSON`): every node this module writes is built here.
314///
315/// Every constructor moves the nodes it is given into the new one:
316/// `json!` would deep-copy each interpolated `Value` (`to_value`), so a
317/// table cell's content was copied again by its row, its table and the
318/// document — on a 640,000-cell table most of the export's time and
319/// twice its AST in peak memory (#875). Literal-only fragments still use
320/// `json!`.
321pub mod ast {
322    use serde_json::{json, Value};
323
324    use super::PANDOC_API_VERSION;
325
326    /// A JSON array of the given nodes, moved in.
327    fn arr<const N: usize>(items: [Value; N]) -> Value {
328        Value::Array(items.into())
329    }
330
331    pub fn document(blocks: Vec<Value>) -> Value {
332        document_with_meta(json!({}), blocks)
333    }
334    /// A document with its `Meta` (#836).
335    pub fn document_with_meta(meta: Value, blocks: Vec<Value>) -> Value {
336        let mut m = serde_json::Map::with_capacity(3);
337        m.insert("pandoc-api-version".into(), json!(PANDOC_API_VERSION));
338        m.insert("meta".into(), meta);
339        m.insert("blocks".into(), Value::Array(blocks));
340        Value::Object(m)
341    }
342    pub fn meta_inlines(inlines: Vec<Value>) -> Value {
343        node("MetaInlines", Value::Array(inlines))
344    }
345    pub fn meta_string(s: &str) -> Value {
346        node("MetaString", json!(s))
347    }
348    pub fn meta_list(items: Vec<Value>) -> Value {
349        node("MetaList", Value::Array(items))
350    }
351    /// `Span Attr [Inline]` with key-value attributes.
352    pub fn span(classes: &[&str], attrs: &[(String, String)], inlines: Vec<Value>) -> Value {
353        let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
354        node(
355            "Span",
356            arr([json!(["", classes, kv]), Value::Array(inlines)]),
357        )
358    }
359
360    /// `Attr`: identifier, classes, key-value pairs.
361    pub fn attr(classes: &[&str]) -> Value {
362        json!(["", classes, []])
363    }
364
365    fn node(t: &str, c: Value) -> Value {
366        let mut m = serde_json::Map::with_capacity(2);
367        m.insert("t".into(), Value::String(t.into()));
368        m.insert("c".into(), c);
369        Value::Object(m)
370    }
371
372    // --- blocks -------------------------------------------------------------
373
374    pub fn para(inlines: Vec<Value>) -> Value {
375        node("Para", Value::Array(inlines))
376    }
377    pub fn plain(inlines: Vec<Value>) -> Value {
378        node("Plain", Value::Array(inlines))
379    }
380    pub fn header(level: usize, inlines: Vec<Value>) -> Value {
381        node(
382            "Header",
383            arr([level.into(), attr(&[]), Value::Array(inlines)]),
384        )
385    }
386    /// A `Header` with an identifier (#849: a bookmarked heading).
387    pub fn header_with_id(level: usize, id: &str, inlines: Vec<Value>) -> Value {
388        node(
389            "Header",
390            arr([level.into(), json!([id, [], []]), Value::Array(inlines)]),
391        )
392    }
393    /// An empty `Span` carrying an identifier and the class `anchor` — the
394    /// link destination Pandoc's docx reader writes for a bookmark (#849).
395    pub fn anchor(id: &str) -> Value {
396        node("Span", json!([[id, ["anchor"], []], []]))
397    }
398    pub fn code_block(language: Option<&str>, text: &str) -> Value {
399        let classes: Vec<&str> = language.into_iter().collect();
400        node("CodeBlock", arr([attr(&classes), text.into()]))
401    }
402    pub fn bullet_list(items: Vec<Vec<Value>>) -> Value {
403        node(
404            "BulletList",
405            Value::Array(items.into_iter().map(Value::Array).collect()),
406        )
407    }
408    /// `OrderedList ListAttributes [[Block]]`: `style` a `ListNumberStyle`
409    /// (`Decimal`, `LowerAlpha`, `UpperRoman`, …), `delim` a
410    /// `ListNumberDelim` (`Period`, `OneParen`, `TwoParens`, `DefaultDelim`).
411    pub fn ordered_list(start: u64, style: &str, delim: &str, items: Vec<Vec<Value>>) -> Value {
412        node(
413            "OrderedList",
414            arr([
415                json!([start, { "t": style }, { "t": delim }]),
416                Value::Array(items.into_iter().map(Value::Array).collect()),
417            ]),
418        )
419    }
420    pub fn definition_list(entries: Vec<(Vec<Value>, Vec<Vec<Value>>)>) -> Value {
421        node(
422            "DefinitionList",
423            Value::Array(
424                entries
425                    .into_iter()
426                    .map(|(term, defs)| {
427                        arr([
428                            Value::Array(term),
429                            Value::Array(defs.into_iter().map(Value::Array).collect()),
430                        ])
431                    })
432                    .collect(),
433            ),
434        )
435    }
436    /// `RawBlock Format Text` — kept by writers of that format, dropped by
437    /// the others.
438    pub fn raw_block(format: &str, text: &str) -> Value {
439        node("RawBlock", json!([format, text]))
440    }
441    pub fn div(classes: &[&str], blocks: Vec<Value>) -> Value {
442        node("Div", arr([attr(classes), Value::Array(blocks)]))
443    }
444    /// `Caption`: no short caption, the long one as blocks.
445    pub fn caption(blocks: Vec<Value>) -> Value {
446        arr([Value::Null, Value::Array(blocks)])
447    }
448    pub fn figure(classes: &[&str], caption: Value, blocks: Vec<Value>) -> Value {
449        node(
450            "Figure",
451            arr([attr(classes), caption, Value::Array(blocks)]),
452        )
453    }
454    /// A table cell: `Cell Attr Alignment RowSpan ColSpan [Block]`.
455    pub fn cell(row_span: usize, col_span: usize, blocks: Vec<Value>) -> Value {
456        arr([
457            attr(&[]),
458            json!({ "t": "AlignDefault" }),
459            row_span.into(),
460            col_span.into(),
461            Value::Array(blocks),
462        ])
463    }
464    pub fn row(cells: Vec<Value>) -> Value {
465        arr([attr(&[]), Value::Array(cells)])
466    }
467    /// `Table Attr Caption [ColSpec] TableHead [TableBody] TableFoot`.
468    pub fn table(caption: Value, num_cols: usize, head: Vec<Value>, body: Vec<Value>) -> Value {
469        let colspecs: Vec<Value> = (0..num_cols)
470            .map(|_| json!([{ "t": "AlignDefault" }, { "t": "ColWidthDefault" }]))
471            .collect();
472        node(
473            "Table",
474            arr([
475                attr(&[]),
476                caption,
477                Value::Array(colspecs),
478                arr([attr(&[]), Value::Array(head)]),
479                arr([arr([attr(&[]), 0.into(), json!([]), Value::Array(body)])]),
480                arr([attr(&[]), json!([])]),
481            ]),
482        )
483    }
484
485    // --- inlines ------------------------------------------------------------
486
487    pub fn str_(s: &str) -> Value {
488        node("Str", json!(s))
489    }
490    pub fn space() -> Value {
491        json!({ "t": "Space" })
492    }
493    pub fn line_break() -> Value {
494        json!({ "t": "LineBreak" })
495    }
496    /// `Strong`, `Emph`, `Underline`, `Strikeout`, `Subscript`, `Superscript`.
497    pub fn wrap(t: &str, inlines: Vec<Value>) -> Value {
498        node(t, Value::Array(inlines))
499    }
500    pub fn code(text: &str) -> Value {
501        node("Code", arr([attr(&[]), text.into()]))
502    }
503    pub fn math(display: bool, tex: &str) -> Value {
504        let kind = if display { "DisplayMath" } else { "InlineMath" };
505        node("Math", json!([{ "t": kind }, tex]))
506    }
507    pub fn link(inlines: Vec<Value>, url: &str) -> Value {
508        node(
509            "Link",
510            arr([attr(&[]), Value::Array(inlines), json!([url, ""])]),
511        )
512    }
513    pub fn image(classes: &[&str], alt: Vec<Value>, src: &str) -> Value {
514        image_sized(classes, &[], alt, src)
515    }
516    /// An `Image` with key-value attributes (`width` / `height`, #834).
517    pub fn image_sized(
518        classes: &[&str],
519        attrs: &[(String, String)],
520        alt: Vec<Value>,
521        src: &str,
522    ) -> Value {
523        let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
524        node(
525            "Image",
526            arr([
527                json!(["", classes, kv]),
528                Value::Array(alt),
529                json!([src, ""]),
530            ]),
531        )
532    }
533    pub fn note(blocks: Vec<Value>) -> Value {
534        node("Note", Value::Array(blocks))
535    }
536}
537
538/// Text as `Str` words and `Space`s, `\n` as `LineBreak`; surrounding
539/// whitespace trimmed (a no-break space stays inside its word, as Pandoc's
540/// readers keep it). A Markdown link `[label](target)` in the text — how
541/// the flat node chain (the `.doc`, AbiWord, … readers) carries a
542/// hyperlink, #852 — is a `Link`.
543fn text_inlines(text: &str) -> Vec<Value> {
544    let mut out = Vec::new();
545    for (i, line) in text.trim().split('\n').enumerate() {
546        if i > 0 {
547            out.push(ast::line_break());
548        }
549        let is_ws = |c: char| c.is_whitespace() && c != '\u{a0}';
550        let line_start = out.len();
551        // Whitespace seen since the last inline of this line: one `Space`
552        // before the next.
553        let mut pending_space = false;
554        let mut rest = line;
555        while !rest.is_empty() {
556            let (plain, link) = match markdown_link(rest) {
557                Some((before, label, target, after)) => {
558                    rest = after;
559                    (before, Some((label, target)))
560                }
561                None => (std::mem::take(&mut rest), None),
562            };
563            let words: Vec<&str> = plain.split(is_ws).filter(|w| !w.is_empty()).collect();
564            if words.is_empty() {
565                pending_space |= !plain.is_empty();
566            } else {
567                if out.len() > line_start && (pending_space || plain.starts_with(is_ws)) {
568                    out.push(ast::space());
569                }
570                for (k, word) in words.iter().enumerate() {
571                    if k > 0 {
572                        out.push(ast::space());
573                    }
574                    out.push(ast::str_(word));
575                }
576                pending_space = plain.ends_with(is_ws);
577            }
578            if let Some((label, target)) = link {
579                if out.len() > line_start && pending_space {
580                    out.push(ast::space());
581                }
582                let mut label_inlines = Vec::new();
583                for (k, word) in label.split_whitespace().enumerate() {
584                    if k > 0 {
585                        label_inlines.push(ast::space());
586                    }
587                    label_inlines.push(ast::str_(word));
588                }
589                // An unsafe destination (`javascript:`, #860) keeps the
590                // label's words alone.
591                if crate::hyperlink::is_safe_hyperlink(target) {
592                    out.push(ast::link(label_inlines, target));
593                } else {
594                    out.extend(label_inlines);
595                }
596                pending_space = false;
597            }
598        }
599    }
600    out
601}
602
603/// The first Markdown link of `text` as `(before, label, target, after)`:
604/// `[label](target)` with a label free of brackets and newlines and a
605/// target free of whitespace and parentheses — the shape the flat readers
606/// write; an image's `![alt](src)` is not one.
607fn markdown_link(text: &str) -> Option<(&str, &str, &str, &str)> {
608    let mut from = 0;
609    while let Some(open) = text[from..].find('[') {
610        let open = from + open;
611        from = open + 1;
612        if text[..open].ends_with('!') {
613            continue;
614        }
615        let close = text[open + 1..].find(']')?;
616        let close = open + 1 + close;
617        let label = &text[open + 1..close];
618        if label.is_empty() || label.contains(['[', '\n']) || !text[close + 1..].starts_with('(') {
619            continue;
620        }
621        let Some(end) = text[close + 2..].find(')') else {
622            continue;
623        };
624        let end = close + 2 + end;
625        let target = &text[close + 2..end];
626        if target.is_empty() || target.contains(|c: char| c.is_whitespace() || c == '(') {
627            continue;
628        }
629        return Some((&text[..open], label, target, &text[end + 1..]));
630    }
631    None
632}
633
634/// [`text_inlines`] with a `Note` spliced in at each call: `notes` is
635/// `[[offset, text], …]` (chars into `text`), the JSON's internal `_notes`.
636/// Whitespace around a call becomes a `Space`, as Pandoc's docx reader
637/// writes `word¹ next` → `Str "word", Note, Space, Str "next"`.
638fn text_with_notes(text: &str, notes: &[Value]) -> Vec<Value> {
639    let mut calls: Vec<(usize, &str)> = notes
640        .iter()
641        .filter_map(|n| Some((n.get(0)?.as_u64()? as usize, n.get(1)?.as_str()?)))
642        .collect();
643    calls.sort_by_key(|&(offset, _)| offset);
644    let chars: Vec<char> = text.chars().collect();
645    let mut out: Vec<Value> = Vec::new();
646    let push_segment = |out: &mut Vec<Value>, seg: &str| {
647        let inlines = text_inlines(seg);
648        if inlines.is_empty() {
649            if !out.is_empty() && seg.chars().any(char::is_whitespace) {
650                out.push(ast::space());
651            }
652            return;
653        }
654        if !out.is_empty() && seg.starts_with(char::is_whitespace) {
655            out.push(ast::space());
656        }
657        out.extend(inlines);
658        if seg.ends_with(char::is_whitespace) {
659            out.push(ast::space());
660        }
661    };
662    let mut prev = 0usize;
663    for (offset, note) in calls {
664        let at = offset.clamp(prev, chars.len());
665        let seg: String = chars[prev..at].iter().collect();
666        push_segment(&mut out, &seg);
667        out.push(ast::note(vec![ast::para(text_inlines(note))]));
668        prev = at;
669    }
670    let rest: String = chars[prev..].iter().collect();
671    push_segment(&mut out, &rest);
672    while out
673        .last()
674        .is_some_and(|v| v.get("t").and_then(Value::as_str) == Some("Space"))
675    {
676        out.pop();
677    }
678    out
679}
680
681/// Inline lists joined by single `Space`s (an inline group's runs) — none
682/// before a run that opens with closing punctuation (`.`, `,`, `)`, …) or
683/// after one that ends with opening punctuation: docling keeps each
684/// formatting run as its own item and its serializers join them with a
685/// space, which would put `link .` into every Pandoc output.
686fn join_inlines(parts: Vec<Vec<Value>>) -> Vec<Value> {
687    fn edge_char(inlines: &[Value], first: bool) -> Option<char> {
688        let node = if first {
689            inlines.first()
690        } else {
691            inlines.last()
692        }?;
693        match node.get("t").and_then(Value::as_str)? {
694            "Str" => {
695                let s = node.get("c")?.as_str()?;
696                if first {
697                    s.chars().next()
698                } else {
699                    s.chars().last()
700                }
701            }
702            // Formatting / links: look inside.
703            "Strong" | "Emph" | "Underline" | "Strikeout" | "Subscript" | "Superscript" => {
704                edge_char(node.get("c")?.as_array()?, first)
705            }
706            "Link" => edge_char(node.get("c")?.get(1)?.as_array()?, first),
707            _ => None,
708        }
709    }
710    let mut out: Vec<Value> = Vec::new();
711    for part in parts.into_iter().filter(|p| !p.is_empty()) {
712        if !out.is_empty() {
713            let closes = edge_char(&part, true)
714                .is_some_and(|c| ".,;:!?)]}%\u{bb}\u{201d}\u{2019}".contains(c));
715            let opens = edge_char(&out, false).is_some_and(|c| "([{\u{ab}\u{201c}".contains(c));
716            if !closes && !opens {
717                out.push(ast::space());
718            }
719        }
720        out.extend(part);
721    }
722    out
723}
724
725/// Pandoc's `Meta` from the document's `metadata` (#836, the JSON export's
726/// `--metadata json` object; Python docling's JSON has none): `title`,
727/// `subject`, `description`, `publisher`, `rights`, `identifier` and `date`
728/// — the document's own date, else when it was created — as `MetaInlines`,
729/// `author` and `keywords` as `MetaList`s of `MetaInlines` (Pandoc's own
730/// shape for several authors), `lang` as `MetaString`, so `pandoc -t docx`
731/// sets the title and author and the templates see them. Empty when the
732/// document has none.
733fn meta_of(json: &Value) -> Value {
734    let mut meta = serde_json::Map::new();
735    let Some(m) = json.get("metadata").and_then(Value::as_object) else {
736        return Value::Object(meta);
737    };
738    let text = |key: &str| {
739        m.get(key)
740            .and_then(Value::as_str)
741            .map(text_inlines)
742            .filter(|t| !t.is_empty())
743    };
744    let list = |key: &str| -> Vec<Value> {
745        m.get(key)
746            .and_then(Value::as_array)
747            .into_iter()
748            .flatten()
749            .filter_map(Value::as_str)
750            .map(text_inlines)
751            .filter(|t| !t.is_empty())
752            .map(ast::meta_inlines)
753            .collect()
754    };
755    for (key, field) in [
756        ("title", "title"),
757        ("subject", "subject"),
758        ("description", "description"),
759        ("publisher", "publisher"),
760        ("rights", "rights"),
761        ("identifier", "identifier"),
762    ] {
763        if let Some(t) = text(field) {
764            meta.insert(key.into(), ast::meta_inlines(t));
765        }
766    }
767    let authors = list("authors");
768    if !authors.is_empty() {
769        meta.insert("author".into(), ast::meta_list(authors));
770    }
771    if let Some(date) = text("date").or_else(|| text("created")) {
772        meta.insert("date".into(), ast::meta_inlines(date));
773    }
774    let keywords = list("keywords");
775    if !keywords.is_empty() {
776        meta.insert("keywords".into(), ast::meta_list(keywords));
777    }
778    if let Some(lang) = m.get("language").and_then(Value::as_str).map(str::trim) {
779        if !lang.is_empty() {
780            meta.insert("lang".into(), ast::meta_string(lang));
781        }
782    }
783    Value::Object(meta)
784}
785
786/// A docling comment note — `[author: Name (initials), time: date]: text`,
787/// the DOCX, `.doc`, ODT and XLSX backends' shape — split into its author,
788/// date and text; a note without the head is all text.
789fn split_comment(note: &str) -> (Option<String>, Option<String>, String) {
790    let Some(rest) = note.strip_prefix('[') else {
791        return (None, None, note.to_string());
792    };
793    let Some((head, body)) = rest.split_once(']') else {
794        return (None, None, note.to_string());
795    };
796    let body = body.strip_prefix(':').unwrap_or(body).trim().to_string();
797    let mut author = None;
798    let mut date = None;
799    for part in head.split(", ") {
800        if let Some(a) = part.strip_prefix("author: ") {
801            author = Some(a.trim().to_string());
802        } else if let Some(t) = part.strip_prefix("time: ") {
803            date = Some(t.trim().to_string());
804        }
805    }
806    (author, date, body)
807}
808
809/// A length in inches as Pandoc's readers write it: `2.5in`, `1.25in` —
810/// up to four decimals, trailing zeros dropped.
811fn inches(v: f64) -> String {
812    let s = format!("{v:.4}");
813    let s = s.trim_end_matches('0').trim_end_matches('.');
814    format!("{}in", if s.is_empty() { "0" } else { s })
815}
816
817/// Pandoc's `ListAttributes` — start, number style, delimiter — from a
818/// list's item markers (#835): `a)` → `(1, LowerAlpha, OneParen)`, `iii.` →
819/// `(3, LowerRoman, Period)`, `(3)` → `(3, Decimal, TwoParens)`, a bare
820/// `12` → `DefaultDelim`. Every list used to be `Decimal` / `Period`, and a
821/// non-decimal start fell back to 1. A single letter reads as Roman only
822/// when the list does — `i` / `I` (what Pandoc's Markdown reader assumes),
823/// or another item's marker a multi-letter numeral — else as alpha, so a
824/// list starting at `c)` is the third letter, not 100. Letters repeat past
825/// `z` (`aa` = 27, Pandoc's own extension). A marker nothing parses
826/// (`1.2.`) keeps the old `(1, Decimal, Period)`.
827fn list_attributes(markers: &[&str]) -> (u64, &'static str, &'static str) {
828    fn split(marker: &str) -> (&str, &'static str) {
829        let m = marker.trim();
830        if let Some(inner) = m.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
831            return (inner.trim(), "TwoParens");
832        }
833        if let Some(inner) = m.strip_suffix(')') {
834            return (inner.trim(), "OneParen");
835        }
836        if let Some(inner) = m.strip_suffix('.') {
837            return (inner.trim(), "Period");
838        }
839        (m, "DefaultDelim")
840    }
841    fn roman(token: &str) -> Option<u64> {
842        let value = |c: char| match c.to_ascii_lowercase() {
843            'i' => Some(1),
844            'v' => Some(5),
845            'x' => Some(10),
846            'l' => Some(50),
847            'c' => Some(100),
848            'd' => Some(500),
849            'm' => Some(1000),
850            _ => None,
851        };
852        let values: Vec<i64> = token.chars().map(value).collect::<Option<_>>()?;
853        if values.is_empty() {
854            return None;
855        }
856        let mut total = 0i64;
857        for (i, &v) in values.iter().enumerate() {
858            // Subtractive notation: `IV` is 5 − 1.
859            if values.get(i + 1).is_some_and(|&next| next > v) {
860                total -= v;
861            } else {
862                total += v;
863            }
864        }
865        (total > 0).then_some(total as u64)
866    }
867    fn alpha(token: &str) -> Option<u64> {
868        let first = token.chars().next()?;
869        if !first.is_ascii_alphabetic() || !token.chars().all(|c| c == first) {
870            return None;
871        }
872        let ordinal = (first.to_ascii_lowercase() as u64) - ('a' as u64) + 1;
873        Some((token.chars().count() as u64 - 1) * 26 + ordinal)
874    }
875    const DEFAULT: (u64, &str, &str) = (1, "Decimal", "Period");
876    let Some(first) = markers.first() else {
877        return DEFAULT;
878    };
879    let (token, delim) = split(first);
880    if token.is_empty() {
881        return DEFAULT;
882    }
883    if token.chars().all(|c| c.is_ascii_digit()) {
884        return token
885            .parse()
886            .map(|n| (n, "Decimal", delim))
887            .unwrap_or(DEFAULT);
888    }
889    if !token.chars().all(|c| c.is_ascii_alphabetic()) {
890        return DEFAULT;
891    }
892    let upper = token.chars().next().is_some_and(|c| c.is_ascii_uppercase());
893    let letters = |t: &str| t.chars().all(|c| c.is_ascii_alphabetic());
894    let reads_roman = roman(token).is_some()
895        && (token.len() > 1
896            || token.eq_ignore_ascii_case("i")
897            || markers[1..].iter().any(|m| {
898                let (t, _) = split(m);
899                t.len() > 1 && letters(t) && roman(t).is_some()
900            }));
901    if reads_roman {
902        let style = if upper { "UpperRoman" } else { "LowerRoman" };
903        return (roman(token).unwrap_or(1), style, delim);
904    }
905    match alpha(token) {
906        Some(n) => (n, if upper { "UpperAlpha" } else { "LowerAlpha" }, delim),
907        None => DEFAULT,
908    }
909}
910
911struct GridCell {
912    /// The covering cell's index in the [`TableView`].
913    cell: usize,
914    start_row: usize,
915    start_col: usize,
916    row_span: usize,
917    col_span: usize,
918}
919
920struct Serializer<'a> {
921    json: &'a Value,
922    /// The document's typed tables, which its table items name; empty for
923    /// a JSON read in ([`from_docling_json`]).
924    tables: &'a [TableData],
925    image_mode: ImageMode,
926    artifacts_dir: String,
927    layers: ContentLayers,
928    heading_depth: HeadingDepth,
929    track_changes: Option<TrackChanges>,
930    artifacts: Vec<(String, Vec<u8>)>,
931    pic_index: usize,
932    visited: HashSet<String>,
933    /// Items some table / picture / graph holds as a caption or footnote:
934    /// rendered by their owner, skipped where the walk meets them.
935    owned_captions: HashSet<String>,
936    owned_footnotes: HashSet<String>,
937}
938
939impl<'a> Serializer<'a> {
940    fn new(json: &'a Value, options: &PandocExportOptions) -> Self {
941        let mut owned_captions = HashSet::new();
942        let mut owned_footnotes = HashSet::new();
943        for bucket in [
944            "pictures",
945            "tables",
946            "key_value_items",
947            "form_items",
948            "texts",
949        ] {
950            for item in Self::array(json.get(bucket)) {
951                for (key, set) in [
952                    ("captions", &mut owned_captions),
953                    ("footnotes", &mut owned_footnotes),
954                ] {
955                    for r in Self::array(item.get(key)) {
956                        if let Some(cref) = r.get("$ref").and_then(Value::as_str) {
957                            set.insert(cref.to_string());
958                        }
959                    }
960                }
961            }
962        }
963        Self {
964            json,
965            tables: &[],
966            image_mode: options.image_mode,
967            artifacts_dir: options.artifacts_dir.clone(),
968            layers: options.layers,
969            heading_depth: options.heading_depth,
970            track_changes: options.track_changes,
971            artifacts: Vec::new(),
972            pic_index: 0,
973            visited: HashSet::new(),
974            owned_captions,
975            owned_footnotes,
976        }
977    }
978
979    fn array(v: Option<&'a Value>) -> impl Iterator<Item = &'a Value> {
980        v.and_then(Value::as_array).into_iter().flatten()
981    }
982
983    fn resolve(&self, cref: &str) -> Option<&'a Value> {
984        let rest = cref.strip_prefix("#/")?;
985        if rest == "body" {
986            return self.json.get("body");
987        }
988        let (bucket, idx) = rest.split_once('/')?;
989        self.json.get(bucket)?.get(idx.parse::<usize>().ok()?)
990    }
991
992    fn refs(v: Option<&'a Value>) -> Vec<&'a str> {
993        Self::array(v)
994            .filter_map(|r| r.get("$ref").and_then(Value::as_str))
995            .collect()
996    }
997
998    fn children(item: &'a Value) -> Vec<&'a str> {
999        Self::refs(item.get("children"))
1000    }
1001
1002    fn self_ref(item: &Value) -> &str {
1003        item.get("self_ref").and_then(Value::as_str).unwrap_or("")
1004    }
1005
1006    fn label(item: &Value) -> &str {
1007        item.get("label").and_then(Value::as_str).unwrap_or("")
1008    }
1009
1010    fn text(item: &Value) -> &str {
1011        item.get("text").and_then(Value::as_str).unwrap_or("")
1012    }
1013
1014    /// The item's link destinations (`_anchors`, #849).
1015    fn anchors(item: &Value) -> Vec<String> {
1016        item.get("_anchors")
1017            .and_then(Value::as_array)
1018            .map(|a| {
1019                a.iter()
1020                    .filter_map(Value::as_str)
1021                    .map(str::to_string)
1022                    .collect()
1023            })
1024            .unwrap_or_default()
1025    }
1026
1027    fn is_group(item: &Value) -> bool {
1028        Self::self_ref(item).starts_with("#/groups/")
1029    }
1030
1031    fn excluded(&self, item: &Value) -> bool {
1032        let layer = item
1033            .get("content_layer")
1034            .and_then(Value::as_str)
1035            .unwrap_or("body");
1036        !self.layers.contains_name(layer)
1037    }
1038
1039    /// docling's `_iterate_items(with_groups=True, traverse_pictures=False)`
1040    /// below `root`, depth-first pre-order; items off the chosen layers are
1041    /// not yielded but their subtrees are walked; under a picture only its
1042    /// captions are visited.
1043    fn descendants(&self, root: &'a Value, out: &mut Vec<&'a Value>) {
1044        let picture = Self::self_ref(root).starts_with("#/pictures/");
1045        let captions = if picture {
1046            Self::refs(root.get("captions"))
1047        } else {
1048            Vec::new()
1049        };
1050        for cref in Self::children(root) {
1051            if picture && !captions.contains(&cref) {
1052                continue;
1053            }
1054            // A caption or footnote some table / picture / graph owns is
1055            // rendered by that owner, inline group and all (#833): neither
1056            // it nor what nests under it is a block of the walk.
1057            if self.owned_captions.contains(cref) || self.owned_footnotes.contains(cref) {
1058                continue;
1059            }
1060            let Some(child) = self.resolve(cref) else {
1061                continue;
1062            };
1063            if !self.excluded(child) {
1064                out.push(child);
1065            }
1066            self.descendants(child, out);
1067        }
1068    }
1069
1070    /// Every not-yet-visited descendant of `item`, serialized in order —
1071    /// a container marks what it renders as visited, so the flat walk
1072    /// skips it.
1073    fn blocks_of(&mut self, item: &'a Value) -> Vec<Value> {
1074        let mut nodes = Vec::new();
1075        self.descendants(item, &mut nodes);
1076        let mut out = Vec::new();
1077        for node in nodes {
1078            if self.visited.insert(Self::self_ref(node).to_string()) {
1079                out.extend(self.serialize(node));
1080            }
1081        }
1082        out
1083    }
1084
1085    fn body(&mut self) -> Vec<Value> {
1086        let Some(body) = self.json.get("body") else {
1087            return Vec::new();
1088        };
1089        self.visited.insert("#/body".to_string());
1090        let mut blocks = self.blocks_of(body);
1091        let notes = self.unplaced_notes();
1092        blocks.extend(notes);
1093        blocks
1094    }
1095
1096    /// Furniture `footnote` items no text item calls — docling's DOCX / ODT
1097    /// note bodies when the JSON carries no call sites (one exported by
1098    /// Python docling), or a call that could not be anchored — written while
1099    /// the furniture layer itself is off: each as a trailing `Note`, so a
1100    /// document rebuilt from the AST still has its notes (#538).
1101    fn unplaced_notes(&mut self) -> Vec<Value> {
1102        let notes: Vec<&'a Value> = Self::array(self.json.get("texts"))
1103            .filter(|t| Self::label(t) == "footnote" && !Self::is_note_body(t))
1104            .filter(|t| t.get("content_layer").and_then(Value::as_str) == Some("furniture"))
1105            .filter(|t| self.excluded(t) && !self.owned_footnotes.contains(Self::self_ref(t)))
1106            .collect();
1107        let mut out = Vec::new();
1108        for t in notes {
1109            // The note's formatting and links too (#833).
1110            let (text, _, _) = self.own_inlines(t);
1111            if !text.is_empty() {
1112                out.push(ast::para(vec![ast::note(vec![ast::para(text)])]));
1113            }
1114        }
1115        out
1116    }
1117
1118    /// One item's blocks.
1119    fn serialize(&mut self, item: &'a Value) -> Vec<Value> {
1120        let sref = Self::self_ref(item);
1121        if Self::is_group(item) {
1122            return match Self::label(item) {
1123                // A comment thread is written as spans on the item it
1124                // annotates (#836), never as blocks; one no item refers to
1125                // (a plain cell's, an orphan's) has no place in the AST.
1126                "comment_section" => Vec::new(),
1127                "list" => self.list_group(item),
1128                "inline" => {
1129                    let (inlines, mut rest) = self.inline_group(item);
1130                    let mut out = Vec::new();
1131                    if !inlines.is_empty() {
1132                        out.push(ast::para(inlines));
1133                    }
1134                    out.append(&mut rest);
1135                    out
1136                }
1137                _ => self.blocks_of(item),
1138            };
1139        }
1140        if sref.starts_with("#/texts/") {
1141            if self.owned_captions.contains(sref) || self.owned_footnotes.contains(sref) {
1142                return Vec::new();
1143            }
1144            // A note body some text item calls is written there, as a `Note`.
1145            if self.excluded(item) || Self::is_note_body(item) {
1146                return Vec::new();
1147            }
1148            return self.text_item(item);
1149        }
1150        if sref.starts_with("#/tables/") {
1151            return self.table(item);
1152        }
1153        if sref.starts_with("#/pictures/") {
1154            return self.picture(item);
1155        }
1156        if sref.starts_with("#/key_value_items/") {
1157            return self.graph_item(item, "key-value-region");
1158        }
1159        if sref.starts_with("#/form_items/") {
1160            return self.graph_item(item, "form-container");
1161        }
1162        if sref.starts_with("#/field_regions/") {
1163            return self.field_region(item);
1164        }
1165        if sref.starts_with("#/field_items/") {
1166            let entries = vec![self.field_item(item)];
1167            return Self::field_blocks(entries);
1168        }
1169        Vec::new()
1170    }
1171
1172    /// A form's field region (#515): its fields as one `DefinitionList` —
1173    /// key → value(s) — in a `Div .field-region`; a field without a key
1174    /// contributes its blocks in place.
1175    fn field_region(&mut self, region: &'a Value) -> Vec<Value> {
1176        let mut entries = Vec::new();
1177        for cref in Self::children(region) {
1178            let Some(child) = self.resolve(cref) else {
1179                continue;
1180            };
1181            if !self.visited.insert(cref.to_string()) {
1182                continue;
1183            }
1184            if cref.starts_with("#/field_items/") {
1185                entries.push(self.field_item(child));
1186            } else {
1187                let mut blocks = if self.excluded(child) {
1188                    Vec::new()
1189                } else {
1190                    self.serialize(child)
1191                };
1192                blocks.extend(self.blocks_of(child));
1193                entries.push((Vec::new(), blocks));
1194            }
1195        }
1196        let blocks = Self::field_blocks(entries);
1197        if blocks.is_empty() {
1198            Vec::new()
1199        } else {
1200            vec![ast::div(&["field-region"], blocks)]
1201        }
1202    }
1203
1204    /// One field: its `marker` and `field_key` texts as the term, each
1205    /// `field_value` (and anything else under it) as a definition.
1206    fn field_item(&mut self, field: &'a Value) -> (Vec<Value>, Vec<Value>) {
1207        let mut nodes = Vec::new();
1208        self.descendants(field, &mut nodes);
1209        let (mut term, mut defs) = (Vec::new(), Vec::new());
1210        for node in nodes {
1211            if !self.visited.insert(Self::self_ref(node).to_string()) {
1212                continue;
1213            }
1214            match Self::label(node) {
1215                "marker" | "field_key" => {
1216                    let inlines = self.formatted(node);
1217                    if !inlines.is_empty() {
1218                        term = join_inlines(vec![term, inlines]);
1219                    }
1220                }
1221                "field_value" => {
1222                    let inlines = self.formatted(node);
1223                    if !inlines.is_empty() {
1224                        defs.push(ast::plain(inlines));
1225                    }
1226                }
1227                _ => defs.extend(self.serialize(node)),
1228            }
1229        }
1230        (term, defs)
1231    }
1232
1233    /// Fields as blocks: runs of keyed fields become `DefinitionList`s, a
1234    /// key-less field's blocks stand on their own.
1235    fn field_blocks(entries: Vec<(Vec<Value>, Vec<Value>)>) -> Vec<Value> {
1236        let mut out = Vec::new();
1237        let mut run: Vec<(Vec<Value>, Vec<Vec<Value>>)> = Vec::new();
1238        for (term, defs) in entries {
1239            if term.is_empty() {
1240                if !run.is_empty() {
1241                    out.push(ast::definition_list(std::mem::take(&mut run)));
1242                }
1243                out.extend(defs);
1244            } else {
1245                let defs = defs.into_iter().map(|d| vec![d]).collect();
1246                run.push((term, defs));
1247            }
1248        }
1249        if !run.is_empty() {
1250            out.push(ast::definition_list(run));
1251        }
1252        out
1253    }
1254
1255    /// A text item's own inlines: its formatted text, or — for an empty
1256    /// item whose first child is an inline group (a list item, heading or
1257    /// caption whose content is a run of formatted spans; whatever else
1258    /// nests under it — a sub-list, a section's paragraphs — follows as
1259    /// blocks of its own, #833) — that group's, with any blocks the group
1260    /// held besides.
1261    fn own_inlines(&mut self, item: &'a Value) -> (Vec<Value>, Vec<Value>, bool) {
1262        let children = Self::children(item);
1263        if Self::text(item).is_empty() {
1264            if let Some(group) = children
1265                .first()
1266                .and_then(|c| self.resolve(c))
1267                .filter(|c| Self::is_group(c) && Self::label(c) == "inline")
1268            {
1269                self.visited.insert(Self::self_ref(group).to_string());
1270                let (inlines, rest) = self.inline_group(group);
1271                return (inlines, rest, true);
1272            }
1273        }
1274        (self.formatted(item), Vec::new(), false)
1275    }
1276
1277    /// `inlines` between the comment spans of the item's reviewer comments
1278    /// (#836): for each `comment_section` group the item's `comments` refer
1279    /// to, one `Span .comment-start` per comment text — `comment-id` (the
1280    /// group's number, `-n` for a thread's replies), `author`, `date` from
1281    /// docling's note head, the comment's text inside — before the inlines
1282    /// and an empty `Span .comment-end` after them, Pandoc's docx reader's
1283    /// `--track-changes=all` shape. The groups are then visited, so a notes
1284    /// layer that is on does not write them again as blocks.
1285    fn with_comments(&mut self, item: &'a Value, inlines: Vec<Value>) -> Vec<Value> {
1286        let refs = Self::refs(item.get("comments"));
1287        if refs.is_empty() {
1288            return inlines;
1289        }
1290        // An explicit accept / reject view carries no comments (#851),
1291        // as Pandoc's `--track-changes=accept|reject` write none.
1292        if matches!(
1293            self.track_changes,
1294            Some(TrackChanges::Accept | TrackChanges::Reject)
1295        ) {
1296            return inlines;
1297        }
1298        let mut starts = Vec::new();
1299        let mut ends = Vec::new();
1300        for cref in refs {
1301            let Some(group) = self.resolve(cref) else {
1302                continue;
1303            };
1304            if !Self::is_group(group) || Self::label(group) != "comment_section" {
1305                continue;
1306            }
1307            self.visited.insert(cref.to_string());
1308            let id = group
1309                .get("name")
1310                .and_then(Value::as_str)
1311                .and_then(|n| n.strip_prefix("comment-"))
1312                .unwrap_or("0")
1313                .to_string();
1314            // The group's texts sit on the notes layer, which the layer
1315            // filter would drop: read the children directly.
1316            let mut k = 0;
1317            for r in Self::children(group) {
1318                self.visited.insert(r.to_string());
1319                let Some(node) = self.resolve(r).filter(|_| r.starts_with("#/texts/")) else {
1320                    continue;
1321                };
1322                let (author, date, text) = split_comment(Self::text(node));
1323                let comment_id = if k == 0 {
1324                    id.clone()
1325                } else {
1326                    format!("{id}-{k}")
1327                };
1328                k += 1;
1329                let mut attrs = vec![("comment-id".to_string(), comment_id.clone())];
1330                if let Some(a) = author {
1331                    attrs.push(("author".to_string(), a));
1332                }
1333                if let Some(d) = date {
1334                    attrs.push(("date".to_string(), d));
1335                }
1336                starts.push(ast::span(&["comment-start"], &attrs, text_inlines(&text)));
1337                ends.push(ast::span(
1338                    &["comment-end"],
1339                    &[("comment-id".to_string(), comment_id)],
1340                    Vec::new(),
1341                ));
1342            }
1343        }
1344        if starts.is_empty() {
1345            return inlines;
1346        }
1347        let mut out = starts;
1348        out.extend(inlines);
1349        out.extend(ends);
1350        out
1351    }
1352
1353    /// The item's text with its formatting and hyperlink applied, and the
1354    /// notes it calls as `Note`s at their call sites (#538).
1355    fn formatted(&self, item: &Value) -> Vec<Value> {
1356        let base = match Self::label(item) {
1357            "code" => vec![ast::code(Self::text(item))],
1358            "formula" if !Self::text(item).is_empty() => {
1359                vec![ast::math(false, Self::text(item).trim())]
1360            }
1361            _ => {
1362                let (text, skipped) = Self::authored_text(item);
1363                match item.get("_notes").and_then(Value::as_array) {
1364                    Some(notes) if !notes.is_empty() => {
1365                        if skipped == 0 {
1366                            text_with_notes(text, notes)
1367                        } else {
1368                            // The note offsets count the numbered text.
1369                            let shifted: Vec<Value> = notes
1370                                .iter()
1371                                .map(|n| {
1372                                    let off = n[0].as_u64().unwrap_or(0).saturating_sub(skipped);
1373                                    serde_json::json!([off, n[1]])
1374                                })
1375                                .collect();
1376                            text_with_notes(text, &shifted)
1377                        }
1378                    }
1379                    _ => text_inlines(text),
1380                }
1381            }
1382        };
1383        Self::decorate(base, item)
1384    }
1385
1386    /// The item's text without the generated numbering it opens with
1387    /// (`_number`, #850), and how many chars were dropped.
1388    fn authored_text(item: &Value) -> (&str, u64) {
1389        let text = Self::text(item);
1390        if let Some(number) = item.get("_number").and_then(Value::as_str) {
1391            if let Some(rest) = text
1392                .strip_prefix(number)
1393                .and_then(|rest| rest.strip_prefix(' '))
1394            {
1395                return (rest, (number.chars().count() + 1) as u64);
1396            }
1397        }
1398        (text, 0)
1399    }
1400
1401    /// A furniture `footnote` item that is the body of a note a text item
1402    /// calls ([`crate::tree::TreeItem::note_body`]).
1403    fn is_note_body(item: &Value) -> bool {
1404        item.get("_note_body").and_then(Value::as_bool) == Some(true)
1405    }
1406
1407    fn decorate(mut inlines: Vec<Value>, item: &Value) -> Vec<Value> {
1408        if inlines.is_empty() {
1409            return inlines;
1410        }
1411        if let Some(f) = item.get("formatting") {
1412            let on = |k: &str| f.get(k).and_then(Value::as_bool).unwrap_or(false);
1413            if on("bold") {
1414                inlines = vec![ast::wrap("Strong", inlines)];
1415            }
1416            if on("italic") {
1417                inlines = vec![ast::wrap("Emph", inlines)];
1418            }
1419            if on("underline") {
1420                inlines = vec![ast::wrap("Underline", inlines)];
1421            }
1422            if on("strikethrough") {
1423                inlines = vec![ast::wrap("Strikeout", inlines)];
1424            }
1425            match f.get("script").and_then(Value::as_str) {
1426                Some("sub") => inlines = vec![ast::wrap("Subscript", inlines)],
1427                Some("super") => inlines = vec![ast::wrap("Superscript", inlines)],
1428                _ => {}
1429            }
1430        }
1431        // An unsafe destination (`javascript:`, #860) keeps the text alone.
1432        if let Some(url) = item
1433            .get("hyperlink")
1434            .and_then(Value::as_str)
1435            .filter(|u| crate::hyperlink::is_safe_hyperlink(u))
1436        {
1437            inlines = vec![ast::link(inlines, url)];
1438        }
1439        // A tracked change kept under `all` (#851): Pandoc's docx reader's
1440        // span, outermost.
1441        if let Some(rev) = item.get("_revision") {
1442            if let Some(kind) = rev.get("kind").and_then(Value::as_str) {
1443                let mut attrs = Vec::new();
1444                for key in ["author", "date"] {
1445                    if let Some(v) = rev.get(key).and_then(Value::as_str) {
1446                        attrs.push((key.to_string(), v.to_string()));
1447                    }
1448                }
1449                inlines = vec![ast::span(&[kind], &attrs, inlines)];
1450            }
1451        }
1452        inlines
1453    }
1454
1455    /// An inline group's runs as inlines joined by `Space`; anything in it
1456    /// that has no inline form (a nested list, a table) comes back as
1457    /// blocks for after the paragraph.
1458    fn inline_group(&mut self, group: &'a Value) -> (Vec<Value>, Vec<Value>) {
1459        let mut nodes = Vec::new();
1460        self.descendants(group, &mut nodes);
1461        let mut runs: Vec<Vec<Value>> = Vec::new();
1462        let mut rest: Vec<Value> = Vec::new();
1463        for node in nodes {
1464            let r = Self::self_ref(node).to_string();
1465            if self.visited.contains(&r) {
1466                continue;
1467            }
1468            self.visited.insert(r.clone());
1469            if r.starts_with("#/texts/") {
1470                if self.owned_captions.contains(&r)
1471                    || self.owned_footnotes.contains(&r)
1472                    || Self::is_note_body(node)
1473                {
1474                    continue;
1475                }
1476                let mut run: Vec<Value> = Self::anchors(node)
1477                    .iter()
1478                    .map(|id| ast::anchor(id))
1479                    .collect();
1480                run.append(&mut self.formatted(node));
1481                runs.push(run);
1482                continue;
1483            }
1484            if Self::is_group(node) && Self::label(node) == "inline" {
1485                let (inl, mut more) = self.inline_group(node);
1486                runs.push(inl);
1487                rest.append(&mut more);
1488                continue;
1489            }
1490            rest.extend(self.serialize(node));
1491        }
1492        (join_inlines(runs), rest)
1493    }
1494
1495    fn text_item(&mut self, item: &'a Value) -> Vec<Value> {
1496        let label = Self::label(item);
1497        let mut out = Vec::new();
1498        match label {
1499            "list_item" => {
1500                // A list item met outside a list group: a one-item list.
1501                let blocks = self.list_item_blocks(item);
1502                if !blocks.is_empty() {
1503                    out.push(ast::bullet_list(vec![blocks]));
1504                }
1505                return out;
1506            }
1507            "code" => {
1508                let lang = item
1509                    .get("code_language")
1510                    .and_then(Value::as_str)
1511                    .filter(|l| !l.is_empty() && *l != "unknown")
1512                    .map(str::to_ascii_lowercase);
1513                out.push(ast::code_block(lang.as_deref(), Self::text(item)));
1514            }
1515            "formula" => {
1516                let tex = Self::text(item).trim();
1517                if !tex.is_empty() {
1518                    out.push(ast::para(vec![ast::math(true, tex)]));
1519                }
1520            }
1521            _ => {
1522                let (inlines, rest, _) = self.own_inlines(item);
1523                let mut inlines = self.with_comments(item, inlines);
1524                // The item's link destinations (#849): a heading takes
1525                // the first as its identifier, the rest open its inlines
1526                // as anchor spans; any other item opens with them all.
1527                let mut anchors = Self::anchors(item);
1528                let header_id =
1529                    if matches!(label, "title" | "section_header") && !anchors.is_empty() {
1530                        Some(anchors.remove(0))
1531                    } else {
1532                        None
1533                    };
1534                if !anchors.is_empty() && (header_id.is_some() || !inlines.is_empty()) {
1535                    let mut with_anchors: Vec<Value> =
1536                        anchors.iter().map(|id| ast::anchor(id)).collect();
1537                    with_anchors.append(&mut inlines);
1538                    inlines = with_anchors;
1539                }
1540                match label {
1541                    "title" | "section_header" => {
1542                        let level = if label == "title" {
1543                            1
1544                        } else {
1545                            let n = item.get("level").and_then(Value::as_u64).unwrap_or(1) as usize;
1546                            match self.heading_depth {
1547                                // docling's depth: level 1 is `##`, under
1548                                // the title's `#`.
1549                                HeadingDepth::Docling => (n + 1).min(6),
1550                                // The source's depth (#848): a DOCX
1551                                // `Heading 1` is `Header 1`, as Pandoc's
1552                                // docx reader writes it.
1553                                HeadingDepth::Source => n.clamp(1, 6),
1554                            }
1555                        };
1556                        if !inlines.is_empty() {
1557                            out.push(match &header_id {
1558                                Some(id) => ast::header_with_id(level, id, inlines),
1559                                None => ast::header(level, inlines),
1560                            });
1561                        }
1562                    }
1563                    "text" | "paragraph" => {
1564                        if !inlines.is_empty() {
1565                            out.push(ast::para(inlines));
1566                        }
1567                    }
1568                    "checkbox_selected" | "checkbox_unselected" => {
1569                        let mark = if label == "checkbox_selected" {
1570                            "☒"
1571                        } else {
1572                            "☐"
1573                        };
1574                        let mut with_mark = vec![ast::str_(mark)];
1575                        if !inlines.is_empty() {
1576                            with_mark.push(ast::space());
1577                            with_mark.append(&mut inlines);
1578                        }
1579                        out.push(ast::para(with_mark));
1580                    }
1581                    other => {
1582                        if !inlines.is_empty() {
1583                            let class = format!("docling-{other}");
1584                            out.push(ast::div(&[&class], vec![ast::para(inlines)]));
1585                        }
1586                    }
1587                }
1588                out.extend(rest);
1589            }
1590        }
1591        // Content nested under the item (an HTML / DOCX heading's section).
1592        out.extend(self.blocks_of(item));
1593        out
1594    }
1595
1596    /// A list item's blocks: its own text as `Plain`, then whatever is
1597    /// nested under it (sub-lists, paragraphs).
1598    fn list_item_blocks(&mut self, item: &'a Value) -> Vec<Value> {
1599        let (inlines, rest, _) = self.own_inlines(item);
1600        let inlines = self.with_comments(item, inlines);
1601        let mut blocks = Vec::new();
1602        if !inlines.is_empty() {
1603            blocks.push(ast::plain(inlines));
1604        }
1605        blocks.extend(rest);
1606        blocks.extend(self.blocks_of(item));
1607        blocks
1608    }
1609
1610    fn list_group(&mut self, group: &'a Value) -> Vec<Value> {
1611        let first = Self::children(group)
1612            .first()
1613            .and_then(|r| self.resolve(r))
1614            .filter(|c| Self::label(c) == "list_item");
1615        let enumerated = first.is_some_and(|c| {
1616            c.get("enumerated")
1617                .and_then(Value::as_bool)
1618                .unwrap_or(false)
1619        });
1620        // The items' markers decide the list's start, number style and
1621        // delimiter (#835).
1622        let markers: Vec<&str> = Self::children(group)
1623            .iter()
1624            .filter_map(|r| self.resolve(r))
1625            .filter(|c| Self::label(c) == "list_item")
1626            .filter_map(|c| c.get("marker").and_then(Value::as_str))
1627            .collect();
1628        let (start, style, delim) = list_attributes(&markers);
1629
1630        let mut nodes = Vec::new();
1631        self.descendants(group, &mut nodes);
1632        let mut items: Vec<Vec<Value>> = Vec::new();
1633        for node in nodes {
1634            let r = Self::self_ref(node).to_string();
1635            if !self.visited.insert(r.clone()) {
1636                continue;
1637            }
1638            if Self::label(node) == "list_item"
1639                && r.starts_with("#/texts/")
1640                && !self.owned_captions.contains(&r)
1641            {
1642                let blocks = self.list_item_blocks(node);
1643                items.push(blocks);
1644                continue;
1645            }
1646            // Something that is not a list item directly in the list (a
1647            // paragraph between items): it belongs to the item before it.
1648            let blocks = self.serialize(node);
1649            if blocks.is_empty() {
1650                continue;
1651            }
1652            match items.last_mut() {
1653                Some(last) => last.extend(blocks),
1654                None => items.push(blocks),
1655            }
1656        }
1657        if items.is_empty() {
1658            return Vec::new();
1659        }
1660        if enumerated {
1661            vec![ast::ordered_list(start, style, delim, items)]
1662        } else {
1663            vec![ast::bullet_list(items)]
1664        }
1665    }
1666
1667    /// The owner's caption texts as one `Caption`, with its footnotes as
1668    /// `Note`s at the end — the float is their call site. Each caption and
1669    /// note keeps its formatting, links and inline group (#833: a bold,
1670    /// linked `<caption>` used to come out as bare words, a multi-run one
1671    /// as an empty caption plus a stray `Para`).
1672    fn caption(&mut self, item: &'a Value) -> Value {
1673        let mut inlines = self.caption_text(item);
1674        for cref in Self::refs(item.get("footnotes")) {
1675            if let Some(note) = self.resolve(cref) {
1676                if cref.starts_with("#/texts/") && !self.excluded(note) {
1677                    let (text, _, _) = self.own_inlines(note);
1678                    if !text.is_empty() {
1679                        inlines.push(ast::note(vec![ast::para(text)]));
1680                    }
1681                }
1682            }
1683        }
1684        if inlines.is_empty() {
1685            ast::caption(Vec::new())
1686        } else {
1687            ast::caption(vec![ast::plain(inlines)])
1688        }
1689    }
1690
1691    /// The owner's caption texts alone (no notes) — a picture's alt text,
1692    /// as Pandoc's readers give a captioned image.
1693    fn caption_text(&mut self, item: &'a Value) -> Vec<Value> {
1694        let caps: Vec<&'a Value> = Self::refs(item.get("captions"))
1695            .into_iter()
1696            .filter(|cref| cref.starts_with("#/texts/"))
1697            .filter_map(|cref| self.resolve(cref))
1698            .filter(|cap| !self.excluded(cap))
1699            .collect();
1700        let mut parts = Vec::new();
1701        for cap in caps {
1702            let (inlines, _, _) = self.own_inlines(cap);
1703            parts.push(inlines);
1704        }
1705        join_inlines(parts)
1706    }
1707
1708    /// docling's `TableData.grid`: each position → the cell covering it.
1709    fn grid(table: &TableView<'_>) -> (usize, Vec<Vec<Option<GridCell>>>) {
1710        let (num_rows, num_cols) = (table.num_rows, table.num_cols);
1711        let mut grid: Vec<Vec<Option<GridCell>>> = (0..num_rows)
1712            .map(|_| (0..num_cols).map(|_| None).collect())
1713            .collect();
1714        for (i, cell) in table.cells.iter().enumerate() {
1715            let (r0, c0) = (cell.start_row, cell.start_col);
1716            let (r1, c1) = (cell.end_row, cell.end_col);
1717            if r0 >= num_rows || c0 >= num_cols {
1718                continue;
1719            }
1720            let (r1, c1) = (r1.clamp(r0 + 1, num_rows), c1.clamp(c0 + 1, num_cols));
1721            for row in grid.iter_mut().take(r1).skip(r0) {
1722                for slot in row.iter_mut().take(c1).skip(c0) {
1723                    *slot = Some(GridCell {
1724                        cell: i,
1725                        start_row: r0,
1726                        start_col: c0,
1727                        row_span: r1 - r0,
1728                        col_span: c1 - c0,
1729                    });
1730                }
1731            }
1732        }
1733        (num_cols, grid)
1734    }
1735
1736    fn table_from_data(&mut self, data: &'a Value, caption: Value) -> Option<Value> {
1737        let table = crate::json::table_view(data, self.tables);
1738        let (num_cols, grid) = Self::grid(&table);
1739        if grid.is_empty() || num_cols == 0 {
1740            return None;
1741        }
1742        let mut rows: Vec<(bool, Value)> = Vec::new();
1743        for (i, row) in grid.iter().enumerate() {
1744            let mut cells = Vec::new();
1745            let mut all_header = true;
1746            let mut anchored = 0;
1747            for (j, slot) in row.iter().enumerate() {
1748                let Some(g) = slot else {
1749                    // A position no cell covers (a table's empty corner) is
1750                    // neutral to the header test.
1751                    cells.push(ast::cell(1, 1, Vec::new()));
1752                    continue;
1753                };
1754                if g.start_row != i || g.start_col != j {
1755                    continue;
1756                }
1757                anchored += 1;
1758                let cell = &table.cells[g.cell];
1759                all_header &= cell.column_header;
1760                let blocks = match cell.cell_ref {
1761                    Some(cref) => match self.resolve(cref) {
1762                        Some(target) => {
1763                            self.visited.insert(cref.to_string());
1764                            self.serialize(target)
1765                        }
1766                        None => Vec::new(),
1767                    },
1768                    None => {
1769                        let inl = text_inlines(cell.text);
1770                        if inl.is_empty() {
1771                            Vec::new()
1772                        } else {
1773                            vec![ast::plain(inl)]
1774                        }
1775                    }
1776                };
1777                cells.push(ast::cell(g.row_span, g.col_span, blocks));
1778            }
1779            rows.push((all_header && anchored > 0, ast::row(cells)));
1780        }
1781        // Leading all-header rows form the head; the rest is the body.
1782        let head_len = rows.iter().take_while(|(h, _)| *h).count();
1783        let head_len = if head_len == rows.len() { 0 } else { head_len };
1784        let mut head = Vec::new();
1785        let mut body = Vec::new();
1786        for (i, (_, row)) in rows.into_iter().enumerate() {
1787            if i < head_len {
1788                head.push(row);
1789            } else {
1790                body.push(row);
1791            }
1792        }
1793        Some(ast::table(caption, num_cols, head, body))
1794    }
1795
1796    fn table(&mut self, item: &'a Value) -> Vec<Value> {
1797        let caption = self.caption(item);
1798        if self.excluded(item) {
1799            return Vec::new();
1800        }
1801        match item.get("data") {
1802            Some(data) => self.table_from_data(data, caption).into_iter().collect(),
1803            None => Vec::new(),
1804        }
1805    }
1806
1807    fn picture(&mut self, item: &'a Value) -> Vec<Value> {
1808        if self.excluded(item) {
1809            return Vec::new();
1810        }
1811        let caption = self.caption(item);
1812        let has_caption = caption
1813            .get(1)
1814            .and_then(Value::as_array)
1815            .is_some_and(|b| !b.is_empty());
1816        let uri = item
1817            .get("image")
1818            .and_then(|i| i.get("uri"))
1819            .and_then(Value::as_str);
1820        let src = match (self.image_mode, uri) {
1821            (ImageMode::Embedded, Some(uri)) if uri.starts_with("data:") => Some(uri.to_string()),
1822            (ImageMode::Referenced, Some(uri)) => self
1823                .reference_image(uri)
1824                .or_else(|| (!uri.starts_with("data:")).then(|| uri.to_string())),
1825            _ => None,
1826        };
1827        // #537: every picture is an `Image`, so it survives into whatever
1828        // Pandoc writes and can be counted — one without a target
1829        // (placeholder mode, or a payload docling could not decode: EMF/WMF)
1830        // carries the `docling-placeholder` class and an empty target, which
1831        // a writer reports as a missing resource and renders as the alt text.
1832        // The alt text is the author's (`meta.description`, #834) — what
1833        // Pandoc's docx reader takes from `wp:docPr descr` — and only
1834        // without one the caption, as before.
1835        let description = item
1836            .get("meta")
1837            .and_then(|m| m.get("description"))
1838            .and_then(|d| d.get("text"))
1839            .and_then(Value::as_str)
1840            .map(text_inlines)
1841            .filter(|t| !t.is_empty());
1842        let alt = match description {
1843            Some(alt) => alt,
1844            None => self.caption_text(item),
1845        };
1846        // The displayed size (`_display`, inches) as `width` / `height`
1847        // attributes, Pandoc's readers' shape for a sized image (#834).
1848        let attrs: Vec<(String, String)> = item
1849            .get("image")
1850            .and_then(|i| i.get("_display"))
1851            .and_then(Value::as_array)
1852            .and_then(|d| Some((d.first()?.as_f64()?, d.get(1)?.as_f64()?)))
1853            .map(|(w, h)| {
1854                vec![
1855                    ("width".to_string(), inches(w)),
1856                    ("height".to_string(), inches(h)),
1857                ]
1858            })
1859            .unwrap_or_default();
1860        let image = match &src {
1861            Some(src) => ast::image_sized(&[], &attrs, alt, src),
1862            None => ast::image_sized(&["docling-placeholder"], &attrs, alt, ""),
1863        };
1864        // A native chart's data grid (`meta.tabular_chart.chart_data`).
1865        let chart = item
1866            .get("meta")
1867            .and_then(|m| m.get("tabular_chart"))
1868            .and_then(|t| t.get("chart_data"))
1869            .and_then(|chart| self.table_from_data(chart, ast::caption(Vec::new())));
1870        if chart.is_none() && !has_caption {
1871            // Pandoc's own readers put a caption-less image in a paragraph.
1872            return vec![ast::para(vec![image])];
1873        }
1874        let mut blocks = vec![ast::plain(vec![image])];
1875        blocks.extend(chart);
1876        vec![ast::figure(&[], caption, blocks)]
1877    }
1878
1879    /// Referenced mode: a `data:` URI becomes an artifact file named like
1880    /// the Markdown export's; the path is the image target.
1881    fn reference_image(&mut self, uri: &str) -> Option<String> {
1882        let rest = uri.strip_prefix("data:")?;
1883        let (mime, payload) = rest.split_once(";base64,")?;
1884        let bytes = crate::base64::decode(payload)?;
1885        let path = format!(
1886            "{}/image_{:06}.{}",
1887            self.artifacts_dir,
1888            self.pic_index,
1889            crate::markdown::ext_for(mime)
1890        );
1891        self.pic_index += 1;
1892        self.artifacts.push((path.clone(), bytes));
1893        Some(path)
1894    }
1895
1896    /// A key-value / form graph: a `DefinitionList` of keys and their
1897    /// values, or — when `to_child` links make it a hierarchy — the nested
1898    /// `BulletList` of it (the HTML export's two shapes), in a classed `Div`
1899    /// with the item's caption after it.
1900    fn graph_item(&mut self, item: &'a Value, class: &str) -> Vec<Value> {
1901        let mut out = Vec::new();
1902        if !self.excluded(item) {
1903            if let Some(graph) = item.get("graph") {
1904                if let Some(block) = Self::graph(graph) {
1905                    out.push(ast::div(&[class], vec![block]));
1906                }
1907            }
1908        }
1909        // The caption (and footnote notes) as a paragraph after the region.
1910        let caption = self.caption(item);
1911        for block in caption
1912            .get(1)
1913            .and_then(Value::as_array)
1914            .into_iter()
1915            .flatten()
1916        {
1917            if let Some(inlines) = block.get("c").and_then(Value::as_array) {
1918                out.push(ast::para(inlines.clone()));
1919            }
1920        }
1921        out
1922    }
1923
1924    fn graph(graph: &'a Value) -> Option<Value> {
1925        let cells: HashMap<u64, &Value> = Self::array(graph.get("cells"))
1926            .filter_map(|c| Some((c.get("cell_id")?.as_u64()?, c)))
1927            .collect();
1928        let order: Vec<u64> = Self::array(graph.get("cells"))
1929            .filter_map(|c| c.get("cell_id")?.as_u64())
1930            .collect();
1931        let mut child_links: HashMap<u64, Vec<u64>> = HashMap::new();
1932        let mut value_links: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
1933        let mut value_order: Vec<u64> = Vec::new();
1934        let mut parents: HashSet<u64> = HashSet::new();
1935        for link in Self::array(graph.get("links")) {
1936            let (Some(s), Some(t)) = (
1937                link.get("source_cell_id").and_then(Value::as_u64),
1938                link.get("target_cell_id").and_then(Value::as_u64),
1939            ) else {
1940                continue;
1941            };
1942            if !cells.contains_key(&s) || !cells.contains_key(&t) {
1943                continue;
1944            }
1945            match link.get("label").and_then(Value::as_str) {
1946                Some("to_child") => {
1947                    child_links.entry(s).or_default().push(t);
1948                    parents.insert(t);
1949                }
1950                Some("to_value") => {
1951                    if !value_links.contains_key(&s) {
1952                        value_order.push(s);
1953                    }
1954                    value_links.entry(s).or_default().push(t);
1955                }
1956                _ => {}
1957            }
1958        }
1959        let cell_text =
1960            |id: u64| text_inlines(cells[&id].get("text").and_then(Value::as_str).unwrap_or(""));
1961        if child_links.is_empty() {
1962            if value_order.is_empty() {
1963                return None;
1964            }
1965            let entries = value_order
1966                .iter()
1967                .map(|&k| {
1968                    let defs = value_links[&k]
1969                        .iter()
1970                        .map(|&v| vec![ast::plain(cell_text(v))])
1971                        .collect();
1972                    (cell_text(k), defs)
1973                })
1974                .collect();
1975            return Some(ast::definition_list(entries));
1976        }
1977        // Hierarchical: a nested bullet list, cycles cut on the descent path.
1978        fn node(
1979            id: u64,
1980            cell_text: &dyn Fn(u64) -> Vec<Value>,
1981            child_links: &HashMap<u64, Vec<u64>>,
1982            value_links: &BTreeMap<u64, Vec<u64>>,
1983            path: &mut HashSet<u64>,
1984        ) -> Option<Vec<Value>> {
1985            if !path.insert(id) {
1986                return None;
1987            }
1988            let mut inlines = cell_text(id);
1989            if let Some(values) = value_links.get(&id) {
1990                let vals = join_inlines(values.iter().map(|&v| cell_text(v)).collect());
1991                inlines = vec![ast::wrap("Strong", inlines)];
1992                inlines.push(ast::str_(":"));
1993                if !vals.is_empty() {
1994                    inlines.push(ast::space());
1995                    inlines.extend(vals);
1996                }
1997            }
1998            let mut blocks = vec![ast::plain(inlines)];
1999            let kids: Vec<Vec<Value>> = child_links
2000                .get(&id)
2001                .into_iter()
2002                .flatten()
2003                .filter_map(|&c| node(c, cell_text, child_links, value_links, path))
2004                .collect();
2005            if !kids.is_empty() {
2006                blocks.push(ast::bullet_list(kids));
2007            }
2008            path.remove(&id);
2009            Some(blocks)
2010        }
2011        let mut path = HashSet::new();
2012        let items: Vec<Vec<Value>> = order
2013            .iter()
2014            .filter(|id| !parents.contains(id))
2015            .filter_map(|&id| node(id, &cell_text, &child_links, &value_links, &mut path))
2016            .collect();
2017        (!items.is_empty()).then(|| ast::bullet_list(items))
2018    }
2019}
2020
2021#[cfg(test)]
2022mod tests {
2023    use super::*;
2024    use serde_json::json;
2025
2026    /// A docling JSON document from `body` children and the item buckets.
2027    fn doc(body: &[&str], texts: Value, groups: Value, tables: Value) -> Value {
2028        json!({
2029            "schema_name": "DoclingDocument",
2030            "name": "t",
2031            "body": {"self_ref": "#/body", "children": body.iter().map(|r| json!({"$ref": r})).collect::<Vec<_>>()},
2032            "texts": texts, "groups": groups, "tables": tables, "pictures": []
2033        })
2034    }
2035
2036    fn text(i: usize, label: &str, text: &str, extra: Value) -> Value {
2037        let mut v = json!({"self_ref": format!("#/texts/{i}"), "children": [], "label": label, "text": text, "content_layer": "body"});
2038        for (k, x) in extra.as_object().unwrap() {
2039            v[k] = x.clone();
2040        }
2041        v
2042    }
2043
2044    fn blocks(json: &Value) -> Vec<Value> {
2045        let (s, _) = from_docling_json(json, &PandocExportOptions::default()).unwrap();
2046        let v: Value = serde_json::from_str(&s).unwrap();
2047        assert_eq!(v["pandoc-api-version"], json!(PANDOC_API_VERSION));
2048        v["blocks"].as_array().unwrap().clone()
2049    }
2050
2051    /// #538: a text item's note calls become `Note`s at their offsets —
2052    /// glued to the word before, a `Space` where the text had one — and the
2053    /// note bodies they carry are not written again as blocks.
2054    #[test]
2055    fn note_calls_become_notes_at_their_call_sites() {
2056        let j = doc(
2057            &["#/texts/0", "#/texts/1"],
2058            json!([
2059                text(
2060                    0,
2061                    "text",
2062                    "debut suite end",
2063                    json!({"_notes": [[5, "first"], [15, "last"]]})
2064                ),
2065                text(
2066                    1,
2067                    "footnote",
2068                    "first",
2069                    json!({"content_layer": "furniture", "_note_body": true})
2070                ),
2071            ]),
2072            json!([]),
2073            json!([]),
2074        );
2075        let note = |t: &str| ast::note(vec![ast::para(vec![ast::str_(t)])]);
2076        assert_eq!(
2077            blocks(&j),
2078            vec![ast::para(vec![
2079                ast::str_("debut"),
2080                note("first"),
2081                ast::space(),
2082                ast::str_("suite"),
2083                ast::space(),
2084                ast::str_("end"),
2085                note("last"),
2086            ])]
2087        );
2088        // With the furniture layer on, a placed note body is still not a block.
2089        let opts = PandocExportOptions {
2090            layers: ContentLayers::ALL,
2091            ..Default::default()
2092        };
2093        let (s, _) = from_docling_json(&j, &opts).unwrap();
2094        assert!(!s.contains("docling-footnote"), "{s}");
2095    }
2096
2097    /// #538: a furniture footnote nothing calls (Python docling's JSON has no
2098    /// call sites) still reaches the AST, as a trailing `Note`.
2099    #[test]
2100    fn uncalled_furniture_footnotes_trail_as_notes() {
2101        let j = doc(
2102            &["#/texts/0", "#/texts/1"],
2103            json!([
2104                text(0, "text", "body", json!({})),
2105                text(
2106                    1,
2107                    "footnote",
2108                    "MARKFN",
2109                    json!({"content_layer": "furniture"})
2110                ),
2111            ]),
2112            json!([]),
2113            json!([]),
2114        );
2115        assert_eq!(
2116            blocks(&j),
2117            vec![
2118                ast::para(vec![ast::str_("body")]),
2119                ast::para(vec![ast::note(vec![ast::para(vec![ast::str_("MARKFN")])])]),
2120            ]
2121        );
2122    }
2123
2124    /// #537: every picture is an `Image` — embedded by default, in a `Para`
2125    /// without a caption, in a `Figure` (caption = alt text) with one; with
2126    /// no target (placeholder mode, an undecodable payload) it is classed
2127    /// `docling-placeholder` instead of vanishing as raw HTML.
2128    #[test]
2129    fn pictures_are_always_images() {
2130        let mut j = doc(
2131            &["#/pictures/0", "#/pictures/1", "#/pictures/2"],
2132            json!([text(0, "caption", "A duck", json!({})),]),
2133            json!([]),
2134            json!([]),
2135        );
2136        j["pictures"] = json!([
2137            {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
2138             "image": {"uri": "data:image/png;base64,AAAA"}, "captions": []},
2139            {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
2140             "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
2141            {"self_ref": "#/pictures/2", "children": [], "label": "picture", "content_layer": "body",
2142             "captions": []},
2143        ]);
2144        let b = blocks(&j);
2145        assert_eq!(
2146            b[0],
2147            ast::para(vec![ast::image(&[], vec![], "data:image/png;base64,AAAA")])
2148        );
2149        assert_eq!(
2150            b[1],
2151            ast::figure(
2152                &[],
2153                ast::caption(vec![ast::plain(vec![
2154                    ast::str_("A"),
2155                    ast::space(),
2156                    ast::str_("duck")
2157                ])]),
2158                vec![ast::plain(vec![ast::image(
2159                    &[],
2160                    vec![ast::str_("A"), ast::space(), ast::str_("duck")],
2161                    "data:image/png;base64,BBBB"
2162                )])]
2163            )
2164        );
2165        assert_eq!(
2166            b[2],
2167            ast::para(vec![ast::image(&["docling-placeholder"], vec![], "")])
2168        );
2169        assert_eq!(b.len(), 3);
2170        // Placeholder mode keeps the node, without the pixels.
2171        let opts = PandocExportOptions {
2172            image_mode: ImageMode::Placeholder,
2173            ..Default::default()
2174        };
2175        let (s, _) = from_docling_json(&j, &opts).unwrap();
2176        assert!(!s.contains("base64") && !s.contains("RawBlock"), "{s}");
2177        assert_eq!(s.matches("docling-placeholder").count(), 3, "{s}");
2178    }
2179
2180    /// #833: a caption of formatted runs (an inline group under the caption
2181    /// item) is the table's caption, its runs decorated, and nothing of it
2182    /// strays into the body; a multi-run heading with a section under it
2183    /// is a `Header` followed by the section's blocks; a multi-run list
2184    /// item with a nested list is `[Plain, BulletList]`; a formatted,
2185    /// linked single-run caption keeps `Strong` and `Link`.
2186    #[test]
2187    fn captions_headings_and_items_keep_their_inline_groups() {
2188        let mut j = doc(
2189            &["#/texts/0", "#/tables/0", "#/texts/3", "#/groups/2"],
2190            json!([
2191                text(
2192                    0,
2193                    "caption",
2194                    "",
2195                    json!({"children": [{"$ref": "#/groups/0"}]})
2196                ),
2197                text(1, "text", "Table 1:", json!({"formatting": {"bold": true}})),
2198                text(
2199                    2,
2200                    "text",
2201                    "Multi-run caption",
2202                    json!({"hyperlink": "https://x.org/c"})
2203                ),
2204                text(
2205                    3,
2206                    "section_header",
2207                    "",
2208                    json!({"level": 1, "children": [{"$ref": "#/groups/1"}, {"$ref": "#/texts/6"}]})
2209                ),
2210                text(4, "text", "1.1", json!({"formatting": {"bold": true}})),
2211                text(5, "text", "Formatted header", json!({})),
2212                text(6, "text", "Body under header.", json!({})),
2213                text(
2214                    7,
2215                    "list_item",
2216                    "",
2217                    json!({"children": [{"$ref": "#/groups/3"}, {"$ref": "#/groups/4"}]})
2218                ),
2219                text(8, "text", "Bold run", json!({"formatting": {"bold": true}})),
2220                text(9, "text", "and tail", json!({})),
2221                text(10, "list_item", "Nested child", json!({})),
2222                text(
2223                    11,
2224                    "caption",
2225                    "Linked bold",
2226                    json!({"formatting": {"bold": true}, "hyperlink": "https://x.org/spec"})
2227                ),
2228            ]),
2229            json!([
2230                {"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "inline", "name": "group"},
2231                {"self_ref": "#/groups/1", "children": [{"$ref": "#/texts/4"}, {"$ref": "#/texts/5"}], "label": "inline", "name": "group"},
2232                {"self_ref": "#/groups/2", "children": [{"$ref": "#/texts/7"}], "label": "list", "name": "list"},
2233                {"self_ref": "#/groups/3", "children": [{"$ref": "#/texts/8"}, {"$ref": "#/texts/9"}], "label": "inline", "name": "group"},
2234                {"self_ref": "#/groups/4", "children": [{"$ref": "#/texts/10"}], "label": "list", "name": "list"},
2235            ]),
2236            json!([{"self_ref": "#/tables/0", "children": [], "label": "table",
2237                    "captions": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/11"}],
2238                    "data": {"num_rows": 1, "num_cols": 1, "table_cells": [
2239                        {"text": "v", "row_span": 1, "col_span": 1, "start_row_offset_idx": 0, "end_row_offset_idx": 1,
2240                         "start_col_offset_idx": 0, "end_col_offset_idx": 1, "column_header": false}]}}]),
2241        );
2242        j["texts"][0]["children"] = json!([{"$ref": "#/groups/0"}]);
2243        let b = blocks(&j);
2244        assert_eq!(b.len(), 4, "{b:?}");
2245        assert_eq!(b[0]["t"], "Table");
2246        let caption = &b[0]["c"][1][1][0];
2247        assert_eq!(caption["t"], "Plain");
2248        assert_eq!(
2249            caption["c"][0],
2250            ast::wrap(
2251                "Strong",
2252                vec![ast::str_("Table"), ast::space(), ast::str_("1:")]
2253            )
2254        );
2255        assert_eq!(caption["c"][2]["t"], "Link");
2256        let linked = caption["c"].as_array().unwrap().last().unwrap();
2257        assert_eq!(linked["t"], "Link");
2258        assert_eq!(linked["c"][1][0]["t"], "Strong");
2259        assert_eq!(b[1]["t"], "Header");
2260        assert_eq!(b[1]["c"][2][0], ast::wrap("Strong", vec![ast::str_("1.1")]));
2261        assert_eq!(
2262            b[2],
2263            ast::para(vec![
2264                ast::str_("Body"),
2265                ast::space(),
2266                ast::str_("under"),
2267                ast::space(),
2268                ast::str_("header.")
2269            ])
2270        );
2271        assert_eq!(b[3]["t"], "BulletList");
2272        let item = b[3]["c"][0].as_array().unwrap();
2273        assert_eq!(item.len(), 2);
2274        assert_eq!(item[0]["t"], "Plain");
2275        assert_eq!(item[0]["c"][0]["t"], "Strong");
2276        assert_eq!(item[1]["t"], "BulletList");
2277    }
2278
2279    /// #834: the `Image`'s alt text is the picture's description (the
2280    /// author's alt text) when it has one, the caption only without; the
2281    /// displayed size (`_display`, inches) becomes `width` / `height`.
2282    #[test]
2283    fn pictures_carry_alt_text_and_display_size() {
2284        let mut j = doc(
2285            &["#/pictures/0", "#/pictures/1"],
2286            json!([text(0, "caption", "A duck", json!({})),]),
2287            json!([]),
2288            json!([]),
2289        );
2290        j["pictures"] = json!([
2291            {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
2292             "meta": {"description": {"text": "Architecture diagram", "created_by": "alt_text"}},
2293             "image": {"uri": "data:image/png;base64,AAAA", "_display": [2.5, 1.25]}, "captions": [{"$ref": "#/texts/0"}]},
2294            {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
2295             "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
2296        ]);
2297        let b = blocks(&j);
2298        let image = &b[0]["c"][2][0]["c"][0];
2299        assert_eq!(image["t"], "Image");
2300        assert_eq!(
2301            image["c"][0],
2302            json!(["", [], [["width", "2.5in"], ["height", "1.25in"]]])
2303        );
2304        assert_eq!(
2305            image["c"][1],
2306            json!([
2307                ast::str_("Architecture"),
2308                ast::space(),
2309                ast::str_("diagram")
2310            ])
2311        );
2312        let image = &b[1]["c"][2][0]["c"][0];
2313        assert_eq!(image["c"][0], json!(["", [], []]));
2314        assert_eq!(
2315            image["c"][1],
2316            json!([ast::str_("A"), ast::space(), ast::str_("duck")])
2317        );
2318        assert_eq!(inches(2.5), "2.5in");
2319        assert_eq!(inches(1.0), "1in");
2320        assert_eq!(inches(0.33333), "0.3333in");
2321    }
2322
2323    /// #835: the markers' number style, delimiter and start.
2324    #[test]
2325    fn list_attributes_follow_the_markers() {
2326        assert_eq!(
2327            list_attributes(&["a)", "b)"]),
2328            (1, "LowerAlpha", "OneParen")
2329        );
2330        assert_eq!(
2331            list_attributes(&["iii.", "iv."]),
2332            (3, "LowerRoman", "Period")
2333        );
2334        assert_eq!(
2335            list_attributes(&["(3)", "(4)"]),
2336            (3, "Decimal", "TwoParens")
2337        );
2338        assert_eq!(
2339            list_attributes(&["c)", "d)"]),
2340            (3, "LowerAlpha", "OneParen")
2341        );
2342        assert_eq!(list_attributes(&["i."]), (1, "LowerRoman", "Period"));
2343        assert_eq!(list_attributes(&["v.", "vi."]), (5, "LowerRoman", "Period"));
2344        assert_eq!(list_attributes(&["x.", "y."]), (24, "LowerAlpha", "Period"));
2345        assert_eq!(list_attributes(&["B."]), (2, "UpperAlpha", "Period"));
2346        assert_eq!(list_attributes(&["IV)"]), (4, "UpperRoman", "OneParen"));
2347        assert_eq!(list_attributes(&["aa)"]), (27, "LowerAlpha", "OneParen"));
2348        assert_eq!(list_attributes(&["12"]), (12, "Decimal", "DefaultDelim"));
2349        assert_eq!(list_attributes(&["3."]), (3, "Decimal", "Period"));
2350        assert_eq!(list_attributes(&["1.2."]), (1, "Decimal", "Period"));
2351        assert_eq!(list_attributes(&[]), (1, "Decimal", "Period"));
2352        let j = doc(
2353            &["#/groups/0"],
2354            json!([
2355                text(
2356                    0,
2357                    "list_item",
2358                    "third",
2359                    json!({"enumerated": true, "marker": "(3)"})
2360                ),
2361                text(
2362                    1,
2363                    "list_item",
2364                    "fourth",
2365                    json!({"enumerated": true, "marker": "(4)"})
2366                ),
2367            ]),
2368            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}], "label": "list", "name": "list"}]),
2369            json!([]),
2370        );
2371        let b = blocks(&j);
2372        assert_eq!(
2373            b[0]["c"][0],
2374            json!([3, {"t": "Decimal"}, {"t": "TwoParens"}])
2375        );
2376    }
2377
2378    /// #836: the document's metadata is Pandoc's `meta`; a reviewer
2379    /// comment is a `comment-start` / `comment-end` span pair around the
2380    /// annotated item's inlines, with its author and date, a reply a
2381    /// further pair; the notes-layer groups never come out as blocks.
2382    #[test]
2383    fn metadata_and_comments_reach_the_ast() {
2384        let mut j = doc(
2385            &["#/texts/0", "#/groups/0"],
2386            json!([
2387                text(
2388                    0,
2389                    "text",
2390                    "The timeout is 30 s.",
2391                    json!({"comments": [{"$ref": "#/groups/0"}]})
2392                ),
2393                text(
2394                    1,
2395                    "text",
2396                    "[author: Reviewer (SR), time: 2026-01-15T12:00:00Z]: Verify it.",
2397                    json!({"content_layer": "notes"})
2398                ),
2399                text(2, "text", "Agreed", json!({"content_layer": "notes"})),
2400            ]),
2401            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "comment_section", "name": "comment-3", "content_layer": "notes"}]),
2402            json!([]),
2403        );
2404        j["metadata"] = json!({
2405            "title": "Synthetic Spec", "authors": ["A. Author", "B. Author"], "keywords": ["architecture", "protocol"],
2406            "language": "en-US", "created": "2026-01-15T10:00:00Z", "subject": "Gateways"
2407        });
2408        let (s, _) = from_docling_json(&j, &PandocExportOptions::default()).unwrap();
2409        let v: Value = serde_json::from_str(&s).unwrap();
2410        assert_eq!(
2411            v["meta"]["title"],
2412            ast::meta_inlines(vec![
2413                ast::str_("Synthetic"),
2414                ast::space(),
2415                ast::str_("Spec")
2416            ])
2417        );
2418        assert_eq!(v["meta"]["author"]["t"], "MetaList");
2419        assert_eq!(v["meta"]["author"]["c"].as_array().unwrap().len(), 2);
2420        assert_eq!(
2421            v["meta"]["keywords"]["c"][1],
2422            ast::meta_inlines(vec![ast::str_("protocol")])
2423        );
2424        assert_eq!(v["meta"]["lang"], ast::meta_string("en-US"));
2425        assert_eq!(
2426            v["meta"]["date"],
2427            ast::meta_inlines(vec![ast::str_("2026-01-15T10:00:00Z")])
2428        );
2429        assert_eq!(v["meta"]["subject"]["t"], "MetaInlines");
2430        let b = v["blocks"].as_array().unwrap();
2431        assert_eq!(b.len(), 1, "{b:?}");
2432        let inl = b[0]["c"].as_array().unwrap();
2433        assert_eq!(inl[0]["t"], "Span");
2434        assert_eq!(
2435            inl[0]["c"][0],
2436            json!([
2437                "",
2438                ["comment-start"],
2439                [
2440                    ["comment-id", "3"],
2441                    ["author", "Reviewer (SR)"],
2442                    ["date", "2026-01-15T12:00:00Z"]
2443                ]
2444            ])
2445        );
2446        assert_eq!(
2447            inl[0]["c"][1],
2448            json!([ast::str_("Verify"), ast::space(), ast::str_("it.")])
2449        );
2450        assert_eq!(
2451            inl[1]["c"][0],
2452            json!(["", ["comment-start"], [["comment-id", "3-1"]]])
2453        );
2454        assert_eq!(inl[2], ast::str_("The"));
2455        let last = inl.last().unwrap();
2456        assert_eq!(
2457            last["c"][0],
2458            json!(["", ["comment-end"], [["comment-id", "3-1"]]])
2459        );
2460        assert_eq!(
2461            inl[inl.len() - 2]["c"][0],
2462            json!(["", ["comment-end"], [["comment-id", "3"]]])
2463        );
2464        // With the notes layer on, the comment texts are still not blocks.
2465        let opts = PandocExportOptions {
2466            layers: ContentLayers::ALL,
2467            ..Default::default()
2468        };
2469        let (s, _) = from_docling_json(&j, &opts).unwrap();
2470        assert_eq!(s.matches("Verify").count(), 1, "{s}");
2471        assert_eq!(
2472            split_comment("[time: t]: x"),
2473            (None, Some("t".into()), "x".into())
2474        );
2475        assert_eq!(split_comment("bare"), (None, None, "bare".into()));
2476        assert_eq!(meta_of(&json!({})), json!({}));
2477    }
2478
2479    #[test]
2480    fn text_splits_into_words_spaces_and_line_breaks() {
2481        assert_eq!(
2482            text_inlines("  two  words\nnext\u{a0}line "),
2483            vec![
2484                ast::str_("two"),
2485                ast::space(),
2486                ast::str_("words"),
2487                ast::line_break(),
2488                ast::str_("next\u{a0}line"),
2489            ]
2490        );
2491        assert!(text_inlines("   ").is_empty());
2492    }
2493
2494    #[test]
2495    fn headings_paragraphs_and_lists() {
2496        let j = doc(
2497            &["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"],
2498            json!([
2499                text(0, "title", "Ducks", json!({})),
2500                text(1, "section_header", "Diet", json!({"level": 1})),
2501                text(
2502                    2,
2503                    "text",
2504                    "Ducks eat plants.",
2505                    json!({"formatting": {"bold": true}, "hyperlink": "https://x.org"})
2506                ),
2507                text(
2508                    3,
2509                    "list_item",
2510                    "seeds",
2511                    json!({"enumerated": true, "marker": "3."})
2512                ),
2513                text(
2514                    4,
2515                    "list_item",
2516                    "insects",
2517                    json!({"enumerated": true, "marker": "4."})
2518                ),
2519            ]),
2520            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/3"}, {"$ref": "#/texts/4"}], "label": "list", "name": "list"}]),
2521            json!([]),
2522        );
2523        let b = blocks(&j);
2524        assert_eq!(b[0], ast::header(1, vec![ast::str_("Ducks")]));
2525        assert_eq!(b[1], ast::header(2, vec![ast::str_("Diet")]));
2526        // formatting inside, the link around it all
2527        assert_eq!(b[2]["t"], "Para");
2528        assert_eq!(b[2]["c"][0]["t"], "Link");
2529        assert_eq!(b[2]["c"][0]["c"][1][0]["t"], "Strong");
2530        assert_eq!(b[3]["t"], "OrderedList");
2531        assert_eq!(b[3]["c"][0][0], 3, "start from the first marker");
2532        assert_eq!(
2533            b[3]["c"][1],
2534            json!([
2535                [ast::plain(vec![ast::str_("seeds")])],
2536                [ast::plain(vec![ast::str_("insects")])]
2537            ])
2538        );
2539    }
2540
2541    /// #848: the two heading projections at their bounds — docling's
2542    /// title-reserving `n + 1` (missing → 2, 0 → 1, capped at 6) and the
2543    /// source's own `n` (missing / 0 → 1, 7 → 6); the title is `Header 1`
2544    /// under both.
2545    #[test]
2546    fn heading_depths_under_both_projections() {
2547        let j = doc(
2548            &[
2549                "#/texts/0",
2550                "#/texts/1",
2551                "#/texts/2",
2552                "#/texts/3",
2553                "#/texts/4",
2554                "#/texts/5",
2555                "#/texts/6",
2556                "#/texts/7",
2557            ],
2558            json!([
2559                text(0, "title", "T", json!({})),
2560                text(1, "section_header", "d1", json!({"level": 1})),
2561                text(2, "section_header", "d2", json!({"level": 2})),
2562                text(3, "section_header", "d5", json!({"level": 5})),
2563                text(4, "section_header", "d6", json!({"level": 6})),
2564                text(5, "section_header", "none", json!({})),
2565                text(6, "section_header", "zero", json!({"level": 0})),
2566                text(7, "section_header", "seven", json!({"level": 7})),
2567            ]),
2568            json!([]),
2569            json!([]),
2570        );
2571        let depths = |heading_depth: HeadingDepth| -> Vec<u64> {
2572            let options = PandocExportOptions {
2573                heading_depth,
2574                ..Default::default()
2575            };
2576            let (s, _) = from_docling_json(&j, &options).unwrap();
2577            let v: Value = serde_json::from_str(&s).unwrap();
2578            v["blocks"]
2579                .as_array()
2580                .unwrap()
2581                .iter()
2582                .map(|b| b["c"][0].as_u64().unwrap())
2583                .collect()
2584        };
2585        assert_eq!(depths(HeadingDepth::Docling), [1, 2, 3, 6, 6, 2, 1, 6]);
2586        assert_eq!(depths(HeadingDepth::Source), [1, 1, 2, 5, 6, 1, 1, 6]);
2587        assert_eq!("Source".parse::<HeadingDepth>(), Ok(HeadingDepth::Source));
2588        assert!("deep".parse::<HeadingDepth>().is_err());
2589    }
2590
2591    /// #852: a Markdown link in an item's text (the flat chain's
2592    /// hyperlink) is a `Link`; brackets that are not one stay text.
2593    #[test]
2594    fn markdown_links_in_text_become_links() {
2595        assert_eq!(
2596            text_inlines("Jump to [target](#target-section) now [x] (y)"),
2597            vec![
2598                ast::str_("Jump"),
2599                ast::space(),
2600                ast::str_("to"),
2601                ast::space(),
2602                ast::link(vec![ast::str_("target")], "#target-section"),
2603                ast::space(),
2604                ast::str_("now"),
2605                ast::space(),
2606                ast::str_("[x]"),
2607                ast::space(),
2608                ast::str_("(y)"),
2609            ]
2610        );
2611        assert_eq!(
2612            text_inlines("[a](https://x.org/a)[b](https://x.org/b)"),
2613            vec![
2614                ast::link(vec![ast::str_("a")], "https://x.org/a"),
2615                ast::link(vec![ast::str_("b")], "https://x.org/b"),
2616            ]
2617        );
2618        assert_eq!(
2619            text_inlines("see ![alt](img.png) here"),
2620            vec![
2621                ast::str_("see"),
2622                ast::space(),
2623                ast::str_("![alt](img.png)"),
2624                ast::space(),
2625                ast::str_("here"),
2626            ]
2627        );
2628    }
2629
2630    /// #851: a run's `_revision` is Pandoc's insertion / deletion span,
2631    /// and an explicit accept / reject view drops the comment spans.
2632    #[test]
2633    fn revisions_become_spans_and_views_drop_comments() {
2634        let j = doc(
2635            &["#/texts/0", "#/texts/1"],
2636            json!([
2637                text(
2638                    0,
2639                    "text",
2640                    "old",
2641                    json!({"_revision": {"kind": "deletion", "author": "Fixture", "date": "2025-01-01T00:00:00Z"}})
2642                ),
2643                text(
2644                    1,
2645                    "text",
2646                    "Commented",
2647                    json!({"comments": [{"$ref": "#/groups/0"}]})
2648                ),
2649                text(
2650                    2,
2651                    "text",
2652                    "[author: Fixture, time: 2025-01-01T00:00:00Z]: Review note",
2653                    json!({"content_layer": "notes"})
2654                ),
2655            ]),
2656            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/2"}], "label": "comment_section", "name": "comment-3", "content_layer": "notes"}]),
2657            json!([]),
2658        );
2659        let b = blocks(&j);
2660        assert_eq!(
2661            b[0],
2662            ast::para(vec![ast::span(
2663                &["deletion"],
2664                &[
2665                    ("author".into(), "Fixture".into()),
2666                    ("date".into(), "2025-01-01T00:00:00Z".into())
2667                ],
2668                vec![ast::str_("old")]
2669            )])
2670        );
2671        assert_eq!(
2672            b[1]["c"][0]["t"], "Span",
2673            "comments written without a policy"
2674        );
2675        let accept = PandocExportOptions {
2676            track_changes: Some(TrackChanges::Accept),
2677            ..Default::default()
2678        };
2679        let (s, _) = from_docling_json(&j, &accept).unwrap();
2680        let v: Value = serde_json::from_str(&s).unwrap();
2681        assert_eq!(v["blocks"][1], ast::para(vec![ast::str_("Commented")]));
2682    }
2683
2684    /// #850: a heading numbered by the backend (`_number`) writes its
2685    /// authored text alone; text that merely starts with digits is kept.
2686    #[test]
2687    fn numbered_headings_write_the_authored_text() {
2688        let j = doc(
2689            &["#/texts/0", "#/texts/1", "#/texts/2"],
2690            json!([
2691                text(
2692                    0,
2693                    "section_header",
2694                    "1.1 2026 Results",
2695                    json!({"level": 2, "_number": "1.1"})
2696                ),
2697                text(1, "section_header", "2026 Results", json!({"level": 1})),
2698                text(
2699                    2,
2700                    "section_header",
2701                    "3 Odd",
2702                    json!({"level": 1, "_number": "4"})
2703                ),
2704            ]),
2705            json!([]),
2706            json!([]),
2707        );
2708        let b = blocks(&j);
2709        assert_eq!(
2710            b[0],
2711            ast::header(
2712                3,
2713                vec![ast::str_("2026"), ast::space(), ast::str_("Results")]
2714            )
2715        );
2716        assert_eq!(
2717            b[1],
2718            ast::header(
2719                2,
2720                vec![ast::str_("2026"), ast::space(), ast::str_("Results")]
2721            )
2722        );
2723        // A `_number` the text does not open with is left alone.
2724        assert_eq!(
2725            b[2],
2726            ast::header(2, vec![ast::str_("3"), ast::space(), ast::str_("Odd")])
2727        );
2728    }
2729
2730    /// #849: a bookmarked heading carries its identifier, a bookmark in
2731    /// body text an empty anchor span, and an internal link's `#id` target
2732    /// is written as is.
2733    #[test]
2734    fn anchors_become_header_ids_and_anchor_spans() {
2735        let j = doc(
2736            &["#/texts/0", "#/texts/1", "#/texts/2"],
2737            json!([
2738                text(
2739                    0,
2740                    "section_header",
2741                    "Target section",
2742                    json!({"level": 1, "_anchors": ["target-section", "Alias"]})
2743                ),
2744                text(1, "text", "Jump", json!({"hyperlink": "#target-section"})),
2745                text(2, "text", "Body mark", json!({"_anchors": ["BodyMark"]})),
2746            ]),
2747            json!([]),
2748            json!([]),
2749        );
2750        let b = blocks(&j);
2751        assert_eq!(
2752            b[0],
2753            ast::header_with_id(
2754                2,
2755                "target-section",
2756                vec![
2757                    ast::anchor("Alias"),
2758                    ast::str_("Target"),
2759                    ast::space(),
2760                    ast::str_("section")
2761                ]
2762            )
2763        );
2764        assert_eq!(
2765            b[1],
2766            ast::para(vec![ast::link(vec![ast::str_("Jump")], "#target-section")])
2767        );
2768        assert_eq!(
2769            b[2],
2770            ast::para(vec![
2771                ast::anchor("BodyMark"),
2772                ast::str_("Body"),
2773                ast::space(),
2774                ast::str_("mark")
2775            ])
2776        );
2777        assert_eq!(auto_identifier("Target section"), "target-section");
2778        assert_eq!(auto_identifier("2026 Results"), "results");
2779        assert_eq!(auto_identifier("1.1 Été & hiver!"), "été-hiver");
2780        assert_eq!(auto_identifier("***"), "section");
2781    }
2782
2783    #[test]
2784    fn code_formulas_checkboxes_and_unmapped_labels() {
2785        let j = doc(
2786            &["#/texts/0", "#/texts/1", "#/texts/2", "#/texts/3"],
2787            json!([
2788                text(0, "code", "print(1)", json!({"code_language": "Python"})),
2789                text(1, "formula", "E = mc^2", json!({})),
2790                text(2, "checkbox_selected", "done", json!({})),
2791                text(3, "reference", "[1] Duck, D. (2020).", json!({})),
2792            ]),
2793            json!([]),
2794            json!([]),
2795        );
2796        let b = blocks(&j);
2797        assert_eq!(b[0], ast::code_block(Some("python"), "print(1)"));
2798        assert_eq!(b[1], ast::para(vec![ast::math(true, "E = mc^2")]));
2799        assert_eq!(b[2]["c"][0], ast::str_("☒"));
2800        assert_eq!(b[3]["t"], "Div");
2801        assert_eq!(b[3]["c"][0][1], json!(["docling-reference"]));
2802    }
2803
2804    #[test]
2805    fn tables_keep_spans_header_rows_and_captions() {
2806        let cell = |r0: usize, c0: usize, rs: usize, cs: usize, t: &str, header: bool| {
2807            json!({"text": t, "row_span": rs, "col_span": cs,
2808                   "start_row_offset_idx": r0, "end_row_offset_idx": r0 + rs,
2809                   "start_col_offset_idx": c0, "end_col_offset_idx": c0 + cs,
2810                   "column_header": header})
2811        };
2812        let j = doc(
2813            &["#/tables/0"],
2814            json!([text(0, "caption", "Table 1: Ducks", json!({}))]),
2815            json!([]),
2816            json!([{"self_ref": "#/tables/0", "children": [{"$ref": "#/texts/0"}], "label": "table",
2817                    "captions": [{"$ref": "#/texts/0"}],
2818                    "data": {"num_rows": 2, "num_cols": 2, "table_cells": [
2819                        cell(0, 0, 1, 2, "Species", true),
2820                        cell(1, 0, 1, 1, "Mallard", false),
2821                        cell(1, 1, 1, 1, "Anas", false)]}}]),
2822        );
2823        let b = blocks(&j);
2824        assert_eq!(b.len(), 1, "the caption is rendered by its table only");
2825        let t = &b[0];
2826        assert_eq!(t["t"], "Table");
2827        assert_eq!(
2828            t["c"][1][1][0],
2829            ast::plain(vec![
2830                ast::str_("Table"),
2831                ast::space(),
2832                ast::str_("1:"),
2833                ast::space(),
2834                ast::str_("Ducks")
2835            ])
2836        );
2837        assert_eq!(t["c"][2].as_array().unwrap().len(), 2);
2838        let head = t["c"][3][1].as_array().unwrap();
2839        assert_eq!(head.len(), 1);
2840        assert_eq!(head[0][1][0][3], 2, "colspan");
2841        assert_eq!(t["c"][4][0][3].as_array().unwrap().len(), 1, "one body row");
2842    }
2843
2844    #[test]
2845    fn api_version_is_checked() {
2846        for ok in ["1.23", "1.23.1", "1.23.1.1"] {
2847            assert!(check_api_version(ok).is_ok(), "{ok}");
2848        }
2849        for bad in ["1.22", "2.0", "1", "1.23.2", "1.23.1.2", "x.y", ""] {
2850            let e = check_api_version(bad).unwrap_err();
2851            assert!(e.to_string().contains("only 1.23"), "{bad}: {e}");
2852        }
2853        let err = to_pandoc(
2854            &DoclingDocument::new("t"),
2855            &PandocExportOptions {
2856                api_version: Some("1.22".into()),
2857                ..Default::default()
2858            },
2859        )
2860        .unwrap_err();
2861        assert_eq!(
2862            err,
2863            PandocError::UnsupportedApiVersion {
2864                requested: "1.22".into()
2865            }
2866        );
2867    }
2868
2869    #[test]
2870    fn empty_document_is_a_valid_pandoc_document() {
2871        let (s, artifacts) =
2872            to_pandoc(&DoclingDocument::new("e"), &PandocExportOptions::default()).unwrap();
2873        assert!(artifacts.is_empty());
2874        assert_eq!(
2875            s,
2876            r#"{"pandoc-api-version":[1,23,1,1],"meta":{},"blocks":[]}"#
2877        );
2878        assert_eq!(s, DoclingDocument::new("e").export_to_pandoc_json());
2879    }
2880}