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