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 from an `N.` marker) / `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 (also the `Image`'s alt text) or a tabular chart's data `Table`; 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//!
39//! No Pandoc equivalent, so not written: provenance (pages, bounding boxes),
40//! confidence and classification meta, comments' authorship, form field
41//! geometry; furniture (headers/footers) and notes are off unless their
42//! [`ContentLayers`] are asked for, exactly as in the HTML export. Images
43//! are targets, not bytes, in Pandoc: the default [`ImageMode::Embedded`]
44//! writes `data:` URIs, so the AST is self-contained and `pandoc -f json -t
45//! docx` embeds every picture (#537); [`ImageMode::Referenced`] links files
46//! under the artifacts directory (relative paths, resolved from where
47//! `pandoc` runs). A picture without a target — [`ImageMode::Placeholder`],
48//! or one whose payload docling cannot decode (EMF/WMF) — is still an
49//! `Image`, classed `docling-placeholder` with an empty target: it survives
50//! into every writer (which reports the missing resource and prints the alt
51//! text) instead of vanishing as raw HTML.
52//!
53//! Version: the output is stamped [`PANDOC_API_VERSION`] — the latest
54//! `pandoc-types` (Pandoc 3.x). Pandoc accepts a document whose
55//! `pandoc-api-version` agrees on the first two components; a caller asking
56//! for another API ([`PandocExportOptions::api_version`]) gets
57//! [`PandocError::UnsupportedApiVersion`] rather than a document Pandoc would
58//! reject. Every node is built through the [`ast`] constructors, the one
59//! place a future API change touches.
60
61use std::collections::{BTreeMap, HashMap, HashSet};
62
63use serde_json::Value;
64
65use crate::document::{ContentLayers, DoclingDocument};
66use crate::markdown::ImageMode;
67
68/// The `pandoc-api-version` written — `pandoc-types` 1.23.1.1 (Pandoc 3.x).
69pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
70
71/// A Pandoc export: the JSON AST, and — for [`ImageMode::Referenced`] — the
72/// image files it links to, as `(path under artifacts_dir, bytes)`.
73pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
74
75/// Options for [`to_pandoc`].
76#[derive(Debug, Clone)]
77pub struct PandocExportOptions {
78    /// How pictures carry their image (see the module docs); `Embedded` by
79    /// default, so the AST alone rebuilds a document with its pictures.
80    pub image_mode: ImageMode,
81    /// The directory referenced images are written under
82    /// (`<artifacts_dir>/image_NNNNNN.<ext>`, the Markdown export's names).
83    pub artifacts_dir: String,
84    /// The content layers written; body only by default.
85    pub layers: ContentLayers,
86    /// The Pandoc API version the caller needs (`"1.23"`, `"1.23.1.1"`, …);
87    /// `None` = [`PANDOC_API_VERSION`]. Only that API is supported.
88    pub api_version: Option<String>,
89}
90
91impl Default for PandocExportOptions {
92    fn default() -> Self {
93        Self {
94            image_mode: ImageMode::Embedded,
95            artifacts_dir: "artifacts".to_string(),
96            layers: ContentLayers::BODY,
97            api_version: None,
98        }
99    }
100}
101
102/// Why a Pandoc export was refused.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum PandocError {
105    /// The requested `pandoc-api-version` is not the one supported.
106    UnsupportedApiVersion { requested: String },
107}
108
109impl std::fmt::Display for PandocError {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            PandocError::UnsupportedApiVersion { requested } => write!(
113                f,
114                "unsupported Pandoc API version '{requested}': only {} (pandoc-types {}, Pandoc 3.x) is supported",
115                version_string(&PANDOC_API_VERSION[..2]),
116                version_string(&PANDOC_API_VERSION),
117            ),
118        }
119    }
120}
121
122impl std::error::Error for PandocError {}
123
124fn version_string(parts: &[u32]) -> String {
125    parts
126        .iter()
127        .map(u32::to_string)
128        .collect::<Vec<_>>()
129        .join(".")
130}
131
132/// Check a requested API version: Pandoc reads a document whose version
133/// shares its first two components (major API, minor API), so `1.23`,
134/// `1.23.1` and `1.23.1.1` are all this export's; anything else is refused.
135pub fn check_api_version(requested: &str) -> Result<(), PandocError> {
136    let err = || PandocError::UnsupportedApiVersion {
137        requested: requested.to_string(),
138    };
139    let parts: Vec<u32> = requested
140        .trim()
141        .split('.')
142        .map(|p| p.parse::<u32>().map_err(|_| err()))
143        .collect::<Result<_, _>>()?;
144    if parts.len() < 2 || parts[..2] != PANDOC_API_VERSION[..2] {
145        return Err(err());
146    }
147    if parts.len() > PANDOC_API_VERSION.len()
148        || parts
149            .iter()
150            .zip(PANDOC_API_VERSION.iter())
151            .skip(2)
152            .any(|(a, b)| a > b)
153    {
154        // A newer patch than this export knows.
155        return Err(err());
156    }
157    Ok(())
158}
159
160/// The document as Pandoc JSON (compact, one line, like `pandoc -t json`),
161/// and for [`ImageMode::Referenced`] the `(path, bytes)` image files to
162/// write.
163pub fn to_pandoc(
164    doc: &DoclingDocument,
165    options: &PandocExportOptions,
166) -> Result<PandocOutput, PandocError> {
167    // The JSON with the note calls docling's model has no field for (#538).
168    from_docling_json(&crate::json::to_json_with_notes(doc), options)
169}
170
171/// [`to_pandoc`] for a document already in docling's JSON wire format —
172/// e.g. one produced by Python docling, or `DoclingDocument.export_to_dict()`
173/// in the Python bindings.
174pub fn from_docling_json(
175    json: &Value,
176    options: &PandocExportOptions,
177) -> Result<PandocOutput, PandocError> {
178    if let Some(v) = &options.api_version {
179        check_api_version(v)?;
180    }
181    // The walk recurses per nesting level; an XBRL instance nests thousands
182    // deep, so it runs on a big-stack thread like the HTML serializer.
183    let render = || {
184        let mut ser = Serializer::new(json, options);
185        let blocks = ser.body();
186        let doc = ast::document(blocks);
187        (
188            serde_json::to_string(&doc).expect("Pandoc JSON is always serializable"),
189            ser.artifacts,
190        )
191    };
192    #[cfg(not(target_arch = "wasm32"))]
193    {
194        Ok(std::thread::scope(|scope| {
195            std::thread::Builder::new()
196                .name("docling-pandoc".into())
197                .stack_size(256 << 20)
198                .spawn_scoped(scope, render)
199                .expect("spawn the pandoc serializer thread")
200                .join()
201                .expect("pandoc serializer thread panicked")
202        }))
203    }
204    #[cfg(target_arch = "wasm32")]
205    {
206        Ok(render())
207    }
208}
209
210/// The Pandoc AST constructors (`Text.Pandoc.Definition`, JSON per
211/// `Text.Pandoc.JSON`): every node this module writes is built here.
212pub mod ast {
213    use serde_json::{json, Value};
214
215    use super::PANDOC_API_VERSION;
216
217    pub fn document(blocks: Vec<Value>) -> Value {
218        json!({ "pandoc-api-version": PANDOC_API_VERSION, "meta": {}, "blocks": blocks })
219    }
220
221    /// `Attr`: identifier, classes, key-value pairs.
222    pub fn attr(classes: &[&str]) -> Value {
223        json!(["", classes, []])
224    }
225
226    fn node(t: &str, c: Value) -> Value {
227        json!({ "t": t, "c": c })
228    }
229
230    // --- blocks -------------------------------------------------------------
231
232    pub fn para(inlines: Vec<Value>) -> Value {
233        node("Para", json!(inlines))
234    }
235    pub fn plain(inlines: Vec<Value>) -> Value {
236        node("Plain", json!(inlines))
237    }
238    pub fn header(level: usize, inlines: Vec<Value>) -> Value {
239        node("Header", json!([level, attr(&[]), inlines]))
240    }
241    pub fn code_block(language: Option<&str>, text: &str) -> Value {
242        let classes: Vec<&str> = language.into_iter().collect();
243        node("CodeBlock", json!([attr(&classes), text]))
244    }
245    pub fn bullet_list(items: Vec<Vec<Value>>) -> Value {
246        node("BulletList", json!(items))
247    }
248    pub fn ordered_list(start: u64, items: Vec<Vec<Value>>) -> Value {
249        node(
250            "OrderedList",
251            json!([[start, { "t": "Decimal" }, { "t": "Period" }], items]),
252        )
253    }
254    pub fn definition_list(entries: Vec<(Vec<Value>, Vec<Vec<Value>>)>) -> Value {
255        node(
256            "DefinitionList",
257            Value::Array(
258                entries
259                    .into_iter()
260                    .map(|(term, defs)| json!([term, defs]))
261                    .collect(),
262            ),
263        )
264    }
265    /// `RawBlock Format Text` — kept by writers of that format, dropped by
266    /// the others.
267    pub fn raw_block(format: &str, text: &str) -> Value {
268        node("RawBlock", json!([format, text]))
269    }
270    pub fn div(classes: &[&str], blocks: Vec<Value>) -> Value {
271        node("Div", json!([attr(classes), blocks]))
272    }
273    /// `Caption`: no short caption, the long one as blocks.
274    pub fn caption(blocks: Vec<Value>) -> Value {
275        json!([null, blocks])
276    }
277    pub fn figure(classes: &[&str], caption: Value, blocks: Vec<Value>) -> Value {
278        node("Figure", json!([attr(classes), caption, blocks]))
279    }
280    /// A table cell: `Cell Attr Alignment RowSpan ColSpan [Block]`.
281    pub fn cell(row_span: usize, col_span: usize, blocks: Vec<Value>) -> Value {
282        json!([attr(&[]), { "t": "AlignDefault" }, row_span, col_span, blocks])
283    }
284    pub fn row(cells: Vec<Value>) -> Value {
285        json!([attr(&[]), cells])
286    }
287    /// `Table Attr Caption [ColSpec] TableHead [TableBody] TableFoot`.
288    pub fn table(caption: Value, num_cols: usize, head: Vec<Value>, body: Vec<Value>) -> Value {
289        let colspecs: Vec<Value> = (0..num_cols)
290            .map(|_| json!([{ "t": "AlignDefault" }, { "t": "ColWidthDefault" }]))
291            .collect();
292        node(
293            "Table",
294            json!([
295                attr(&[]),
296                caption,
297                colspecs,
298                [attr(&[]), head],
299                [[attr(&[]), 0, [], body]],
300                [attr(&[]), []]
301            ]),
302        )
303    }
304
305    // --- inlines ------------------------------------------------------------
306
307    pub fn str_(s: &str) -> Value {
308        node("Str", json!(s))
309    }
310    pub fn space() -> Value {
311        json!({ "t": "Space" })
312    }
313    pub fn line_break() -> Value {
314        json!({ "t": "LineBreak" })
315    }
316    /// `Strong`, `Emph`, `Underline`, `Strikeout`, `Subscript`, `Superscript`.
317    pub fn wrap(t: &str, inlines: Vec<Value>) -> Value {
318        node(t, json!(inlines))
319    }
320    pub fn code(text: &str) -> Value {
321        node("Code", json!([attr(&[]), text]))
322    }
323    pub fn math(display: bool, tex: &str) -> Value {
324        let kind = if display { "DisplayMath" } else { "InlineMath" };
325        node("Math", json!([{ "t": kind }, tex]))
326    }
327    pub fn link(inlines: Vec<Value>, url: &str) -> Value {
328        node("Link", json!([attr(&[]), inlines, [url, ""]]))
329    }
330    pub fn image(classes: &[&str], alt: Vec<Value>, src: &str) -> Value {
331        node("Image", json!([attr(classes), alt, [src, ""]]))
332    }
333    pub fn note(blocks: Vec<Value>) -> Value {
334        node("Note", json!(blocks))
335    }
336}
337
338/// Text as `Str` words and `Space`s, `\n` as `LineBreak`; surrounding
339/// whitespace trimmed (a no-break space stays inside its word, as Pandoc's
340/// readers keep it).
341fn text_inlines(text: &str) -> Vec<Value> {
342    let mut out = Vec::new();
343    for (i, line) in text.trim().split('\n').enumerate() {
344        if i > 0 {
345            out.push(ast::line_break());
346        }
347        let mut first = true;
348        for word in line
349            .split(|c: char| c.is_whitespace() && c != '\u{a0}')
350            .filter(|w| !w.is_empty())
351        {
352            if !first {
353                out.push(ast::space());
354            }
355            out.push(ast::str_(word));
356            first = false;
357        }
358    }
359    out
360}
361
362/// [`text_inlines`] with a `Note` spliced in at each call: `notes` is
363/// `[[offset, text], …]` (chars into `text`), the JSON's internal `_notes`.
364/// Whitespace around a call becomes a `Space`, as Pandoc's docx reader
365/// writes `word¹ next` → `Str "word", Note, Space, Str "next"`.
366fn text_with_notes(text: &str, notes: &[Value]) -> Vec<Value> {
367    let mut calls: Vec<(usize, &str)> = notes
368        .iter()
369        .filter_map(|n| Some((n.get(0)?.as_u64()? as usize, n.get(1)?.as_str()?)))
370        .collect();
371    calls.sort_by_key(|&(offset, _)| offset);
372    let chars: Vec<char> = text.chars().collect();
373    let mut out: Vec<Value> = Vec::new();
374    let push_segment = |out: &mut Vec<Value>, seg: &str| {
375        let inlines = text_inlines(seg);
376        if inlines.is_empty() {
377            if !out.is_empty() && seg.chars().any(char::is_whitespace) {
378                out.push(ast::space());
379            }
380            return;
381        }
382        if !out.is_empty() && seg.starts_with(char::is_whitespace) {
383            out.push(ast::space());
384        }
385        out.extend(inlines);
386        if seg.ends_with(char::is_whitespace) {
387            out.push(ast::space());
388        }
389    };
390    let mut prev = 0usize;
391    for (offset, note) in calls {
392        let at = offset.clamp(prev, chars.len());
393        let seg: String = chars[prev..at].iter().collect();
394        push_segment(&mut out, &seg);
395        out.push(ast::note(vec![ast::para(text_inlines(note))]));
396        prev = at;
397    }
398    let rest: String = chars[prev..].iter().collect();
399    push_segment(&mut out, &rest);
400    while out
401        .last()
402        .is_some_and(|v| v.get("t").and_then(Value::as_str) == Some("Space"))
403    {
404        out.pop();
405    }
406    out
407}
408
409/// Inline lists joined by single `Space`s (an inline group's runs) — none
410/// before a run that opens with closing punctuation (`.`, `,`, `)`, …) or
411/// after one that ends with opening punctuation: docling keeps each
412/// formatting run as its own item and its serializers join them with a
413/// space, which would put `link .` into every Pandoc output.
414fn join_inlines(parts: Vec<Vec<Value>>) -> Vec<Value> {
415    fn edge_char(inlines: &[Value], first: bool) -> Option<char> {
416        let node = if first {
417            inlines.first()
418        } else {
419            inlines.last()
420        }?;
421        match node.get("t").and_then(Value::as_str)? {
422            "Str" => {
423                let s = node.get("c")?.as_str()?;
424                if first {
425                    s.chars().next()
426                } else {
427                    s.chars().last()
428                }
429            }
430            // Formatting / links: look inside.
431            "Strong" | "Emph" | "Underline" | "Strikeout" | "Subscript" | "Superscript" => {
432                edge_char(node.get("c")?.as_array()?, first)
433            }
434            "Link" => edge_char(node.get("c")?.get(1)?.as_array()?, first),
435            _ => None,
436        }
437    }
438    let mut out: Vec<Value> = Vec::new();
439    for part in parts.into_iter().filter(|p| !p.is_empty()) {
440        if !out.is_empty() {
441            let closes = edge_char(&part, true)
442                .is_some_and(|c| ".,;:!?)]}%\u{bb}\u{201d}\u{2019}".contains(c));
443            let opens = edge_char(&out, false).is_some_and(|c| "([{\u{ab}\u{201c}".contains(c));
444            if !closes && !opens {
445                out.push(ast::space());
446            }
447        }
448        out.extend(part);
449    }
450    out
451}
452
453/// An `N.` / `N)` list marker's number (an ordered list's `start`).
454fn marker_start(marker: &str) -> Option<u64> {
455    marker
456        .trim()
457        .trim_end_matches(['.', ')'])
458        .parse::<u64>()
459        .ok()
460}
461
462struct GridCell<'a> {
463    cell: &'a Value,
464    start_row: usize,
465    start_col: usize,
466    row_span: usize,
467    col_span: usize,
468}
469
470struct Serializer<'a> {
471    json: &'a Value,
472    image_mode: ImageMode,
473    artifacts_dir: String,
474    layers: ContentLayers,
475    artifacts: Vec<(String, Vec<u8>)>,
476    pic_index: usize,
477    visited: HashSet<String>,
478    /// Items some table / picture / graph holds as a caption or footnote:
479    /// rendered by their owner, skipped where the walk meets them.
480    owned_captions: HashSet<String>,
481    owned_footnotes: HashSet<String>,
482}
483
484impl<'a> Serializer<'a> {
485    fn new(json: &'a Value, options: &PandocExportOptions) -> Self {
486        let mut owned_captions = HashSet::new();
487        let mut owned_footnotes = HashSet::new();
488        for bucket in [
489            "pictures",
490            "tables",
491            "key_value_items",
492            "form_items",
493            "texts",
494        ] {
495            for item in Self::array(json.get(bucket)) {
496                for (key, set) in [
497                    ("captions", &mut owned_captions),
498                    ("footnotes", &mut owned_footnotes),
499                ] {
500                    for r in Self::array(item.get(key)) {
501                        if let Some(cref) = r.get("$ref").and_then(Value::as_str) {
502                            set.insert(cref.to_string());
503                        }
504                    }
505                }
506            }
507        }
508        Self {
509            json,
510            image_mode: options.image_mode,
511            artifacts_dir: options.artifacts_dir.clone(),
512            layers: options.layers,
513            artifacts: Vec::new(),
514            pic_index: 0,
515            visited: HashSet::new(),
516            owned_captions,
517            owned_footnotes,
518        }
519    }
520
521    fn array(v: Option<&'a Value>) -> impl Iterator<Item = &'a Value> {
522        v.and_then(Value::as_array).into_iter().flatten()
523    }
524
525    fn resolve(&self, cref: &str) -> Option<&'a Value> {
526        let rest = cref.strip_prefix("#/")?;
527        if rest == "body" {
528            return self.json.get("body");
529        }
530        let (bucket, idx) = rest.split_once('/')?;
531        self.json.get(bucket)?.get(idx.parse::<usize>().ok()?)
532    }
533
534    fn refs(v: Option<&'a Value>) -> Vec<&'a str> {
535        Self::array(v)
536            .filter_map(|r| r.get("$ref").and_then(Value::as_str))
537            .collect()
538    }
539
540    fn children(item: &'a Value) -> Vec<&'a str> {
541        Self::refs(item.get("children"))
542    }
543
544    fn self_ref(item: &Value) -> &str {
545        item.get("self_ref").and_then(Value::as_str).unwrap_or("")
546    }
547
548    fn label(item: &Value) -> &str {
549        item.get("label").and_then(Value::as_str).unwrap_or("")
550    }
551
552    fn text(item: &Value) -> &str {
553        item.get("text").and_then(Value::as_str).unwrap_or("")
554    }
555
556    fn is_group(item: &Value) -> bool {
557        Self::self_ref(item).starts_with("#/groups/")
558    }
559
560    fn excluded(&self, item: &Value) -> bool {
561        let layer = item
562            .get("content_layer")
563            .and_then(Value::as_str)
564            .unwrap_or("body");
565        !self.layers.contains_name(layer)
566    }
567
568    /// docling's `_iterate_items(with_groups=True, traverse_pictures=False)`
569    /// below `root`, depth-first pre-order; items off the chosen layers are
570    /// not yielded but their subtrees are walked; under a picture only its
571    /// captions are visited.
572    fn descendants(&self, root: &'a Value, out: &mut Vec<&'a Value>) {
573        let picture = Self::self_ref(root).starts_with("#/pictures/");
574        let captions = if picture {
575            Self::refs(root.get("captions"))
576        } else {
577            Vec::new()
578        };
579        for cref in Self::children(root) {
580            if picture && !captions.contains(&cref) {
581                continue;
582            }
583            let Some(child) = self.resolve(cref) else {
584                continue;
585            };
586            if !self.excluded(child) {
587                out.push(child);
588            }
589            self.descendants(child, out);
590        }
591    }
592
593    /// Every not-yet-visited descendant of `item`, serialized in order —
594    /// a container marks what it renders as visited, so the flat walk
595    /// skips it.
596    fn blocks_of(&mut self, item: &'a Value) -> Vec<Value> {
597        let mut nodes = Vec::new();
598        self.descendants(item, &mut nodes);
599        let mut out = Vec::new();
600        for node in nodes {
601            if self.visited.insert(Self::self_ref(node).to_string()) {
602                out.extend(self.serialize(node));
603            }
604        }
605        out
606    }
607
608    fn body(&mut self) -> Vec<Value> {
609        let Some(body) = self.json.get("body") else {
610            return Vec::new();
611        };
612        self.visited.insert("#/body".to_string());
613        let mut blocks = self.blocks_of(body);
614        blocks.extend(self.unplaced_notes());
615        blocks
616    }
617
618    /// Furniture `footnote` items no text item calls — docling's DOCX / ODT
619    /// note bodies when the JSON carries no call sites (one exported by
620    /// Python docling), or a call that could not be anchored — written while
621    /// the furniture layer itself is off: each as a trailing `Note`, so a
622    /// document rebuilt from the AST still has its notes (#538).
623    fn unplaced_notes(&self) -> Vec<Value> {
624        Self::array(self.json.get("texts"))
625            .filter(|t| Self::label(t) == "footnote" && !Self::is_note_body(t))
626            .filter(|t| t.get("content_layer").and_then(Value::as_str) == Some("furniture"))
627            .filter(|t| self.excluded(t) && !self.owned_footnotes.contains(Self::self_ref(t)))
628            .filter_map(|t| {
629                let text = text_inlines(Self::text(t));
630                (!text.is_empty()).then(|| ast::para(vec![ast::note(vec![ast::para(text)])]))
631            })
632            .collect()
633    }
634
635    /// One item's blocks.
636    fn serialize(&mut self, item: &'a Value) -> Vec<Value> {
637        let sref = Self::self_ref(item);
638        if Self::is_group(item) {
639            return match Self::label(item) {
640                "list" => self.list_group(item),
641                "inline" => {
642                    let (inlines, mut rest) = self.inline_group(item);
643                    let mut out = Vec::new();
644                    if !inlines.is_empty() {
645                        out.push(ast::para(inlines));
646                    }
647                    out.append(&mut rest);
648                    out
649                }
650                _ => self.blocks_of(item),
651            };
652        }
653        if sref.starts_with("#/texts/") {
654            if self.owned_captions.contains(sref) || self.owned_footnotes.contains(sref) {
655                return Vec::new();
656            }
657            // A note body some text item calls is written there, as a `Note`.
658            if self.excluded(item) || Self::is_note_body(item) {
659                return Vec::new();
660            }
661            return self.text_item(item);
662        }
663        if sref.starts_with("#/tables/") {
664            return self.table(item);
665        }
666        if sref.starts_with("#/pictures/") {
667            return self.picture(item);
668        }
669        if sref.starts_with("#/key_value_items/") {
670            return self.graph_item(item, "key-value-region");
671        }
672        if sref.starts_with("#/form_items/") {
673            return self.graph_item(item, "form-container");
674        }
675        if sref.starts_with("#/field_regions/") {
676            return self.field_region(item);
677        }
678        if sref.starts_with("#/field_items/") {
679            let entries = vec![self.field_item(item)];
680            return Self::field_blocks(entries);
681        }
682        Vec::new()
683    }
684
685    /// A form's field region (#515): its fields as one `DefinitionList` —
686    /// key → value(s) — in a `Div .field-region`; a field without a key
687    /// contributes its blocks in place.
688    fn field_region(&mut self, region: &'a Value) -> Vec<Value> {
689        let mut entries = Vec::new();
690        for cref in Self::children(region) {
691            let Some(child) = self.resolve(cref) else {
692                continue;
693            };
694            if !self.visited.insert(cref.to_string()) {
695                continue;
696            }
697            if cref.starts_with("#/field_items/") {
698                entries.push(self.field_item(child));
699            } else {
700                let mut blocks = if self.excluded(child) {
701                    Vec::new()
702                } else {
703                    self.serialize(child)
704                };
705                blocks.extend(self.blocks_of(child));
706                entries.push((Vec::new(), blocks));
707            }
708        }
709        let blocks = Self::field_blocks(entries);
710        if blocks.is_empty() {
711            Vec::new()
712        } else {
713            vec![ast::div(&["field-region"], blocks)]
714        }
715    }
716
717    /// One field: its `marker` and `field_key` texts as the term, each
718    /// `field_value` (and anything else under it) as a definition.
719    fn field_item(&mut self, field: &'a Value) -> (Vec<Value>, Vec<Value>) {
720        let mut nodes = Vec::new();
721        self.descendants(field, &mut nodes);
722        let (mut term, mut defs) = (Vec::new(), Vec::new());
723        for node in nodes {
724            if !self.visited.insert(Self::self_ref(node).to_string()) {
725                continue;
726            }
727            match Self::label(node) {
728                "marker" | "field_key" => {
729                    let inlines = self.formatted(node);
730                    if !inlines.is_empty() {
731                        term = join_inlines(vec![term, inlines]);
732                    }
733                }
734                "field_value" => {
735                    let inlines = self.formatted(node);
736                    if !inlines.is_empty() {
737                        defs.push(ast::plain(inlines));
738                    }
739                }
740                _ => defs.extend(self.serialize(node)),
741            }
742        }
743        (term, defs)
744    }
745
746    /// Fields as blocks: runs of keyed fields become `DefinitionList`s, a
747    /// key-less field's blocks stand on their own.
748    fn field_blocks(entries: Vec<(Vec<Value>, Vec<Value>)>) -> Vec<Value> {
749        let mut out = Vec::new();
750        let mut run: Vec<(Vec<Value>, Vec<Vec<Value>>)> = Vec::new();
751        for (term, defs) in entries {
752            if term.is_empty() {
753                if !run.is_empty() {
754                    out.push(ast::definition_list(std::mem::take(&mut run)));
755                }
756                out.extend(defs);
757            } else {
758                let defs = defs.into_iter().map(|d| vec![d]).collect();
759                run.push((term, defs));
760            }
761        }
762        if !run.is_empty() {
763            out.push(ast::definition_list(run));
764        }
765        out
766    }
767
768    /// A text item's own inlines: its formatted text, or — for an empty
769    /// item wrapping one inline group (a list item or heading whose content
770    /// is a run of formatted spans) — that group's, with any blocks the
771    /// group held besides.
772    fn own_inlines(&mut self, item: &'a Value) -> (Vec<Value>, Vec<Value>, bool) {
773        let children = Self::children(item);
774        if Self::text(item).is_empty() && children.len() == 1 {
775            if let Some(group) = self
776                .resolve(children[0])
777                .filter(|c| Self::is_group(c) && Self::label(c) == "inline")
778            {
779                self.visited.insert(Self::self_ref(group).to_string());
780                let (inlines, rest) = self.inline_group(group);
781                return (inlines, rest, true);
782            }
783        }
784        (self.formatted(item), Vec::new(), false)
785    }
786
787    /// The item's text with its formatting and hyperlink applied, and the
788    /// notes it calls as `Note`s at their call sites (#538).
789    fn formatted(&self, item: &Value) -> Vec<Value> {
790        let base = match Self::label(item) {
791            "code" => vec![ast::code(Self::text(item))],
792            "formula" if !Self::text(item).is_empty() => {
793                vec![ast::math(false, Self::text(item).trim())]
794            }
795            _ => match item.get("_notes").and_then(Value::as_array) {
796                Some(notes) if !notes.is_empty() => text_with_notes(Self::text(item), notes),
797                _ => text_inlines(Self::text(item)),
798            },
799        };
800        Self::decorate(base, item)
801    }
802
803    /// A furniture `footnote` item that is the body of a note a text item
804    /// calls ([`crate::tree::TreeItem::note_body`]).
805    fn is_note_body(item: &Value) -> bool {
806        item.get("_note_body").and_then(Value::as_bool) == Some(true)
807    }
808
809    fn decorate(mut inlines: Vec<Value>, item: &Value) -> Vec<Value> {
810        if inlines.is_empty() {
811            return inlines;
812        }
813        if let Some(f) = item.get("formatting") {
814            let on = |k: &str| f.get(k).and_then(Value::as_bool).unwrap_or(false);
815            if on("bold") {
816                inlines = vec![ast::wrap("Strong", inlines)];
817            }
818            if on("italic") {
819                inlines = vec![ast::wrap("Emph", inlines)];
820            }
821            if on("underline") {
822                inlines = vec![ast::wrap("Underline", inlines)];
823            }
824            if on("strikethrough") {
825                inlines = vec![ast::wrap("Strikeout", inlines)];
826            }
827            match f.get("script").and_then(Value::as_str) {
828                Some("sub") => inlines = vec![ast::wrap("Subscript", inlines)],
829                Some("super") => inlines = vec![ast::wrap("Superscript", inlines)],
830                _ => {}
831            }
832        }
833        if let Some(url) = item.get("hyperlink").and_then(Value::as_str) {
834            inlines = vec![ast::link(inlines, url)];
835        }
836        inlines
837    }
838
839    /// An inline group's runs as inlines joined by `Space`; anything in it
840    /// that has no inline form (a nested list, a table) comes back as
841    /// blocks for after the paragraph.
842    fn inline_group(&mut self, group: &'a Value) -> (Vec<Value>, Vec<Value>) {
843        let mut nodes = Vec::new();
844        self.descendants(group, &mut nodes);
845        let mut runs: Vec<Vec<Value>> = Vec::new();
846        let mut rest: Vec<Value> = Vec::new();
847        for node in nodes {
848            let r = Self::self_ref(node).to_string();
849            if self.visited.contains(&r) {
850                continue;
851            }
852            self.visited.insert(r.clone());
853            if r.starts_with("#/texts/") {
854                if self.owned_captions.contains(&r)
855                    || self.owned_footnotes.contains(&r)
856                    || Self::is_note_body(node)
857                {
858                    continue;
859                }
860                runs.push(self.formatted(node));
861                continue;
862            }
863            if Self::is_group(node) && Self::label(node) == "inline" {
864                let (inl, mut more) = self.inline_group(node);
865                runs.push(inl);
866                rest.append(&mut more);
867                continue;
868            }
869            rest.extend(self.serialize(node));
870        }
871        (join_inlines(runs), rest)
872    }
873
874    fn text_item(&mut self, item: &'a Value) -> Vec<Value> {
875        let label = Self::label(item);
876        let mut out = Vec::new();
877        match label {
878            "list_item" => {
879                // A list item met outside a list group: a one-item list.
880                let blocks = self.list_item_blocks(item);
881                if !blocks.is_empty() {
882                    out.push(ast::bullet_list(vec![blocks]));
883                }
884                return out;
885            }
886            "code" => {
887                let lang = item
888                    .get("code_language")
889                    .and_then(Value::as_str)
890                    .filter(|l| !l.is_empty() && *l != "unknown")
891                    .map(str::to_ascii_lowercase);
892                out.push(ast::code_block(lang.as_deref(), Self::text(item)));
893            }
894            "formula" => {
895                let tex = Self::text(item).trim();
896                if !tex.is_empty() {
897                    out.push(ast::para(vec![ast::math(true, tex)]));
898                }
899            }
900            _ => {
901                let (mut inlines, rest, _) = self.own_inlines(item);
902                match label {
903                    "title" | "section_header" => {
904                        let level = if label == "title" {
905                            1
906                        } else {
907                            (item.get("level").and_then(Value::as_u64).unwrap_or(1) as usize + 1)
908                                .min(6)
909                        };
910                        if !inlines.is_empty() {
911                            out.push(ast::header(level, inlines));
912                        }
913                    }
914                    "text" | "paragraph" => {
915                        if !inlines.is_empty() {
916                            out.push(ast::para(inlines));
917                        }
918                    }
919                    "checkbox_selected" | "checkbox_unselected" => {
920                        let mark = if label == "checkbox_selected" {
921                            "☒"
922                        } else {
923                            "☐"
924                        };
925                        let mut with_mark = vec![ast::str_(mark)];
926                        if !inlines.is_empty() {
927                            with_mark.push(ast::space());
928                            with_mark.append(&mut inlines);
929                        }
930                        out.push(ast::para(with_mark));
931                    }
932                    other => {
933                        if !inlines.is_empty() {
934                            let class = format!("docling-{other}");
935                            out.push(ast::div(&[&class], vec![ast::para(inlines)]));
936                        }
937                    }
938                }
939                out.extend(rest);
940            }
941        }
942        // Content nested under the item (an HTML / DOCX heading's section).
943        out.extend(self.blocks_of(item));
944        out
945    }
946
947    /// A list item's blocks: its own text as `Plain`, then whatever is
948    /// nested under it (sub-lists, paragraphs).
949    fn list_item_blocks(&mut self, item: &'a Value) -> Vec<Value> {
950        let (inlines, rest, _) = self.own_inlines(item);
951        let mut blocks = Vec::new();
952        if !inlines.is_empty() {
953            blocks.push(ast::plain(inlines));
954        }
955        blocks.extend(rest);
956        blocks.extend(self.blocks_of(item));
957        blocks
958    }
959
960    fn list_group(&mut self, group: &'a Value) -> Vec<Value> {
961        let first = Self::children(group)
962            .first()
963            .and_then(|r| self.resolve(r))
964            .filter(|c| Self::label(c) == "list_item");
965        let enumerated = first.is_some_and(|c| {
966            c.get("enumerated")
967                .and_then(Value::as_bool)
968                .unwrap_or(false)
969        });
970        let start = first
971            .and_then(|c| c.get("marker").and_then(Value::as_str))
972            .and_then(marker_start)
973            .unwrap_or(1);
974
975        let mut nodes = Vec::new();
976        self.descendants(group, &mut nodes);
977        let mut items: Vec<Vec<Value>> = Vec::new();
978        for node in nodes {
979            let r = Self::self_ref(node).to_string();
980            if !self.visited.insert(r.clone()) {
981                continue;
982            }
983            if Self::label(node) == "list_item"
984                && r.starts_with("#/texts/")
985                && !self.owned_captions.contains(&r)
986            {
987                let blocks = self.list_item_blocks(node);
988                items.push(blocks);
989                continue;
990            }
991            // Something that is not a list item directly in the list (a
992            // paragraph between items): it belongs to the item before it.
993            let blocks = self.serialize(node);
994            if blocks.is_empty() {
995                continue;
996            }
997            match items.last_mut() {
998                Some(last) => last.extend(blocks),
999                None => items.push(blocks),
1000            }
1001        }
1002        if items.is_empty() {
1003            return Vec::new();
1004        }
1005        if enumerated {
1006            vec![ast::ordered_list(start, items)]
1007        } else {
1008            vec![ast::bullet_list(items)]
1009        }
1010    }
1011
1012    /// The owner's caption texts as one `Caption`, with its footnotes as
1013    /// `Note`s at the end — the float is their call site.
1014    fn caption(&self, item: &'a Value) -> Value {
1015        let mut parts: Vec<Vec<Value>> = Vec::new();
1016        for cref in Self::refs(item.get("captions")) {
1017            if let Some(cap) = self.resolve(cref) {
1018                if cref.starts_with("#/texts/") && !self.excluded(cap) {
1019                    parts.push(text_inlines(Self::text(cap)));
1020                }
1021            }
1022        }
1023        let mut inlines = join_inlines(parts);
1024        for cref in Self::refs(item.get("footnotes")) {
1025            if let Some(note) = self.resolve(cref) {
1026                if cref.starts_with("#/texts/") && !self.excluded(note) {
1027                    let text = text_inlines(Self::text(note));
1028                    if !text.is_empty() {
1029                        inlines.push(ast::note(vec![ast::para(text)]));
1030                    }
1031                }
1032            }
1033        }
1034        if inlines.is_empty() {
1035            ast::caption(Vec::new())
1036        } else {
1037            ast::caption(vec![ast::plain(inlines)])
1038        }
1039    }
1040
1041    /// The owner's caption texts alone (no notes) — a picture's alt text,
1042    /// as Pandoc's readers give a captioned image.
1043    fn caption_text(&self, item: &'a Value) -> Vec<Value> {
1044        let parts = Self::refs(item.get("captions"))
1045            .into_iter()
1046            .filter(|cref| cref.starts_with("#/texts/"))
1047            .filter_map(|cref| self.resolve(cref))
1048            .filter(|cap| !self.excluded(cap))
1049            .map(|cap| text_inlines(Self::text(cap)))
1050            .collect();
1051        join_inlines(parts)
1052    }
1053
1054    /// docling's `TableData.grid`: each position → the cell covering it.
1055    fn grid(data: &'a Value) -> (usize, Vec<Vec<Option<GridCell<'a>>>>) {
1056        let num_rows = data.get("num_rows").and_then(Value::as_u64).unwrap_or(0) as usize;
1057        let num_cols = data.get("num_cols").and_then(Value::as_u64).unwrap_or(0) as usize;
1058        let mut grid: Vec<Vec<Option<GridCell<'a>>>> = (0..num_rows)
1059            .map(|_| (0..num_cols).map(|_| None).collect())
1060            .collect();
1061        for cell in Self::array(data.get("table_cells")) {
1062            let get = |k: &str| cell.get(k).and_then(Value::as_u64).unwrap_or(0) as usize;
1063            let (r0, c0) = (get("start_row_offset_idx"), get("start_col_offset_idx"));
1064            let (r1, c1) = (get("end_row_offset_idx"), get("end_col_offset_idx"));
1065            if r0 >= num_rows || c0 >= num_cols {
1066                continue;
1067            }
1068            let (r1, c1) = (r1.clamp(r0 + 1, num_rows), c1.clamp(c0 + 1, num_cols));
1069            for row in grid.iter_mut().take(r1).skip(r0) {
1070                for slot in row.iter_mut().take(c1).skip(c0) {
1071                    *slot = Some(GridCell {
1072                        cell,
1073                        start_row: r0,
1074                        start_col: c0,
1075                        row_span: r1 - r0,
1076                        col_span: c1 - c0,
1077                    });
1078                }
1079            }
1080        }
1081        (num_cols, grid)
1082    }
1083
1084    fn table_from_data(&mut self, data: &'a Value, caption: Value) -> Option<Value> {
1085        let (num_cols, grid) = Self::grid(data);
1086        if grid.is_empty() || num_cols == 0 {
1087            return None;
1088        }
1089        let flag = |c: &Value, k: &str| c.get(k).and_then(Value::as_bool).unwrap_or(false);
1090        let mut rows: Vec<(bool, Value)> = Vec::new();
1091        for (i, row) in grid.iter().enumerate() {
1092            let mut cells = Vec::new();
1093            let mut all_header = true;
1094            let mut anchored = 0;
1095            for (j, slot) in row.iter().enumerate() {
1096                let Some(g) = slot else {
1097                    // A position no cell covers (a table's empty corner) is
1098                    // neutral to the header test.
1099                    cells.push(ast::cell(1, 1, Vec::new()));
1100                    continue;
1101                };
1102                if g.start_row != i || g.start_col != j {
1103                    continue;
1104                }
1105                anchored += 1;
1106                all_header &= flag(g.cell, "column_header");
1107                let blocks = match g
1108                    .cell
1109                    .get("ref")
1110                    .and_then(|r| r.get("$ref"))
1111                    .and_then(Value::as_str)
1112                {
1113                    Some(cref) => match self.resolve(cref) {
1114                        Some(target) => {
1115                            self.visited.insert(cref.to_string());
1116                            self.serialize(target)
1117                        }
1118                        None => Vec::new(),
1119                    },
1120                    None => {
1121                        let inl =
1122                            text_inlines(g.cell.get("text").and_then(Value::as_str).unwrap_or(""));
1123                        if inl.is_empty() {
1124                            Vec::new()
1125                        } else {
1126                            vec![ast::plain(inl)]
1127                        }
1128                    }
1129                };
1130                cells.push(ast::cell(g.row_span, g.col_span, blocks));
1131            }
1132            rows.push((all_header && anchored > 0, ast::row(cells)));
1133        }
1134        // Leading all-header rows form the head; the rest is the body.
1135        let head_len = rows.iter().take_while(|(h, _)| *h).count();
1136        let head_len = if head_len == rows.len() { 0 } else { head_len };
1137        let mut head = Vec::new();
1138        let mut body = Vec::new();
1139        for (i, (_, row)) in rows.into_iter().enumerate() {
1140            if i < head_len {
1141                head.push(row);
1142            } else {
1143                body.push(row);
1144            }
1145        }
1146        Some(ast::table(caption, num_cols, head, body))
1147    }
1148
1149    fn table(&mut self, item: &'a Value) -> Vec<Value> {
1150        let caption = self.caption(item);
1151        if self.excluded(item) {
1152            return Vec::new();
1153        }
1154        match item.get("data") {
1155            Some(data) => self.table_from_data(data, caption).into_iter().collect(),
1156            None => Vec::new(),
1157        }
1158    }
1159
1160    fn picture(&mut self, item: &'a Value) -> Vec<Value> {
1161        if self.excluded(item) {
1162            return Vec::new();
1163        }
1164        let caption = self.caption(item);
1165        let has_caption = caption
1166            .get(1)
1167            .and_then(Value::as_array)
1168            .is_some_and(|b| !b.is_empty());
1169        let uri = item
1170            .get("image")
1171            .and_then(|i| i.get("uri"))
1172            .and_then(Value::as_str);
1173        let src = match (self.image_mode, uri) {
1174            (ImageMode::Embedded, Some(uri)) if uri.starts_with("data:") => Some(uri.to_string()),
1175            (ImageMode::Referenced, Some(uri)) => self
1176                .reference_image(uri)
1177                .or_else(|| (!uri.starts_with("data:")).then(|| uri.to_string())),
1178            _ => None,
1179        };
1180        // #537: every picture is an `Image`, so it survives into whatever
1181        // Pandoc writes and can be counted — one without a target
1182        // (placeholder mode, or a payload docling could not decode: EMF/WMF)
1183        // carries the `docling-placeholder` class and an empty target, which
1184        // a writer reports as a missing resource and renders as the alt text.
1185        let alt = self.caption_text(item);
1186        let image = match &src {
1187            Some(src) => ast::image(&[], alt, src),
1188            None => ast::image(&["docling-placeholder"], alt, ""),
1189        };
1190        // A native chart's data grid (`meta.tabular_chart.chart_data`).
1191        let chart = item
1192            .get("meta")
1193            .and_then(|m| m.get("tabular_chart"))
1194            .and_then(|t| t.get("chart_data"))
1195            .and_then(|chart| self.table_from_data(chart, ast::caption(Vec::new())));
1196        if chart.is_none() && !has_caption {
1197            // Pandoc's own readers put a caption-less image in a paragraph.
1198            return vec![ast::para(vec![image])];
1199        }
1200        let mut blocks = vec![ast::plain(vec![image])];
1201        blocks.extend(chart);
1202        vec![ast::figure(&[], caption, blocks)]
1203    }
1204
1205    /// Referenced mode: a `data:` URI becomes an artifact file named like
1206    /// the Markdown export's; the path is the image target.
1207    fn reference_image(&mut self, uri: &str) -> Option<String> {
1208        let rest = uri.strip_prefix("data:")?;
1209        let (mime, payload) = rest.split_once(";base64,")?;
1210        let bytes = crate::base64::decode(payload)?;
1211        let path = format!(
1212            "{}/image_{:06}.{}",
1213            self.artifacts_dir,
1214            self.pic_index,
1215            crate::markdown::ext_for(mime)
1216        );
1217        self.pic_index += 1;
1218        self.artifacts.push((path.clone(), bytes));
1219        Some(path)
1220    }
1221
1222    /// A key-value / form graph: a `DefinitionList` of keys and their
1223    /// values, or — when `to_child` links make it a hierarchy — the nested
1224    /// `BulletList` of it (the HTML export's two shapes), in a classed `Div`
1225    /// with the item's caption after it.
1226    fn graph_item(&mut self, item: &'a Value, class: &str) -> Vec<Value> {
1227        let mut out = Vec::new();
1228        if !self.excluded(item) {
1229            if let Some(graph) = item.get("graph") {
1230                if let Some(block) = Self::graph(graph) {
1231                    out.push(ast::div(&[class], vec![block]));
1232                }
1233            }
1234        }
1235        // The caption (and footnote notes) as a paragraph after the region.
1236        let caption = self.caption(item);
1237        for block in caption
1238            .get(1)
1239            .and_then(Value::as_array)
1240            .into_iter()
1241            .flatten()
1242        {
1243            if let Some(inlines) = block.get("c").and_then(Value::as_array) {
1244                out.push(ast::para(inlines.clone()));
1245            }
1246        }
1247        out
1248    }
1249
1250    fn graph(graph: &'a Value) -> Option<Value> {
1251        let cells: HashMap<u64, &Value> = Self::array(graph.get("cells"))
1252            .filter_map(|c| Some((c.get("cell_id")?.as_u64()?, c)))
1253            .collect();
1254        let order: Vec<u64> = Self::array(graph.get("cells"))
1255            .filter_map(|c| c.get("cell_id")?.as_u64())
1256            .collect();
1257        let mut child_links: HashMap<u64, Vec<u64>> = HashMap::new();
1258        let mut value_links: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
1259        let mut value_order: Vec<u64> = Vec::new();
1260        let mut parents: HashSet<u64> = HashSet::new();
1261        for link in Self::array(graph.get("links")) {
1262            let (Some(s), Some(t)) = (
1263                link.get("source_cell_id").and_then(Value::as_u64),
1264                link.get("target_cell_id").and_then(Value::as_u64),
1265            ) else {
1266                continue;
1267            };
1268            if !cells.contains_key(&s) || !cells.contains_key(&t) {
1269                continue;
1270            }
1271            match link.get("label").and_then(Value::as_str) {
1272                Some("to_child") => {
1273                    child_links.entry(s).or_default().push(t);
1274                    parents.insert(t);
1275                }
1276                Some("to_value") => {
1277                    if !value_links.contains_key(&s) {
1278                        value_order.push(s);
1279                    }
1280                    value_links.entry(s).or_default().push(t);
1281                }
1282                _ => {}
1283            }
1284        }
1285        let cell_text =
1286            |id: u64| text_inlines(cells[&id].get("text").and_then(Value::as_str).unwrap_or(""));
1287        if child_links.is_empty() {
1288            if value_order.is_empty() {
1289                return None;
1290            }
1291            let entries = value_order
1292                .iter()
1293                .map(|&k| {
1294                    let defs = value_links[&k]
1295                        .iter()
1296                        .map(|&v| vec![ast::plain(cell_text(v))])
1297                        .collect();
1298                    (cell_text(k), defs)
1299                })
1300                .collect();
1301            return Some(ast::definition_list(entries));
1302        }
1303        // Hierarchical: a nested bullet list, cycles cut on the descent path.
1304        fn node(
1305            id: u64,
1306            cell_text: &dyn Fn(u64) -> Vec<Value>,
1307            child_links: &HashMap<u64, Vec<u64>>,
1308            value_links: &BTreeMap<u64, Vec<u64>>,
1309            path: &mut HashSet<u64>,
1310        ) -> Option<Vec<Value>> {
1311            if !path.insert(id) {
1312                return None;
1313            }
1314            let mut inlines = cell_text(id);
1315            if let Some(values) = value_links.get(&id) {
1316                let vals = join_inlines(values.iter().map(|&v| cell_text(v)).collect());
1317                inlines = vec![ast::wrap("Strong", inlines)];
1318                inlines.push(ast::str_(":"));
1319                if !vals.is_empty() {
1320                    inlines.push(ast::space());
1321                    inlines.extend(vals);
1322                }
1323            }
1324            let mut blocks = vec![ast::plain(inlines)];
1325            let kids: Vec<Vec<Value>> = child_links
1326                .get(&id)
1327                .into_iter()
1328                .flatten()
1329                .filter_map(|&c| node(c, cell_text, child_links, value_links, path))
1330                .collect();
1331            if !kids.is_empty() {
1332                blocks.push(ast::bullet_list(kids));
1333            }
1334            path.remove(&id);
1335            Some(blocks)
1336        }
1337        let mut path = HashSet::new();
1338        let items: Vec<Vec<Value>> = order
1339            .iter()
1340            .filter(|id| !parents.contains(id))
1341            .filter_map(|&id| node(id, &cell_text, &child_links, &value_links, &mut path))
1342            .collect();
1343        (!items.is_empty()).then(|| ast::bullet_list(items))
1344    }
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350    use serde_json::json;
1351
1352    /// A docling JSON document from `body` children and the item buckets.
1353    fn doc(body: &[&str], texts: Value, groups: Value, tables: Value) -> Value {
1354        json!({
1355            "schema_name": "DoclingDocument",
1356            "name": "t",
1357            "body": {"self_ref": "#/body", "children": body.iter().map(|r| json!({"$ref": r})).collect::<Vec<_>>()},
1358            "texts": texts, "groups": groups, "tables": tables, "pictures": []
1359        })
1360    }
1361
1362    fn text(i: usize, label: &str, text: &str, extra: Value) -> Value {
1363        let mut v = json!({"self_ref": format!("#/texts/{i}"), "children": [], "label": label, "text": text, "content_layer": "body"});
1364        for (k, x) in extra.as_object().unwrap() {
1365            v[k] = x.clone();
1366        }
1367        v
1368    }
1369
1370    fn blocks(json: &Value) -> Vec<Value> {
1371        let (s, _) = from_docling_json(json, &PandocExportOptions::default()).unwrap();
1372        let v: Value = serde_json::from_str(&s).unwrap();
1373        assert_eq!(v["pandoc-api-version"], json!(PANDOC_API_VERSION));
1374        v["blocks"].as_array().unwrap().clone()
1375    }
1376
1377    /// #538: a text item's note calls become `Note`s at their offsets —
1378    /// glued to the word before, a `Space` where the text had one — and the
1379    /// note bodies they carry are not written again as blocks.
1380    #[test]
1381    fn note_calls_become_notes_at_their_call_sites() {
1382        let j = doc(
1383            &["#/texts/0", "#/texts/1"],
1384            json!([
1385                text(
1386                    0,
1387                    "text",
1388                    "debut suite end",
1389                    json!({"_notes": [[5, "first"], [15, "last"]]})
1390                ),
1391                text(
1392                    1,
1393                    "footnote",
1394                    "first",
1395                    json!({"content_layer": "furniture", "_note_body": true})
1396                ),
1397            ]),
1398            json!([]),
1399            json!([]),
1400        );
1401        let note = |t: &str| ast::note(vec![ast::para(vec![ast::str_(t)])]);
1402        assert_eq!(
1403            blocks(&j),
1404            vec![ast::para(vec![
1405                ast::str_("debut"),
1406                note("first"),
1407                ast::space(),
1408                ast::str_("suite"),
1409                ast::space(),
1410                ast::str_("end"),
1411                note("last"),
1412            ])]
1413        );
1414        // With the furniture layer on, a placed note body is still not a block.
1415        let opts = PandocExportOptions {
1416            layers: ContentLayers::ALL,
1417            ..Default::default()
1418        };
1419        let (s, _) = from_docling_json(&j, &opts).unwrap();
1420        assert!(!s.contains("docling-footnote"), "{s}");
1421    }
1422
1423    /// #538: a furniture footnote nothing calls (Python docling's JSON has no
1424    /// call sites) still reaches the AST, as a trailing `Note`.
1425    #[test]
1426    fn uncalled_furniture_footnotes_trail_as_notes() {
1427        let j = doc(
1428            &["#/texts/0", "#/texts/1"],
1429            json!([
1430                text(0, "text", "body", json!({})),
1431                text(
1432                    1,
1433                    "footnote",
1434                    "MARKFN",
1435                    json!({"content_layer": "furniture"})
1436                ),
1437            ]),
1438            json!([]),
1439            json!([]),
1440        );
1441        assert_eq!(
1442            blocks(&j),
1443            vec![
1444                ast::para(vec![ast::str_("body")]),
1445                ast::para(vec![ast::note(vec![ast::para(vec![ast::str_("MARKFN")])])]),
1446            ]
1447        );
1448    }
1449
1450    /// #537: every picture is an `Image` — embedded by default, in a `Para`
1451    /// without a caption, in a `Figure` (caption = alt text) with one; with
1452    /// no target (placeholder mode, an undecodable payload) it is classed
1453    /// `docling-placeholder` instead of vanishing as raw HTML.
1454    #[test]
1455    fn pictures_are_always_images() {
1456        let mut j = doc(
1457            &["#/pictures/0", "#/pictures/1", "#/pictures/2"],
1458            json!([text(0, "caption", "A duck", json!({})),]),
1459            json!([]),
1460            json!([]),
1461        );
1462        j["pictures"] = json!([
1463            {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
1464             "image": {"uri": "data:image/png;base64,AAAA"}, "captions": []},
1465            {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
1466             "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
1467            {"self_ref": "#/pictures/2", "children": [], "label": "picture", "content_layer": "body",
1468             "captions": []},
1469        ]);
1470        let b = blocks(&j);
1471        assert_eq!(
1472            b[0],
1473            ast::para(vec![ast::image(&[], vec![], "data:image/png;base64,AAAA")])
1474        );
1475        assert_eq!(
1476            b[1],
1477            ast::figure(
1478                &[],
1479                ast::caption(vec![ast::plain(vec![
1480                    ast::str_("A"),
1481                    ast::space(),
1482                    ast::str_("duck")
1483                ])]),
1484                vec![ast::plain(vec![ast::image(
1485                    &[],
1486                    vec![ast::str_("A"), ast::space(), ast::str_("duck")],
1487                    "data:image/png;base64,BBBB"
1488                )])]
1489            )
1490        );
1491        assert_eq!(
1492            b[2],
1493            ast::para(vec![ast::image(&["docling-placeholder"], vec![], "")])
1494        );
1495        assert_eq!(b.len(), 3);
1496        // Placeholder mode keeps the node, without the pixels.
1497        let opts = PandocExportOptions {
1498            image_mode: ImageMode::Placeholder,
1499            ..Default::default()
1500        };
1501        let (s, _) = from_docling_json(&j, &opts).unwrap();
1502        assert!(!s.contains("base64") && !s.contains("RawBlock"), "{s}");
1503        assert_eq!(s.matches("docling-placeholder").count(), 3, "{s}");
1504    }
1505
1506    #[test]
1507    fn text_splits_into_words_spaces_and_line_breaks() {
1508        assert_eq!(
1509            text_inlines("  two  words\nnext\u{a0}line "),
1510            vec![
1511                ast::str_("two"),
1512                ast::space(),
1513                ast::str_("words"),
1514                ast::line_break(),
1515                ast::str_("next\u{a0}line"),
1516            ]
1517        );
1518        assert!(text_inlines("   ").is_empty());
1519    }
1520
1521    #[test]
1522    fn headings_paragraphs_and_lists() {
1523        let j = doc(
1524            &["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"],
1525            json!([
1526                text(0, "title", "Ducks", json!({})),
1527                text(1, "section_header", "Diet", json!({"level": 1})),
1528                text(
1529                    2,
1530                    "text",
1531                    "Ducks eat plants.",
1532                    json!({"formatting": {"bold": true}, "hyperlink": "https://x.org"})
1533                ),
1534                text(
1535                    3,
1536                    "list_item",
1537                    "seeds",
1538                    json!({"enumerated": true, "marker": "3."})
1539                ),
1540                text(
1541                    4,
1542                    "list_item",
1543                    "insects",
1544                    json!({"enumerated": true, "marker": "4."})
1545                ),
1546            ]),
1547            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/3"}, {"$ref": "#/texts/4"}], "label": "list", "name": "list"}]),
1548            json!([]),
1549        );
1550        let b = blocks(&j);
1551        assert_eq!(b[0], ast::header(1, vec![ast::str_("Ducks")]));
1552        assert_eq!(b[1], ast::header(2, vec![ast::str_("Diet")]));
1553        // formatting inside, the link around it all
1554        assert_eq!(b[2]["t"], "Para");
1555        assert_eq!(b[2]["c"][0]["t"], "Link");
1556        assert_eq!(b[2]["c"][0]["c"][1][0]["t"], "Strong");
1557        assert_eq!(b[3]["t"], "OrderedList");
1558        assert_eq!(b[3]["c"][0][0], 3, "start from the first marker");
1559        assert_eq!(
1560            b[3]["c"][1],
1561            json!([
1562                [ast::plain(vec![ast::str_("seeds")])],
1563                [ast::plain(vec![ast::str_("insects")])]
1564            ])
1565        );
1566    }
1567
1568    #[test]
1569    fn code_formulas_checkboxes_and_unmapped_labels() {
1570        let j = doc(
1571            &["#/texts/0", "#/texts/1", "#/texts/2", "#/texts/3"],
1572            json!([
1573                text(0, "code", "print(1)", json!({"code_language": "Python"})),
1574                text(1, "formula", "E = mc^2", json!({})),
1575                text(2, "checkbox_selected", "done", json!({})),
1576                text(3, "reference", "[1] Duck, D. (2020).", json!({})),
1577            ]),
1578            json!([]),
1579            json!([]),
1580        );
1581        let b = blocks(&j);
1582        assert_eq!(b[0], ast::code_block(Some("python"), "print(1)"));
1583        assert_eq!(b[1], ast::para(vec![ast::math(true, "E = mc^2")]));
1584        assert_eq!(b[2]["c"][0], ast::str_("☒"));
1585        assert_eq!(b[3]["t"], "Div");
1586        assert_eq!(b[3]["c"][0][1], json!(["docling-reference"]));
1587    }
1588
1589    #[test]
1590    fn tables_keep_spans_header_rows_and_captions() {
1591        let cell = |r0: usize, c0: usize, rs: usize, cs: usize, t: &str, header: bool| {
1592            json!({"text": t, "row_span": rs, "col_span": cs,
1593                   "start_row_offset_idx": r0, "end_row_offset_idx": r0 + rs,
1594                   "start_col_offset_idx": c0, "end_col_offset_idx": c0 + cs,
1595                   "column_header": header})
1596        };
1597        let j = doc(
1598            &["#/tables/0"],
1599            json!([text(0, "caption", "Table 1: Ducks", json!({}))]),
1600            json!([]),
1601            json!([{"self_ref": "#/tables/0", "children": [{"$ref": "#/texts/0"}], "label": "table",
1602                    "captions": [{"$ref": "#/texts/0"}],
1603                    "data": {"num_rows": 2, "num_cols": 2, "table_cells": [
1604                        cell(0, 0, 1, 2, "Species", true),
1605                        cell(1, 0, 1, 1, "Mallard", false),
1606                        cell(1, 1, 1, 1, "Anas", false)]}}]),
1607        );
1608        let b = blocks(&j);
1609        assert_eq!(b.len(), 1, "the caption is rendered by its table only");
1610        let t = &b[0];
1611        assert_eq!(t["t"], "Table");
1612        assert_eq!(
1613            t["c"][1][1][0],
1614            ast::plain(vec![
1615                ast::str_("Table"),
1616                ast::space(),
1617                ast::str_("1:"),
1618                ast::space(),
1619                ast::str_("Ducks")
1620            ])
1621        );
1622        assert_eq!(t["c"][2].as_array().unwrap().len(), 2);
1623        let head = t["c"][3][1].as_array().unwrap();
1624        assert_eq!(head.len(), 1);
1625        assert_eq!(head[0][1][0][3], 2, "colspan");
1626        assert_eq!(t["c"][4][0][3].as_array().unwrap().len(), 1, "one body row");
1627    }
1628
1629    #[test]
1630    fn api_version_is_checked() {
1631        for ok in ["1.23", "1.23.1", "1.23.1.1"] {
1632            assert!(check_api_version(ok).is_ok(), "{ok}");
1633        }
1634        for bad in ["1.22", "2.0", "1", "1.23.2", "1.23.1.2", "x.y", ""] {
1635            let e = check_api_version(bad).unwrap_err();
1636            assert!(e.to_string().contains("only 1.23"), "{bad}: {e}");
1637        }
1638        let err = to_pandoc(
1639            &DoclingDocument::new("t"),
1640            &PandocExportOptions {
1641                api_version: Some("1.22".into()),
1642                ..Default::default()
1643            },
1644        )
1645        .unwrap_err();
1646        assert_eq!(
1647            err,
1648            PandocError::UnsupportedApiVersion {
1649                requested: "1.22".into()
1650            }
1651        );
1652    }
1653
1654    #[test]
1655    fn empty_document_is_a_valid_pandoc_document() {
1656        let (s, artifacts) =
1657            to_pandoc(&DoclingDocument::new("e"), &PandocExportOptions::default()).unwrap();
1658        assert!(artifacts.is_empty());
1659        assert_eq!(
1660            s,
1661            r#"{"pandoc-api-version":[1,23,1,1],"meta":{},"blocks":[]}"#
1662        );
1663        assert_eq!(s, DoclingDocument::new("e").export_to_pandoc_json());
1664    }
1665}