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