Skip to main content

docling_core/
json.rs

1//! Export a [`DoclingDocument`] to docling-core's native JSON wire format
2//! (`DoclingDocument` schema v1.10.0) — the same shape `export_to_dict()` /
3//! `save_as_json()` produce in Python docling, and the inverse of the
4//! JSON-docling reader.
5//!
6//! The crate's [`Node`] model bakes Markdown escaping (and inline markers) into
7//! its text, whereas docling stores raw text and escapes at render time. We
8//! therefore *un-escape* on the way out so a docling-core round-trip
9//! (`load_from_json().export_to_markdown()`) reproduces the same Markdown.
10
11use serde_json::{json, Map, Value};
12
13use crate::document::{CaptionParent, ContentLayer, DoclingDocument, Node, Table};
14
15const SCHEMA_VERSION: &str = "1.10.0";
16
17/// docling-core's `CodeLanguageLabel` values (anything else serializes as
18/// `unknown`, which the model requires for code items).
19const CODE_LANGUAGES: &[&str] = &[
20    "Ada",
21    "Awk",
22    "Bash",
23    "bc",
24    "C",
25    "C#",
26    "C++",
27    "CMake",
28    "COBOL",
29    "CSS",
30    "Ceylon",
31    "Clojure",
32    "Crystal",
33    "Cuda",
34    "Cython",
35    "D",
36    "Dart",
37    "dc",
38    "Dockerfile",
39    "DocLang",
40    "Elixir",
41    "Erlang",
42    "FORTRAN",
43    "Forth",
44    "Go",
45    "HTML",
46    "Haskell",
47    "Haxe",
48    "Java",
49    "JavaScript",
50    "JSON",
51    "Julia",
52    "Kotlin",
53    "Latex",
54    "Lisp",
55    "Lua",
56    "Matlab",
57    "MoonScript",
58    "Nim",
59    "OCaml",
60    "ObjectiveC",
61    "Octave",
62    "PHP",
63    "Pascal",
64    "Perl",
65    "Prolog",
66    "Python",
67    "Racket",
68    "Ruby",
69    "Rust",
70    "SML",
71    "SQL",
72    "Scala",
73    "Scheme",
74    "Swift",
75    "Tikz",
76    "TypeScript",
77    "VisualBasic",
78    "XML",
79    "YAML",
80];
81
82/// Map a fence language to docling's `CodeLanguageLabel` (case-insensitive), else
83/// `unknown`.
84/// docling's `CodeLanguageLabel` for a language name (`unknown` when it is
85/// not one docling knows) — the mapping the JSON `code_language` field uses,
86/// for a backend that wants to test a hint before storing it.
87pub fn code_language_label(lang: &str) -> &'static str {
88    code_language(Some(lang))
89}
90
91pub(crate) fn code_language(lang: Option<&str>) -> &'static str {
92    match lang {
93        Some(l) => CODE_LANGUAGES
94            .iter()
95            .find(|c| c.eq_ignore_ascii_case(l))
96            .copied()
97            .unwrap_or("unknown"),
98        None => "unknown",
99    }
100}
101
102/// An item's exact provenance, written verbatim (see [`Builder::prov_json`]):
103/// a [`Node::Prov`] wrapper's top-left page box, or a tree item's
104/// [`TreeProv`](crate::tree::TreeProv).
105#[derive(Clone, Copy)]
106struct ExactProv {
107    page_no: usize,
108    bbox: [f64; 4],
109    bottom_left: bool,
110    charspan: [usize; 2],
111}
112
113impl From<&crate::tree::TreeProv> for ExactProv {
114    fn from(p: &crate::tree::TreeProv) -> Self {
115        ExactProv {
116            page_no: p.page_no,
117            bbox: p.bbox,
118            bottom_left: p.bottom_left,
119            charspan: p.charspan,
120        }
121    }
122}
123
124/// Page sizes by page number; the first entry per page wins.
125type PageSizes = std::collections::HashMap<usize, (f64, f64)>;
126
127fn page_sizes(pages: &[(usize, f64, f64)]) -> PageSizes {
128    let mut sizes = PageSizes::with_capacity(pages.len());
129    for &(p, w, h) in pages {
130        sizes.entry(p).or_insert((w, h));
131    }
132    sizes
133}
134
135/// docling-core's `_clamp_provenance_bboxes_to_pages`: each `prov` box of
136/// every item on a known page is clamped to `[0, width] × [0, height]`
137/// (whatever its `coord_origin` — docling clamps the stored numbers), and a
138/// table whose provenance sits on one page has its cell boxes clamped too.
139/// Runs over the finished `texts`, `pictures`, `tables` and
140/// `key_value_items` (the form-field buckets are left as built).
141fn clamp_boxes_to_pages(b: &mut Builder) {
142    if b.pages.is_empty() {
143        return;
144    }
145    // A map, not a scan per box: that was items × pages on long documents.
146    let sizes = page_sizes(&b.pages);
147    for item in &mut b.texts {
148        match item {
149            Item::Text(t) => t.prov.clamp(&sizes),
150            Item::Json(v) => clamp_item(v, &sizes),
151        }
152    }
153    for item in b
154        .pictures
155        .iter_mut()
156        .chain(b.tables.iter_mut())
157        .chain(b.key_value_items.iter_mut())
158    {
159        clamp_item(item, &sizes);
160    }
161}
162
163fn clamp_item(item: &mut Value, sizes: &PageSizes) {
164    let size = |page_no: &Value| -> Option<(f64, f64)> {
165        let n = page_no.as_u64()? as usize;
166        sizes.get(&n).copied()
167    };
168    let clamp_bbox = |bbox: &mut Value, (w, h): (f64, f64)| {
169        for (key, hi) in [("l", w), ("r", w), ("t", h), ("b", h)] {
170            if let Some(v) = bbox.get(key).and_then(Value::as_f64) {
171                bbox[key] = json!(round2(v.clamp(0.0, hi.max(0.0))));
172            }
173        }
174    };
175    let mut table_page: Option<Option<(f64, f64)>> = None;
176    if let Some(provs) = item.get_mut("prov").and_then(Value::as_array_mut) {
177        let mut page_nos: Vec<u64> = Vec::new();
178        for prov in provs.iter_mut() {
179            if let Some(n) = prov.get("page_no").and_then(Value::as_u64) {
180                page_nos.push(n);
181            }
182            let Some(sz) = prov.get("page_no").and_then(size) else {
183                continue;
184            };
185            if let Some(bbox) = prov.get_mut("bbox") {
186                clamp_bbox(bbox, sz);
187            }
188        }
189        page_nos.sort_unstable();
190        page_nos.dedup();
191        if let [only] = page_nos[..] {
192            table_page = Some(size(&json!(only)));
193        }
194    }
195    // A table's cells (and the `grid` docling derives from them).
196    if let (Some(Some(sz)), Some(data)) = (table_page, item.get_mut("data")) {
197        for key in ["table_cells", "grid"] {
198            let Some(rows) = data.get_mut(key).and_then(Value::as_array_mut) else {
199                continue;
200            };
201            for entry in rows.iter_mut() {
202                let cells: Vec<&mut Value> = match entry {
203                    Value::Array(row) => row.iter_mut().collect(),
204                    other => vec![other],
205                };
206                for cell in cells {
207                    if let Some(bbox) = cell.get_mut("bbox").filter(|b| b.is_object()) {
208                        clamp_bbox(bbox, sz);
209                    }
210                }
211            }
212        }
213    }
214}
215
216/// docling-core's `Formatting` model, every field written.
217fn formatting_json(f: &crate::tree::Formatting) -> Value {
218    json!({
219        "bold": f.bold,
220        "italic": f.italic,
221        "underline": f.underline,
222        "strikethrough": f.strikethrough,
223        "script": match f.script {
224            crate::Script::Baseline => "baseline",
225            crate::Script::Sub => "sub",
226            crate::Script::Super => "super",
227        },
228    })
229}
230
231/// docling's `TrackSource` entry: `kind`, the two offsets, then the optional
232/// `identifier` / `voice` (dropped when `None`, as `exclude_none` does).
233fn track_json(t: &crate::tree::TreeTrack) -> Value {
234    let mut m = serde_json::Map::new();
235    m.insert("kind".into(), json!("track"));
236    m.insert("start_time".into(), json!(t.start_time));
237    m.insert("end_time".into(), json!(t.end_time));
238    if let Some(id) = &t.identifier {
239        m.insert("identifier".into(), json!(id));
240    }
241    if let Some(v) = &t.voice {
242        m.insert("voice".into(), json!(v));
243    }
244    Value::Object(m)
245}
246
247/// docling-core's `ImageRef` (pydantic field order: mimetype, dpi, size —
248/// as floats — uri), with the bytes inlined as a `data:` URI — a PNG, like
249/// `ImageRef.from_pil`'s (`pixel_digest::docling_data_uri`).
250fn image_ref_json(img: &crate::PictureImage) -> Value {
251    let (mimetype, uri) = crate::pixel_digest::docling_data_uri(img);
252    json!({
253        "mimetype": mimetype,
254        "dpi": img.dpi,
255        "size": { "width": img.width as f64, "height": img.height as f64 },
256        "uri": uri,
257    })
258}
259
260/// Build the docling-core JSON object for `doc`.
261pub fn to_json(doc: &DoclingDocument) -> Value {
262    build_json(doc, false)
263}
264
265/// [`to_json`] plus the note calls docling's model cannot express: a text
266/// item calling footnotes carries `"_notes": [[offset, text], …]`, a
267/// footnote item that is such a note's body `"_note_body": true` (#538),
268/// a text item that is a link destination `"_anchors": ["id", …]` (#849),
269/// a heading whose text opens with generated numbering `"_number": "1.2"`
270/// (#850), a run kept as a tracked change `"_revision": {"kind":
271/// "insertion" | "deletion", "author", "date"}` (#851),
272/// a picture's image its displayed size `"_display": [width, height]` in
273/// inches (#834), and the document's `metadata` whether or not the JSON
274/// export was asked for it (#836). The Pandoc writer reads these; nothing
275/// is ever exported with them.
276pub(crate) fn to_json_with_notes(doc: &DoclingDocument) -> Value {
277    build_json(doc, true)
278}
279
280fn build_parts(doc: &DoclingDocument, note_keys: bool) -> Parts {
281    let mut b = Builder {
282        note_keys,
283        ..Builder::default()
284    };
285    // A backend that built docling's item tree ([`crate::tree`]) has already
286    // decided every parent, child and creation index; serialize that. The
287    // flat nodes are for the other serializers.
288    let body = match &doc.tree {
289        Some(tree) => {
290            // The page map still comes from the flat stream's markers (a
291            // PPTX slide's EMU size): the tree holds items, not pages.
292            for n in &doc.nodes {
293                if let Node::PageInfo {
294                    page_no,
295                    width,
296                    height,
297                } = n
298                {
299                    if *page_no > 0 {
300                        b.pages.push((*page_no, *width as f64, *height as f64));
301                    }
302                }
303            }
304            // docling-core's `validate_misplaced_list_items` runs on every
305            // document it serializes; apply it the same way (keeping the
306            // items' children, which docling-core drops — #586).
307            let mut tree = tree.clone();
308            tree.wrap_misplaced_list_items();
309            b.write_tree(&tree).into_iter().map(Child::json).collect()
310        }
311        None => b.walk_into(&doc.nodes, "#/body"),
312    };
313    b.link_comments();
314
315    // docling-core's `validate_document` — a pydantic `model_validator` every
316    // `DoclingDocument` passes through when a `ConversionResult` (or a
317    // serializer) is built around it — clamps every provenance box, and a
318    // table's cell boxes, into its page's bounds in place
319    // (`_clamp_provenance_bboxes_to_pages`). The JSON docling writes therefore
320    // carries the clamped boxes: a spreadsheet region whose page was sized
321    // `right − left` × `bottom − top` loses its offset, a slide shape hanging
322    // off the slide edge is cut at it. Reproduce that on the finished items.
323    clamp_boxes_to_pages(&mut b);
324
325    let pages = b
326        .pages
327        .iter()
328        .map(|(n, w, h)| {
329            let r2 = |v: f64| (v * 100.0).round() / 100.0;
330            // docling-core's `PageItem` field order: size, image, page_no
331            // (#520 — the image only when page images were generated).
332            let mut page = serde_json::Map::new();
333            page.insert("size".into(), json!({ "width": r2(*w), "height": r2(*h) }));
334            if let Some(img) = doc.page_images.get(n) {
335                page.insert("image".into(), image_ref_json(img));
336            }
337            page.insert("page_no".into(), json!(n));
338            (n.to_string(), Value::Object(page))
339        })
340        .collect::<serde_json::Map<String, Value>>();
341    // docling only emits `field_regions` / `field_items` when a document has
342    // form fields, and places them just before `pages`.
343    let fields = (!b.field_regions.is_empty()).then_some((b.field_regions, b.field_items));
344    Parts {
345        name: doc.name.clone(),
346        // The Pandoc writer (`note_keys`) always gets the metadata, for
347        // its `meta` (#836); the JSON export only when asked.
348        metadata: (doc.metadata_output.in_json() || note_keys)
349            .then(|| doc.metadata.to_json())
350            .filter(|m| m.as_object().is_some_and(|o| !o.is_empty())),
351        origin: json!({
352            "mimetype": "text/plain",
353            "binary_hash": fnv1a(&doc.name),
354            "filename": doc.name,
355        }),
356        furniture: json!({
357            "self_ref": "#/furniture",
358            "children": [],
359            "content_layer": "furniture",
360            "name": "_root_",
361            "label": "unspecified",
362        }),
363        body,
364        groups: b.groups,
365        texts: b.texts,
366        pictures: b.pictures,
367        tables: b.tables,
368        key_value_items: b.key_value_items,
369        fields,
370        pages,
371    }
372}
373
374fn build_json(doc: &DoclingDocument, note_keys: bool) -> Value {
375    build_parts(doc, note_keys).into_value()
376}
377
378/// The finished document, before it is a `Value`: one representation
379/// behind both [`to_json`] (a `Value`) and [`write_json`] (bytes, with no
380/// `Value` ever built — the text items stay compact [`Item`]s throughout).
381/// Both produce the same keys in the same order.
382struct Parts {
383    name: String,
384    /// The document's metadata (#679), after `origin`, only when the
385    /// document's output mode asks for it and there is any.
386    metadata: Option<Value>,
387    origin: Value,
388    furniture: Value,
389    /// The body's `children`; the rest of the body object is fixed.
390    body: Vec<Child>,
391    groups: Vec<Value>,
392    texts: Vec<Item>,
393    pictures: Vec<Value>,
394    tables: Vec<Value>,
395    key_value_items: Vec<Value>,
396    /// `field_regions` and `field_items`, present only for a document with
397    /// form fields (non-form documents' JSON has neither key).
398    fields: Option<(Vec<Value>, Vec<Value>)>,
399    pages: serde_json::Map<String, Value>,
400}
401
402impl Parts {
403    fn into_value(self) -> Value {
404        // Moved in, never through `json!`, which would deep-copy each array.
405        let mut out = serde_json::Map::with_capacity(15);
406        out.insert("schema_name".into(), "DoclingDocument".into());
407        out.insert("version".into(), SCHEMA_VERSION.into());
408        out.insert("name".into(), Value::String(self.name));
409        out.insert("origin".into(), self.origin);
410        if let Some(m) = self.metadata {
411            out.insert("metadata".into(), m);
412        }
413        out.insert("furniture".into(), self.furniture);
414        out.insert(
415            "body".into(),
416            object([
417                ("self_ref", "#/body".into()),
418                (
419                    "children",
420                    Value::Array(self.body.into_iter().map(Child::into_value).collect()),
421                ),
422                ("content_layer", "body".into()),
423                ("name", "_root_".into()),
424                ("label", "unspecified".into()),
425            ]),
426        );
427        out.insert("groups".into(), Value::Array(self.groups));
428        out.insert(
429            "texts".into(),
430            Value::Array(
431                self.texts
432                    .into_iter()
433                    .enumerate()
434                    .map(|(i, item)| item.into_value(i))
435                    .collect(),
436            ),
437        );
438        out.insert("pictures".into(), Value::Array(self.pictures));
439        out.insert("tables".into(), Value::Array(self.tables));
440        out.insert("key_value_items".into(), Value::Array(self.key_value_items));
441        out.insert("form_items".into(), Value::Array(Vec::new()));
442        if let Some((regions, items)) = self.fields {
443            out.insert("field_regions".into(), Value::Array(regions));
444            out.insert("field_items".into(), Value::Array(items));
445        }
446        out.insert("pages".into(), Value::Object(self.pages));
447        Value::Object(out)
448    }
449}
450
451impl serde::Serialize for Parts {
452    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
453        use serde::ser::SerializeMap;
454        let mut m = s.serialize_map(Some(
455            13 + 2 * usize::from(self.fields.is_some()) + usize::from(self.metadata.is_some()),
456        ))?;
457        m.serialize_entry("schema_name", "DoclingDocument")?;
458        m.serialize_entry("version", SCHEMA_VERSION)?;
459        m.serialize_entry("name", &self.name)?;
460        m.serialize_entry("origin", &self.origin)?;
461        if let Some(meta) = &self.metadata {
462            m.serialize_entry("metadata", meta)?;
463        }
464        m.serialize_entry("furniture", &self.furniture)?;
465        m.serialize_entry("body", &Body(&self.body))?;
466        m.serialize_entry("groups", &self.groups)?;
467        m.serialize_entry("texts", &Texts(&self.texts))?;
468        m.serialize_entry("pictures", &self.pictures)?;
469        m.serialize_entry("tables", &self.tables)?;
470        m.serialize_entry("key_value_items", &self.key_value_items)?;
471        m.serialize_entry("form_items", &[(); 0])?;
472        if let Some((regions, items)) = &self.fields {
473            m.serialize_entry("field_regions", regions)?;
474            m.serialize_entry("field_items", items)?;
475        }
476        m.serialize_entry("pages", &self.pages)?;
477        m.end()
478    }
479}
480
481/// The body object: fixed keys around its `children`.
482struct Body<'a>(&'a [Child]);
483
484impl serde::Serialize for Body<'_> {
485    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
486        use serde::ser::SerializeMap;
487        let mut m = s.serialize_map(Some(5))?;
488        m.serialize_entry("self_ref", "#/body")?;
489        m.serialize_entry("children", self.0)?;
490        m.serialize_entry("content_layer", "body")?;
491        m.serialize_entry("name", "_root_")?;
492        m.serialize_entry("label", "unspecified")?;
493        m.end()
494    }
495}
496
497/// Write `doc`'s docling JSON to `w` — byte for byte what serializing
498/// [`to_json`]'s `Value` writes (compact, or `pretty` as
499/// `serde_json::to_writer_pretty` indents it), without building that
500/// `Value`. On a long PDF the `Value` is most of the export's memory: ~2.7 KB
501/// per text item (an owned-key map per JSON object) against a few hundred
502/// bytes here.
503pub fn write_json<W: std::io::Write>(
504    doc: &DoclingDocument,
505    w: W,
506    pretty: bool,
507) -> serde_json::Result<()> {
508    use serde::Serialize;
509    let parts = build_parts(doc, false);
510    if pretty {
511        parts.serialize(&mut serde_json::Serializer::pretty(w))
512    } else {
513        parts.serialize(&mut serde_json::Serializer::new(w))
514    }
515}
516
517/// A DocumentPictureClassifier's predictions as the picture's `meta` — they
518/// land twice, exactly like docling 2.x writes them: the newer
519/// `meta.classification` field (pydantic field order: confidence, created_by,
520/// class_name) and the deprecated-but-still-emitted `classification`
521/// annotation, carried here under an `annotations` key that `add_picture`
522/// lifts onto the item.
523fn classification_meta(classes: &[crate::PictureClass]) -> Value {
524    json!({
525        "classification": {
526            "predictions": classes.iter().map(|c| json!({
527                "confidence": c.confidence as f64,
528                "created_by": "DocumentPictureClassifier",
529                "class_name": c.class_name,
530            })).collect::<Vec<_>>(),
531        },
532        "annotations": [{
533            "kind": "classification",
534            "provenance": "DocumentPictureClassifier",
535            "predicted_classes": classes.iter().map(|c| json!({
536                "class_name": c.class_name,
537                "confidence": c.confidence as f64,
538            })).collect::<Vec<_>>(),
539        }],
540    })
541}
542
543/// The picture-OCR enrichment's text (#645) as docling writes a picture
544/// description: `meta.description` — a `DescriptionMetaField`, pydantic
545/// field order `created_by` (the model, here the OCR engine) then `text` —
546/// plus the deprecated-but-still-emitted `PictureDescriptionData`
547/// annotation (`kind`, `text`, `provenance`), both exactly what
548/// `PictureDescriptionBaseModel` attaches. Carried like
549/// [`classification_meta`]'s: `annotations` is lifted onto the item.
550fn description_meta(desc: &crate::PictureDescription) -> Value {
551    json!({
552        "description": {
553            "created_by": desc.provenance,
554            "text": desc.text,
555        },
556        "annotations": [{
557            "kind": "description",
558            "text": desc.text,
559            "provenance": desc.provenance,
560        }],
561    })
562}
563
564/// A flat picture node's `meta`: the classifier's predictions and/or the
565/// OCR description, merged — `description` precedes `classification` in
566/// `PictureMeta`'s field order (it is inherited from the floating-item
567/// base), the annotations run in the order docling's enrichment pipeline
568/// appends them (classification, then description).
569fn picture_meta(
570    classes: Option<&[crate::PictureClass]>,
571    desc: Option<&crate::PictureDescription>,
572) -> Option<Value> {
573    let mut meta = Map::new();
574    let mut annotations = Vec::new();
575    if let Some(desc) = desc {
576        let mut m = description_meta(desc);
577        annotations.push(m["annotations"][0].take());
578        meta.insert("description".into(), m["description"].take());
579    }
580    if let Some(classes) = classes {
581        let mut m = classification_meta(classes);
582        annotations.insert(0, m["annotations"][0].take());
583        meta.insert("classification".into(), m["classification"].take());
584    }
585    if meta.is_empty() {
586        return None;
587    }
588    meta.insert("annotations".into(), Value::Array(annotations));
589    Some(Value::Object(meta))
590}
591
592/// docling's `TableData` for a table: `table_cells`, `num_rows`/`num_cols`
593/// and the `grid` that repeats each cell at every position it covers. Shared
594/// by table items and a chart picture's `meta.tabular_chart.chart_data`.
595/// One table cell as docling's `TableCell` JSON: the eleven fields in
596/// pydantic order, `bbox` appended when the cell carries one. Built straight
597/// into a `Map` sized for the entries — the table grid repeats every cell
598/// object once per spanned slot, so on a table-heavy document (a patent's
599/// claims tables, an EBCDIC dump) this constructor and its clones *are* the
600/// JSON export's cost; the `json!` macro built the same object through a
601/// growing map with a rehash per doubling.
602#[allow(clippy::too_many_arguments)]
603fn cell_value(
604    row_span: usize,
605    col_span: usize,
606    start_row: usize,
607    end_row: usize,
608    start_col: usize,
609    end_col: usize,
610    text: String,
611    column_header: bool,
612    row_header: bool,
613    row_section: bool,
614    bbox: Option<[f32; 4]>,
615) -> Value {
616    let mut m = serde_json::Map::with_capacity(12);
617    m.insert("row_span".into(), row_span.into());
618    m.insert("col_span".into(), col_span.into());
619    m.insert("start_row_offset_idx".into(), start_row.into());
620    m.insert("end_row_offset_idx".into(), end_row.into());
621    m.insert("start_col_offset_idx".into(), start_col.into());
622    m.insert("end_col_offset_idx".into(), end_col.into());
623    m.insert("text".into(), Value::String(text));
624    m.insert("column_header".into(), column_header.into());
625    m.insert("row_header".into(), row_header.into());
626    m.insert("row_section".into(), row_section.into());
627    m.insert("fillable".into(), false.into());
628    if let Some(b) = bbox {
629        m.insert(
630            "bbox".into(),
631            json!({
632                "l": b[0], "t": b[1], "r": b[2], "b": b[3],
633                "coord_origin": "TOPLEFT",
634            }),
635        );
636    }
637    Value::Object(m)
638}
639
640/// `TableData` from a table whose cell text is Markdown-flavoured (the flat
641/// nodes: escapes to undo, GFM hard-break markers to strip).
642fn table_data(t: &Table) -> Value {
643    table_data_with(t, false)
644}
645
646/// `TableData`; with `raw` the cell text is docling's own raw cell text (a
647/// backend-built tree) and is written verbatim.
648fn table_data_with(t: &Table, raw: bool) -> Value {
649    let cell_text = |s: &str| {
650        if raw {
651            s.to_string()
652        } else {
653            unescape_text(&crate::markdown::strip_hard_breaks(s))
654        }
655    };
656    let mut num_rows = t.rows.len();
657    let mut num_cols = t.rows.iter().map(Vec::len).max().unwrap_or(0);
658    // Rectangular `rows` are the grid, and cells reaching past it are
659    // clipped — docling's own `TableData.grid` on a USPTO table whose
660    // replicated cells outrun `num_cols`, or an HTML rowspan past the last
661    // row. Ragged rows are no grid at all: a backend that compacts them
662    // (xlsx `skip_empty_cells`, #271) keeps the true offsets in first-class
663    // cells, so there the grid spans every cell's extent and the omitted
664    // positions come back as docling's empty filler cells.
665    let ragged = t.rows.iter().any(|r| r.len() != num_cols);
666    if let Some(first_class) = t.cells.as_ref().filter(|c| ragged && !c.is_empty()) {
667        for c in first_class {
668            num_rows = num_rows.max(c.start_row + c.row_span);
669            num_cols = num_cols.max(c.start_col + c.col_span);
670        }
671    }
672    let mut grid = Vec::with_capacity(num_rows);
673    let mut cells = Vec::new();
674    // Grid slot → index into `cells` (the anchor cell covering it). A flat
675    // row-major table instead of a `HashMap<(r, c), Value>` of clones: the
676    // grid is filled from it with one clone per slot, and nothing is hashed.
677    let mut slot: Vec<Option<usize>> = vec![None; num_rows * num_cols];
678    if let Some(first_class) = t.cells.as_ref().filter(|c| !c.is_empty()) {
679        for c in first_class {
680            let idx = cells.len();
681            cells.push(cell_value(
682                c.row_span,
683                c.col_span,
684                c.start_row,
685                c.start_row + c.row_span,
686                c.start_col,
687                c.start_col + c.col_span,
688                cell_text(&c.text),
689                c.column_header,
690                c.row_header,
691                c.row_section,
692                c.bbox,
693            ));
694            for r in c.start_row..(c.start_row + c.row_span).min(num_rows) {
695                for k in c.start_col..(c.start_col + c.col_span).min(num_cols) {
696                    slot[r * num_cols + k] = Some(idx);
697                }
698            }
699        }
700        for r in 0..num_rows {
701            let mut grid_row = Vec::with_capacity(num_cols);
702            for c in 0..num_cols {
703                grid_row.push(match slot[r * num_cols + c] {
704                    Some(i) => cells[i].clone(),
705                    None => cell_value(
706                        1,
707                        1,
708                        r,
709                        r + 1,
710                        c,
711                        c + 1,
712                        String::new(),
713                        false,
714                        false,
715                        false,
716                        None,
717                    ),
718                });
719            }
720            grid.push(grid_row);
721        }
722    } else {
723        let s = t.structure.as_ref();
724        let flag = |grid: Option<&Vec<Vec<bool>>>, r: usize, c: usize| -> bool {
725            grid.and_then(|g| g.get(r))
726                .and_then(|row| row.get(c))
727                .copied()
728                .unwrap_or(false)
729        };
730        let anchor_of = |r: usize, c: usize| -> (usize, usize) {
731            let (mut r0, mut c0) = (r, c);
732            while c0 > 0 && flag(s.map(|s| &s.col_continuation), r, c0) {
733                c0 -= 1;
734            }
735            while r0 > 0 && flag(s.map(|s| &s.row_continuation), r0, c0) {
736                r0 -= 1;
737            }
738            (r0, c0)
739        };
740        // Each slot's anchor, computed once; the anchor's extent is the
741        // farthest slot that resolves to it.
742        let anchors: Vec<(usize, usize)> = (0..num_rows)
743            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
744            .map(|(r, c)| anchor_of(r, c))
745            .collect();
746        let mut extent: Vec<(usize, usize)> = (0..num_rows)
747            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
748            .collect();
749        for (i, &(ar, ac)) in anchors.iter().enumerate() {
750            let (r, c) = (i / num_cols.max(1), i % num_cols.max(1));
751            let e = &mut extent[ar * num_cols + ac];
752            e.0 = e.0.max(r);
753            e.1 = e.1.max(c);
754        }
755        for (r, row) in t.rows.iter().enumerate() {
756            let mut grid_row = Vec::with_capacity(num_cols);
757            for c in 0..num_cols {
758                let (ar, ac) = anchors[r * num_cols + c];
759                if (ar, ac) == (r, c) {
760                    let (er, ec) = extent[r * num_cols + c];
761                    let text = row.get(c).map(|s| cell_text(s)).unwrap_or_default();
762                    let column_header = match s.filter(|s| !s.col_header.is_empty()) {
763                        Some(s) => flag(Some(&s.col_header), r, c),
764                        None => r == 0,
765                    };
766                    slot[r * num_cols + c] = Some(cells.len());
767                    cells.push(cell_value(
768                        er - r + 1,
769                        ec - c + 1,
770                        r,
771                        er + 1,
772                        c,
773                        ec + 1,
774                        text,
775                        column_header,
776                        flag(s.map(|s| &s.row_header), r, c),
777                        false,
778                        None,
779                    ));
780                }
781                grid_row.push(match slot[ar * num_cols + ac] {
782                    Some(i) => cells[i].clone(),
783                    None => Value::Null,
784                });
785            }
786            grid.push(grid_row);
787        }
788    }
789    json!({
790        "table_cells": cells,
791        "num_rows": num_rows,
792        "num_cols": num_cols,
793        "orientation": "rot_0",
794        "grid": grid,
795    })
796}
797
798/// An item's `prov` array as the builder holds it: empty, or docling's one
799/// `ProvenanceItem` — kept typed so a text item need not be a `Value` until
800/// it is written (see [`Item`]).
801#[derive(Clone, Copy)]
802enum ProvSlot {
803    None,
804    One {
805        page_no: usize,
806        /// `l t r b`, already rounded to 2 decimals.
807        bbox: [f64; 4],
808        bottom_left: bool,
809        charspan: [usize; 2],
810    },
811}
812
813impl ProvSlot {
814    fn origin(bottom_left: bool) -> &'static str {
815        if bottom_left {
816            "BOTTOMLEFT"
817        } else {
818            "TOPLEFT"
819        }
820    }
821
822    /// The `prov` array: `page_no`, `bbox` (`l t r b` + `coord_origin`) and
823    /// `charspan`, in pydantic field order.
824    fn to_value(self) -> Value {
825        let ProvSlot::One {
826            page_no,
827            bbox: [l, t, r, b],
828            bottom_left,
829            charspan,
830        } = self
831        else {
832            return Value::Array(Vec::new());
833        };
834        let bbox = object([
835            ("l", l.into()),
836            ("t", t.into()),
837            ("r", r.into()),
838            ("b", b.into()),
839            ("coord_origin", Self::origin(bottom_left).into()),
840        ]);
841        let span = Value::Array(vec![charspan[0].into(), charspan[1].into()]);
842        Value::Array(vec![object([
843            ("page_no", page_no.into()),
844            ("bbox", bbox),
845            ("charspan", span),
846        ])])
847    }
848
849    /// docling's page clamp ([`clamp_boxes_to_pages`]) on the typed box.
850    fn clamp(&mut self, sizes: &PageSizes) {
851        if let ProvSlot::One { page_no, bbox, .. } = self {
852            if let Some(&(w, h)) = sizes.get(page_no) {
853                for (v, hi) in bbox.iter_mut().zip([w, h, w, h]) {
854                    // A non-finite coordinate is `null` in the JSON, which the
855                    // clamp leaves alone.
856                    if v.is_finite() {
857                        *v = round2(v.clamp(0.0, hi.max(0.0)));
858                    }
859                }
860            }
861        }
862    }
863}
864
865impl serde::Serialize for ProvSlot {
866    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
867        use serde::ser::{SerializeMap, SerializeSeq};
868        let ProvSlot::One {
869            page_no,
870            bbox: [l, t, r, b],
871            bottom_left,
872            charspan,
873        } = *self
874        else {
875            return s.serialize_seq(Some(0))?.end();
876        };
877        struct Bbox([f64; 4], &'static str);
878        impl serde::Serialize for Bbox {
879            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
880                let mut m = s.serialize_map(Some(5))?;
881                for (k, v) in ["l", "t", "r", "b"].iter().zip(self.0) {
882                    m.serialize_entry(k, &v)?;
883                }
884                m.serialize_entry("coord_origin", self.1)?;
885                m.end()
886            }
887        }
888        struct Entry(usize, Bbox, [usize; 2]);
889        impl serde::Serialize for Entry {
890            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
891                let mut m = s.serialize_map(Some(3))?;
892                m.serialize_entry("page_no", &self.0)?;
893                m.serialize_entry("bbox", &self.1)?;
894                m.serialize_entry("charspan", &self.2)?;
895                m.end()
896            }
897        }
898        let mut seq = s.serialize_seq(Some(1))?;
899        seq.serialize_element(&Entry(
900            page_no,
901            Bbox([l, t, r, b], Self::origin(bottom_left)),
902            charspan,
903        ))?;
904        seq.end()
905    }
906}
907
908/// The plain text item — docling's `TextItem` with no children, on the body
909/// layer, `orig` equal to `text` and no extra fields: what a PDF's text layer
910/// is made of, millions of times over on a long document. As a `Value` each
911/// cost ~2.7 KB (a map of owned keys per object); typed it is ~150 bytes, and
912/// it serializes to exactly the same JSON. Its `self_ref` is its position
913/// (`#/texts/{i}`), so it is not stored.
914struct TextItem {
915    parent: Box<str>,
916    label: Box<str>,
917    prov: ProvSlot,
918    text: Box<str>,
919}
920
921impl TextItem {
922    fn into_value(self, index: usize) -> Value {
923        let text = String::from(self.text);
924        object([
925            ("self_ref", Value::String(format!("#/texts/{index}"))),
926            ("parent", ref_value(self.parent)),
927            ("children", Value::Array(Vec::new())),
928            ("content_layer", "body".into()),
929            ("label", Value::String(self.label.into())),
930            ("prov", self.prov.to_value()),
931            ("orig", Value::String(text.clone())),
932            ("text", Value::String(text)),
933        ])
934    }
935}
936
937/// A `texts` entry: the compact [`TextItem`] until something needs to edit
938/// it as JSON (a nested list's children, a content-layer move, a comment
939/// back-ref), then the `Value` it stands for. Both boxed: a slot is 16 bytes.
940enum Item {
941    Text(Box<TextItem>),
942    Json(Box<Value>),
943}
944
945impl Item {
946    fn json(v: Value) -> Self {
947        Item::Json(Box::new(v))
948    }
949
950    /// The item at `texts[index]` as an editable `Value`, converting a
951    /// [`TextItem`] in place.
952    fn value_mut(&mut self, index: usize) -> &mut Value {
953        if let Item::Text(t) = self {
954            let t = std::mem::replace(
955                t,
956                Box::new(TextItem {
957                    parent: "".into(),
958                    label: "".into(),
959                    prov: ProvSlot::None,
960                    text: "".into(),
961                }),
962            );
963            *self = Item::json(t.into_value(index));
964        }
965        match self {
966            Item::Json(v) => v,
967            Item::Text(_) => unreachable!("converted above"),
968        }
969    }
970
971    fn into_value(self, index: usize) -> Value {
972        match self {
973            Item::Text(t) => t.into_value(index),
974            Item::Json(v) => *v,
975        }
976    }
977}
978
979/// The `texts` array, serialized in place.
980struct Texts<'a>(&'a [Item]);
981
982impl serde::Serialize for Texts<'_> {
983    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
984        use serde::ser::{SerializeMap, SerializeSeq};
985        struct Text<'a>(usize, &'a TextItem);
986        impl serde::Serialize for Text<'_> {
987            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
988                let Text(i, t) = *self;
989                let mut m = s.serialize_map(Some(8))?;
990                m.serialize_entry("self_ref", &format_args!("#/texts/{i}"))?;
991                m.serialize_entry("parent", &Child::Ref(t.parent.clone()))?;
992                m.serialize_entry("children", &[(); 0])?;
993                m.serialize_entry("content_layer", "body")?;
994                m.serialize_entry("label", &t.label)?;
995                m.serialize_entry("prov", &t.prov)?;
996                m.serialize_entry("orig", &t.text)?;
997                m.serialize_entry("text", &t.text)?;
998                m.end()
999            }
1000        }
1001        let mut seq = s.serialize_seq(Some(self.0.len()))?;
1002        for (i, item) in self.0.iter().enumerate() {
1003            match item {
1004                Item::Text(t) => seq.serialize_element(&Text(i, t))?,
1005                Item::Json(v) => seq.serialize_element(v)?,
1006            }
1007        }
1008        seq.end()
1009    }
1010}
1011
1012/// An entry of a `children` list as the walk collects it: a `{"$ref": …}`
1013/// held as its target string (a body of millions of refs costs ~40 bytes
1014/// each, not a ~270-byte `Value` map), or any other JSON.
1015enum Child {
1016    Ref(Box<str>),
1017    Json(Box<Value>),
1018}
1019
1020impl Child {
1021    fn json(v: Value) -> Self {
1022        Child::Json(Box::new(v))
1023    }
1024
1025    fn into_value(self) -> Value {
1026        match self {
1027            Child::Ref(r) => ref_value(r),
1028            Child::Json(v) => *v,
1029        }
1030    }
1031}
1032
1033impl serde::Serialize for Child {
1034    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1035        use serde::ser::SerializeMap;
1036        match self {
1037            Child::Ref(r) => {
1038                let mut m = s.serialize_map(Some(1))?;
1039                m.serialize_entry("$ref", r)?;
1040                m.end()
1041            }
1042            Child::Json(v) => v.serialize(s),
1043        }
1044    }
1045}
1046
1047fn round2(v: f64) -> f64 {
1048    (v * 100.0).round() / 100.0
1049}
1050
1051#[derive(Default)]
1052struct Builder {
1053    /// Write the internal `_notes` / `_note_body` keys of tree text items
1054    /// ([`crate::tree::TreeItem::notes`]) — for the Pandoc writer only
1055    /// ([`to_json_with_notes`]); docling's JSON has no such fields.
1056    note_keys: bool,
1057    texts: Vec<Item>,
1058    groups: Vec<Value>,
1059    tables: Vec<Value>,
1060    pictures: Vec<Value>,
1061    field_regions: Vec<Value>,
1062    field_items: Vec<Value>,
1063    key_value_items: Vec<Value>,
1064    /// Pages seen so far (`page_no`, width, height in points) — from the
1065    /// [`Node::PageInfo`] markers the PDF paths emit; empty for every other
1066    /// backend, which keeps their JSON byte-identical (`"pages": {}`, no prov).
1067    pages: Vec<(usize, f64, f64)>,
1068    /// The page the walk is currently on (0 before the first marker).
1069    cur_page: usize,
1070    cur_w: f64,
1071    cur_h: f64,
1072    /// The enclosing [`Node::Located`] wrapper's 0–511 grid box, waiting to be
1073    /// consumed as the next item's provenance.
1074    pending_loc: Option<[u16; 4]>,
1075    /// The enclosing [`Node::Prov`] wrapper's (or the tree item's) exact
1076    /// provenance, which takes precedence over the grid box.
1077    pending_exact: Option<ExactProv>,
1078    /// The enclosing [`Node::Track`]'s timing, for the text item it wraps
1079    /// (#614).
1080    pending_track: Option<(crate::tree::TreeTrack, String)>,
1081    /// [`Node::Anchor`]s met since the last text item (#852): that item's
1082    /// `_anchors` on the Pandoc path.
1083    pending_anchors: Vec<String>,
1084    /// `$ref`s an item wants placed in its parent's `children` *before* its
1085    /// own — a chart's caption item, which docling's office backends add to
1086    /// the container ahead of the picture that references it.
1087    pending_siblings: Vec<Value>,
1088    /// `$ref`s an item wants placed in its parent's `children` right *after*
1089    /// its own — an HTML `<figure>`-wrapped table's caption
1090    /// ([`CaptionParent::ContainerAfter`]).
1091    pending_after: Vec<Value>,
1092    /// Caption `$ref`s that hang off `#/body` while their item sits deeper
1093    /// ([`CaptionParent::Body`]): docling appends them to the body's children
1094    /// as they are created, so they follow the top-level item being walked.
1095    pending_body: Vec<Value>,
1096    /// What each [`Node::CommentSection`] is referenced by, in document order —
1097    /// its group `$ref`, or its note text's when the section says so. The index
1098    /// is what a [`Node::Commented`] annotation carries.
1099    comment_groups: Vec<String>,
1100    /// The last `comment_section` group emitted, as `(name, self_ref)`: a
1101    /// following section of the same name adds its note to that group rather
1102    /// than opening another — one `comment-{sheet}-{cell}` group holds every
1103    /// message of a threaded comment (docling#4353).
1104    last_comment_section: Option<(String, String)>,
1105    /// Annotated items awaiting their refs: comments are usually emitted
1106    /// *after* the body they annotate (docx appends them), so the link is
1107    /// patched in once the whole document has been walked.
1108    pending_comments: Vec<(String, Vec<usize>)>,
1109}
1110
1111impl Builder {
1112    /// Consume the pending location (if any) into a docling `prov` array: the
1113    /// 0-511 grid denormalized against the current page into BOTTOMLEFT
1114    /// points, rounded to 2 decimals like docling's own export. `char_len` is
1115    /// the item's text length in characters (0 for tables and pictures, whose
1116    /// charspan docling emits as `[0, 0]`).
1117    fn take_prov(&mut self, char_len: usize) -> Value {
1118        self.take_prov_slot(char_len).to_value()
1119    }
1120
1121    /// [`Self::take_prov`], typed.
1122    fn take_prov_slot(&mut self, char_len: usize) -> ProvSlot {
1123        let prov = self.prov_slot(char_len, false);
1124        self.pending_exact = None;
1125        self.pending_loc = None;
1126        prov
1127    }
1128
1129    /// The pending provenance without consuming it. An exact [`Node::Prov`]
1130    /// box wins over the grid; its own `charspan` is used unless
1131    /// `span_over_text` asks for `[0, char_len]` (a chart caption's span
1132    /// covers the caption text where the chart's is `[0, 0]`).
1133    fn prov_slot(&self, char_len: usize, span_over_text: bool) -> ProvSlot {
1134        let r2 = round2;
1135        if let Some(ExactProv {
1136            page_no,
1137            bbox: [l, t, r, b],
1138            bottom_left,
1139            charspan,
1140        }) = self.pending_exact
1141        {
1142            let charspan = if span_over_text {
1143                [0, char_len]
1144            } else {
1145                charspan
1146            };
1147            return ProvSlot::One {
1148                page_no,
1149                bbox: [r2(l), r2(t), r2(r), r2(b)],
1150                bottom_left,
1151                charspan,
1152            };
1153        }
1154        let Some([x0, y0, x1, y1]) = self.pending_loc else {
1155            return ProvSlot::None;
1156        };
1157        // An all-zero grid box is the sentinel for "this item has no geometry"
1158        // (a slide's speaker notes, say). docling writes a zero bbox for it,
1159        // not a box spanning the page, which is what denormalizing would give.
1160        if [x0, y0, x1, y1] == [0, 0, 0, 0] {
1161            return ProvSlot::One {
1162                page_no: self.cur_page,
1163                bbox: [0.0; 4],
1164                bottom_left: true,
1165                charspan: [0, char_len],
1166            };
1167        }
1168        ProvSlot::One {
1169            page_no: self.cur_page,
1170            bbox: [
1171                r2(x0 as f64 * self.cur_w / 512.0),
1172                r2(self.cur_h - y0 as f64 * self.cur_h / 512.0),
1173                r2(x1 as f64 * self.cur_w / 512.0),
1174                r2(self.cur_h - y1 as f64 * self.cur_h / 512.0),
1175            ],
1176            bottom_left: true,
1177            charspan: [0, char_len],
1178        }
1179    }
1180
1181    /// Adopt a node's own location field (tables, formulas, list items carry
1182    /// one instead of a [`Node::Located`] wrapper) when no wrapper is pending.
1183    fn adopt_loc(&mut self, loc: Option<[u16; 4]>) {
1184        if self.pending_loc.is_none() && self.cur_page > 0 {
1185            self.pending_loc = loc;
1186        }
1187    }
1188
1189    /// Resolve the [`Node::Commented`] annotations collected during the walk
1190    /// into docling's `comments: [{"$ref": "#/groups/N"}]` key on the annotated
1191    /// item. It has to run after the whole walk: docx appends its comment
1192    /// bodies, so the groups they live in are usually allocated *later* than
1193    /// the paragraphs pointing at them. docling emits the key between `prov`
1194    /// and `orig`, so insert it in place rather than appending (serde_json runs
1195    /// with `preserve_order`, i.e. key order is output order).
1196    fn link_comments(&mut self) {
1197        let refs: Vec<(String, Vec<Value>)> = std::mem::take(&mut self.pending_comments)
1198            .into_iter()
1199            .map(|(item, comments)| {
1200                let refs = comments
1201                    .iter()
1202                    .filter_map(|i| self.comment_groups.get(*i))
1203                    .map(ref_value)
1204                    .collect();
1205                (item, refs)
1206            })
1207            .collect();
1208        for (item, comment_refs) in refs {
1209            if comment_refs.is_empty() {
1210                continue;
1211            }
1212            let Some(target) = self.item_mut(&item) else {
1213                continue;
1214            };
1215            let Some(obj) = target.as_object_mut() else {
1216                continue;
1217            };
1218            let tail: Vec<(String, Value)> = obj
1219                .iter()
1220                .skip_while(|(k, _)| k.as_str() != "prov")
1221                .skip(1)
1222                .map(|(k, v)| (k.clone(), v.clone()))
1223                .collect();
1224            for (k, _) in &tail {
1225                obj.shift_remove(k);
1226            }
1227            obj.insert("comments".into(), Value::Array(comment_refs));
1228            for (k, v) in tail {
1229                obj.insert(k, v);
1230            }
1231        }
1232    }
1233
1234    /// The stored JSON object a `#/texts/N`-style self-ref points at.
1235    fn item_mut(&mut self, self_ref: &str) -> Option<&mut Value> {
1236        let idx = ref_index(self_ref)?;
1237        if self_ref.starts_with("#/texts/") {
1238            return self.texts.get_mut(idx).map(|item| item.value_mut(idx));
1239        }
1240        let bucket = if self_ref.starts_with("#/tables/") {
1241            &mut self.tables
1242        } else if self_ref.starts_with("#/pictures/") {
1243            &mut self.pictures
1244        } else if self_ref.starts_with("#/groups/") {
1245            &mut self.groups
1246        } else {
1247            return None;
1248        };
1249        bucket.get_mut(idx)
1250    }
1251
1252    /// Serialize a backend-built [`ItemTree`](crate::tree::ItemTree): every
1253    /// item in creation order into its bucket, with the parent / children /
1254    /// layer the tree recorded. Returns the body's `children` refs.
1255    fn write_tree(&mut self, tree: &crate::tree::ItemTree) -> Vec<Value> {
1256        use crate::tree::TreeKind;
1257        // Every item's `self_ref` first: children may be listed before they
1258        // are written (a rich cell's group is created after its content).
1259        let mut refs: Vec<String> = Vec::with_capacity(tree.items.len());
1260        let (mut nt, mut ng, mut ntb, mut np, mut nf, mut nk) = (0, 0, 0, 0, 0, 0);
1261        for item in &tree.items {
1262            if item.deleted {
1263                refs.push(String::new());
1264                continue;
1265            }
1266            let r = match &item.kind {
1267                TreeKind::Text { .. } | TreeKind::Code { .. } => {
1268                    nt += 1;
1269                    format!("#/texts/{}", nt - 1)
1270                }
1271                TreeKind::Group { .. } => {
1272                    ng += 1;
1273                    format!("#/groups/{}", ng - 1)
1274                }
1275                TreeKind::Table { .. } => {
1276                    ntb += 1;
1277                    format!("#/tables/{}", ntb - 1)
1278                }
1279                TreeKind::Picture { .. } => {
1280                    np += 1;
1281                    format!("#/pictures/{}", np - 1)
1282                }
1283                TreeKind::FieldRegion { items } => {
1284                    // A region's marker / key / value parts are text items
1285                    // too, numbered where docling creates them.
1286                    nt += items
1287                        .iter()
1288                        .map(|i| {
1289                            [&i.marker, &i.key, &i.value]
1290                                .iter()
1291                                .filter(|p| p.is_some())
1292                                .count()
1293                        })
1294                        .sum::<usize>();
1295                    nf += 1;
1296                    format!("#/field_regions/{}", nf - 1)
1297                }
1298                TreeKind::KeyValueGraph { .. } => {
1299                    nk += 1;
1300                    format!("#/key_value_items/{}", nk - 1)
1301                }
1302            };
1303            refs.push(r);
1304        }
1305        let ref_of = |id: usize| ref_value(refs[id].as_str());
1306        for (id, item) in tree.items.iter().enumerate() {
1307            if item.deleted {
1308                continue;
1309            }
1310            let parent = item.parent.map_or("#/body", |p| refs[p].as_str());
1311            // A deleted item has no ref; one still listed as a child (a stale
1312            // backend link) is left out rather than written as `"$ref": ""`,
1313            // which docling-core rejects on load (#527).
1314            let children: Vec<Value> = item
1315                .children
1316                .iter()
1317                .filter(|&&c| !tree.items[c].deleted)
1318                .map(|&c| ref_of(c))
1319                .collect();
1320            let layer = item.layer.map_or("body", |l| l.value());
1321            // The item's own provenance, consumed by the writer below
1322            // (`take_prov`); an item without one writes `prov: []`.
1323            self.pending_exact = item.prov.as_ref().map(ExactProv::from);
1324            let self_ref = match &item.kind {
1325                TreeKind::Text {
1326                    label,
1327                    text,
1328                    orig,
1329                    formatting,
1330                    hyperlink,
1331                    level,
1332                    list,
1333                } => {
1334                    // docling's field order: …, text, formatting, hyperlink,
1335                    // then the subclass fields (`level`; `enumerated`, `marker`).
1336                    let mut tail = serde_json::Map::new();
1337                    if let Some(f) = formatting {
1338                        tail.insert("formatting".into(), formatting_json(f));
1339                    }
1340                    if let Some(h) = hyperlink {
1341                        tail.insert("hyperlink".into(), json!(h));
1342                    }
1343                    if let Some(l) = level {
1344                        tail.insert("level".into(), json!(l));
1345                    }
1346                    if let Some(l) = list {
1347                        tail.insert("enumerated".into(), json!(l.enumerated));
1348                        tail.insert("marker".into(), json!(l.marker));
1349                    }
1350                    let r = format!("#/texts/{}", self.texts.len());
1351                    let prov = self.take_prov(text.chars().count());
1352                    let mut item_json = json!({
1353                        "self_ref": r,
1354                        "parent": { "$ref": parent },
1355                        "children": children,
1356                        "content_layer": layer,
1357                        "label": label,
1358                        "prov": prov,
1359                    });
1360                    // docling's `comments` back-refs sit between `prov` and
1361                    // `orig`, and are written only when set.
1362                    if !item.comments.is_empty() {
1363                        item_json["comments"] =
1364                            Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
1365                    }
1366                    // `DocItem.source`, likewise only when set: a WebVTT
1367                    // cue's `TrackSource`, whose `None` fields are omitted.
1368                    if let Some(track) = &item.source {
1369                        item_json["source"] = json!([track_json(track)]);
1370                    }
1371                    merge(
1372                        &mut item_json,
1373                        json!({
1374                            "orig": orig.as_deref().unwrap_or(text),
1375                            "text": text,
1376                        }),
1377                    );
1378                    merge(&mut item_json, Value::Object(tail));
1379                    if self.note_keys {
1380                        if !item.notes.is_empty() {
1381                            item_json["_notes"] = Value::Array(
1382                                item.notes
1383                                    .iter()
1384                                    .map(|n| json!([n.offset, n.text]))
1385                                    .collect(),
1386                            );
1387                        }
1388                        if item.note_body {
1389                            item_json["_note_body"] = json!(true);
1390                        }
1391                        if !item.anchors.is_empty() {
1392                            item_json["_anchors"] = json!(item.anchors);
1393                        }
1394                        if let Some(number) = &item.number {
1395                            item_json["_number"] = json!(number);
1396                        }
1397                        if let Some(rev) = &item.revision {
1398                            let mut r = serde_json::Map::new();
1399                            r.insert("kind".into(), json!(rev.kind.as_str()));
1400                            if let Some(a) = &rev.author {
1401                                r.insert("author".into(), json!(a));
1402                            }
1403                            if let Some(d) = &rev.date {
1404                                r.insert("date".into(), json!(d));
1405                            }
1406                            item_json["_revision"] = Value::Object(r);
1407                        }
1408                    }
1409                    self.texts.push(Item::json(item_json));
1410                    r
1411                }
1412                TreeKind::Code {
1413                    text,
1414                    orig,
1415                    language,
1416                    formatting,
1417                    hyperlink,
1418                    captions,
1419                } => {
1420                    let r = format!("#/texts/{}", self.texts.len());
1421                    let prov = self.take_prov(text.chars().count());
1422                    let mut item_json = json!({
1423                        "self_ref": r,
1424                        "parent": { "$ref": parent },
1425                        "children": children,
1426                        "content_layer": layer,
1427                        "label": "code",
1428                        "prov": prov,
1429                    });
1430                    if !item.comments.is_empty() {
1431                        item_json["comments"] =
1432                            Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
1433                    }
1434                    merge(
1435                        &mut item_json,
1436                        json!({
1437                            "orig": orig.as_deref().unwrap_or(text),
1438                            "text": text,
1439                        }),
1440                    );
1441                    if let Some(f) = formatting {
1442                        item_json["formatting"] = formatting_json(f);
1443                    }
1444                    if let Some(h) = hyperlink {
1445                        item_json["hyperlink"] = json!(h);
1446                    }
1447                    merge(
1448                        &mut item_json,
1449                        json!({
1450                            "captions": captions.iter().map(|&c| ref_of(c)).collect::<Vec<_>>(),
1451                            "references": [],
1452                            "footnotes": [],
1453                            "code_language": code_language(language.as_deref()),
1454                        }),
1455                    );
1456                    self.texts.push(Item::json(item_json));
1457                    r
1458                }
1459                TreeKind::Group { label, name } => {
1460                    self.pending_exact = None;
1461                    let r = format!("#/groups/{}", self.groups.len());
1462                    self.groups.push(json!({
1463                        "self_ref": r,
1464                        "parent": { "$ref": parent },
1465                        "children": children,
1466                        "content_layer": layer,
1467                        "name": name,
1468                        "label": label,
1469                    }));
1470                    r
1471                }
1472                TreeKind::Table {
1473                    table,
1474                    rich_cells,
1475                    captions,
1476                } => {
1477                    // docling's raw cell text: nothing to unescape or strip.
1478                    let r = self.add_table_with(table, parent, true);
1479                    let idx = ref_index(&r).expect("table ref");
1480                    let t = &mut self.tables[idx];
1481                    t["children"] = Value::Array(children);
1482                    t["content_layer"] = json!(layer);
1483                    t["captions"] = Value::Array(captions.iter().map(|&c| ref_of(c)).collect());
1484                    // A `RichTableCell` is the plain cell plus a `ref` to the
1485                    // group holding its content — on `table_cells` only; the
1486                    // derived `grid` shows plain cells.
1487                    for &(row, col, group) in rich_cells {
1488                        let cell_ref = ref_of(group);
1489                        let hit = |c: &Value| {
1490                            c["start_row_offset_idx"] == json!(row)
1491                                && c["start_col_offset_idx"] == json!(col)
1492                        };
1493                        if let Some(cells) = t["data"]["table_cells"].as_array_mut() {
1494                            for c in cells.iter_mut().filter(|c| hit(c)) {
1495                                c["ref"] = cell_ref.clone();
1496                            }
1497                        }
1498                    }
1499                    r
1500                }
1501                TreeKind::Picture {
1502                    captions,
1503                    image,
1504                    classification,
1505                    extra_classes,
1506                    description,
1507                    confidence,
1508                    chart,
1509                    dpi,
1510                } => {
1511                    // A chart's meta: the kind as the one classification
1512                    // prediction (pydantic's field order puts `confidence`
1513                    // first when present), then the reconstructed data grid
1514                    // (#405).
1515                    let mut meta = classification.as_ref().map(|c| {
1516                        let mut pred = serde_json::Map::new();
1517                        if let Some(conf) = confidence {
1518                            pred.insert("confidence".into(), json!(conf));
1519                        }
1520                        pred.insert("class_name".into(), json!(c));
1521                        let mut preds = vec![Value::Object(pred)];
1522                        preds.extend(extra_classes.iter().map(|c| json!({ "class_name": c })));
1523                        json!({ "classification": { "predictions": preds } })
1524                    });
1525                    if let (Some(m), Some(t)) = (meta.as_mut(), chart) {
1526                        if !t.rows.is_empty() {
1527                            m["tabular_chart"] = json!({ "chart_data": table_data(t) });
1528                        }
1529                    }
1530                    // The OCR description (#645) goes first in the meta —
1531                    // `PictureMeta` field order — and as the annotation the
1532                    // flat node writes (see `description_meta`).
1533                    if let Some(desc) = description {
1534                        let mut d = description_meta(desc);
1535                        let mut merged = Map::new();
1536                        merged.insert("description".into(), d["description"].take());
1537                        if let Some(Value::Object(rest)) = meta {
1538                            merged.extend(rest);
1539                        }
1540                        merged.insert("annotations".into(), d["annotations"].take());
1541                        meta = Some(Value::Object(merged));
1542                    }
1543                    let prov = self.take_prov(0);
1544                    let r = self.push_picture(
1545                        prov,
1546                        captions.iter().map(|&c| ref_of(c)).collect(),
1547                        children,
1548                        image.as_ref(),
1549                        meta,
1550                        parent,
1551                    );
1552                    if let Some(idx) = ref_index(&r) {
1553                        self.pictures[idx]["content_layer"] = json!(layer);
1554                        // The image's dpi is the file's when the backend read
1555                        // it (python-pptx does); the default 72 otherwise.
1556                        if let (Some(dpi), Some(img)) = (dpi, self.pictures[idx].get_mut("image")) {
1557                            img["dpi"] = json!(dpi);
1558                        }
1559                    }
1560                    r
1561                }
1562                TreeKind::FieldRegion { items } => {
1563                    self.pending_exact = None;
1564                    let r = self.add_field_region(items, parent);
1565                    if let Some(region) = self.field_regions.last_mut() {
1566                        region["content_layer"] = json!(layer);
1567                    }
1568                    r
1569                }
1570                TreeKind::KeyValueGraph { cells, links } => {
1571                    self.pending_exact = None;
1572                    let r = self.add_key_value_graph(cells, links, parent);
1573                    if let Some(item) = self.key_value_items.last_mut() {
1574                        item["content_layer"] = json!(layer);
1575                    }
1576                    r
1577                }
1578            };
1579            debug_assert_eq!(self_ref, refs[id], "tree item {id} numbered out of order");
1580        }
1581        tree.body
1582            .iter()
1583            .filter(|&&c| !tree.items[c].deleted)
1584            .map(|&c| ref_of(c))
1585            .collect()
1586    }
1587
1588    fn add_node(&mut self, node: &Node, parent: &str) -> Option<String> {
1589        match node {
1590            Node::Heading { level: 1, text } => {
1591                Some(self.add_text("title", text, parent, json!({})))
1592            }
1593            Node::Heading { level, text } => Some(self.add_text(
1594                "section_header",
1595                text,
1596                parent,
1597                json!({ "level": level.saturating_sub(1) }),
1598            )),
1599            Node::Caption { text, href } => {
1600                let extra = match href {
1601                    Some(url) => json!({ "hyperlink": url }),
1602                    None => json!({}),
1603                };
1604                Some(self.add_text("caption", text, parent, extra))
1605            }
1606            // A PDF footnote: docling's own label, and its link as the item's
1607            // `hyperlink` rather than Markdown baked into the text.
1608            Node::LabeledText { label, text, href } => {
1609                let extra = match href {
1610                    Some(url) => json!({ "hyperlink": url }),
1611                    None => json!({}),
1612                };
1613                Some(self.add_text(label, text, parent, extra))
1614            }
1615            Node::Paragraph { text } | Node::ListText { text, .. } => {
1616                // A whole-paragraph display equation is a formula item (docling
1617                // wraps it in `$$…$$` and, unlike a text item, never escapes it).
1618                let t = text.trim();
1619                match t.strip_prefix("$$").and_then(|s| s.strip_suffix("$$")) {
1620                    Some(inner) if !inner.is_empty() => Some(self.add_formula(inner, parent)),
1621                    _ => Some(self.add_text("text", text, parent, json!({}))),
1622                }
1623            }
1624            Node::CheckboxItem { checked, text } => {
1625                // docling's checkbox item: the `checkbox_selected` /
1626                // `checkbox_unselected` label carries the state and the text
1627                // is the bare option label (right_to_left_03's `خير`,
1628                // docx_checkboxes' `Orange juice`). The `- [x]` task-list
1629                // marker is Markdown's rendering of it, not item text (#609).
1630                let label = if *checked {
1631                    "checkbox_selected"
1632                } else {
1633                    "checkbox_unselected"
1634                };
1635                Some(self.add_text(label, text, parent, json!({})))
1636            }
1637            Node::Code {
1638                language,
1639                text,
1640                orig,
1641                ..
1642            } => Some(self.add_code(text, language.as_deref(), orig.as_deref(), parent)),
1643            // A CodeFormula-decoded display formula: `text` carries the LaTeX,
1644            // `orig` the raw glyph extraction (docling's enriched shape).
1645            Node::Formula {
1646                latex,
1647                orig,
1648                location,
1649            } => {
1650                self.adopt_loc(*location);
1651                Some(self.add_formula_item(latex, orig, parent))
1652            }
1653            // docling's notes-layer `comment_section` group holding the
1654            // comment's text item. What the annotated items point at differs
1655            // upstream: the docx backend links the group (so a comment's
1656            // replies group together), everything going through
1657            // docling-core's `add_comment` links the note text itself.
1658            Node::CommentSection {
1659                name,
1660                text,
1661                refs_note_text,
1662                grouped,
1663            } => {
1664                if !*grouped {
1665                    // docling-core's bare `add_comment`: the note text sits
1666                    // directly under the parent and is what the back-refs
1667                    // point at.
1668                    let child =
1669                        self.add_text("text", text, parent, json!({ "content_layer": "notes" }));
1670                    self.comment_groups.push(child.clone());
1671                    return Some(child);
1672                }
1673                if let Some((_, self_ref)) = self
1674                    .last_comment_section
1675                    .as_ref()
1676                    .filter(|(n, _)| n == name)
1677                    .cloned()
1678                {
1679                    // Another message of the same thread: a further note in
1680                    // the group already open for this cell.
1681                    let child =
1682                        self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
1683                    if let Some(children) =
1684                        self.groups[group_index(&self_ref)]["children"].as_array_mut()
1685                    {
1686                        children.push(ref_value(child.as_str()));
1687                    }
1688                    self.comment_groups.push(if *refs_note_text {
1689                        child
1690                    } else {
1691                        self_ref.clone()
1692                    });
1693                    return Some(self_ref);
1694                }
1695                let self_ref = format!("#/groups/{}", self.groups.len());
1696                self.groups.push(Value::Null);
1697                let child =
1698                    self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
1699                self.groups[group_index(&self_ref)] = json!({
1700                    "self_ref": self_ref,
1701                    "parent": { "$ref": parent },
1702                    "children": [{ "$ref": child }],
1703                    "content_layer": "notes",
1704                    "name": name,
1705                    "label": "comment_section",
1706                });
1707                self.last_comment_section = Some((name.clone(), self_ref.clone()));
1708                self.comment_groups.push(if *refs_note_text {
1709                    child
1710                } else {
1711                    self_ref.clone()
1712                });
1713                Some(self_ref)
1714            }
1715            // The annotation itself is a cross-reference: emit the item, then
1716            // remember it so the group refs can be filled in at the end.
1717            Node::Commented { comments, inner } => {
1718                let item = self.add_node(inner, parent)?;
1719                if !comments.is_empty() {
1720                    self.pending_comments.push((item.clone(), comments.clone()));
1721                }
1722                Some(item)
1723            }
1724            Node::Table(t) => Some(self.add_table(t, parent)),
1725            Node::Picture {
1726                caption,
1727                caption_href,
1728                image,
1729                classification,
1730                description,
1731                caption_parent,
1732                caption_location,
1733            } => Some(self.add_picture(
1734                caption.as_deref(),
1735                caption_href.as_deref(),
1736                image.as_ref(),
1737                picture_meta(classification.as_deref(), description.as_ref()),
1738                parent,
1739                (*caption_parent, *caption_location),
1740            )),
1741            // A chart is a picture item in the JSON with docling's chart
1742            // meta — `classification` (the chart kind, as the one prediction)
1743            // and `tabular_chart.chart_data`, the series reconstructed as a
1744            // `TableData` (#405) — and no image payload.
1745            Node::Chart {
1746                kind,
1747                table,
1748                caption,
1749                location,
1750            } => {
1751                self.adopt_loc(*location);
1752                let mut meta = json!({
1753                    "classification": { "predictions": [{ "class_name": kind }] },
1754                });
1755                if !table.rows.is_empty() {
1756                    meta["tabular_chart"] = json!({ "chart_data": table_data(table) });
1757                }
1758                // docling's office backends add the chart's title as a caption
1759                // item of the *container* (the sheet group, the slide), listed
1760                // before the picture that references it, with the chart's own
1761                // box and a charspan over the caption text — not as a child
1762                // of the picture, which is where a PDF caption lives.
1763                let mut captions = Vec::new();
1764                if let Some(cap) = caption.as_deref().filter(|c| !c.is_empty()) {
1765                    let prov = self.prov_slot(unescape_text(cap).chars().count(), true);
1766                    let cap_ref = self.add_text_with("caption", cap, parent, json!({}), prov);
1767                    self.pending_siblings.push(ref_value(cap_ref.as_str()));
1768                    captions.push(ref_value(cap_ref));
1769                }
1770                let prov = self.take_prov(0);
1771                Some(self.push_picture(prov, captions, Vec::new(), None, Some(meta), parent))
1772            }
1773            // A DocLang-only node is omitted from the JSON body.
1774            Node::DoclangOnly(_) => None,
1775            Node::Group {
1776                label,
1777                name,
1778                layer,
1779                children,
1780            } => Some(self.add_group(label, name.as_deref(), *layer, children, parent)),
1781            Node::FieldRegion { items } => Some(self.add_field_region(items, parent)),
1782            Node::KeyValueGraph { cells, links } => {
1783                Some(self.add_key_value_graph(cells, links, parent))
1784            }
1785            // A rich inline group is a text item over its Markdown text; the
1786            // structured runs are DocLang-only, so the JSON matches a paragraph.
1787            Node::InlineGroup { md_text, .. } => {
1788                Some(self.add_text("text", md_text, parent, json!({})))
1789            }
1790            // A plain-text backend dump is a single text item over the file body.
1791            Node::TextDump(text) => Some(self.add_text("text", text, parent, json!({}))),
1792            // Speaker notes are content a deck carries, and docling puts them
1793            // in the JSON on their own layer, so a consumer reading only JSON
1794            // can pick them (#402). Page furniture stays out of the flat
1795            // path; a backend that builds docling's item tree (HTML) puts its
1796            // furniture-layer items in the JSON through `write_tree`.
1797            Node::Furniture {
1798                layer: ContentLayer::Notes,
1799                inner,
1800            } => {
1801                let item = self.add_node(inner, parent)?;
1802                self.set_layer(&item, "notes");
1803                Some(item)
1804            }
1805            Node::Furniture { .. } => None,
1806            // PDF page headers and footers: docling writes them as body-parented
1807            // `page_header`/`page_footer` text items on the furniture layer, so a
1808            // JSON consumer can read running headers (printed page ids, dates).
1809            Node::PageFurniture {
1810                footer,
1811                location,
1812                text,
1813            } => {
1814                if self.cur_page > 0 {
1815                    self.pending_loc = Some(*location);
1816                }
1817                let label = if *footer {
1818                    "page_footer"
1819                } else {
1820                    "page_header"
1821                };
1822                let item = self.add_text(label, text, parent, json!({}));
1823                self.pending_loc = None;
1824                self.set_layer(&item, "furniture");
1825                Some(item)
1826            }
1827            // A labelled furniture paragraph (a `.doc` header / footer or
1828            // note body, #535): the item docling's office backends write.
1829            Node::FurnitureText { label, text } => {
1830                let item = self.add_text(label, text, parent, json!({}));
1831                self.set_layer(&item, "furniture");
1832                Some(item)
1833            }
1834            // A PDF picture's contained text (docling's `_add_child_elements`):
1835            // items parented to the picture just written, listed after its
1836            // caption in that picture's `children`.
1837            Node::PictureChildren(children) => {
1838                let pic = self.pictures.len().checked_sub(1)?;
1839                let pic_ref = format!("#/pictures/{pic}");
1840                let refs = self.walk_into(children, &pic_ref);
1841                if let Some(list) = self.pictures[pic]["children"].as_array_mut() {
1842                    list.extend(refs.into_iter().map(Child::into_value));
1843                }
1844                None
1845            }
1846            // A location wrapper turns into the wrapped item's `prov` entry —
1847            // but only on pages the PDF paths described with a PageInfo marker
1848            // (other geometry-bearing backends, e.g. PPTX shapes, keep their
1849            // pre-#171 provenance-less JSON until they emit markers too).
1850            Node::Located { location, inner } => {
1851                if self.cur_page > 0 {
1852                    self.pending_loc = Some(*location);
1853                }
1854                let r = self.add_node(inner, parent);
1855                self.pending_loc = None;
1856                r
1857            }
1858            Node::Anchor { id } => {
1859                self.pending_anchors.push(id.clone());
1860                None
1861            }
1862            Node::Track { track, cue, inner } => {
1863                self.pending_track = Some((track.clone(), cue.clone()));
1864                let r = self.add_node(inner, parent);
1865                self.pending_track = None;
1866                r
1867            }
1868            Node::Prov {
1869                page_no,
1870                bbox,
1871                charspan,
1872                inner,
1873                ..
1874            } => {
1875                self.pending_exact = Some(ExactProv {
1876                    page_no: *page_no,
1877                    bbox: bbox.map(f64::from),
1878                    bottom_left: false,
1879                    charspan: *charspan,
1880                });
1881                let r = self.add_node(inner, parent);
1882                self.pending_exact = None;
1883                r
1884            }
1885            // Page breaks are DocLang-only; docling omits them from the JSON body.
1886            Node::PageBreak => None,
1887            // The page marker: record the page's number and size for the
1888            // `pages` map, and denormalize every following location against it.
1889            Node::PageInfo {
1890                page_no,
1891                width,
1892                height,
1893            } => {
1894                self.cur_page = *page_no;
1895                self.cur_w = *width as f64;
1896                self.cur_h = *height as f64;
1897                if *page_no > 0 {
1898                    self.pages.push((*page_no, self.cur_w, self.cur_h));
1899                }
1900                None
1901            }
1902            // Handled by `add_list` in `walk`.
1903            Node::ListItem { .. } => None,
1904        }
1905    }
1906
1907    /// A form key-value region: `field_regions/N` holds the region, each field is
1908    /// a `field_items/M` whose children are its `marker` / `field_key` /
1909    /// `field_value` texts (absent parts are simply omitted).
1910    fn add_field_region(&mut self, items: &[crate::FieldItem], parent: &str) -> String {
1911        let self_ref = format!("#/field_regions/{}", self.field_regions.len());
1912        self.field_regions.push(Value::Null);
1913        let region_index = self.field_regions.len() - 1;
1914        let mut item_refs = Vec::new();
1915        for item in items {
1916            item_refs.push(ref_value(self.add_field_item(item, &self_ref)));
1917        }
1918        self.field_regions[region_index] = json!({
1919            "self_ref": self_ref,
1920            "parent": { "$ref": parent },
1921            "children": item_refs,
1922            "content_layer": "body",
1923            "label": "field_region",
1924            "prov": [],
1925        });
1926        self_ref
1927    }
1928
1929    /// A `KeyValueItem`: docling's `GraphData` written cell for cell and link
1930    /// for link (`key_value_items/N`), the item itself childless and without
1931    /// provenance, the way the XBRL backend creates it.
1932    fn add_key_value_graph(
1933        &mut self,
1934        cells: &[crate::GraphCell],
1935        links: &[crate::GraphLink],
1936        parent: &str,
1937    ) -> String {
1938        let self_ref = format!("#/key_value_items/{}", self.key_value_items.len());
1939        let cells: Vec<Value> = cells
1940            .iter()
1941            .map(|c| {
1942                json!({
1943                    "label": c.label,
1944                    "cell_id": c.cell_id,
1945                    "text": c.text,
1946                    "orig": c.orig,
1947                })
1948            })
1949            .collect();
1950        let links: Vec<Value> = links
1951            .iter()
1952            .map(|l| {
1953                json!({
1954                    "label": l.label,
1955                    "source_cell_id": l.source_cell_id,
1956                    "target_cell_id": l.target_cell_id,
1957                })
1958            })
1959            .collect();
1960        self.key_value_items.push(json!({
1961            "self_ref": self_ref,
1962            "parent": { "$ref": parent },
1963            "children": [],
1964            "content_layer": "body",
1965            "label": "key_value_region",
1966            "prov": [],
1967            "captions": [],
1968            "references": [],
1969            "footnotes": [],
1970            "graph": { "cells": cells, "links": links },
1971        }));
1972        self_ref
1973    }
1974
1975    fn add_field_item(&mut self, item: &crate::FieldItem, parent: &str) -> String {
1976        let self_ref = format!("#/field_items/{}", self.field_items.len());
1977        self.field_items.push(Value::Null);
1978        let item_index = self.field_items.len() - 1;
1979        let mut child_refs = Vec::new();
1980        for (label, text) in [
1981            ("marker", &item.marker),
1982            ("field_key", &item.key),
1983            ("field_value", &item.value),
1984        ] {
1985            if let Some(text) = text {
1986                // A value's `kind` (docling's `read_only` / `fillable`)
1987                // follows its text.
1988                let extra = match (label, &item.value_kind) {
1989                    ("field_value", Some(kind)) => json!({ "kind": kind }),
1990                    _ => json!({}),
1991                };
1992                child_refs.push(ref_value(self.add_text(label, text, &self_ref, extra)));
1993            }
1994        }
1995        self.field_items[item_index] = json!({
1996            "self_ref": self_ref,
1997            "parent": { "$ref": parent },
1998            "children": child_refs,
1999            "content_layer": "body",
2000            "label": "field_item",
2001            "prov": [],
2002        });
2003        self_ref
2004    }
2005
2006    /// Move an already-emitted item onto a content layer. Notes are single
2007    /// text items today; a deeper notes subtree would need its children moved
2008    /// too, and no backend builds one.
2009    fn set_layer(&mut self, self_ref: &str, layer: &str) {
2010        if let Some(item) = self.item_mut(self_ref) {
2011            item["content_layer"] = json!(layer);
2012        }
2013    }
2014
2015    fn add_text(&mut self, label: &str, text: &str, parent: &str, extra: Value) -> String {
2016        let prov = self.take_prov_slot(unescape_text(text).chars().count());
2017        self.add_text_with(label, text, parent, extra, prov)
2018    }
2019
2020    /// [`Self::add_text`] with an explicit `prov` (a chart caption shares the
2021    /// chart's box without consuming it).
2022    fn add_text_with(
2023        &mut self,
2024        label: &str,
2025        text: &str,
2026        parent: &str,
2027        extra: Value,
2028        prov: ProvSlot,
2029    ) -> String {
2030        let index = self.texts.len();
2031        let self_ref = format!("#/texts/{index}");
2032        // A [`Node::Track`] item is docling's ASR text item (#614): the
2033        // segment's words as `text`, its timing as `source` — the `[time: …]`
2034        // prefix the wrapped paragraph shows is Markdown's alone. It carries a
2035        // field the compact [`TextItem`] doesn't, so it is built as JSON.
2036        let mut item = match self.pending_track.take() {
2037            None => Item::Text(Box::new(TextItem {
2038                parent: parent.into(),
2039                label: label.into(),
2040                prov,
2041                text: unescape_text(text).into(),
2042            })),
2043            Some((track, cue)) => {
2044                let mut m = serde_json::Map::with_capacity(9);
2045                m.insert("self_ref".into(), Value::String(self_ref.clone()));
2046                m.insert("parent".into(), ref_value(parent));
2047                m.insert("children".into(), Value::Array(Vec::new()));
2048                m.insert("content_layer".into(), "body".into());
2049                m.insert("label".into(), label.into());
2050                m.insert("prov".into(), prov.to_value());
2051                // `DocItem.source` follows `prov` (docling's field order).
2052                m.insert("source".into(), Value::Array(vec![track_json(&track)]));
2053                m.insert("orig".into(), Value::String(cue.clone()));
2054                m.insert("text".into(), Value::String(cue));
2055                Item::json(Value::Object(m))
2056            }
2057        };
2058        if extra.as_object().is_some_and(|e| !e.is_empty()) {
2059            merge(item.value_mut(index), extra);
2060        }
2061        // The link destinations met before this item (#852), on the Pandoc
2062        // path only — docling's model has no field for them.
2063        let anchors = std::mem::take(&mut self.pending_anchors);
2064        if self.note_keys && !anchors.is_empty() {
2065            merge(item.value_mut(index), json!({ "_anchors": anchors }));
2066        }
2067        self.texts.push(item);
2068        self_ref
2069    }
2070
2071    /// A display-math formula item. `latex` is the raw content (no `$$`); docling
2072    /// re-wraps it and never escapes it.
2073    fn add_formula(&mut self, latex: &str, parent: &str) -> String {
2074        let self_ref = format!("#/texts/{}", self.texts.len());
2075        let prov = self.take_prov(latex.chars().count());
2076        self.texts.push(Item::json(json!({
2077            "self_ref": self_ref,
2078            "parent": { "$ref": parent },
2079            "children": [],
2080            "content_layer": "body",
2081            "label": "formula",
2082            "prov": prov,
2083            "orig": latex,
2084            "text": latex,
2085        })));
2086        self_ref
2087    }
2088
2089    /// A CodeFormula-enriched display formula: `text` is the model's LaTeX
2090    /// while `orig` keeps the raw glyph extraction (docling's enriched shape;
2091    /// the plain [`Self::add_formula`] above sets both to the same string).
2092    fn add_formula_item(&mut self, latex: &str, orig: &str, parent: &str) -> String {
2093        let self_ref = format!("#/texts/{}", self.texts.len());
2094        let prov = self.take_prov(latex.chars().count());
2095        self.texts.push(Item::json(json!({
2096            "self_ref": self_ref,
2097            "parent": { "$ref": parent },
2098            "children": [],
2099            "content_layer": "body",
2100            "label": "formula",
2101            "prov": prov,
2102            "orig": orig,
2103            "text": latex,
2104        })));
2105        self_ref
2106    }
2107
2108    fn add_code(
2109        &mut self,
2110        text: &str,
2111        language: Option<&str>,
2112        orig: Option<&str>,
2113        parent: &str,
2114    ) -> String {
2115        let self_ref = format!("#/texts/{}", self.texts.len());
2116        let raw = unescape_text(text);
2117        let prov = self.take_prov(raw.chars().count());
2118        self.texts.push(Item::json(json!({
2119            "self_ref": self_ref,
2120            "parent": { "$ref": parent },
2121            "children": [],
2122            "content_layer": "body",
2123            "label": "code",
2124            "prov": prov,
2125            // With code enrichment, `text` is the model's rewrite while `orig`
2126            // keeps the raw extraction; otherwise both are the same string.
2127            "orig": orig.map(unescape_text).unwrap_or_else(|| raw.clone()),
2128            "text": raw,
2129            "captions": [],
2130            "references": [],
2131            "footnotes": [],
2132            "code_language": code_language(language),
2133        })));
2134        self_ref
2135    }
2136
2137    /// Build a list group from a run of (possibly multi-level) list items. A
2138    /// deeper level starts a nested list under the preceding item.
2139    fn add_list(&mut self, items: &[Node], parent: &str) -> String {
2140        let self_ref = format!("#/groups/{}", self.groups.len());
2141        // reserve the slot so nested groups get later indices
2142        self.groups.push(Value::Null);
2143        let base = level_of(&items[0]);
2144        let mut children = Vec::new();
2145        let mut i = 0;
2146        while i < items.len() {
2147            // Empty paragraphs absorbed into the run (blank lines between items)
2148            // are not list items — skip them.
2149            if !matches!(items[i], Node::ListItem { .. }) {
2150                i += 1;
2151                continue;
2152            }
2153            let lvl = level_of(&items[i]);
2154            if lvl > base {
2155                // shouldn't happen at the head; skip defensively
2156                i += 1;
2157                continue;
2158            }
2159            let item_ref = self.add_list_item(&items[i], &self_ref);
2160            // collect any deeper items that nest under this one
2161            let mut j = i + 1;
2162            while j < items.len() && level_of(&items[j]) > base {
2163                j += 1;
2164            }
2165            if j > i + 1 {
2166                let mut nested = Vec::new();
2167                self.add_sibling_lists(&items[i + 1..j], &item_ref, &mut nested);
2168                // the nested list group(s) are children of this item
2169                if let Some(idx) = ref_index(&item_ref) {
2170                    self.texts[idx].value_mut(idx)["children"]
2171                        .as_array_mut()
2172                        .unwrap()
2173                        .extend(nested);
2174                }
2175            }
2176            children.push(ref_value(item_ref));
2177            i = j;
2178        }
2179        self.groups[group_index(&self_ref)] = json!({
2180            "self_ref": self_ref,
2181            "parent": { "$ref": parent },
2182            "children": children,
2183            "content_layer": "body",
2184            "name": "list",
2185            "label": "list",
2186        });
2187        self_ref
2188    }
2189
2190    fn add_list_item(&mut self, node: &Node, parent: &str) -> String {
2191        let Node::ListItem {
2192            ordered,
2193            number,
2194            text,
2195            location,
2196            ..
2197        } = node
2198        else {
2199            unreachable!()
2200        };
2201        self.adopt_loc(*location);
2202        let self_ref = format!("#/texts/{}", self.texts.len());
2203        let raw = unescape_text(text);
2204        let prov = self.take_prov(raw.chars().count());
2205        let marker = if *ordered {
2206            format!("{number}.")
2207        } else {
2208            "-".to_string()
2209        };
2210        self.texts.push(Item::json(json!({
2211            "self_ref": self_ref,
2212            "parent": { "$ref": parent },
2213            "children": [],
2214            "content_layer": "body",
2215            "label": "list_item",
2216            "prov": prov,
2217            "orig": raw,
2218            "text": raw,
2219            "enumerated": ordered,
2220            "marker": marker,
2221        })));
2222        self_ref
2223    }
2224
2225    fn add_table(&mut self, t: &Table, parent: &str) -> String {
2226        self.add_table_with(t, parent, false)
2227    }
2228
2229    /// [`Self::add_table`]; `raw` cell text is written verbatim (see
2230    /// [`table_data_with`]).
2231    fn add_table_with(&mut self, t: &Table, parent: &str, raw: bool) -> String {
2232        let self_ref = format!("#/tables/{}", self.tables.len());
2233        self.adopt_loc(t.location);
2234        let prov = self.take_prov(0);
2235        // The caption is a separate text item the table references (docling's
2236        // `TableItem.captions`), added before the grid so its box isn't
2237        // inherited by a later item.
2238        let (captions, children) = match t.caption.as_deref().filter(|c| !c.is_empty()) {
2239            Some(cap) => self.add_caption(
2240                cap,
2241                json!({}),
2242                &self_ref,
2243                parent,
2244                (t.caption_parent, t.caption_location),
2245            ),
2246            None => (Vec::new(), Vec::new()),
2247        };
2248        let data = table_data_with(t, raw);
2249        self.tables.push(json!({
2250            "self_ref": self_ref,
2251            "parent": { "$ref": parent },
2252            "children": children,
2253            "content_layer": "body",
2254            "label": "table",
2255            "prov": prov,
2256            "captions": captions,
2257            "references": [],
2258            "footnotes": [],
2259            "data": data,
2260            "annotations": [],
2261        }));
2262        self_ref
2263    }
2264
2265    /// Add a picture's or table's caption text item where `choice` says it
2266    /// hangs (#390), returning the `captions` entry for the item and the
2267    /// item's own `children` (the caption, when it is the item's child).
2268    /// The caption never consumes the item's pending provenance — the item
2269    /// takes its box first. Its own box, when the backend kept one (the PDF
2270    /// pipeline's caption region, #609), becomes the caption's `prov` on the
2271    /// item's page, as docling's reading-order model gives every caption the
2272    /// cluster it came from; without one the caption has no `prov`.
2273    fn add_caption(
2274        &mut self,
2275        text: &str,
2276        extra: Value,
2277        self_ref: &str,
2278        parent: &str,
2279        (choice, location): (CaptionParent, Option<[u16; 4]>),
2280    ) -> (Vec<Value>, Vec<Value>) {
2281        // docling's PDF pipeline parents the caption to the item; every
2282        // declarative backend leaves `add_text`'s default — the body — even
2283        // for an item inside a group; the office backends and HTML's
2284        // `<figure>` hang it off the item's container.
2285        let cap_parent = match choice {
2286            CaptionParent::Item => self_ref,
2287            CaptionParent::Container | CaptionParent::ContainerAfter => parent,
2288            CaptionParent::Body => "#/body",
2289        };
2290        if self.cur_page > 0 && location.is_some() {
2291            self.pending_loc = location;
2292        }
2293        let cap_ref = ref_value(self.add_text("caption", text, cap_parent, extra));
2294        match choice {
2295            CaptionParent::Item => return (vec![cap_ref.clone()], vec![cap_ref]),
2296            // Created ahead of the item, so it precedes the item in the
2297            // container's children — and, on the body, in the body's.
2298            CaptionParent::Container => self.pending_siblings.push(cap_ref.clone()),
2299            CaptionParent::Body if parent == "#/body" => {
2300                self.pending_siblings.push(cap_ref.clone())
2301            }
2302            CaptionParent::ContainerAfter => self.pending_after.push(cap_ref.clone()),
2303            // The item sits deeper: the body's children get the caption after
2304            // the top-level item under walk, where docling appended it.
2305            CaptionParent::Body => self.pending_body.push(cap_ref.clone()),
2306        }
2307        (vec![cap_ref], Vec::new())
2308    }
2309
2310    /// `meta` is the picture's docling `PictureMeta` (a classifier's
2311    /// predictions, a chart's kind and data), `None` for a plain picture.
2312    fn add_picture(
2313        &mut self,
2314        caption: Option<&str>,
2315        caption_href: Option<&str>,
2316        image: Option<&crate::PictureImage>,
2317        meta: Option<Value>,
2318        parent: &str,
2319        caption_at: (CaptionParent, Option<[u16; 4]>),
2320    ) -> String {
2321        let self_ref = format!("#/pictures/{}", self.pictures.len());
2322        // Take the picture's own provenance before the caption text is added —
2323        // the caption is a separate item and must not inherit the crop's box.
2324        let prov = self.take_prov(0);
2325        let (captions, children) = match caption.filter(|c| !c.is_empty()) {
2326            Some(cap) => {
2327                // Emit the caption as a text item that the picture references. A
2328                // wrapping `<a href>`'s link rides as docling's `hyperlink` field
2329                // on the caption item (#328).
2330                let extra = match caption_href {
2331                    Some(href) => json!({ "hyperlink": href }),
2332                    None => json!({}),
2333                };
2334                self.add_caption(cap, extra, &self_ref, parent, caption_at)
2335            }
2336            None => (Vec::new(), Vec::new()),
2337        };
2338        self.push_picture(prov, captions, children, image, meta, parent)
2339    }
2340
2341    /// Append the picture item itself — `prov`, `captions` and `children`
2342    /// (a PDF caption is the picture's child) already settled.
2343    fn push_picture(
2344        &mut self,
2345        prov: Value,
2346        captions: Vec<Value>,
2347        children: Vec<Value>,
2348        image: Option<&crate::PictureImage>,
2349        meta: Option<Value>,
2350        parent: &str,
2351    ) -> String {
2352        let self_ref = format!("#/pictures/{}", self.pictures.len());
2353        // The legacy `classification` annotation rides along with a
2354        // classifier's `meta` (see `classification_meta`); a chart's meta has
2355        // none, like docling's.
2356        let annotations = meta
2357            .as_ref()
2358            .and_then(|m| m.get("annotations").cloned())
2359            .unwrap_or_else(|| json!([]));
2360        let meta = meta.map(|mut m| {
2361            if let Some(obj) = m.as_object_mut() {
2362                obj.remove("annotations");
2363            }
2364            m
2365        });
2366        // `meta` sits between `content_layer` and `label` in docling's field
2367        // order (and `preserve_order` keeps ours byte-compatible), so the item
2368        // is built in one shot per shape rather than patched afterwards.
2369        let mut item = match meta {
2370            Some(meta) => json!({
2371                "self_ref": self_ref,
2372                "parent": { "$ref": parent },
2373                "children": children,
2374                "content_layer": "body",
2375                "meta": meta,
2376                "label": "picture",
2377                "prov": prov,
2378                "captions": captions,
2379                "references": [],
2380                "footnotes": [],
2381                "annotations": annotations,
2382            }),
2383            None => json!({
2384                "self_ref": self_ref,
2385                "parent": { "$ref": parent },
2386                "children": children,
2387                "content_layer": "body",
2388                "label": "picture",
2389                "prov": prov,
2390                "captions": captions,
2391                "references": [],
2392                "footnotes": [],
2393                "annotations": annotations,
2394            }),
2395        };
2396        // docling stores the extracted image as an `ImageRef` (data URI + size,
2397        // the size as floats) between `footnotes` and `annotations` — pydantic
2398        // field order, which `preserve_order` lets us reproduce by rebuilding
2399        // the tail.
2400        if let Some(img) = image {
2401            let mut image = image_ref_json(img);
2402            // The displayed size, for the Pandoc writer only (#834):
2403            // docling's `ImageRef` has no field for it.
2404            if self.note_keys {
2405                if let Some([w, h]) = img.display_size {
2406                    image["_display"] = json!([w, h]);
2407                }
2408            }
2409            if let Some(obj) = item.as_object_mut() {
2410                let annotations = obj.remove("annotations").unwrap_or_else(|| json!([]));
2411                obj.insert("image".into(), image);
2412                obj.insert("annotations".into(), annotations);
2413            }
2414        }
2415        self.pictures.push(item);
2416        self_ref
2417    }
2418
2419    fn add_group(
2420        &mut self,
2421        label: &str,
2422        name: Option<&str>,
2423        layer: Option<ContentLayer>,
2424        nodes: &[Node],
2425        parent: &str,
2426    ) -> String {
2427        let self_ref = format!("#/groups/{}", self.groups.len());
2428        self.groups.push(Value::Null);
2429        // Everything the walk creates belongs to this group, so a non-body
2430        // layer (a hidden sheet) is stamped on the whole subtree afterwards —
2431        // docling puts the layer on the group *and* on every item under it.
2432        let mark = (
2433            self.texts.len(),
2434            self.tables.len(),
2435            self.pictures.len(),
2436            self.groups.len(),
2437        );
2438        let children: Vec<Value> = self
2439            .walk_into(nodes, &self_ref)
2440            .into_iter()
2441            .map(Child::into_value)
2442            .collect();
2443        let name = name.unwrap_or(if label == "inline" { "group" } else { label });
2444        let content_layer = layer.map_or("body", |l| l.value());
2445        self.groups[group_index(&self_ref)] = json!({
2446            "self_ref": self_ref,
2447            "parent": { "$ref": parent },
2448            "children": children,
2449            "content_layer": content_layer,
2450            "name": name,
2451            "label": label,
2452        });
2453        if layer.is_some() {
2454            let (t, tb, p, g) = mark;
2455            for item in self.texts[t..]
2456                .iter_mut()
2457                .enumerate()
2458                .map(|(k, item)| item.value_mut(t + k))
2459                .chain(self.tables[tb..].iter_mut())
2460                .chain(self.pictures[p..].iter_mut())
2461                .chain(self.groups[g..].iter_mut())
2462            {
2463                if let Some(obj) = item.as_object_mut() {
2464                    obj.insert("content_layer".into(), json!(content_layer));
2465                }
2466            }
2467        }
2468        self_ref
2469    }
2470
2471    /// Walk a slice of sibling nodes, returning each child's `$ref`; runs of
2472    /// list items are folded into list groups (one per sibling list).
2473    fn walk_into(&mut self, nodes: &[Node], parent: &str) -> Vec<Child> {
2474        // Siblings that all carry a creation rank (an XLSX sheet's items) are
2475        // *added* in that order — so `#/tables/N` and friends are numbered as
2476        // docling numbers them — while their refs keep the node order, which
2477        // is docling's position-sorted `children`.
2478        let seqs: Option<Vec<usize>> = nodes
2479            .iter()
2480            .map(|n| match n {
2481                Node::Prov { seq: Some(s), .. } => Some(*s),
2482                _ => None,
2483            })
2484            .collect();
2485        if let Some(seqs) = seqs.filter(|s| !s.is_empty()) {
2486            let mut order: Vec<usize> = (0..nodes.len()).collect();
2487            order.sort_by_key(|&i| seqs[i]);
2488            let mut slots: Vec<Vec<Child>> = (0..nodes.len()).map(|_| Vec::new()).collect();
2489            for i in order {
2490                if let Some(r) = self.add_node(&nodes[i], parent) {
2491                    slots[i].extend(self.pending_siblings.drain(..).map(Child::json));
2492                    slots[i].push(Child::Ref(r.into()));
2493                    slots[i].extend(self.pending_after.drain(..).map(Child::json));
2494                }
2495                if parent == "#/body" {
2496                    slots[i].extend(self.pending_body.drain(..).map(Child::json));
2497                }
2498            }
2499            return slots.into_iter().flatten().collect();
2500        }
2501        let mut children = Vec::new();
2502        let mut i = 0;
2503        while i < nodes.len() {
2504            if matches!(nodes[i], Node::ListItem { .. }) {
2505                let start = i;
2506                i += 1;
2507                loop {
2508                    match nodes.get(i) {
2509                        Some(Node::ListItem { .. }) => i += 1,
2510                        // Absorb an empty paragraph sitting between two list
2511                        // items (docling keeps the ListGroup contiguous).
2512                        Some(Node::Paragraph { text })
2513                            if text.is_empty()
2514                                && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
2515                        {
2516                            i += 1
2517                        }
2518                        _ => break,
2519                    }
2520                }
2521                let mut lists = Vec::new();
2522                self.add_sibling_lists(&nodes[start..i], parent, &mut lists);
2523                children.extend(lists.into_iter().map(Child::json));
2524            } else {
2525                if let Some(r) = self.add_node(&nodes[i], parent) {
2526                    children.extend(self.pending_siblings.drain(..).map(Child::json));
2527                    children.push(Child::Ref(r.into()));
2528                    children.extend(self.pending_after.drain(..).map(Child::json));
2529                }
2530                i += 1;
2531            }
2532            // Body-parented captions of items deeper in the tree follow the
2533            // top-level item they were created under (#390).
2534            if parent == "#/body" {
2535                children.extend(self.pending_body.drain(..).map(Child::json));
2536            }
2537        }
2538        children
2539    }
2540
2541    /// A run of list items may hold several *sibling* lists; emit one list group
2542    /// per sibling. The boundary is the backend's `first_in_list` flag on a
2543    /// base-level item — the same rule as the Markdown serializer's blank line
2544    /// (#385; the kind-flip and number-gap guesses are gone).
2545    fn add_sibling_lists(&mut self, run: &[Node], parent: &str, out: &mut Vec<Value>) {
2546        let base = level_of(&run[0]);
2547        let mut seg = 0;
2548        for k in 0..run.len() {
2549            let Node::ListItem {
2550                first_in_list,
2551                level,
2552                ..
2553            } = &run[k]
2554            else {
2555                continue;
2556            };
2557            if *level != base {
2558                continue; // nested item — handled inside add_list
2559            }
2560            if k > seg && *first_in_list {
2561                out.push(ref_value(self.add_list(&run[seg..k], parent)));
2562                seg = k;
2563            }
2564        }
2565        out.push(ref_value(self.add_list(&run[seg..], parent)));
2566    }
2567}
2568
2569fn level_of(node: &Node) -> u8 {
2570    match node {
2571        Node::ListItem { level, .. } => *level,
2572        _ => 0,
2573    }
2574}
2575
2576fn group_index(self_ref: &str) -> usize {
2577    self_ref.rsplit('/').next().unwrap().parse().unwrap()
2578}
2579
2580fn ref_index(self_ref: &str) -> Option<usize> {
2581    self_ref.rsplit('/').next()?.parse().ok()
2582}
2583
2584/// Merge the key/values of `extra` (an object) into `target` (an object).
2585fn merge(target: &mut Value, extra: Value) {
2586    if let (Some(t), Value::Object(e)) = (target.as_object_mut(), extra) {
2587        t.extend(e);
2588    }
2589}
2590
2591/// A JSON object of exactly these entries, in this order. The hot per-item
2592/// constructors use this instead of `json!`: the macro grows its map by
2593/// doubling (an 8-key item kept 14 slots) and deep-copies every interpolated
2594/// `Value` through `to_value`. On a long text-heavy PDF those item objects
2595/// *are* the export's footprint (the same reasoning as [`cell_value`]).
2596fn object<const N: usize>(entries: [(&str, Value); N]) -> Value {
2597    let mut m = serde_json::Map::with_capacity(N);
2598    for (k, v) in entries {
2599        m.insert(k.to_owned(), v);
2600    }
2601    Value::Object(m)
2602}
2603
2604/// A `{"$ref": r}` reference object.
2605fn ref_value(r: impl Into<String>) -> Value {
2606    object([("$ref", Value::String(r.into()))])
2607}
2608
2609/// Reverse [`crate`]'s Markdown text escaping (HTML entities + `\_`).
2610fn unescape_text(s: &str) -> String {
2611    s.replace("&lt;", "<")
2612        .replace("&gt;", ">")
2613        .replace("&amp;", "&")
2614        .replace("\\_", "_")
2615}
2616
2617/// 64-bit FNV-1a, a stand-in for docling's `binary_hash` (we lack the source bytes
2618/// at export time; the value only needs to be a stable u64).
2619fn fnv1a(s: &str) -> u64 {
2620    let mut h: u64 = 0xcbf29ce484222325;
2621    for b in s.bytes() {
2622        h ^= b as u64;
2623        h = h.wrapping_mul(0x100000001b3);
2624    }
2625    h
2626}
2627
2628#[cfg(test)]
2629mod tests {
2630    use crate::{
2631        CaptionParent, ContentLayer, DoclingDocument, ImageMode, Node, PictureImage, Table,
2632    };
2633    use serde_json::Value;
2634
2635    fn cell_at(text: &str, row: usize, col: usize, col_span: usize) -> crate::TableCell {
2636        crate::TableCell {
2637            text: text.into(),
2638            bbox: None,
2639            start_row: row,
2640            start_col: col,
2641            row_span: 1,
2642            col_span,
2643            column_header: row == 0,
2644            row_header: false,
2645            row_section: false,
2646        }
2647    }
2648
2649    /// First-class cells past the rows: rectangular rows are the grid and
2650    /// clip them (docling's `TableData.grid` on USPTO's overrun replicas);
2651    /// ragged rows — an xlsx table compacted by `skip_empty_cells` (#271) —
2652    /// are no grid, so the cells' extent sizes it and the omitted positions
2653    /// come back as empty filler cells in `grid` only.
2654    #[test]
2655    fn ragged_rows_take_their_grid_from_first_class_cells() {
2656        let cells = vec![
2657            cell_at("h0", 0, 0, 1),
2658            cell_at("h2", 0, 2, 1),
2659            cell_at("wide", 1, 0, 3),
2660        ];
2661        let export = |rows: Vec<Vec<String>>| -> Value {
2662            let mut doc = DoclingDocument::new("t");
2663            doc.push(Node::Table(Table {
2664                rows,
2665                cells: Some(cells.clone()),
2666                ..Table::default()
2667            }));
2668            let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2669            v["tables"][0]["data"].clone()
2670        };
2671        // Compacted rows: 2 and 3 wide, the cells say the grid is 3 wide.
2672        let data = export(vec![
2673            vec!["h0".into(), "h2".into()],
2674            vec!["wide".into(), "wide".into(), "wide".into()],
2675        ]);
2676        assert_eq!(data["num_rows"], 2);
2677        assert_eq!(data["num_cols"], 3);
2678        assert_eq!(data["table_cells"].as_array().unwrap().len(), 3);
2679        let row0: Vec<&str> = data["grid"][0]
2680            .as_array()
2681            .unwrap()
2682            .iter()
2683            .map(|c| c["text"].as_str().unwrap())
2684            .collect();
2685        assert_eq!(row0, ["h0", "", "h2"]);
2686        assert_eq!(data["table_cells"][2]["col_span"], 3);
2687        // Rectangular rows narrower than the cells: the rows win, the cells
2688        // are clipped to them exactly as before.
2689        let data = export(vec![
2690            vec!["h0".into(), "h2".into()],
2691            vec!["wide".into(), "wide".into()],
2692        ]);
2693        assert_eq!(data["num_cols"], 2);
2694        assert_eq!(data["grid"][0].as_array().unwrap().len(), 2);
2695    }
2696
2697    fn doc_with_image() -> DoclingDocument {
2698        let mut doc = DoclingDocument::new("t");
2699        doc.push(Node::Picture {
2700            caption: Some("Fig 1".into()),
2701            caption_href: None,
2702            image: Some(PictureImage {
2703                dpi: crate::PictureImage::DEFAULT_DPI,
2704                mimetype: "image/png".into(),
2705                width: 4,
2706                height: 2,
2707                data: b"foobar".to_vec(),
2708                display_size: None,
2709            }),
2710            classification: None,
2711            description: None,
2712            caption_parent: Default::default(),
2713            caption_location: None,
2714        });
2715        doc
2716    }
2717
2718    /// #402: a deck's speaker notes are content, and docling puts them in the
2719    /// JSON on the `notes` layer so a consumer reading only JSON can pick them
2720    /// out. Markdown still serializes the body layer alone, and page furniture
2721    /// stays out of the JSON, where docling does keep it.
2722    #[test]
2723    fn notes_layer_items_reach_the_json_but_furniture_does_not() {
2724        let mut doc = DoclingDocument::new("t");
2725        doc.push(Node::Heading {
2726            level: 1,
2727            text: "Slide One".into(),
2728        });
2729        doc.push(Node::Furniture {
2730            layer: ContentLayer::Notes,
2731            inner: Box::new(Node::Located {
2732                location: [0, 0, 0, 0],
2733                inner: Box::new(Node::Paragraph {
2734                    text: "Speaker note for slide 1.".into(),
2735                }),
2736            }),
2737        });
2738        doc.push(Node::Furniture {
2739            layer: ContentLayer::Furniture,
2740            inner: Box::new(Node::Paragraph {
2741                text: "page header".into(),
2742            }),
2743        });
2744
2745        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2746        let texts = v["texts"].as_array().unwrap();
2747        assert_eq!(
2748            texts
2749                .iter()
2750                .map(|t| (
2751                    t["label"].as_str().unwrap(),
2752                    t["content_layer"].as_str().unwrap(),
2753                    t["text"].as_str().unwrap()
2754                ))
2755                .collect::<Vec<_>>(),
2756            vec![
2757                ("title", "body", "Slide One"),
2758                ("text", "notes", "Speaker note for slide 1."),
2759            ],
2760            "the note is carried on its own layer; the furniture is not carried"
2761        );
2762        // The body layer is what Markdown serializes, so it does not change.
2763        assert_eq!(doc.export_to_markdown(), "# Slide One\n");
2764    }
2765
2766    /// #410: a backend that describes merged ranges only as continuation
2767    /// flags (xlsx `<mergeCell>`, docx `gridSpan`/`vMerge`) gets docling's
2768    /// one-`TableCell`-per-range JSON: the anchor's offsets and spans, the
2769    /// entry repeated across the grid positions it covers — not a 1×1 cell
2770    /// per position with the text copied into each.
2771    #[test]
2772    fn continuation_flags_become_spanning_cells() {
2773        let mut doc = DoclingDocument::new("t");
2774        // A1:C2 merged ("merged"), then a plain row underneath.
2775        let rows = vec![
2776            vec!["merged".to_string(), "merged".into(), "merged".into()],
2777            vec!["merged".to_string(), "merged".into(), "merged".into()],
2778            vec!["a".to_string(), "b".into(), "c".into()],
2779        ];
2780        doc.push(Node::Table(crate::Table {
2781            rows,
2782            location: None,
2783            structure: Some(crate::TableStructure {
2784                header_row: vec![true, false, false],
2785                col_continuation: vec![
2786                    vec![false, true, true],
2787                    vec![false, true, true],
2788                    vec![false, false, false],
2789                ],
2790                row_continuation: vec![
2791                    vec![false, false, false],
2792                    vec![true, true, true],
2793                    vec![false, false, false],
2794                ],
2795                row_header: Vec::new(),
2796                col_header: Vec::new(),
2797            }),
2798            cell_blocks: None,
2799            cells: None,
2800            caption: None,
2801            caption_parent: Default::default(),
2802            caption_location: None,
2803        }));
2804        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2805        let data = &v["tables"][0]["data"];
2806        assert_eq!(data["num_rows"], 3);
2807        assert_eq!(data["num_cols"], 3);
2808        let cells = data["table_cells"].as_array().unwrap();
2809        assert_eq!(
2810            cells.len(),
2811            4,
2812            "one cell for the range, three for the plain row"
2813        );
2814        assert_eq!(
2815            cells[0],
2816            serde_json::json!({
2817                "row_span": 2, "col_span": 3,
2818                "start_row_offset_idx": 0, "end_row_offset_idx": 2,
2819                "start_col_offset_idx": 0, "end_col_offset_idx": 3,
2820                "text": "merged", "column_header": true, "row_header": false,
2821                "row_section": false, "fillable": false,
2822            })
2823        );
2824        assert_eq!(cells[1]["text"], "a");
2825        assert_eq!(cells[1]["row_span"], 1);
2826        assert_eq!(cells[1]["column_header"], false);
2827        // The grid repeats the range's entry at every position it covers.
2828        let grid = data["grid"].as_array().unwrap();
2829        assert_eq!(grid.len(), 3);
2830        for (r, row) in grid.iter().take(2).enumerate() {
2831            for (c, cell) in row.as_array().unwrap().iter().enumerate() {
2832                assert_eq!(*cell, cells[0], "grid[{r}][{c}]");
2833            }
2834        }
2835        assert_eq!(grid[2][2]["text"], "c");
2836    }
2837
2838    /// A [`Node::Prov`] wrapper is docling's provenance verbatim — the exact
2839    /// box in a top-left origin, the backend's charspan — and the page marker
2840    /// before it sizes the page; a chart's caption becomes a sibling of the
2841    /// picture in the container, listed first, sharing the chart's box with
2842    /// a charspan over its text. That is the JSON shape of an XLSX sheet.
2843    #[test]
2844    fn exact_provenance_pages_and_chart_captions_follow_docling() {
2845        let mut doc = DoclingDocument::new("t");
2846        doc.push(Node::PageInfo {
2847            page_no: 1,
2848            width: 3.0,
2849            height: 4.0,
2850        });
2851        let table = crate::Table {
2852            rows: vec![vec!["a".to_string(), "b".into()]],
2853            ..Default::default()
2854        };
2855        doc.push(Node::Group {
2856            label: "sheet".into(),
2857            name: Some("Data".into()),
2858            layer: None,
2859            children: vec![
2860                // Node order is the position-sorted one; creation order (the
2861                // `seq`) had the chart first — so the chart is `#/pictures/0`
2862                // *and* its caption `#/texts/0`, while the table stays the
2863                // group's first child.
2864                Node::Prov {
2865                    page_no: 1,
2866                    bbox: [0.0, 0.0, 3.0, 4.0],
2867                    charspan: [0, 0],
2868                    seq: Some(1),
2869                    inner: Box::new(Node::Table(table.clone())),
2870                },
2871                Node::Prov {
2872                    page_no: 1,
2873                    bbox: [0.0, 1.0, 1.0, 1.0],
2874                    charspan: [0, 0],
2875                    seq: Some(0),
2876                    inner: Box::new(Node::Chart {
2877                        kind: "bar_chart".into(),
2878                        table,
2879                        caption: Some("Sales".into()),
2880                        location: Some([0, 128, 170, 128]),
2881                    }),
2882                },
2883            ],
2884        });
2885        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2886        assert_eq!(
2887            v["pages"],
2888            serde_json::json!({"1": {"size": {"width": 3.0, "height": 4.0}, "page_no": 1}})
2889        );
2890        assert_eq!(
2891            v["tables"][0]["prov"],
2892            serde_json::json!([{
2893                "page_no": 1,
2894                "bbox": {"l": 0.0, "t": 0.0, "r": 3.0, "b": 4.0, "coord_origin": "TOPLEFT"},
2895                "charspan": [0, 0],
2896            }])
2897        );
2898        assert_eq!(v["tables"][0]["data"]["orientation"], "rot_0");
2899        // The caption is the group's child *before* the picture, parented to
2900        // the group, and referenced by the picture.
2901        let sheet = &v["groups"][0];
2902        assert_eq!(
2903            sheet["children"],
2904            serde_json::json!([
2905                {"$ref": "#/tables/0"}, {"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}
2906            ])
2907        );
2908        let cap = &v["texts"][0];
2909        assert_eq!(cap["label"], "caption");
2910        assert_eq!(cap["parent"], serde_json::json!({"$ref": "#/groups/0"}));
2911        assert_eq!(cap["prov"][0]["charspan"], serde_json::json!([0, 5]));
2912        assert_eq!(cap["prov"][0]["bbox"]["b"], 1.0);
2913        let pic = &v["pictures"][0];
2914        assert_eq!(pic["captions"], serde_json::json!([{"$ref": "#/texts/0"}]));
2915        assert_eq!(pic["prov"][0]["charspan"], serde_json::json!([0, 0]));
2916        assert_eq!(pic["prov"][0]["bbox"]["coord_origin"], "TOPLEFT");
2917        assert_eq!(
2918            pic["meta"]["classification"]["predictions"][0]["class_name"],
2919            "bar_chart"
2920        );
2921        assert_eq!(pic["meta"]["tabular_chart"]["chart_data"]["num_cols"], 2);
2922    }
2923
2924    /// An all-zero location is the "no geometry" sentinel — a slide's speaker
2925    /// notes carry one — and docling writes it as a zero bbox, not as a box
2926    /// spanning the whole page, which is what denormalizing the grid gives.
2927    #[test]
2928    fn a_zero_location_is_a_zero_bbox_not_the_whole_page() {
2929        let mut doc = DoclingDocument::new("t");
2930        doc.push(Node::PageInfo {
2931            page_no: 1,
2932            width: 12192000.0,
2933            height: 6858000.0,
2934        });
2935        doc.push(Node::Furniture {
2936            layer: ContentLayer::Notes,
2937            inner: Box::new(Node::Located {
2938                location: [0, 0, 0, 0],
2939                inner: Box::new(Node::Paragraph {
2940                    text: "a note".into(),
2941                }),
2942            }),
2943        });
2944        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2945        let prov = &v["texts"][0]["prov"][0];
2946        assert_eq!(prov["page_no"], 1);
2947        assert_eq!(prov["charspan"], serde_json::json!([0, 6]));
2948        assert_eq!(
2949            prov["bbox"],
2950            serde_json::json!({"l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT"})
2951        );
2952        // The page itself is recorded at its true size.
2953        assert_eq!(
2954            v["pages"]["1"]["size"],
2955            serde_json::json!({"width": 12192000.0, "height": 6858000.0})
2956        );
2957    }
2958
2959    /// #171: PageInfo markers become the `pages` map, and `Located` wrappers /
2960    /// node-level locations become per-item `prov` — the 0–511 grid
2961    /// denormalized against the page into BOTTOMLEFT points. Without markers
2962    /// (every declarative backend) the JSON stays exactly as before: empty
2963    /// `pages`, `prov: []` even for located nodes.
2964    /// #609: a PDF caption and a checkbox item carry their own page and box.
2965    /// docling's reading-order model gives every caption the cluster it came
2966    /// from (`_add_caption_or_footnote`), not its picture's or table's, and
2967    /// a checkbox is a text item like any other.
2968    #[test]
2969    fn captions_and_checkboxes_carry_their_own_prov() {
2970        let mut doc = DoclingDocument::new("t");
2971        doc.push(Node::PageInfo {
2972            page_no: 1,
2973            width: 512.0,
2974            height: 512.0,
2975        });
2976        doc.push(Node::Located {
2977            location: [64, 64, 448, 256],
2978            inner: Box::new(Node::Picture {
2979                caption: Some("Figure 1".into()),
2980                caption_href: None,
2981                image: None,
2982                classification: None,
2983                description: None,
2984                caption_parent: CaptionParent::Item,
2985                caption_location: Some([64, 264, 448, 280]),
2986            }),
2987        });
2988        doc.push(Node::PageInfo {
2989            page_no: 2,
2990            width: 512.0,
2991            height: 512.0,
2992        });
2993        doc.push(Node::Located {
2994            location: [64, 120, 448, 300],
2995            inner: Box::new(Node::Table(Table {
2996                rows: vec![vec!["a".into()]],
2997                caption: Some("Table 1".into()),
2998                caption_parent: CaptionParent::Item,
2999                caption_location: Some([64, 100, 300, 112]),
3000                ..Table::default()
3001            })),
3002        });
3003        doc.push(Node::Located {
3004            location: [64, 320, 200, 332],
3005            inner: Box::new(Node::CheckboxItem {
3006                checked: false,
3007                text: "First option".into(),
3008            }),
3009        });
3010        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3011        let texts = v["texts"].as_array().unwrap();
3012        let by_text = |t: &str| texts.iter().find(|x| x["text"] == t).expect(t).clone();
3013        let bbox = |p: &Value| {
3014            let b = &p["prov"][0]["bbox"];
3015            [&b["l"], &b["t"], &b["r"], &b["b"]].map(|x| x.as_f64().unwrap())
3016        };
3017        // Figure 1: its own box (grid == points on a 512 page, flipped to
3018        // BOTTOMLEFT), not the picture's.
3019        let fig = by_text("Figure 1");
3020        assert_eq!(fig["prov"][0]["page_no"], 1);
3021        assert_eq!(bbox(&fig), [64.0, 248.0, 448.0, 232.0]);
3022        assert_eq!(fig["prov"][0]["charspan"], serde_json::json!([0, 8]));
3023        assert_eq!(fig["parent"]["$ref"], "#/pictures/0");
3024        assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
3025        // Table 1 on page 2, its own box; the table keeps its own.
3026        let tab = by_text("Table 1");
3027        assert_eq!(tab["prov"][0]["page_no"], 2);
3028        assert_eq!(bbox(&tab), [64.0, 412.0, 300.0, 400.0]);
3029        assert_eq!(v["tables"][0]["prov"][0]["bbox"]["t"], 392.0);
3030        // The checkbox item.
3031        let cb = by_text("First option");
3032        assert_eq!(cb["label"], "checkbox_unselected");
3033        assert_eq!(cb["prov"][0]["page_no"], 2);
3034        assert_eq!(bbox(&cb), [64.0, 192.0, 200.0, 180.0]);
3035        assert!(texts
3036            .iter()
3037            .all(|t| !t["prov"].as_array().unwrap().is_empty()));
3038
3039        // Without a caption box (every declarative backend) the caption
3040        // still has no prov, and nothing else moves.
3041        let mut plain = DoclingDocument::new("t");
3042        plain.push(Node::PageInfo {
3043            page_no: 1,
3044            width: 512.0,
3045            height: 512.0,
3046        });
3047        plain.push(Node::Located {
3048            location: [64, 64, 448, 256],
3049            inner: Box::new(Node::Picture {
3050                caption: Some("Figure 1".into()),
3051                caption_href: None,
3052                image: None,
3053                classification: None,
3054                description: None,
3055                caption_parent: CaptionParent::Item,
3056                caption_location: None,
3057            }),
3058        });
3059        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
3060        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
3061        assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
3062    }
3063
3064    #[test]
3065    fn page_markers_produce_pages_and_prov() {
3066        let mut doc = DoclingDocument::new("t");
3067        doc.push(Node::PageInfo {
3068            page_no: 1,
3069            width: 512.0,
3070            height: 1024.0,
3071        });
3072        doc.push(Node::Located {
3073            location: [128, 64, 256, 128], // quarter/eighth points of the grid
3074            inner: Box::new(Node::Paragraph {
3075                text: "hello".into(),
3076            }),
3077        });
3078        doc.push(Node::Table(Table {
3079            rows: vec![vec!["a".into()]],
3080            location: Some([0, 0, 512, 512]),
3081            ..Table::default()
3082        }));
3083        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3084        assert_eq!(v["pages"]["1"]["page_no"], 1);
3085        assert_eq!(v["pages"]["1"]["size"]["width"], 512.0);
3086        assert_eq!(v["pages"]["1"]["size"]["height"], 1024.0);
3087        // 512-wide page: grid x scales 1:1; 1024-high: grid y doubles, then
3088        // flips to the BOTTOMLEFT origin (t from grid-top 64 → 1024-128=896).
3089        let prov = &v["texts"][0]["prov"][0];
3090        assert_eq!(prov["page_no"], 1);
3091        assert_eq!(prov["bbox"]["l"], 128.0);
3092        assert_eq!(prov["bbox"]["t"], 896.0);
3093        assert_eq!(prov["bbox"]["r"], 256.0);
3094        assert_eq!(prov["bbox"]["b"], 768.0);
3095        assert_eq!(prov["bbox"]["coord_origin"], "BOTTOMLEFT");
3096        assert_eq!(prov["charspan"][1], 5);
3097        // The table adopts its own location field; charspan is [0, 0].
3098        let tprov = &v["tables"][0]["prov"][0];
3099        assert_eq!(tprov["bbox"]["t"], 1024.0);
3100        assert_eq!(tprov["bbox"]["b"], 0.0);
3101        assert_eq!(tprov["charspan"][1], 0);
3102
3103        // No markers → the pre-#171 shape, byte for byte.
3104        let mut plain = DoclingDocument::new("t");
3105        plain.push(Node::Located {
3106            location: [1, 2, 3, 4],
3107            inner: Box::new(Node::Paragraph { text: "x".into() }),
3108        });
3109        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
3110        assert_eq!(v["pages"], serde_json::json!({}));
3111        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
3112    }
3113
3114    /// PDF page headers/footers reach the JSON as docling writes them:
3115    /// body-parented text items on the furniture layer, with their box.
3116    #[test]
3117    fn page_furniture_becomes_furniture_layer_text() {
3118        let mut doc = DoclingDocument::new("t");
3119        doc.push(Node::PageInfo {
3120            page_no: 1,
3121            width: 512.0,
3122            height: 512.0,
3123        });
3124        doc.push(Node::PageFurniture {
3125            footer: false,
3126            location: [10, 0, 100, 20],
3127            text: "Chapter 1".into(),
3128        });
3129        doc.push(Node::Paragraph {
3130            text: "body".into(),
3131        });
3132        doc.push(Node::PageFurniture {
3133            footer: true,
3134            location: [400, 490, 500, 512],
3135            text: "1.10.2".into(),
3136        });
3137        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3138        let texts = v["texts"].as_array().unwrap();
3139        assert_eq!(texts.len(), 3);
3140        assert_eq!(texts[0]["label"], "page_header");
3141        assert_eq!(texts[0]["content_layer"], "furniture");
3142        assert_eq!(texts[0]["parent"]["$ref"], "#/body");
3143        assert_eq!(texts[0]["prov"][0]["bbox"]["l"], 10.0);
3144        assert_eq!(texts[1]["content_layer"], "body");
3145        assert_eq!(texts[1]["prov"], serde_json::json!([]));
3146        assert_eq!(texts[2]["label"], "page_footer");
3147        assert_eq!(texts[2]["text"], "1.10.2");
3148        assert_eq!(texts[2]["content_layer"], "furniture");
3149    }
3150
3151    #[test]
3152    fn picture_image_in_markdown_modes_and_json() {
3153        let doc = doc_with_image();
3154        // placeholder (default) ignores the image
3155        assert!(doc.export_to_markdown().contains("<!-- image -->"));
3156        // embedded → base64 data URI (b"foobar" → "Zm9vYmFy")
3157        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Embedded, "artifacts");
3158        assert!(
3159            md.contains("![Image](data:image/png;base64,Zm9vYmFy)"),
3160            "got:\n{md}"
3161        );
3162        assert!(files.is_empty());
3163        // referenced → file link + collected bytes
3164        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Referenced, "artifacts");
3165        assert!(
3166            md.contains("![Image](artifacts/image_000000.png)"),
3167            "got:\n{md}"
3168        );
3169        assert_eq!(
3170            files,
3171            vec![("artifacts/image_000000.png".to_string(), b"foobar".to_vec())]
3172        );
3173        // JSON carries the ImageRef (data URI + size — floats, as docling's
3174        // `Size` is — placed before `annotations`).
3175        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3176        assert_eq!(v["pictures"][0]["image"]["mimetype"], "image/png");
3177        assert_eq!(v["pictures"][0]["image"]["size"]["width"], 4.0);
3178        let keys: Vec<&str> = v["pictures"][0]
3179            .as_object()
3180            .unwrap()
3181            .keys()
3182            .map(String::as_str)
3183            .collect();
3184        assert_eq!(&keys[keys.len() - 2..], ["image", "annotations"]);
3185        assert_eq!(
3186            v["pictures"][0]["image"]["uri"],
3187            "data:image/png;base64,Zm9vYmFy"
3188        );
3189    }
3190
3191    #[test]
3192    fn exports_docling_schema() {
3193        let mut doc = DoclingDocument::new("t");
3194        doc.push(Node::Heading {
3195            level: 1,
3196            text: "Title".into(),
3197        });
3198        doc.push(Node::Heading {
3199            level: 2,
3200            text: "Sec".into(),
3201        });
3202        doc.push(Node::Paragraph {
3203            text: "Body &amp; more".into(),
3204        }); // markdown-escaped
3205        doc.push(Node::ListItem {
3206            ordered: false,
3207            number: 0,
3208            first_in_list: true,
3209            text: "one".into(),
3210            level: 0,
3211            marker: None,
3212            location: None,
3213            dclx: None,
3214            href: None,
3215            layer: None,
3216        });
3217        doc.push(Node::ListItem {
3218            ordered: false,
3219            number: 0,
3220            first_in_list: false,
3221            text: "two".into(),
3222            level: 0,
3223            marker: None,
3224            location: None,
3225            dclx: None,
3226            href: None,
3227            layer: None,
3228        });
3229        doc.push(Node::Table(Table {
3230            rows: vec![vec!["A".into(), "B".into()]],
3231            location: None,
3232            structure: None,
3233            cell_blocks: None,
3234            cells: None,
3235            caption: None,
3236            caption_parent: Default::default(),
3237            caption_location: None,
3238        }));
3239
3240        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3241        assert_eq!(v["schema_name"], "DoclingDocument");
3242        assert_eq!(v["version"], "1.10.0");
3243        assert_eq!(v["texts"][0]["label"], "title");
3244        assert_eq!(v["texts"][1]["label"], "section_header");
3245        assert_eq!(v["texts"][1]["level"], 1); // heading level 2 → docling level 1
3246        assert_eq!(v["texts"][2]["text"], "Body & more"); // un-escaped for the wire format
3247                                                          // consecutive list items fold into one list group, parented to it
3248        assert_eq!(v["groups"][0]["label"], "list");
3249        assert_eq!(v["groups"][0]["children"].as_array().unwrap().len(), 2);
3250        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
3251        assert_eq!(v["texts"][3]["marker"], "-");
3252        // table grid + header flag
3253        assert_eq!(v["tables"][0]["data"]["num_cols"], 2);
3254        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["column_header"], true);
3255    }
3256    /// A named group on a non-body layer — docling's hidden spreadsheet sheet:
3257    /// the group carries the sheet's name and the `invisible` layer, and every
3258    /// item inside it carries the layer too.
3259    #[test]
3260    fn a_layered_group_stamps_its_whole_subtree() {
3261        let doc = DoclingDocument {
3262            name: "s".into(),
3263            nodes: vec![
3264                Node::Group {
3265                    label: "sheet".into(),
3266                    name: Some("Sheet1".into()),
3267                    layer: None,
3268                    children: vec![Node::Paragraph {
3269                        text: "visible".into(),
3270                    }],
3271                },
3272                Node::Group {
3273                    label: "sheet".into(),
3274                    name: Some("Sheet2".into()),
3275                    layer: Some(ContentLayer::Invisible),
3276                    children: vec![Node::Paragraph {
3277                        text: "hidden".into(),
3278                    }],
3279                },
3280            ],
3281            ..DoclingDocument::new("s")
3282        };
3283        let v = crate::json::to_json(&doc);
3284        assert_eq!(v["groups"][0]["label"], "sheet");
3285        assert_eq!(v["groups"][0]["name"], "Sheet1");
3286        assert_eq!(v["groups"][0]["content_layer"], "body");
3287        assert_eq!(v["texts"][0]["content_layer"], "body");
3288        assert_eq!(v["groups"][1]["name"], "Sheet2");
3289        assert_eq!(v["groups"][1]["content_layer"], "invisible");
3290        assert_eq!(v["texts"][1]["content_layer"], "invisible");
3291        // The group's children are the items, and the body holds the groups.
3292        assert_eq!(v["groups"][1]["children"][0]["$ref"], "#/texts/1");
3293        assert_eq!(v["body"]["children"][1]["$ref"], "#/groups/1");
3294    }
3295
3296    /// A backend-built item tree is serialized as it is: items numbered in
3297    /// creation order per bucket (a field region's part texts included), the
3298    /// tree's parents / children / layers, docling's field order for
3299    /// `formatting`, `hyperlink`, `level`, `enumerated`/`marker`, a rich
3300    /// cell's `ref` on `table_cells` only, raw cell text.
3301    /// The DOCX tree's extras: an item `delete`d (docling's `delete_items`,
3302    /// the spacer between two items of a resumed list) is neither written nor
3303    /// numbered, `comments` back-refs sit between `prov` and `orig`, and a
3304    /// chart picture carries `classification` plus `tabular_chart`.
3305    #[test]
3306    fn deleted_items_comment_refs_and_chart_meta_in_the_tree() {
3307        use crate::tree::{ItemTree, TreeKind};
3308        let mut t = ItemTree::default();
3309        let text = |txt: &str| TreeKind::Text {
3310            label: "text".into(),
3311            text: txt.into(),
3312            orig: None,
3313            formatting: None,
3314            hyperlink: None,
3315            level: None,
3316            list: None,
3317        };
3318        let a = t.add(None, None, text("a"));
3319        let blank = t.add(None, None, text(""));
3320        let b = t.add(None, None, text("b"));
3321        t.delete(blank);
3322        let group = t.add(
3323            None,
3324            Some(ContentLayer::Notes),
3325            TreeKind::Group {
3326                label: "comment_section".into(),
3327                name: "comment-0".into(),
3328            },
3329        );
3330        t.add(Some(group), Some(ContentLayer::Notes), text("note"));
3331        t.items[a].comments.push(group);
3332        t.add(
3333            None,
3334            None,
3335            TreeKind::Picture {
3336                captions: Vec::new(),
3337                image: None,
3338                extra_classes: Vec::new(),
3339                classification: Some("bar_chart".into()),
3340                description: None,
3341                confidence: None,
3342                chart: Some(Table {
3343                    rows: vec![vec!["".into(), "s".into()], vec!["c".into(), "1".into()]],
3344                    ..Table::default()
3345                }),
3346                dpi: None,
3347            },
3348        );
3349        assert_eq!(t.last_text(), Some(4), "the note; the blank is skipped");
3350        assert_eq!(t.bucket_index(b), 1, "numbered past the deleted item");
3351        let mut doc = DoclingDocument::new("t");
3352        doc.tree = Some(t);
3353        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3354        let texts = v["texts"].as_array().unwrap();
3355        assert_eq!(texts.len(), 3);
3356        assert_eq!(texts[1]["text"], "b");
3357        assert_eq!(texts[1]["self_ref"], "#/texts/1");
3358        assert_eq!(
3359            v["body"]["children"],
3360            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}, {"$ref": "#/groups/0"}, {"$ref": "#/pictures/0"}])
3361        );
3362        let keys: Vec<&str> = texts[0]
3363            .as_object()
3364            .unwrap()
3365            .keys()
3366            .map(String::as_str)
3367            .collect();
3368        assert_eq!(
3369            keys,
3370            vec![
3371                "self_ref",
3372                "parent",
3373                "children",
3374                "content_layer",
3375                "label",
3376                "prov",
3377                "comments",
3378                "orig",
3379                "text"
3380            ]
3381        );
3382        assert_eq!(
3383            texts[0]["comments"],
3384            serde_json::json!([{"$ref": "#/groups/0"}])
3385        );
3386        assert!(texts[1].get("comments").is_none());
3387        let meta = &v["pictures"][0]["meta"];
3388        assert_eq!(
3389            meta["classification"]["predictions"][0]["class_name"],
3390            "bar_chart"
3391        );
3392        assert_eq!(meta["tabular_chart"]["chart_data"]["num_rows"], 2);
3393    }
3394
3395    /// A tree item's `TreeProv` is written verbatim — a raw EMU box with
3396    /// whatever origin tag the backend set (`BOTTOMLEFT` here) and a per-item
3397    /// charspan, a note's zero `TOPLEFT` box — a picture's `image.dpi` is the file's when the backend
3398    /// read one, an item without provenance writes `prov: []`, and the page
3399    /// map still comes from the flat stream's markers.
3400    #[test]
3401    fn tree_items_carry_exact_provenance_and_dpi() {
3402        use crate::tree::{ItemTree, TreeKind, TreeProv};
3403        let text = |label: &str, t: &str| TreeKind::Text {
3404            label: label.into(),
3405            text: t.into(),
3406            orig: None,
3407            formatting: None,
3408            hyperlink: None,
3409            level: None,
3410            list: None,
3411        };
3412        let mut t = ItemTree::default();
3413        let slide = t.add(
3414            None,
3415            None,
3416            TreeKind::Group {
3417                label: "chapter".into(),
3418                name: "slide-0".into(),
3419            },
3420        );
3421        t.add_with_prov(
3422            Some(slide),
3423            None,
3424            text("paragraph", "héllo"),
3425            TreeProv {
3426                page_no: 1,
3427                bbox: [914400.0, 1828800.0, 2743200.0, 457200.0],
3428                bottom_left: true,
3429                charspan: [0, 5],
3430            },
3431        );
3432        t.add_with_prov(
3433            Some(slide),
3434            None,
3435            TreeKind::Picture {
3436                captions: Vec::new(),
3437                image: Some(crate::PictureImage {
3438                    dpi: crate::PictureImage::DEFAULT_DPI,
3439                    mimetype: "image/png".into(),
3440                    width: 2,
3441                    height: 2,
3442                    data: vec![0],
3443                    display_size: None,
3444                }),
3445                extra_classes: Vec::new(),
3446                classification: None,
3447                description: None,
3448                confidence: None,
3449                chart: None,
3450                dpi: Some(300),
3451            },
3452            TreeProv {
3453                page_no: 1,
3454                bbox: [0.0; 4],
3455                bottom_left: false,
3456                charspan: [0, 0],
3457            },
3458        );
3459        t.add(
3460            Some(slide),
3461            Some(ContentLayer::Notes),
3462            text("text", "no geometry"),
3463        );
3464        let mut doc = DoclingDocument::new("t");
3465        doc.push(Node::PageInfo {
3466            page_no: 1,
3467            width: 9144000.0,
3468            height: 6858000.0,
3469        });
3470        doc.tree = Some(t);
3471        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3472        assert_eq!(
3473            v["texts"][0]["prov"],
3474            serde_json::json!([{
3475                "page_no": 1,
3476                "bbox": { "l": 914400.0, "t": 1828800.0, "r": 2743200.0, "b": 457200.0, "coord_origin": "BOTTOMLEFT" },
3477                "charspan": [0, 5],
3478            }])
3479        );
3480        assert_eq!(v["texts"][0]["label"], "paragraph");
3481        assert_eq!(
3482            v["pictures"][0]["prov"][0]["bbox"]["coord_origin"],
3483            "TOPLEFT"
3484        );
3485        assert_eq!(v["pictures"][0]["image"]["dpi"], 300);
3486        assert_eq!(v["texts"][1]["prov"], serde_json::json!([]));
3487        assert_eq!(v["texts"][1]["content_layer"], "notes");
3488        assert_eq!(v["pages"]["1"]["size"]["width"], 9144000.0);
3489        assert_eq!(v["pages"]["1"]["page_no"], 1);
3490    }
3491
3492    /// docling-core's `validate_document` clamps every provenance box (and a
3493    /// one-page table's cell boxes) into its page — the state every
3494    /// `ConversionResult` leaves a document in, so the state docling's JSON
3495    /// shows. A box on a page the document does not describe is left alone.
3496    #[test]
3497    fn provenance_boxes_are_clamped_to_their_page() {
3498        let mut doc = DoclingDocument::new("t");
3499        doc.push(Node::PageInfo {
3500            page_no: 1,
3501            width: 10.0,
3502            height: 8.0,
3503        });
3504        doc.push(Node::Prov {
3505            page_no: 1,
3506            bbox: [-1.0, 2.0, 12.0, 9.5],
3507            charspan: [0, 1],
3508            seq: None,
3509            inner: Box::new(Node::Paragraph { text: "x".into() }),
3510        });
3511        let mut table = Table {
3512            rows: vec![vec!["a".into()]],
3513            ..Table::default()
3514        };
3515        table.cells = Some(vec![crate::TableCell {
3516            text: "a".into(),
3517            bbox: Some([1.0, 1.0, 11.0, 9.0]),
3518            start_row: 0,
3519            start_col: 0,
3520            row_span: 1,
3521            col_span: 1,
3522            column_header: false,
3523            row_header: false,
3524            row_section: false,
3525        }]);
3526        doc.push(Node::Prov {
3527            page_no: 1,
3528            bbox: [0.0, 0.0, 10.0, 8.0],
3529            charspan: [0, 0],
3530            seq: None,
3531            inner: Box::new(Node::Table(table)),
3532        });
3533        doc.push(Node::Prov {
3534            page_no: 7,
3535            bbox: [-5.0, 0.0, 50.0, 50.0],
3536            charspan: [0, 1],
3537            seq: None,
3538            inner: Box::new(Node::Paragraph { text: "y".into() }),
3539        });
3540        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3541        assert_eq!(
3542            v["texts"][0]["prov"][0]["bbox"],
3543            serde_json::json!({ "l": 0.0, "t": 2.0, "r": 10.0, "b": 8.0, "coord_origin": "TOPLEFT" })
3544        );
3545        let cell = &v["tables"][0]["data"]["table_cells"][0]["bbox"];
3546        assert_eq!(
3547            (cell["l"].as_f64(), cell["r"].as_f64(), cell["b"].as_f64()),
3548            (Some(1.0), Some(10.0), Some(8.0))
3549        );
3550        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["bbox"]["r"], 10.0);
3551        assert_eq!(
3552            v["texts"][1]["prov"][0]["bbox"]["r"], 50.0,
3553            "page 7 is not described"
3554        );
3555    }
3556
3557    /// docling-core's `validate_misplaced_list_items`, as it re-homes list
3558    /// items outside a `list` group (#527) — the refs below are what
3559    /// docling-core 2.99 writes for the same document: the two consecutive
3560    /// items on the body share a group, each item in another group gets its
3561    /// own, the runs are handled last-first (new groups and re-added texts
3562    /// numbered in that order), and a well-placed item is left alone.
3563    #[test]
3564    fn misplaced_list_items_are_wrapped_like_docling_core() {
3565        use crate::tree::{ItemTree, ListMeta, TreeKind};
3566        let mut t = ItemTree::default();
3567        let li = |txt: &str| TreeKind::Text {
3568            label: "list_item".into(),
3569            text: txt.into(),
3570            orig: None,
3571            formatting: None,
3572            hyperlink: None,
3573            level: None,
3574            list: Some(ListMeta {
3575                enumerated: false,
3576                marker: "-".into(),
3577            }),
3578        };
3579        let group = |label: &str, name: &str| TreeKind::Group {
3580            label: label.into(),
3581            name: name.into(),
3582        };
3583        t.add(None, None, li("A"));
3584        t.add(None, None, li("B"));
3585        t.add(
3586            None,
3587            None,
3588            TreeKind::Text {
3589                label: "text".into(),
3590                text: "plain".into(),
3591                orig: None,
3592                formatting: None,
3593                hyperlink: None,
3594                level: None,
3595                list: None,
3596            },
3597        );
3598        let cell = t.add(None, None, group("unspecified", "cell"));
3599        t.add(Some(cell), None, li("C"));
3600        t.add(Some(cell), None, li("D"));
3601        let list = t.add(None, None, group("list", "list"));
3602        t.add(Some(list), None, li("E"));
3603        let doc = DoclingDocument {
3604            tree: Some(t),
3605            ..DoclingDocument::new("t")
3606        };
3607        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3608        let refs = |x: &Value| -> Vec<String> {
3609            x.as_array()
3610                .unwrap()
3611                .iter()
3612                .map(|r| r["$ref"].as_str().unwrap().to_string())
3613                .collect()
3614        };
3615        assert_eq!(
3616            refs(&v["body"]["children"]),
3617            ["#/groups/4", "#/texts/0", "#/groups/0", "#/groups/1"]
3618        );
3619        assert_eq!(
3620            refs(&v["groups"][0]["children"]),
3621            ["#/groups/3", "#/groups/2"]
3622        );
3623        for (g, name, kids) in [
3624            (2, "group", vec!["#/texts/2"]),
3625            (3, "group", vec!["#/texts/3"]),
3626            (4, "group", vec!["#/texts/4", "#/texts/5"]),
3627            (1, "list", vec!["#/texts/1"]),
3628        ] {
3629            assert_eq!(v["groups"][g]["label"], "list");
3630            assert_eq!(v["groups"][g]["name"], name);
3631            assert_eq!(refs(&v["groups"][g]["children"]), kids);
3632        }
3633        let texts: Vec<&str> = v["texts"]
3634            .as_array()
3635            .unwrap()
3636            .iter()
3637            .map(|t| t["text"].as_str().unwrap())
3638            .collect();
3639        assert_eq!(texts, ["plain", "E", "D", "C", "A", "B"]);
3640        assert_eq!(v["texts"][2]["parent"]["$ref"], "#/groups/2");
3641    }
3642
3643    /// A child the tree still lists after deleting it is left out instead
3644    /// of written as `"$ref": ""`, which docling-core rejects (#527).
3645    #[test]
3646    fn a_deleted_child_is_not_written_as_an_empty_ref() {
3647        use crate::tree::{ItemTree, TreeKind};
3648        let mut t = ItemTree::default();
3649        let text = |txt: &str| TreeKind::Text {
3650            label: "text".into(),
3651            text: txt.into(),
3652            orig: None,
3653            formatting: None,
3654            hyperlink: None,
3655            level: None,
3656            list: None,
3657        };
3658        let g = t.add(
3659            None,
3660            None,
3661            TreeKind::Group {
3662                label: "unspecified".into(),
3663                name: "g".into(),
3664            },
3665        );
3666        let gone = t.add(Some(g), None, text(""));
3667        t.add(Some(g), None, text("kept"));
3668        t.items[gone].deleted = true; // still in `g`'s children
3669        let doc = DoclingDocument {
3670            tree: Some(t),
3671            ..DoclingDocument::new("t")
3672        };
3673        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3674        assert_eq!(
3675            v["groups"][0]["children"],
3676            serde_json::json!([{"$ref": "#/texts/0"}])
3677        );
3678    }
3679
3680    #[test]
3681    fn a_backend_item_tree_is_written_verbatim() {
3682        use crate::tree::{Formatting, ItemTree, ListMeta, TreeKind};
3683        let mut t = ItemTree::default();
3684        let text = |label: &str, txt: &str| TreeKind::Text {
3685            label: label.into(),
3686            text: txt.into(),
3687            orig: None,
3688            formatting: None,
3689            hyperlink: None,
3690            level: None,
3691            list: None,
3692        };
3693        let title = t.add(None, Some(ContentLayer::Furniture), text("title", "Page"));
3694        let h = t.add(None, None, text("title", "Heading"));
3695        let group = t.add(
3696            Some(h),
3697            None,
3698            TreeKind::Group {
3699                label: "inline".into(),
3700                name: "group".into(),
3701            },
3702        );
3703        t.add(
3704            Some(group),
3705            None,
3706            TreeKind::Text {
3707                label: "text".into(),
3708                text: "bold".into(),
3709                orig: None,
3710                formatting: Some(Formatting {
3711                    bold: true,
3712                    ..Formatting::default()
3713                }),
3714                hyperlink: Some("https://example.com/".into()),
3715                level: None,
3716                list: None,
3717            },
3718        );
3719        t.add(
3720            Some(group),
3721            None,
3722            TreeKind::Code {
3723                text: "x = 1".into(),
3724                orig: None,
3725                language: Some("python".into()),
3726                formatting: None,
3727                hyperlink: None,
3728                captions: Vec::new(),
3729            },
3730        );
3731        let sub = t.add(
3732            Some(h),
3733            None,
3734            TreeKind::Text {
3735                label: "section_header".into(),
3736                text: "Sub".into(),
3737                orig: Some("Sub\u{2019}".into()),
3738                formatting: None,
3739                hyperlink: None,
3740                level: Some(1),
3741                list: None,
3742            },
3743        );
3744        let item = t.add(
3745            Some(sub),
3746            None,
3747            TreeKind::Text {
3748                label: "list_item".into(),
3749                text: "item".into(),
3750                orig: None,
3751                formatting: None,
3752                hyperlink: None,
3753                level: None,
3754                list: Some(ListMeta {
3755                    enumerated: true,
3756                    marker: "3.".into(),
3757                }),
3758            },
3759        );
3760        let _region = t.add(
3761            Some(sub),
3762            None,
3763            TreeKind::FieldRegion {
3764                items: vec![crate::FieldItem {
3765                    marker: None,
3766                    key: Some("Name".into()),
3767                    value: Some("Duck".into()),
3768                    value_kind: Some("read_only".into()),
3769                }],
3770            },
3771        );
3772        let table = t.add(
3773            Some(sub),
3774            None,
3775            TreeKind::Table {
3776                table: Table {
3777                    rows: vec![vec!["a  \n&lt;".into(), "b".into()]],
3778                    cells: Some(vec![
3779                        crate::TableCell {
3780                            text: "a  \n&lt;".into(),
3781                            bbox: None,
3782                            start_row: 0,
3783                            start_col: 0,
3784                            row_span: 3,
3785                            col_span: 1,
3786                            column_header: false,
3787                            row_header: true,
3788                            row_section: false,
3789                        },
3790                        crate::TableCell {
3791                            text: "b".into(),
3792                            bbox: None,
3793                            start_row: 0,
3794                            start_col: 1,
3795                            row_span: 1,
3796                            col_span: 1,
3797                            column_header: false,
3798                            row_header: false,
3799                            row_section: false,
3800                        },
3801                    ]),
3802                    ..Table::default()
3803                },
3804                rich_cells: vec![(0, 1, 0)], // patched below
3805                captions: Vec::new(),
3806            },
3807        );
3808        let cell_group = t.add(
3809            Some(table),
3810            None,
3811            TreeKind::Group {
3812                label: "unspecified".into(),
3813                name: "rich_cell_group_1_0_0".into(),
3814            },
3815        );
3816        if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
3817            *rich_cells = vec![(0, 1, cell_group)];
3818        }
3819        let after = t.add(Some(sub), None, text("text", "after the region"));
3820        let _ = (title, after);
3821        // A list item belongs in a `list` group — one outside it would be
3822        // re-homed by `wrap_misplaced_list_items` (created last, so the
3823        // other groups keep their numbers).
3824        let list = t.add(
3825            Some(sub),
3826            None,
3827            TreeKind::Group {
3828                label: "list".into(),
3829                name: "list".into(),
3830            },
3831        );
3832        t.reparent(item, Some(list));
3833
3834        let doc = DoclingDocument {
3835            tree: Some(t),
3836            ..DoclingDocument::new("t")
3837        };
3838        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3839        // Creation order: Page, Heading, bold, x = 1, Sub, item, [Name, Duck], after.
3840        let texts: Vec<&str> = v["texts"]
3841            .as_array()
3842            .unwrap()
3843            .iter()
3844            .map(|t| t["text"].as_str().unwrap())
3845            .collect();
3846        assert_eq!(
3847            texts,
3848            [
3849                "Page",
3850                "Heading",
3851                "bold",
3852                "x = 1",
3853                "Sub",
3854                "item",
3855                "Name",
3856                "Duck",
3857                "after the region"
3858            ]
3859        );
3860        assert_eq!(
3861            v["body"]["children"],
3862            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}])
3863        );
3864        assert_eq!(v["texts"][0]["content_layer"], "furniture");
3865        assert_eq!(
3866            v["texts"][1]["children"],
3867            serde_json::json!([{"$ref": "#/groups/0"}, {"$ref": "#/texts/4"}])
3868        );
3869        let bold = &v["texts"][2];
3870        assert_eq!(bold["parent"]["$ref"], "#/groups/0");
3871        let keys: Vec<&str> = bold
3872            .as_object()
3873            .unwrap()
3874            .keys()
3875            .map(String::as_str)
3876            .collect();
3877        assert_eq!(
3878            keys,
3879            [
3880                "self_ref",
3881                "parent",
3882                "children",
3883                "content_layer",
3884                "label",
3885                "prov",
3886                "orig",
3887                "text",
3888                "formatting",
3889                "hyperlink"
3890            ]
3891        );
3892        assert_eq!(
3893            bold["formatting"],
3894            serde_json::json!({"bold": true, "italic": false, "underline": false, "strikethrough": false, "script": "baseline"})
3895        );
3896        let code = &v["texts"][3];
3897        assert_eq!(code["label"], "code");
3898        assert_eq!(code["code_language"], "Python");
3899        let sub = &v["texts"][4];
3900        assert_eq!(sub["orig"], "Sub\u{2019}");
3901        assert_eq!(sub["level"], 1);
3902        let item = &v["texts"][5];
3903        let keys: Vec<&str> = item
3904            .as_object()
3905            .unwrap()
3906            .keys()
3907            .map(String::as_str)
3908            .collect();
3909        assert_eq!(
3910            keys,
3911            [
3912                "self_ref",
3913                "parent",
3914                "children",
3915                "content_layer",
3916                "label",
3917                "prov",
3918                "orig",
3919                "text",
3920                "enumerated",
3921                "marker"
3922            ]
3923        );
3924        assert_eq!(item["marker"], "3.");
3925        assert_eq!(v["texts"][7]["kind"], "read_only");
3926        assert_eq!(v["field_regions"][0]["parent"]["$ref"], "#/texts/4");
3927        let table = &v["tables"][0];
3928        assert_eq!(
3929            table["children"],
3930            serde_json::json!([{"$ref": "#/groups/1"}])
3931        );
3932        let cells = table["data"]["table_cells"].as_array().unwrap();
3933        assert_eq!(
3934            cells[0]["text"], "a  \n&lt;",
3935            "raw cell text is written verbatim"
3936        );
3937        assert_eq!(
3938            cells[0]["end_row_offset_idx"], 3,
3939            "declared spans are not clamped"
3940        );
3941        assert_eq!(cells[1]["ref"], serde_json::json!({"$ref": "#/groups/1"}));
3942        assert!(cells[0].get("ref").is_none());
3943        assert!(
3944            table["data"]["grid"][0][1].get("ref").is_none(),
3945            "the grid shows plain cells"
3946        );
3947        assert_eq!(v["groups"][1]["name"], "rich_cell_group_1_0_0");
3948    }
3949
3950    /// A comment section that links its note text rather than its group — the
3951    /// spreadsheet shape, where docling-core's `add_comment` appends the text
3952    /// item's ref to each target.
3953    #[test]
3954    fn a_comment_section_can_be_referenced_by_its_note_text() {
3955        let doc = DoclingDocument {
3956            name: "c".into(),
3957            nodes: vec![
3958                Node::Commented {
3959                    comments: vec![0],
3960                    inner: Box::new(Node::Paragraph {
3961                        text: "annotated".into(),
3962                    }),
3963                },
3964                Node::CommentSection {
3965                    name: "comment-Sheet1-A1".into(),
3966                    text: "[author: A]: note".into(),
3967                    refs_note_text: true,
3968                    grouped: true,
3969                },
3970            ],
3971            ..DoclingDocument::new("c")
3972        };
3973        let v = crate::json::to_json(&doc);
3974        assert_eq!(v["groups"][0]["name"], "comment-Sheet1-A1");
3975        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/texts/1");
3976    }
3977
3978    /// docx reviewer comments: a `comment_section` group on the notes layer
3979    /// holding the note text, and a `comments` back-ref on the annotated item —
3980    /// keyed between `prov` and `orig`, the slot docling emits it in.
3981    #[test]
3982    fn comment_sections_link_back_to_their_items() {
3983        let doc = DoclingDocument {
3984            name: "c".into(),
3985            nodes: vec![
3986                Node::Commented {
3987                    comments: vec![0],
3988                    inner: Box::new(Node::Paragraph {
3989                        text: "annotated".into(),
3990                    }),
3991                },
3992                Node::Paragraph {
3993                    text: "plain".into(),
3994                },
3995                Node::CommentSection {
3996                    name: "comment-7".into(),
3997                    text: "[time: t]: note".into(),
3998                    refs_note_text: false,
3999                    grouped: true,
4000                },
4001            ],
4002            ..DoclingDocument::new("c")
4003        };
4004        let v = crate::json::to_json(&doc);
4005        // The group is the comment section; its only child is the notes text.
4006        assert_eq!(v["groups"][0]["label"], "comment_section");
4007        assert_eq!(v["groups"][0]["name"], "comment-7");
4008        assert_eq!(v["groups"][0]["content_layer"], "notes");
4009        assert_eq!(v["groups"][0]["children"][0]["$ref"], "#/texts/2");
4010        assert_eq!(v["texts"][2]["content_layer"], "notes");
4011        // The annotated item points back at the group; the plain one has no key.
4012        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/groups/0");
4013        assert!(v["texts"][1].get("comments").is_none());
4014        // docling's key order: … prov, comments, orig, text.
4015        let keys: Vec<&str> = v["texts"][0]
4016            .as_object()
4017            .unwrap()
4018            .keys()
4019            .map(String::as_str)
4020            .collect();
4021        assert_eq!(
4022            &keys[keys.len() - 4..],
4023            &["prov", "comments", "orig", "text"]
4024        );
4025    }
4026
4027    fn picture(caption: &str, caption_parent: CaptionParent) -> Node {
4028        Node::Picture {
4029            caption: Some(caption.into()),
4030            caption_href: None,
4031            image: None,
4032            classification: None,
4033            description: None,
4034            caption_parent,
4035            caption_location: None,
4036        }
4037    }
4038
4039    fn group(children: Vec<Node>) -> Node {
4040        Node::Group {
4041            label: "section".into(),
4042            name: None,
4043            layer: None,
4044            children,
4045        }
4046    }
4047
4048    fn refs(v: &Value) -> Vec<&str> {
4049        v.as_array()
4050            .unwrap()
4051            .iter()
4052            .map(|r| r["$ref"].as_str().unwrap())
4053            .collect()
4054    }
4055
4056    /// #390: a declarative backend's caption is docling's `add_text` default —
4057    /// a body child, appended as it is created — wherever the picture sits:
4058    /// ahead of a top-level picture, behind the top-level item enclosing a
4059    /// nested one. The picture references it either way and has no children.
4060    #[test]
4061    fn a_body_caption_follows_the_enclosing_top_level_item() {
4062        let mut doc = DoclingDocument::new("t");
4063        doc.push(picture("top", CaptionParent::Body));
4064        doc.push(group(vec![
4065            Node::Paragraph { text: "p".into() },
4066            picture("nested", CaptionParent::Body),
4067        ]));
4068        doc.push(Node::Paragraph {
4069            text: "after".into(),
4070        });
4071        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4072        assert_eq!(
4073            refs(&v["body"]["children"]),
4074            [
4075                "#/texts/0",
4076                "#/pictures/0",
4077                "#/groups/0",
4078                "#/texts/2",
4079                "#/texts/3"
4080            ]
4081        );
4082        assert_eq!(
4083            refs(&v["groups"][0]["children"]),
4084            ["#/texts/1", "#/pictures/1"]
4085        );
4086        for (cap, pic) in [(0, 0), (2, 1)] {
4087            assert_eq!(v["texts"][cap]["label"], "caption");
4088            assert_eq!(v["texts"][cap]["parent"]["$ref"], "#/body");
4089            assert_eq!(
4090                refs(&v["pictures"][pic]["captions"]),
4091                [format!("#/texts/{cap}")]
4092            );
4093            assert_eq!(v["pictures"][pic]["children"], serde_json::json!([]));
4094        }
4095    }
4096
4097    /// The PDF pipeline's caption is the picture's (or table's) own child,
4098    /// as docling attaches a layout caption.
4099    #[test]
4100    fn an_item_caption_is_the_items_first_child() {
4101        let mut doc = DoclingDocument::new("t");
4102        doc.push(picture("fig", CaptionParent::Item));
4103        doc.push(Node::Table(Table {
4104            rows: vec![vec!["a".into()]],
4105            caption: Some("tab".into()),
4106            caption_parent: CaptionParent::Item,
4107            ..Table::default()
4108        }));
4109        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4110        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/tables/0"]);
4111        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/pictures/0");
4112        assert_eq!(refs(&v["pictures"][0]["children"]), ["#/texts/0"]);
4113        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
4114        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/tables/0");
4115        assert_eq!(refs(&v["tables"][0]["children"]), ["#/texts/1"]);
4116        assert_eq!(refs(&v["tables"][0]["captions"]), ["#/texts/1"]);
4117    }
4118
4119    /// A PDF picture's contained text (`Node::PictureChildren`) is written as
4120    /// docling's `_add_child_elements` writes it: items parented to the
4121    /// picture, after its caption in the picture's `children`, never in the
4122    /// body; a list item opens its own list group under the picture. The
4123    /// Markdown and the chunker's refs are unaffected.
4124    #[test]
4125    fn picture_children_hang_off_the_picture_after_its_caption() {
4126        let mut doc = DoclingDocument::new("t");
4127        doc.push(picture("fig", CaptionParent::Item));
4128        doc.push(Node::PictureChildren(vec![
4129            Node::Heading {
4130                level: 2,
4131                text: "in-figure title".into(),
4132            },
4133            Node::Paragraph {
4134                text: "axis label".into(),
4135            },
4136            Node::ListItem {
4137                ordered: false,
4138                number: 0,
4139                first_in_list: true,
4140                text: "callout".into(),
4141                level: 0,
4142                marker: None,
4143                location: None,
4144                dclx: None,
4145                href: None,
4146                layer: None,
4147            },
4148        ]));
4149        doc.push(Node::Paragraph {
4150            text: "after".into(),
4151        });
4152        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4153        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/texts/4"]);
4154        assert_eq!(
4155            refs(&v["pictures"][0]["children"]),
4156            ["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"]
4157        );
4158        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
4159        assert_eq!(v["texts"][1]["label"], "section_header");
4160        assert_eq!(v["texts"][2]["label"], "text");
4161        for t in 1..=2 {
4162            assert_eq!(v["texts"][t]["parent"]["$ref"], "#/pictures/0");
4163            assert_eq!(v["texts"][t]["content_layer"], "body");
4164        }
4165        assert_eq!(v["groups"][0]["parent"]["$ref"], "#/pictures/0");
4166        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
4167        let md = doc.export_to_markdown();
4168        assert!(
4169            !md.contains("axis label") && !md.contains("callout"),
4170            "{md}"
4171        );
4172        assert!(md.contains("after"), "{md}");
4173    }
4174
4175    /// A container caption sits beside its item under the item's parent —
4176    /// ahead of it (an office chart's title) or behind it (an HTML
4177    /// `<figure>`'s table, whose figcaption docling adds after the table).
4178    #[test]
4179    fn a_container_caption_is_the_items_sibling() {
4180        let mut doc = DoclingDocument::new("t");
4181        doc.push(group(vec![
4182            picture("chart", CaptionParent::Container),
4183            Node::Table(Table {
4184                rows: vec![vec!["a".into()]],
4185                caption: Some("figcaption".into()),
4186                caption_parent: CaptionParent::ContainerAfter,
4187                ..Table::default()
4188            }),
4189        ]));
4190        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4191        assert_eq!(refs(&v["body"]["children"]), ["#/groups/0"]);
4192        assert_eq!(
4193            refs(&v["groups"][0]["children"]),
4194            ["#/texts/0", "#/pictures/0", "#/tables/0", "#/texts/1"]
4195        );
4196        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/groups/0");
4197        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/groups/0");
4198        assert_eq!(v["pictures"][0]["children"], serde_json::json!([]));
4199        assert_eq!(v["tables"][0]["children"], serde_json::json!([]));
4200    }
4201
4202    /// A PDF footnote (#620 follow-up): the JSON keeps docling's
4203    /// `footnote` label, the raw text and the link as `hyperlink`; the
4204    /// Markdown still wraps the whole item as `[text](uri)`, and the plain
4205    /// text drops the link like docling-core's `export_to_text`.
4206    #[test]
4207    fn a_labeled_text_keeps_its_label_and_hyperlink_out_of_the_text() {
4208        let mut doc = DoclingDocument::new("t");
4209        doc.push(Node::LabeledText {
4210            label: "footnote".into(),
4211            text: "1 https://example.com/a\\_b".into(),
4212            href: Some("https://example.com/a_b".into()),
4213        });
4214        doc.push(Node::LabeledText {
4215            label: "footnote".into(),
4216            text: "© 2022 the authors".into(),
4217            href: None,
4218        });
4219        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4220        let t = &v["texts"][0];
4221        assert_eq!(t["label"], "footnote");
4222        assert_eq!(t["text"], "1 https://example.com/a_b");
4223        assert_eq!(t["orig"], "1 https://example.com/a_b");
4224        assert_eq!(t["hyperlink"], "https://example.com/a_b");
4225        assert_eq!(v["texts"][1]["label"], "footnote");
4226        assert!(v["texts"][1].get("hyperlink").is_none());
4227        assert_eq!(
4228            doc.export_to_markdown(),
4229            "[1 https://example.com/a\\_b](https://example.com/a_b)\n\n© 2022 the authors\n"
4230        );
4231        assert_eq!(
4232            doc.export_to_text(),
4233            "1 https://example.com/a_b\n\n© 2022 the authors"
4234        );
4235    }
4236}