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                    description,
1478                    confidence,
1479                    chart,
1480                    dpi,
1481                } => {
1482                    // A chart's meta: the kind as the one classification
1483                    // prediction (pydantic's field order puts `confidence`
1484                    // first when present), then the reconstructed data grid
1485                    // (#405).
1486                    let mut meta = classification.as_ref().map(|c| {
1487                        let mut pred = serde_json::Map::new();
1488                        if let Some(conf) = confidence {
1489                            pred.insert("confidence".into(), json!(conf));
1490                        }
1491                        pred.insert("class_name".into(), json!(c));
1492                        json!({ "classification": { "predictions": [pred] } })
1493                    });
1494                    if let (Some(m), Some(t)) = (meta.as_mut(), chart) {
1495                        if !t.rows.is_empty() {
1496                            m["tabular_chart"] = json!({ "chart_data": table_data(t) });
1497                        }
1498                    }
1499                    // The OCR description (#645) goes first in the meta —
1500                    // `PictureMeta` field order — and as the annotation the
1501                    // flat node writes (see `description_meta`).
1502                    if let Some(desc) = description {
1503                        let mut d = description_meta(desc);
1504                        let mut merged = Map::new();
1505                        merged.insert("description".into(), d["description"].take());
1506                        if let Some(Value::Object(rest)) = meta {
1507                            merged.extend(rest);
1508                        }
1509                        merged.insert("annotations".into(), d["annotations"].take());
1510                        meta = Some(Value::Object(merged));
1511                    }
1512                    let prov = self.take_prov(0);
1513                    let r = self.push_picture(
1514                        prov,
1515                        captions.iter().map(|&c| ref_of(c)).collect(),
1516                        children,
1517                        image.as_ref(),
1518                        meta,
1519                        parent,
1520                    );
1521                    if let Some(idx) = ref_index(&r) {
1522                        self.pictures[idx]["content_layer"] = json!(layer);
1523                        // The image's dpi is the file's when the backend read
1524                        // it (python-pptx does); the default 72 otherwise.
1525                        if let (Some(dpi), Some(img)) = (dpi, self.pictures[idx].get_mut("image")) {
1526                            img["dpi"] = json!(dpi);
1527                        }
1528                    }
1529                    r
1530                }
1531                TreeKind::FieldRegion { items } => {
1532                    self.pending_exact = None;
1533                    let r = self.add_field_region(items, parent);
1534                    if let Some(region) = self.field_regions.last_mut() {
1535                        region["content_layer"] = json!(layer);
1536                    }
1537                    r
1538                }
1539                TreeKind::KeyValueGraph { cells, links } => {
1540                    self.pending_exact = None;
1541                    let r = self.add_key_value_graph(cells, links, parent);
1542                    if let Some(item) = self.key_value_items.last_mut() {
1543                        item["content_layer"] = json!(layer);
1544                    }
1545                    r
1546                }
1547            };
1548            debug_assert_eq!(self_ref, refs[id], "tree item {id} numbered out of order");
1549        }
1550        tree.body
1551            .iter()
1552            .filter(|&&c| !tree.items[c].deleted)
1553            .map(|&c| ref_of(c))
1554            .collect()
1555    }
1556
1557    fn add_node(&mut self, node: &Node, parent: &str) -> Option<String> {
1558        match node {
1559            Node::Heading { level: 1, text } => {
1560                Some(self.add_text("title", text, parent, json!({})))
1561            }
1562            Node::Heading { level, text } => Some(self.add_text(
1563                "section_header",
1564                text,
1565                parent,
1566                json!({ "level": level.saturating_sub(1) }),
1567            )),
1568            Node::Caption { text, href } => {
1569                let extra = match href {
1570                    Some(url) => json!({ "hyperlink": url }),
1571                    None => json!({}),
1572                };
1573                Some(self.add_text("caption", text, parent, extra))
1574            }
1575            // A PDF footnote: docling's own label, and its link as the item's
1576            // `hyperlink` rather than Markdown baked into the text.
1577            Node::LabeledText { label, text, href } => {
1578                let extra = match href {
1579                    Some(url) => json!({ "hyperlink": url }),
1580                    None => json!({}),
1581                };
1582                Some(self.add_text(label, text, parent, extra))
1583            }
1584            Node::Paragraph { text } => {
1585                // A whole-paragraph display equation is a formula item (docling
1586                // wraps it in `$$…$$` and, unlike a text item, never escapes it).
1587                let t = text.trim();
1588                match t.strip_prefix("$$").and_then(|s| s.strip_suffix("$$")) {
1589                    Some(inner) if !inner.is_empty() => Some(self.add_formula(inner, parent)),
1590                    _ => Some(self.add_text("text", text, parent, json!({}))),
1591                }
1592            }
1593            Node::CheckboxItem { checked, text } => {
1594                // docling's checkbox item: the `checkbox_selected` /
1595                // `checkbox_unselected` label carries the state and the text
1596                // is the bare option label (right_to_left_03's `خير`,
1597                // docx_checkboxes' `Orange juice`). The `- [x]` task-list
1598                // marker is Markdown's rendering of it, not item text (#609).
1599                let label = if *checked {
1600                    "checkbox_selected"
1601                } else {
1602                    "checkbox_unselected"
1603                };
1604                Some(self.add_text(label, text, parent, json!({})))
1605            }
1606            Node::Code {
1607                language,
1608                text,
1609                orig,
1610                ..
1611            } => Some(self.add_code(text, language.as_deref(), orig.as_deref(), parent)),
1612            // A CodeFormula-decoded display formula: `text` carries the LaTeX,
1613            // `orig` the raw glyph extraction (docling's enriched shape).
1614            Node::Formula {
1615                latex,
1616                orig,
1617                location,
1618            } => {
1619                self.adopt_loc(*location);
1620                Some(self.add_formula_item(latex, orig, parent))
1621            }
1622            // docling's notes-layer `comment_section` group holding the
1623            // comment's text item. What the annotated items point at differs
1624            // upstream: the docx backend links the group (so a comment's
1625            // replies group together), everything going through
1626            // docling-core's `add_comment` links the note text itself.
1627            Node::CommentSection {
1628                name,
1629                text,
1630                refs_note_text,
1631                grouped,
1632            } => {
1633                if !*grouped {
1634                    // docling-core's bare `add_comment`: the note text sits
1635                    // directly under the parent and is what the back-refs
1636                    // point at.
1637                    let child =
1638                        self.add_text("text", text, parent, json!({ "content_layer": "notes" }));
1639                    self.comment_groups.push(child.clone());
1640                    return Some(child);
1641                }
1642                if let Some((_, self_ref)) = self
1643                    .last_comment_section
1644                    .as_ref()
1645                    .filter(|(n, _)| n == name)
1646                    .cloned()
1647                {
1648                    // Another message of the same thread: a further note in
1649                    // the group already open for this cell.
1650                    let child =
1651                        self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
1652                    if let Some(children) =
1653                        self.groups[group_index(&self_ref)]["children"].as_array_mut()
1654                    {
1655                        children.push(ref_value(child.as_str()));
1656                    }
1657                    self.comment_groups.push(if *refs_note_text {
1658                        child
1659                    } else {
1660                        self_ref.clone()
1661                    });
1662                    return Some(self_ref);
1663                }
1664                let self_ref = format!("#/groups/{}", self.groups.len());
1665                self.groups.push(Value::Null);
1666                let child =
1667                    self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
1668                self.groups[group_index(&self_ref)] = json!({
1669                    "self_ref": self_ref,
1670                    "parent": { "$ref": parent },
1671                    "children": [{ "$ref": child }],
1672                    "content_layer": "notes",
1673                    "name": name,
1674                    "label": "comment_section",
1675                });
1676                self.last_comment_section = Some((name.clone(), self_ref.clone()));
1677                self.comment_groups.push(if *refs_note_text {
1678                    child
1679                } else {
1680                    self_ref.clone()
1681                });
1682                Some(self_ref)
1683            }
1684            // The annotation itself is a cross-reference: emit the item, then
1685            // remember it so the group refs can be filled in at the end.
1686            Node::Commented { comments, inner } => {
1687                let item = self.add_node(inner, parent)?;
1688                if !comments.is_empty() {
1689                    self.pending_comments.push((item.clone(), comments.clone()));
1690                }
1691                Some(item)
1692            }
1693            Node::Table(t) => Some(self.add_table(t, parent)),
1694            Node::Picture {
1695                caption,
1696                caption_href,
1697                image,
1698                classification,
1699                description,
1700                caption_parent,
1701                caption_location,
1702            } => Some(self.add_picture(
1703                caption.as_deref(),
1704                caption_href.as_deref(),
1705                image.as_ref(),
1706                picture_meta(classification.as_deref(), description.as_ref()),
1707                parent,
1708                (*caption_parent, *caption_location),
1709            )),
1710            // A chart is a picture item in the JSON with docling's chart
1711            // meta — `classification` (the chart kind, as the one prediction)
1712            // and `tabular_chart.chart_data`, the series reconstructed as a
1713            // `TableData` (#405) — and no image payload.
1714            Node::Chart {
1715                kind,
1716                table,
1717                caption,
1718                location,
1719            } => {
1720                self.adopt_loc(*location);
1721                let mut meta = json!({
1722                    "classification": { "predictions": [{ "class_name": kind }] },
1723                });
1724                if !table.rows.is_empty() {
1725                    meta["tabular_chart"] = json!({ "chart_data": table_data(table) });
1726                }
1727                // docling's office backends add the chart's title as a caption
1728                // item of the *container* (the sheet group, the slide), listed
1729                // before the picture that references it, with the chart's own
1730                // box and a charspan over the caption text — not as a child
1731                // of the picture, which is where a PDF caption lives.
1732                let mut captions = Vec::new();
1733                if let Some(cap) = caption.as_deref().filter(|c| !c.is_empty()) {
1734                    let prov = self.prov_slot(unescape_text(cap).chars().count(), true);
1735                    let cap_ref = self.add_text_with("caption", cap, parent, json!({}), prov);
1736                    self.pending_siblings.push(ref_value(cap_ref.as_str()));
1737                    captions.push(ref_value(cap_ref));
1738                }
1739                let prov = self.take_prov(0);
1740                Some(self.push_picture(prov, captions, Vec::new(), None, Some(meta), parent))
1741            }
1742            // A DocLang-only node is omitted from the JSON body.
1743            Node::DoclangOnly(_) => None,
1744            Node::Group {
1745                label,
1746                name,
1747                layer,
1748                children,
1749            } => Some(self.add_group(label, name.as_deref(), *layer, children, parent)),
1750            Node::FieldRegion { items } => Some(self.add_field_region(items, parent)),
1751            Node::KeyValueGraph { cells, links } => {
1752                Some(self.add_key_value_graph(cells, links, parent))
1753            }
1754            // A rich inline group is a text item over its Markdown text; the
1755            // structured runs are DocLang-only, so the JSON matches a paragraph.
1756            Node::InlineGroup { md_text, .. } => {
1757                Some(self.add_text("text", md_text, parent, json!({})))
1758            }
1759            // A plain-text backend dump is a single text item over the file body.
1760            Node::TextDump(text) => Some(self.add_text("text", text, parent, json!({}))),
1761            // Speaker notes are content a deck carries, and docling puts them
1762            // in the JSON on their own layer, so a consumer reading only JSON
1763            // can pick them (#402). Page furniture stays out of the flat
1764            // path; a backend that builds docling's item tree (HTML) puts its
1765            // furniture-layer items in the JSON through `write_tree`.
1766            Node::Furniture {
1767                layer: ContentLayer::Notes,
1768                inner,
1769            } => {
1770                let item = self.add_node(inner, parent)?;
1771                self.set_layer(&item, "notes");
1772                Some(item)
1773            }
1774            Node::Furniture { .. } => None,
1775            // PDF page headers and footers: docling writes them as body-parented
1776            // `page_header`/`page_footer` text items on the furniture layer, so a
1777            // JSON consumer can read running headers (printed page ids, dates).
1778            Node::PageFurniture {
1779                footer,
1780                location,
1781                text,
1782            } => {
1783                if self.cur_page > 0 {
1784                    self.pending_loc = Some(*location);
1785                }
1786                let label = if *footer {
1787                    "page_footer"
1788                } else {
1789                    "page_header"
1790                };
1791                let item = self.add_text(label, text, parent, json!({}));
1792                self.pending_loc = None;
1793                self.set_layer(&item, "furniture");
1794                Some(item)
1795            }
1796            // A labelled furniture paragraph (a `.doc` header / footer or
1797            // note body, #535): the item docling's office backends write.
1798            Node::FurnitureText { label, text } => {
1799                let item = self.add_text(label, text, parent, json!({}));
1800                self.set_layer(&item, "furniture");
1801                Some(item)
1802            }
1803            // A PDF picture's contained text (docling's `_add_child_elements`):
1804            // items parented to the picture just written, listed after its
1805            // caption in that picture's `children`.
1806            Node::PictureChildren(children) => {
1807                let pic = self.pictures.len().checked_sub(1)?;
1808                let pic_ref = format!("#/pictures/{pic}");
1809                let refs = self.walk_into(children, &pic_ref);
1810                if let Some(list) = self.pictures[pic]["children"].as_array_mut() {
1811                    list.extend(refs.into_iter().map(Child::into_value));
1812                }
1813                None
1814            }
1815            // A location wrapper turns into the wrapped item's `prov` entry —
1816            // but only on pages the PDF paths described with a PageInfo marker
1817            // (other geometry-bearing backends, e.g. PPTX shapes, keep their
1818            // pre-#171 provenance-less JSON until they emit markers too).
1819            Node::Located { location, inner } => {
1820                if self.cur_page > 0 {
1821                    self.pending_loc = Some(*location);
1822                }
1823                let r = self.add_node(inner, parent);
1824                self.pending_loc = None;
1825                r
1826            }
1827            Node::Track { track, cue, inner } => {
1828                self.pending_track = Some((track.clone(), cue.clone()));
1829                let r = self.add_node(inner, parent);
1830                self.pending_track = None;
1831                r
1832            }
1833            Node::Prov {
1834                page_no,
1835                bbox,
1836                charspan,
1837                inner,
1838                ..
1839            } => {
1840                self.pending_exact = Some(ExactProv {
1841                    page_no: *page_no,
1842                    bbox: bbox.map(f64::from),
1843                    bottom_left: false,
1844                    charspan: *charspan,
1845                });
1846                let r = self.add_node(inner, parent);
1847                self.pending_exact = None;
1848                r
1849            }
1850            // Page breaks are DocLang-only; docling omits them from the JSON body.
1851            Node::PageBreak => None,
1852            // The page marker: record the page's number and size for the
1853            // `pages` map, and denormalize every following location against it.
1854            Node::PageInfo {
1855                page_no,
1856                width,
1857                height,
1858            } => {
1859                self.cur_page = *page_no;
1860                self.cur_w = *width as f64;
1861                self.cur_h = *height as f64;
1862                if *page_no > 0 {
1863                    self.pages.push((*page_no, self.cur_w, self.cur_h));
1864                }
1865                None
1866            }
1867            // Handled by `add_list` in `walk`.
1868            Node::ListItem { .. } => None,
1869        }
1870    }
1871
1872    /// A form key-value region: `field_regions/N` holds the region, each field is
1873    /// a `field_items/M` whose children are its `marker` / `field_key` /
1874    /// `field_value` texts (absent parts are simply omitted).
1875    fn add_field_region(&mut self, items: &[crate::FieldItem], parent: &str) -> String {
1876        let self_ref = format!("#/field_regions/{}", self.field_regions.len());
1877        self.field_regions.push(Value::Null);
1878        let region_index = self.field_regions.len() - 1;
1879        let mut item_refs = Vec::new();
1880        for item in items {
1881            item_refs.push(ref_value(self.add_field_item(item, &self_ref)));
1882        }
1883        self.field_regions[region_index] = json!({
1884            "self_ref": self_ref,
1885            "parent": { "$ref": parent },
1886            "children": item_refs,
1887            "content_layer": "body",
1888            "label": "field_region",
1889            "prov": [],
1890        });
1891        self_ref
1892    }
1893
1894    /// A `KeyValueItem`: docling's `GraphData` written cell for cell and link
1895    /// for link (`key_value_items/N`), the item itself childless and without
1896    /// provenance, the way the XBRL backend creates it.
1897    fn add_key_value_graph(
1898        &mut self,
1899        cells: &[crate::GraphCell],
1900        links: &[crate::GraphLink],
1901        parent: &str,
1902    ) -> String {
1903        let self_ref = format!("#/key_value_items/{}", self.key_value_items.len());
1904        let cells: Vec<Value> = cells
1905            .iter()
1906            .map(|c| {
1907                json!({
1908                    "label": c.label,
1909                    "cell_id": c.cell_id,
1910                    "text": c.text,
1911                    "orig": c.orig,
1912                })
1913            })
1914            .collect();
1915        let links: Vec<Value> = links
1916            .iter()
1917            .map(|l| {
1918                json!({
1919                    "label": l.label,
1920                    "source_cell_id": l.source_cell_id,
1921                    "target_cell_id": l.target_cell_id,
1922                })
1923            })
1924            .collect();
1925        self.key_value_items.push(json!({
1926            "self_ref": self_ref,
1927            "parent": { "$ref": parent },
1928            "children": [],
1929            "content_layer": "body",
1930            "label": "key_value_region",
1931            "prov": [],
1932            "captions": [],
1933            "references": [],
1934            "footnotes": [],
1935            "graph": { "cells": cells, "links": links },
1936        }));
1937        self_ref
1938    }
1939
1940    fn add_field_item(&mut self, item: &crate::FieldItem, parent: &str) -> String {
1941        let self_ref = format!("#/field_items/{}", self.field_items.len());
1942        self.field_items.push(Value::Null);
1943        let item_index = self.field_items.len() - 1;
1944        let mut child_refs = Vec::new();
1945        for (label, text) in [
1946            ("marker", &item.marker),
1947            ("field_key", &item.key),
1948            ("field_value", &item.value),
1949        ] {
1950            if let Some(text) = text {
1951                // A value's `kind` (docling's `read_only` / `fillable`)
1952                // follows its text.
1953                let extra = match (label, &item.value_kind) {
1954                    ("field_value", Some(kind)) => json!({ "kind": kind }),
1955                    _ => json!({}),
1956                };
1957                child_refs.push(ref_value(self.add_text(label, text, &self_ref, extra)));
1958            }
1959        }
1960        self.field_items[item_index] = json!({
1961            "self_ref": self_ref,
1962            "parent": { "$ref": parent },
1963            "children": child_refs,
1964            "content_layer": "body",
1965            "label": "field_item",
1966            "prov": [],
1967        });
1968        self_ref
1969    }
1970
1971    /// Move an already-emitted item onto a content layer. Notes are single
1972    /// text items today; a deeper notes subtree would need its children moved
1973    /// too, and no backend builds one.
1974    fn set_layer(&mut self, self_ref: &str, layer: &str) {
1975        if let Some(item) = self.item_mut(self_ref) {
1976            item["content_layer"] = json!(layer);
1977        }
1978    }
1979
1980    fn add_text(&mut self, label: &str, text: &str, parent: &str, extra: Value) -> String {
1981        let prov = self.take_prov_slot(unescape_text(text).chars().count());
1982        self.add_text_with(label, text, parent, extra, prov)
1983    }
1984
1985    /// [`Self::add_text`] with an explicit `prov` (a chart caption shares the
1986    /// chart's box without consuming it).
1987    fn add_text_with(
1988        &mut self,
1989        label: &str,
1990        text: &str,
1991        parent: &str,
1992        extra: Value,
1993        prov: ProvSlot,
1994    ) -> String {
1995        let index = self.texts.len();
1996        let self_ref = format!("#/texts/{index}");
1997        // A [`Node::Track`] item is docling's ASR text item (#614): the
1998        // segment's words as `text`, its timing as `source` — the `[time: …]`
1999        // prefix the wrapped paragraph shows is Markdown's alone. It carries a
2000        // field the compact [`TextItem`] doesn't, so it is built as JSON.
2001        let mut item = match self.pending_track.take() {
2002            None => Item::Text(Box::new(TextItem {
2003                parent: parent.into(),
2004                label: label.into(),
2005                prov,
2006                text: unescape_text(text).into(),
2007            })),
2008            Some((track, cue)) => {
2009                let mut m = serde_json::Map::with_capacity(9);
2010                m.insert("self_ref".into(), Value::String(self_ref.clone()));
2011                m.insert("parent".into(), ref_value(parent));
2012                m.insert("children".into(), Value::Array(Vec::new()));
2013                m.insert("content_layer".into(), "body".into());
2014                m.insert("label".into(), label.into());
2015                m.insert("prov".into(), prov.to_value());
2016                // `DocItem.source` follows `prov` (docling's field order).
2017                m.insert("source".into(), Value::Array(vec![track_json(&track)]));
2018                m.insert("orig".into(), Value::String(cue.clone()));
2019                m.insert("text".into(), Value::String(cue));
2020                Item::json(Value::Object(m))
2021            }
2022        };
2023        if extra.as_object().is_some_and(|e| !e.is_empty()) {
2024            merge(item.value_mut(index), extra);
2025        }
2026        self.texts.push(item);
2027        self_ref
2028    }
2029
2030    /// A display-math formula item. `latex` is the raw content (no `$$`); docling
2031    /// re-wraps it and never escapes it.
2032    fn add_formula(&mut self, latex: &str, parent: &str) -> String {
2033        let self_ref = format!("#/texts/{}", self.texts.len());
2034        let prov = self.take_prov(latex.chars().count());
2035        self.texts.push(Item::json(json!({
2036            "self_ref": self_ref,
2037            "parent": { "$ref": parent },
2038            "children": [],
2039            "content_layer": "body",
2040            "label": "formula",
2041            "prov": prov,
2042            "orig": latex,
2043            "text": latex,
2044        })));
2045        self_ref
2046    }
2047
2048    /// A CodeFormula-enriched display formula: `text` is the model's LaTeX
2049    /// while `orig` keeps the raw glyph extraction (docling's enriched shape;
2050    /// the plain [`Self::add_formula`] above sets both to the same string).
2051    fn add_formula_item(&mut self, latex: &str, orig: &str, parent: &str) -> String {
2052        let self_ref = format!("#/texts/{}", self.texts.len());
2053        let prov = self.take_prov(latex.chars().count());
2054        self.texts.push(Item::json(json!({
2055            "self_ref": self_ref,
2056            "parent": { "$ref": parent },
2057            "children": [],
2058            "content_layer": "body",
2059            "label": "formula",
2060            "prov": prov,
2061            "orig": orig,
2062            "text": latex,
2063        })));
2064        self_ref
2065    }
2066
2067    fn add_code(
2068        &mut self,
2069        text: &str,
2070        language: Option<&str>,
2071        orig: Option<&str>,
2072        parent: &str,
2073    ) -> String {
2074        let self_ref = format!("#/texts/{}", self.texts.len());
2075        let raw = unescape_text(text);
2076        let prov = self.take_prov(raw.chars().count());
2077        self.texts.push(Item::json(json!({
2078            "self_ref": self_ref,
2079            "parent": { "$ref": parent },
2080            "children": [],
2081            "content_layer": "body",
2082            "label": "code",
2083            "prov": prov,
2084            // With code enrichment, `text` is the model's rewrite while `orig`
2085            // keeps the raw extraction; otherwise both are the same string.
2086            "orig": orig.map(unescape_text).unwrap_or_else(|| raw.clone()),
2087            "text": raw,
2088            "captions": [],
2089            "references": [],
2090            "footnotes": [],
2091            "code_language": code_language(language),
2092        })));
2093        self_ref
2094    }
2095
2096    /// Build a list group from a run of (possibly multi-level) list items. A
2097    /// deeper level starts a nested list under the preceding item.
2098    fn add_list(&mut self, items: &[Node], parent: &str) -> String {
2099        let self_ref = format!("#/groups/{}", self.groups.len());
2100        // reserve the slot so nested groups get later indices
2101        self.groups.push(Value::Null);
2102        let base = level_of(&items[0]);
2103        let mut children = Vec::new();
2104        let mut i = 0;
2105        while i < items.len() {
2106            // Empty paragraphs absorbed into the run (blank lines between items)
2107            // are not list items — skip them.
2108            if !matches!(items[i], Node::ListItem { .. }) {
2109                i += 1;
2110                continue;
2111            }
2112            let lvl = level_of(&items[i]);
2113            if lvl > base {
2114                // shouldn't happen at the head; skip defensively
2115                i += 1;
2116                continue;
2117            }
2118            let item_ref = self.add_list_item(&items[i], &self_ref);
2119            // collect any deeper items that nest under this one
2120            let mut j = i + 1;
2121            while j < items.len() && level_of(&items[j]) > base {
2122                j += 1;
2123            }
2124            if j > i + 1 {
2125                let mut nested = Vec::new();
2126                self.add_sibling_lists(&items[i + 1..j], &item_ref, &mut nested);
2127                // the nested list group(s) are children of this item
2128                if let Some(idx) = ref_index(&item_ref) {
2129                    self.texts[idx].value_mut(idx)["children"]
2130                        .as_array_mut()
2131                        .unwrap()
2132                        .extend(nested);
2133                }
2134            }
2135            children.push(ref_value(item_ref));
2136            i = j;
2137        }
2138        self.groups[group_index(&self_ref)] = json!({
2139            "self_ref": self_ref,
2140            "parent": { "$ref": parent },
2141            "children": children,
2142            "content_layer": "body",
2143            "name": "list",
2144            "label": "list",
2145        });
2146        self_ref
2147    }
2148
2149    fn add_list_item(&mut self, node: &Node, parent: &str) -> String {
2150        let Node::ListItem {
2151            ordered,
2152            number,
2153            text,
2154            location,
2155            ..
2156        } = node
2157        else {
2158            unreachable!()
2159        };
2160        self.adopt_loc(*location);
2161        let self_ref = format!("#/texts/{}", self.texts.len());
2162        let raw = unescape_text(text);
2163        let prov = self.take_prov(raw.chars().count());
2164        let marker = if *ordered {
2165            format!("{number}.")
2166        } else {
2167            "-".to_string()
2168        };
2169        self.texts.push(Item::json(json!({
2170            "self_ref": self_ref,
2171            "parent": { "$ref": parent },
2172            "children": [],
2173            "content_layer": "body",
2174            "label": "list_item",
2175            "prov": prov,
2176            "orig": raw,
2177            "text": raw,
2178            "enumerated": ordered,
2179            "marker": marker,
2180        })));
2181        self_ref
2182    }
2183
2184    fn add_table(&mut self, t: &Table, parent: &str) -> String {
2185        self.add_table_with(t, parent, false)
2186    }
2187
2188    /// [`Self::add_table`]; `raw` cell text is written verbatim (see
2189    /// [`table_data_with`]).
2190    fn add_table_with(&mut self, t: &Table, parent: &str, raw: bool) -> String {
2191        let self_ref = format!("#/tables/{}", self.tables.len());
2192        self.adopt_loc(t.location);
2193        let prov = self.take_prov(0);
2194        // The caption is a separate text item the table references (docling's
2195        // `TableItem.captions`), added before the grid so its box isn't
2196        // inherited by a later item.
2197        let (captions, children) = match t.caption.as_deref().filter(|c| !c.is_empty()) {
2198            Some(cap) => self.add_caption(
2199                cap,
2200                json!({}),
2201                &self_ref,
2202                parent,
2203                (t.caption_parent, t.caption_location),
2204            ),
2205            None => (Vec::new(), Vec::new()),
2206        };
2207        let data = table_data_with(t, raw);
2208        self.tables.push(json!({
2209            "self_ref": self_ref,
2210            "parent": { "$ref": parent },
2211            "children": children,
2212            "content_layer": "body",
2213            "label": "table",
2214            "prov": prov,
2215            "captions": captions,
2216            "references": [],
2217            "footnotes": [],
2218            "data": data,
2219            "annotations": [],
2220        }));
2221        self_ref
2222    }
2223
2224    /// Add a picture's or table's caption text item where `choice` says it
2225    /// hangs (#390), returning the `captions` entry for the item and the
2226    /// item's own `children` (the caption, when it is the item's child).
2227    /// The caption never consumes the item's pending provenance — the item
2228    /// takes its box first. Its own box, when the backend kept one (the PDF
2229    /// pipeline's caption region, #609), becomes the caption's `prov` on the
2230    /// item's page, as docling's reading-order model gives every caption the
2231    /// cluster it came from; without one the caption has no `prov`.
2232    fn add_caption(
2233        &mut self,
2234        text: &str,
2235        extra: Value,
2236        self_ref: &str,
2237        parent: &str,
2238        (choice, location): (CaptionParent, Option<[u16; 4]>),
2239    ) -> (Vec<Value>, Vec<Value>) {
2240        // docling's PDF pipeline parents the caption to the item; every
2241        // declarative backend leaves `add_text`'s default — the body — even
2242        // for an item inside a group; the office backends and HTML's
2243        // `<figure>` hang it off the item's container.
2244        let cap_parent = match choice {
2245            CaptionParent::Item => self_ref,
2246            CaptionParent::Container | CaptionParent::ContainerAfter => parent,
2247            CaptionParent::Body => "#/body",
2248        };
2249        if self.cur_page > 0 && location.is_some() {
2250            self.pending_loc = location;
2251        }
2252        let cap_ref = ref_value(self.add_text("caption", text, cap_parent, extra));
2253        match choice {
2254            CaptionParent::Item => return (vec![cap_ref.clone()], vec![cap_ref]),
2255            // Created ahead of the item, so it precedes the item in the
2256            // container's children — and, on the body, in the body's.
2257            CaptionParent::Container => self.pending_siblings.push(cap_ref.clone()),
2258            CaptionParent::Body if parent == "#/body" => {
2259                self.pending_siblings.push(cap_ref.clone())
2260            }
2261            CaptionParent::ContainerAfter => self.pending_after.push(cap_ref.clone()),
2262            // The item sits deeper: the body's children get the caption after
2263            // the top-level item under walk, where docling appended it.
2264            CaptionParent::Body => self.pending_body.push(cap_ref.clone()),
2265        }
2266        (vec![cap_ref], Vec::new())
2267    }
2268
2269    /// `meta` is the picture's docling `PictureMeta` (a classifier's
2270    /// predictions, a chart's kind and data), `None` for a plain picture.
2271    fn add_picture(
2272        &mut self,
2273        caption: Option<&str>,
2274        caption_href: Option<&str>,
2275        image: Option<&crate::PictureImage>,
2276        meta: Option<Value>,
2277        parent: &str,
2278        caption_at: (CaptionParent, Option<[u16; 4]>),
2279    ) -> String {
2280        let self_ref = format!("#/pictures/{}", self.pictures.len());
2281        // Take the picture's own provenance before the caption text is added —
2282        // the caption is a separate item and must not inherit the crop's box.
2283        let prov = self.take_prov(0);
2284        let (captions, children) = match caption.filter(|c| !c.is_empty()) {
2285            Some(cap) => {
2286                // Emit the caption as a text item that the picture references. A
2287                // wrapping `<a href>`'s link rides as docling's `hyperlink` field
2288                // on the caption item (#328).
2289                let extra = match caption_href {
2290                    Some(href) => json!({ "hyperlink": href }),
2291                    None => json!({}),
2292                };
2293                self.add_caption(cap, extra, &self_ref, parent, caption_at)
2294            }
2295            None => (Vec::new(), Vec::new()),
2296        };
2297        self.push_picture(prov, captions, children, image, meta, parent)
2298    }
2299
2300    /// Append the picture item itself — `prov`, `captions` and `children`
2301    /// (a PDF caption is the picture's child) already settled.
2302    fn push_picture(
2303        &mut self,
2304        prov: Value,
2305        captions: Vec<Value>,
2306        children: Vec<Value>,
2307        image: Option<&crate::PictureImage>,
2308        meta: Option<Value>,
2309        parent: &str,
2310    ) -> String {
2311        let self_ref = format!("#/pictures/{}", self.pictures.len());
2312        // The legacy `classification` annotation rides along with a
2313        // classifier's `meta` (see `classification_meta`); a chart's meta has
2314        // none, like docling's.
2315        let annotations = meta
2316            .as_ref()
2317            .and_then(|m| m.get("annotations").cloned())
2318            .unwrap_or_else(|| json!([]));
2319        let meta = meta.map(|mut m| {
2320            if let Some(obj) = m.as_object_mut() {
2321                obj.remove("annotations");
2322            }
2323            m
2324        });
2325        // `meta` sits between `content_layer` and `label` in docling's field
2326        // order (and `preserve_order` keeps ours byte-compatible), so the item
2327        // is built in one shot per shape rather than patched afterwards.
2328        let mut item = match meta {
2329            Some(meta) => json!({
2330                "self_ref": self_ref,
2331                "parent": { "$ref": parent },
2332                "children": children,
2333                "content_layer": "body",
2334                "meta": meta,
2335                "label": "picture",
2336                "prov": prov,
2337                "captions": captions,
2338                "references": [],
2339                "footnotes": [],
2340                "annotations": annotations,
2341            }),
2342            None => json!({
2343                "self_ref": self_ref,
2344                "parent": { "$ref": parent },
2345                "children": children,
2346                "content_layer": "body",
2347                "label": "picture",
2348                "prov": prov,
2349                "captions": captions,
2350                "references": [],
2351                "footnotes": [],
2352                "annotations": annotations,
2353            }),
2354        };
2355        // docling stores the extracted image as an `ImageRef` (data URI + size,
2356        // the size as floats) between `footnotes` and `annotations` — pydantic
2357        // field order, which `preserve_order` lets us reproduce by rebuilding
2358        // the tail.
2359        if let Some(img) = image {
2360            let image = image_ref_json(img);
2361            if let Some(obj) = item.as_object_mut() {
2362                let annotations = obj.remove("annotations").unwrap_or_else(|| json!([]));
2363                obj.insert("image".into(), image);
2364                obj.insert("annotations".into(), annotations);
2365            }
2366        }
2367        self.pictures.push(item);
2368        self_ref
2369    }
2370
2371    fn add_group(
2372        &mut self,
2373        label: &str,
2374        name: Option<&str>,
2375        layer: Option<ContentLayer>,
2376        nodes: &[Node],
2377        parent: &str,
2378    ) -> String {
2379        let self_ref = format!("#/groups/{}", self.groups.len());
2380        self.groups.push(Value::Null);
2381        // Everything the walk creates belongs to this group, so a non-body
2382        // layer (a hidden sheet) is stamped on the whole subtree afterwards —
2383        // docling puts the layer on the group *and* on every item under it.
2384        let mark = (
2385            self.texts.len(),
2386            self.tables.len(),
2387            self.pictures.len(),
2388            self.groups.len(),
2389        );
2390        let children: Vec<Value> = self
2391            .walk_into(nodes, &self_ref)
2392            .into_iter()
2393            .map(Child::into_value)
2394            .collect();
2395        let name = name.unwrap_or(if label == "inline" { "group" } else { label });
2396        let content_layer = layer.map_or("body", |l| l.value());
2397        self.groups[group_index(&self_ref)] = json!({
2398            "self_ref": self_ref,
2399            "parent": { "$ref": parent },
2400            "children": children,
2401            "content_layer": content_layer,
2402            "name": name,
2403            "label": label,
2404        });
2405        if layer.is_some() {
2406            let (t, tb, p, g) = mark;
2407            for item in self.texts[t..]
2408                .iter_mut()
2409                .enumerate()
2410                .map(|(k, item)| item.value_mut(t + k))
2411                .chain(self.tables[tb..].iter_mut())
2412                .chain(self.pictures[p..].iter_mut())
2413                .chain(self.groups[g..].iter_mut())
2414            {
2415                if let Some(obj) = item.as_object_mut() {
2416                    obj.insert("content_layer".into(), json!(content_layer));
2417                }
2418            }
2419        }
2420        self_ref
2421    }
2422
2423    /// Walk a slice of sibling nodes, returning each child's `$ref`; runs of
2424    /// list items are folded into list groups (one per sibling list).
2425    fn walk_into(&mut self, nodes: &[Node], parent: &str) -> Vec<Child> {
2426        // Siblings that all carry a creation rank (an XLSX sheet's items) are
2427        // *added* in that order — so `#/tables/N` and friends are numbered as
2428        // docling numbers them — while their refs keep the node order, which
2429        // is docling's position-sorted `children`.
2430        let seqs: Option<Vec<usize>> = nodes
2431            .iter()
2432            .map(|n| match n {
2433                Node::Prov { seq: Some(s), .. } => Some(*s),
2434                _ => None,
2435            })
2436            .collect();
2437        if let Some(seqs) = seqs.filter(|s| !s.is_empty()) {
2438            let mut order: Vec<usize> = (0..nodes.len()).collect();
2439            order.sort_by_key(|&i| seqs[i]);
2440            let mut slots: Vec<Vec<Child>> = (0..nodes.len()).map(|_| Vec::new()).collect();
2441            for i in order {
2442                if let Some(r) = self.add_node(&nodes[i], parent) {
2443                    slots[i].extend(self.pending_siblings.drain(..).map(Child::json));
2444                    slots[i].push(Child::Ref(r.into()));
2445                    slots[i].extend(self.pending_after.drain(..).map(Child::json));
2446                }
2447                if parent == "#/body" {
2448                    slots[i].extend(self.pending_body.drain(..).map(Child::json));
2449                }
2450            }
2451            return slots.into_iter().flatten().collect();
2452        }
2453        let mut children = Vec::new();
2454        let mut i = 0;
2455        while i < nodes.len() {
2456            if matches!(nodes[i], Node::ListItem { .. }) {
2457                let start = i;
2458                i += 1;
2459                loop {
2460                    match nodes.get(i) {
2461                        Some(Node::ListItem { .. }) => i += 1,
2462                        // Absorb an empty paragraph sitting between two list
2463                        // items (docling keeps the ListGroup contiguous).
2464                        Some(Node::Paragraph { text })
2465                            if text.is_empty()
2466                                && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
2467                        {
2468                            i += 1
2469                        }
2470                        _ => break,
2471                    }
2472                }
2473                let mut lists = Vec::new();
2474                self.add_sibling_lists(&nodes[start..i], parent, &mut lists);
2475                children.extend(lists.into_iter().map(Child::json));
2476            } else {
2477                if let Some(r) = self.add_node(&nodes[i], parent) {
2478                    children.extend(self.pending_siblings.drain(..).map(Child::json));
2479                    children.push(Child::Ref(r.into()));
2480                    children.extend(self.pending_after.drain(..).map(Child::json));
2481                }
2482                i += 1;
2483            }
2484            // Body-parented captions of items deeper in the tree follow the
2485            // top-level item they were created under (#390).
2486            if parent == "#/body" {
2487                children.extend(self.pending_body.drain(..).map(Child::json));
2488            }
2489        }
2490        children
2491    }
2492
2493    /// A run of list items may hold several *sibling* lists; emit one list group
2494    /// per sibling. The boundary is the backend's `first_in_list` flag on a
2495    /// base-level item — the same rule as the Markdown serializer's blank line
2496    /// (#385; the kind-flip and number-gap guesses are gone).
2497    fn add_sibling_lists(&mut self, run: &[Node], parent: &str, out: &mut Vec<Value>) {
2498        let base = level_of(&run[0]);
2499        let mut seg = 0;
2500        for k in 0..run.len() {
2501            let Node::ListItem {
2502                first_in_list,
2503                level,
2504                ..
2505            } = &run[k]
2506            else {
2507                continue;
2508            };
2509            if *level != base {
2510                continue; // nested item — handled inside add_list
2511            }
2512            if k > seg && *first_in_list {
2513                out.push(ref_value(self.add_list(&run[seg..k], parent)));
2514                seg = k;
2515            }
2516        }
2517        out.push(ref_value(self.add_list(&run[seg..], parent)));
2518    }
2519}
2520
2521fn level_of(node: &Node) -> u8 {
2522    match node {
2523        Node::ListItem { level, .. } => *level,
2524        _ => 0,
2525    }
2526}
2527
2528fn group_index(self_ref: &str) -> usize {
2529    self_ref.rsplit('/').next().unwrap().parse().unwrap()
2530}
2531
2532fn ref_index(self_ref: &str) -> Option<usize> {
2533    self_ref.rsplit('/').next()?.parse().ok()
2534}
2535
2536/// Merge the key/values of `extra` (an object) into `target` (an object).
2537fn merge(target: &mut Value, extra: Value) {
2538    if let (Some(t), Value::Object(e)) = (target.as_object_mut(), extra) {
2539        t.extend(e);
2540    }
2541}
2542
2543/// A JSON object of exactly these entries, in this order. The hot per-item
2544/// constructors use this instead of `json!`: the macro grows its map by
2545/// doubling (an 8-key item kept 14 slots) and deep-copies every interpolated
2546/// `Value` through `to_value`. On a long text-heavy PDF those item objects
2547/// *are* the export's footprint (the same reasoning as [`cell_value`]).
2548fn object<const N: usize>(entries: [(&str, Value); N]) -> Value {
2549    let mut m = serde_json::Map::with_capacity(N);
2550    for (k, v) in entries {
2551        m.insert(k.to_owned(), v);
2552    }
2553    Value::Object(m)
2554}
2555
2556/// A `{"$ref": r}` reference object.
2557fn ref_value(r: impl Into<String>) -> Value {
2558    object([("$ref", Value::String(r.into()))])
2559}
2560
2561/// Reverse [`crate`]'s Markdown text escaping (HTML entities + `\_`).
2562fn unescape_text(s: &str) -> String {
2563    s.replace("&lt;", "<")
2564        .replace("&gt;", ">")
2565        .replace("&amp;", "&")
2566        .replace("\\_", "_")
2567}
2568
2569/// 64-bit FNV-1a, a stand-in for docling's `binary_hash` (we lack the source bytes
2570/// at export time; the value only needs to be a stable u64).
2571fn fnv1a(s: &str) -> u64 {
2572    let mut h: u64 = 0xcbf29ce484222325;
2573    for b in s.bytes() {
2574        h ^= b as u64;
2575        h = h.wrapping_mul(0x100000001b3);
2576    }
2577    h
2578}
2579
2580#[cfg(test)]
2581mod tests {
2582    use crate::{
2583        CaptionParent, ContentLayer, DoclingDocument, ImageMode, Node, PictureImage, Table,
2584    };
2585    use serde_json::Value;
2586
2587    fn cell_at(text: &str, row: usize, col: usize, col_span: usize) -> crate::TableCell {
2588        crate::TableCell {
2589            text: text.into(),
2590            bbox: None,
2591            start_row: row,
2592            start_col: col,
2593            row_span: 1,
2594            col_span,
2595            column_header: row == 0,
2596            row_header: false,
2597            row_section: false,
2598        }
2599    }
2600
2601    /// First-class cells past the rows: rectangular rows are the grid and
2602    /// clip them (docling's `TableData.grid` on USPTO's overrun replicas);
2603    /// ragged rows — an xlsx table compacted by `skip_empty_cells` (#271) —
2604    /// are no grid, so the cells' extent sizes it and the omitted positions
2605    /// come back as empty filler cells in `grid` only.
2606    #[test]
2607    fn ragged_rows_take_their_grid_from_first_class_cells() {
2608        let cells = vec![
2609            cell_at("h0", 0, 0, 1),
2610            cell_at("h2", 0, 2, 1),
2611            cell_at("wide", 1, 0, 3),
2612        ];
2613        let export = |rows: Vec<Vec<String>>| -> Value {
2614            let mut doc = DoclingDocument::new("t");
2615            doc.push(Node::Table(Table {
2616                rows,
2617                cells: Some(cells.clone()),
2618                ..Table::default()
2619            }));
2620            let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2621            v["tables"][0]["data"].clone()
2622        };
2623        // Compacted rows: 2 and 3 wide, the cells say the grid is 3 wide.
2624        let data = export(vec![
2625            vec!["h0".into(), "h2".into()],
2626            vec!["wide".into(), "wide".into(), "wide".into()],
2627        ]);
2628        assert_eq!(data["num_rows"], 2);
2629        assert_eq!(data["num_cols"], 3);
2630        assert_eq!(data["table_cells"].as_array().unwrap().len(), 3);
2631        let row0: Vec<&str> = data["grid"][0]
2632            .as_array()
2633            .unwrap()
2634            .iter()
2635            .map(|c| c["text"].as_str().unwrap())
2636            .collect();
2637        assert_eq!(row0, ["h0", "", "h2"]);
2638        assert_eq!(data["table_cells"][2]["col_span"], 3);
2639        // Rectangular rows narrower than the cells: the rows win, the cells
2640        // are clipped to them exactly as before.
2641        let data = export(vec![
2642            vec!["h0".into(), "h2".into()],
2643            vec!["wide".into(), "wide".into()],
2644        ]);
2645        assert_eq!(data["num_cols"], 2);
2646        assert_eq!(data["grid"][0].as_array().unwrap().len(), 2);
2647    }
2648
2649    fn doc_with_image() -> DoclingDocument {
2650        let mut doc = DoclingDocument::new("t");
2651        doc.push(Node::Picture {
2652            caption: Some("Fig 1".into()),
2653            caption_href: None,
2654            image: Some(PictureImage {
2655                dpi: crate::PictureImage::DEFAULT_DPI,
2656                mimetype: "image/png".into(),
2657                width: 4,
2658                height: 2,
2659                data: b"foobar".to_vec(),
2660            }),
2661            classification: None,
2662            description: None,
2663            caption_parent: Default::default(),
2664            caption_location: None,
2665        });
2666        doc
2667    }
2668
2669    /// #402: a deck's speaker notes are content, and docling puts them in the
2670    /// JSON on the `notes` layer so a consumer reading only JSON can pick them
2671    /// out. Markdown still serializes the body layer alone, and page furniture
2672    /// stays out of the JSON, where docling does keep it.
2673    #[test]
2674    fn notes_layer_items_reach_the_json_but_furniture_does_not() {
2675        let mut doc = DoclingDocument::new("t");
2676        doc.push(Node::Heading {
2677            level: 1,
2678            text: "Slide One".into(),
2679        });
2680        doc.push(Node::Furniture {
2681            layer: ContentLayer::Notes,
2682            inner: Box::new(Node::Located {
2683                location: [0, 0, 0, 0],
2684                inner: Box::new(Node::Paragraph {
2685                    text: "Speaker note for slide 1.".into(),
2686                }),
2687            }),
2688        });
2689        doc.push(Node::Furniture {
2690            layer: ContentLayer::Furniture,
2691            inner: Box::new(Node::Paragraph {
2692                text: "page header".into(),
2693            }),
2694        });
2695
2696        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2697        let texts = v["texts"].as_array().unwrap();
2698        assert_eq!(
2699            texts
2700                .iter()
2701                .map(|t| (
2702                    t["label"].as_str().unwrap(),
2703                    t["content_layer"].as_str().unwrap(),
2704                    t["text"].as_str().unwrap()
2705                ))
2706                .collect::<Vec<_>>(),
2707            vec![
2708                ("title", "body", "Slide One"),
2709                ("text", "notes", "Speaker note for slide 1."),
2710            ],
2711            "the note is carried on its own layer; the furniture is not carried"
2712        );
2713        // The body layer is what Markdown serializes, so it does not change.
2714        assert_eq!(doc.export_to_markdown(), "# Slide One\n");
2715    }
2716
2717    /// #410: a backend that describes merged ranges only as continuation
2718    /// flags (xlsx `<mergeCell>`, docx `gridSpan`/`vMerge`) gets docling's
2719    /// one-`TableCell`-per-range JSON: the anchor's offsets and spans, the
2720    /// entry repeated across the grid positions it covers — not a 1×1 cell
2721    /// per position with the text copied into each.
2722    #[test]
2723    fn continuation_flags_become_spanning_cells() {
2724        let mut doc = DoclingDocument::new("t");
2725        // A1:C2 merged ("merged"), then a plain row underneath.
2726        let rows = vec![
2727            vec!["merged".to_string(), "merged".into(), "merged".into()],
2728            vec!["merged".to_string(), "merged".into(), "merged".into()],
2729            vec!["a".to_string(), "b".into(), "c".into()],
2730        ];
2731        doc.push(Node::Table(crate::Table {
2732            rows,
2733            location: None,
2734            structure: Some(crate::TableStructure {
2735                header_row: vec![true, false, false],
2736                col_continuation: vec![
2737                    vec![false, true, true],
2738                    vec![false, true, true],
2739                    vec![false, false, false],
2740                ],
2741                row_continuation: vec![
2742                    vec![false, false, false],
2743                    vec![true, true, true],
2744                    vec![false, false, false],
2745                ],
2746                row_header: Vec::new(),
2747                col_header: Vec::new(),
2748            }),
2749            cell_blocks: None,
2750            cells: None,
2751            caption: None,
2752            caption_parent: Default::default(),
2753            caption_location: None,
2754        }));
2755        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2756        let data = &v["tables"][0]["data"];
2757        assert_eq!(data["num_rows"], 3);
2758        assert_eq!(data["num_cols"], 3);
2759        let cells = data["table_cells"].as_array().unwrap();
2760        assert_eq!(
2761            cells.len(),
2762            4,
2763            "one cell for the range, three for the plain row"
2764        );
2765        assert_eq!(
2766            cells[0],
2767            serde_json::json!({
2768                "row_span": 2, "col_span": 3,
2769                "start_row_offset_idx": 0, "end_row_offset_idx": 2,
2770                "start_col_offset_idx": 0, "end_col_offset_idx": 3,
2771                "text": "merged", "column_header": true, "row_header": false,
2772                "row_section": false, "fillable": false,
2773            })
2774        );
2775        assert_eq!(cells[1]["text"], "a");
2776        assert_eq!(cells[1]["row_span"], 1);
2777        assert_eq!(cells[1]["column_header"], false);
2778        // The grid repeats the range's entry at every position it covers.
2779        let grid = data["grid"].as_array().unwrap();
2780        assert_eq!(grid.len(), 3);
2781        for (r, row) in grid.iter().take(2).enumerate() {
2782            for (c, cell) in row.as_array().unwrap().iter().enumerate() {
2783                assert_eq!(*cell, cells[0], "grid[{r}][{c}]");
2784            }
2785        }
2786        assert_eq!(grid[2][2]["text"], "c");
2787    }
2788
2789    /// A [`Node::Prov`] wrapper is docling's provenance verbatim — the exact
2790    /// box in a top-left origin, the backend's charspan — and the page marker
2791    /// before it sizes the page; a chart's caption becomes a sibling of the
2792    /// picture in the container, listed first, sharing the chart's box with
2793    /// a charspan over its text. That is the JSON shape of an XLSX sheet.
2794    #[test]
2795    fn exact_provenance_pages_and_chart_captions_follow_docling() {
2796        let mut doc = DoclingDocument::new("t");
2797        doc.push(Node::PageInfo {
2798            page_no: 1,
2799            width: 3.0,
2800            height: 4.0,
2801        });
2802        let table = crate::Table {
2803            rows: vec![vec!["a".to_string(), "b".into()]],
2804            ..Default::default()
2805        };
2806        doc.push(Node::Group {
2807            label: "sheet".into(),
2808            name: Some("Data".into()),
2809            layer: None,
2810            children: vec![
2811                // Node order is the position-sorted one; creation order (the
2812                // `seq`) had the chart first — so the chart is `#/pictures/0`
2813                // *and* its caption `#/texts/0`, while the table stays the
2814                // group's first child.
2815                Node::Prov {
2816                    page_no: 1,
2817                    bbox: [0.0, 0.0, 3.0, 4.0],
2818                    charspan: [0, 0],
2819                    seq: Some(1),
2820                    inner: Box::new(Node::Table(table.clone())),
2821                },
2822                Node::Prov {
2823                    page_no: 1,
2824                    bbox: [0.0, 1.0, 1.0, 1.0],
2825                    charspan: [0, 0],
2826                    seq: Some(0),
2827                    inner: Box::new(Node::Chart {
2828                        kind: "bar_chart".into(),
2829                        table,
2830                        caption: Some("Sales".into()),
2831                        location: Some([0, 128, 170, 128]),
2832                    }),
2833                },
2834            ],
2835        });
2836        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2837        assert_eq!(
2838            v["pages"],
2839            serde_json::json!({"1": {"size": {"width": 3.0, "height": 4.0}, "page_no": 1}})
2840        );
2841        assert_eq!(
2842            v["tables"][0]["prov"],
2843            serde_json::json!([{
2844                "page_no": 1,
2845                "bbox": {"l": 0.0, "t": 0.0, "r": 3.0, "b": 4.0, "coord_origin": "TOPLEFT"},
2846                "charspan": [0, 0],
2847            }])
2848        );
2849        assert_eq!(v["tables"][0]["data"]["orientation"], "rot_0");
2850        // The caption is the group's child *before* the picture, parented to
2851        // the group, and referenced by the picture.
2852        let sheet = &v["groups"][0];
2853        assert_eq!(
2854            sheet["children"],
2855            serde_json::json!([
2856                {"$ref": "#/tables/0"}, {"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}
2857            ])
2858        );
2859        let cap = &v["texts"][0];
2860        assert_eq!(cap["label"], "caption");
2861        assert_eq!(cap["parent"], serde_json::json!({"$ref": "#/groups/0"}));
2862        assert_eq!(cap["prov"][0]["charspan"], serde_json::json!([0, 5]));
2863        assert_eq!(cap["prov"][0]["bbox"]["b"], 1.0);
2864        let pic = &v["pictures"][0];
2865        assert_eq!(pic["captions"], serde_json::json!([{"$ref": "#/texts/0"}]));
2866        assert_eq!(pic["prov"][0]["charspan"], serde_json::json!([0, 0]));
2867        assert_eq!(pic["prov"][0]["bbox"]["coord_origin"], "TOPLEFT");
2868        assert_eq!(
2869            pic["meta"]["classification"]["predictions"][0]["class_name"],
2870            "bar_chart"
2871        );
2872        assert_eq!(pic["meta"]["tabular_chart"]["chart_data"]["num_cols"], 2);
2873    }
2874
2875    /// An all-zero location is the "no geometry" sentinel — a slide's speaker
2876    /// notes carry one — and docling writes it as a zero bbox, not as a box
2877    /// spanning the whole page, which is what denormalizing the grid gives.
2878    #[test]
2879    fn a_zero_location_is_a_zero_bbox_not_the_whole_page() {
2880        let mut doc = DoclingDocument::new("t");
2881        doc.push(Node::PageInfo {
2882            page_no: 1,
2883            width: 12192000.0,
2884            height: 6858000.0,
2885        });
2886        doc.push(Node::Furniture {
2887            layer: ContentLayer::Notes,
2888            inner: Box::new(Node::Located {
2889                location: [0, 0, 0, 0],
2890                inner: Box::new(Node::Paragraph {
2891                    text: "a note".into(),
2892                }),
2893            }),
2894        });
2895        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2896        let prov = &v["texts"][0]["prov"][0];
2897        assert_eq!(prov["page_no"], 1);
2898        assert_eq!(prov["charspan"], serde_json::json!([0, 6]));
2899        assert_eq!(
2900            prov["bbox"],
2901            serde_json::json!({"l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT"})
2902        );
2903        // The page itself is recorded at its true size.
2904        assert_eq!(
2905            v["pages"]["1"]["size"],
2906            serde_json::json!({"width": 12192000.0, "height": 6858000.0})
2907        );
2908    }
2909
2910    /// #171: PageInfo markers become the `pages` map, and `Located` wrappers /
2911    /// node-level locations become per-item `prov` — the 0–511 grid
2912    /// denormalized against the page into BOTTOMLEFT points. Without markers
2913    /// (every declarative backend) the JSON stays exactly as before: empty
2914    /// `pages`, `prov: []` even for located nodes.
2915    /// #609: a PDF caption and a checkbox item carry their own page and box.
2916    /// docling's reading-order model gives every caption the cluster it came
2917    /// from (`_add_caption_or_footnote`), not its picture's or table's, and
2918    /// a checkbox is a text item like any other.
2919    #[test]
2920    fn captions_and_checkboxes_carry_their_own_prov() {
2921        let mut doc = DoclingDocument::new("t");
2922        doc.push(Node::PageInfo {
2923            page_no: 1,
2924            width: 512.0,
2925            height: 512.0,
2926        });
2927        doc.push(Node::Located {
2928            location: [64, 64, 448, 256],
2929            inner: Box::new(Node::Picture {
2930                caption: Some("Figure 1".into()),
2931                caption_href: None,
2932                image: None,
2933                classification: None,
2934                description: None,
2935                caption_parent: CaptionParent::Item,
2936                caption_location: Some([64, 264, 448, 280]),
2937            }),
2938        });
2939        doc.push(Node::PageInfo {
2940            page_no: 2,
2941            width: 512.0,
2942            height: 512.0,
2943        });
2944        doc.push(Node::Located {
2945            location: [64, 120, 448, 300],
2946            inner: Box::new(Node::Table(Table {
2947                rows: vec![vec!["a".into()]],
2948                caption: Some("Table 1".into()),
2949                caption_parent: CaptionParent::Item,
2950                caption_location: Some([64, 100, 300, 112]),
2951                ..Table::default()
2952            })),
2953        });
2954        doc.push(Node::Located {
2955            location: [64, 320, 200, 332],
2956            inner: Box::new(Node::CheckboxItem {
2957                checked: false,
2958                text: "First option".into(),
2959            }),
2960        });
2961        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2962        let texts = v["texts"].as_array().unwrap();
2963        let by_text = |t: &str| texts.iter().find(|x| x["text"] == t).expect(t).clone();
2964        let bbox = |p: &Value| {
2965            let b = &p["prov"][0]["bbox"];
2966            [&b["l"], &b["t"], &b["r"], &b["b"]].map(|x| x.as_f64().unwrap())
2967        };
2968        // Figure 1: its own box (grid == points on a 512 page, flipped to
2969        // BOTTOMLEFT), not the picture's.
2970        let fig = by_text("Figure 1");
2971        assert_eq!(fig["prov"][0]["page_no"], 1);
2972        assert_eq!(bbox(&fig), [64.0, 248.0, 448.0, 232.0]);
2973        assert_eq!(fig["prov"][0]["charspan"], serde_json::json!([0, 8]));
2974        assert_eq!(fig["parent"]["$ref"], "#/pictures/0");
2975        assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
2976        // Table 1 on page 2, its own box; the table keeps its own.
2977        let tab = by_text("Table 1");
2978        assert_eq!(tab["prov"][0]["page_no"], 2);
2979        assert_eq!(bbox(&tab), [64.0, 412.0, 300.0, 400.0]);
2980        assert_eq!(v["tables"][0]["prov"][0]["bbox"]["t"], 392.0);
2981        // The checkbox item.
2982        let cb = by_text("First option");
2983        assert_eq!(cb["label"], "checkbox_unselected");
2984        assert_eq!(cb["prov"][0]["page_no"], 2);
2985        assert_eq!(bbox(&cb), [64.0, 192.0, 200.0, 180.0]);
2986        assert!(texts
2987            .iter()
2988            .all(|t| !t["prov"].as_array().unwrap().is_empty()));
2989
2990        // Without a caption box (every declarative backend) the caption
2991        // still has no prov, and nothing else moves.
2992        let mut plain = DoclingDocument::new("t");
2993        plain.push(Node::PageInfo {
2994            page_no: 1,
2995            width: 512.0,
2996            height: 512.0,
2997        });
2998        plain.push(Node::Located {
2999            location: [64, 64, 448, 256],
3000            inner: Box::new(Node::Picture {
3001                caption: Some("Figure 1".into()),
3002                caption_href: None,
3003                image: None,
3004                classification: None,
3005                description: None,
3006                caption_parent: CaptionParent::Item,
3007                caption_location: None,
3008            }),
3009        });
3010        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
3011        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
3012        assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
3013    }
3014
3015    #[test]
3016    fn page_markers_produce_pages_and_prov() {
3017        let mut doc = DoclingDocument::new("t");
3018        doc.push(Node::PageInfo {
3019            page_no: 1,
3020            width: 512.0,
3021            height: 1024.0,
3022        });
3023        doc.push(Node::Located {
3024            location: [128, 64, 256, 128], // quarter/eighth points of the grid
3025            inner: Box::new(Node::Paragraph {
3026                text: "hello".into(),
3027            }),
3028        });
3029        doc.push(Node::Table(Table {
3030            rows: vec![vec!["a".into()]],
3031            location: Some([0, 0, 512, 512]),
3032            ..Table::default()
3033        }));
3034        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3035        assert_eq!(v["pages"]["1"]["page_no"], 1);
3036        assert_eq!(v["pages"]["1"]["size"]["width"], 512.0);
3037        assert_eq!(v["pages"]["1"]["size"]["height"], 1024.0);
3038        // 512-wide page: grid x scales 1:1; 1024-high: grid y doubles, then
3039        // flips to the BOTTOMLEFT origin (t from grid-top 64 → 1024-128=896).
3040        let prov = &v["texts"][0]["prov"][0];
3041        assert_eq!(prov["page_no"], 1);
3042        assert_eq!(prov["bbox"]["l"], 128.0);
3043        assert_eq!(prov["bbox"]["t"], 896.0);
3044        assert_eq!(prov["bbox"]["r"], 256.0);
3045        assert_eq!(prov["bbox"]["b"], 768.0);
3046        assert_eq!(prov["bbox"]["coord_origin"], "BOTTOMLEFT");
3047        assert_eq!(prov["charspan"][1], 5);
3048        // The table adopts its own location field; charspan is [0, 0].
3049        let tprov = &v["tables"][0]["prov"][0];
3050        assert_eq!(tprov["bbox"]["t"], 1024.0);
3051        assert_eq!(tprov["bbox"]["b"], 0.0);
3052        assert_eq!(tprov["charspan"][1], 0);
3053
3054        // No markers → the pre-#171 shape, byte for byte.
3055        let mut plain = DoclingDocument::new("t");
3056        plain.push(Node::Located {
3057            location: [1, 2, 3, 4],
3058            inner: Box::new(Node::Paragraph { text: "x".into() }),
3059        });
3060        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
3061        assert_eq!(v["pages"], serde_json::json!({}));
3062        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
3063    }
3064
3065    /// PDF page headers/footers reach the JSON as docling writes them:
3066    /// body-parented text items on the furniture layer, with their box.
3067    #[test]
3068    fn page_furniture_becomes_furniture_layer_text() {
3069        let mut doc = DoclingDocument::new("t");
3070        doc.push(Node::PageInfo {
3071            page_no: 1,
3072            width: 512.0,
3073            height: 512.0,
3074        });
3075        doc.push(Node::PageFurniture {
3076            footer: false,
3077            location: [10, 0, 100, 20],
3078            text: "Chapter 1".into(),
3079        });
3080        doc.push(Node::Paragraph {
3081            text: "body".into(),
3082        });
3083        doc.push(Node::PageFurniture {
3084            footer: true,
3085            location: [400, 490, 500, 512],
3086            text: "1.10.2".into(),
3087        });
3088        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3089        let texts = v["texts"].as_array().unwrap();
3090        assert_eq!(texts.len(), 3);
3091        assert_eq!(texts[0]["label"], "page_header");
3092        assert_eq!(texts[0]["content_layer"], "furniture");
3093        assert_eq!(texts[0]["parent"]["$ref"], "#/body");
3094        assert_eq!(texts[0]["prov"][0]["bbox"]["l"], 10.0);
3095        assert_eq!(texts[1]["content_layer"], "body");
3096        assert_eq!(texts[1]["prov"], serde_json::json!([]));
3097        assert_eq!(texts[2]["label"], "page_footer");
3098        assert_eq!(texts[2]["text"], "1.10.2");
3099        assert_eq!(texts[2]["content_layer"], "furniture");
3100    }
3101
3102    #[test]
3103    fn picture_image_in_markdown_modes_and_json() {
3104        let doc = doc_with_image();
3105        // placeholder (default) ignores the image
3106        assert!(doc.export_to_markdown().contains("<!-- image -->"));
3107        // embedded → base64 data URI (b"foobar" → "Zm9vYmFy")
3108        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Embedded, "artifacts");
3109        assert!(
3110            md.contains("![Image](data:image/png;base64,Zm9vYmFy)"),
3111            "got:\n{md}"
3112        );
3113        assert!(files.is_empty());
3114        // referenced → file link + collected bytes
3115        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Referenced, "artifacts");
3116        assert!(
3117            md.contains("![Image](artifacts/image_000000.png)"),
3118            "got:\n{md}"
3119        );
3120        assert_eq!(
3121            files,
3122            vec![("artifacts/image_000000.png".to_string(), b"foobar".to_vec())]
3123        );
3124        // JSON carries the ImageRef (data URI + size — floats, as docling's
3125        // `Size` is — placed before `annotations`).
3126        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3127        assert_eq!(v["pictures"][0]["image"]["mimetype"], "image/png");
3128        assert_eq!(v["pictures"][0]["image"]["size"]["width"], 4.0);
3129        let keys: Vec<&str> = v["pictures"][0]
3130            .as_object()
3131            .unwrap()
3132            .keys()
3133            .map(String::as_str)
3134            .collect();
3135        assert_eq!(&keys[keys.len() - 2..], ["image", "annotations"]);
3136        assert_eq!(
3137            v["pictures"][0]["image"]["uri"],
3138            "data:image/png;base64,Zm9vYmFy"
3139        );
3140    }
3141
3142    #[test]
3143    fn exports_docling_schema() {
3144        let mut doc = DoclingDocument::new("t");
3145        doc.push(Node::Heading {
3146            level: 1,
3147            text: "Title".into(),
3148        });
3149        doc.push(Node::Heading {
3150            level: 2,
3151            text: "Sec".into(),
3152        });
3153        doc.push(Node::Paragraph {
3154            text: "Body &amp; more".into(),
3155        }); // markdown-escaped
3156        doc.push(Node::ListItem {
3157            ordered: false,
3158            number: 0,
3159            first_in_list: true,
3160            text: "one".into(),
3161            level: 0,
3162            marker: None,
3163            location: None,
3164            dclx: None,
3165            href: None,
3166            layer: None,
3167        });
3168        doc.push(Node::ListItem {
3169            ordered: false,
3170            number: 0,
3171            first_in_list: false,
3172            text: "two".into(),
3173            level: 0,
3174            marker: None,
3175            location: None,
3176            dclx: None,
3177            href: None,
3178            layer: None,
3179        });
3180        doc.push(Node::Table(Table {
3181            rows: vec![vec!["A".into(), "B".into()]],
3182            location: None,
3183            structure: None,
3184            cell_blocks: None,
3185            cells: None,
3186            caption: None,
3187            caption_parent: Default::default(),
3188            caption_location: None,
3189        }));
3190
3191        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3192        assert_eq!(v["schema_name"], "DoclingDocument");
3193        assert_eq!(v["version"], "1.10.0");
3194        assert_eq!(v["texts"][0]["label"], "title");
3195        assert_eq!(v["texts"][1]["label"], "section_header");
3196        assert_eq!(v["texts"][1]["level"], 1); // heading level 2 → docling level 1
3197        assert_eq!(v["texts"][2]["text"], "Body & more"); // un-escaped for the wire format
3198                                                          // consecutive list items fold into one list group, parented to it
3199        assert_eq!(v["groups"][0]["label"], "list");
3200        assert_eq!(v["groups"][0]["children"].as_array().unwrap().len(), 2);
3201        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
3202        assert_eq!(v["texts"][3]["marker"], "-");
3203        // table grid + header flag
3204        assert_eq!(v["tables"][0]["data"]["num_cols"], 2);
3205        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["column_header"], true);
3206    }
3207    /// A named group on a non-body layer — docling's hidden spreadsheet sheet:
3208    /// the group carries the sheet's name and the `invisible` layer, and every
3209    /// item inside it carries the layer too.
3210    #[test]
3211    fn a_layered_group_stamps_its_whole_subtree() {
3212        let doc = DoclingDocument {
3213            name: "s".into(),
3214            nodes: vec![
3215                Node::Group {
3216                    label: "sheet".into(),
3217                    name: Some("Sheet1".into()),
3218                    layer: None,
3219                    children: vec![Node::Paragraph {
3220                        text: "visible".into(),
3221                    }],
3222                },
3223                Node::Group {
3224                    label: "sheet".into(),
3225                    name: Some("Sheet2".into()),
3226                    layer: Some(ContentLayer::Invisible),
3227                    children: vec![Node::Paragraph {
3228                        text: "hidden".into(),
3229                    }],
3230                },
3231            ],
3232            ..DoclingDocument::new("s")
3233        };
3234        let v = crate::json::to_json(&doc);
3235        assert_eq!(v["groups"][0]["label"], "sheet");
3236        assert_eq!(v["groups"][0]["name"], "Sheet1");
3237        assert_eq!(v["groups"][0]["content_layer"], "body");
3238        assert_eq!(v["texts"][0]["content_layer"], "body");
3239        assert_eq!(v["groups"][1]["name"], "Sheet2");
3240        assert_eq!(v["groups"][1]["content_layer"], "invisible");
3241        assert_eq!(v["texts"][1]["content_layer"], "invisible");
3242        // The group's children are the items, and the body holds the groups.
3243        assert_eq!(v["groups"][1]["children"][0]["$ref"], "#/texts/1");
3244        assert_eq!(v["body"]["children"][1]["$ref"], "#/groups/1");
3245    }
3246
3247    /// A backend-built item tree is serialized as it is: items numbered in
3248    /// creation order per bucket (a field region's part texts included), the
3249    /// tree's parents / children / layers, docling's field order for
3250    /// `formatting`, `hyperlink`, `level`, `enumerated`/`marker`, a rich
3251    /// cell's `ref` on `table_cells` only, raw cell text.
3252    /// The DOCX tree's extras: an item `delete`d (docling's `delete_items`,
3253    /// the spacer between two items of a resumed list) is neither written nor
3254    /// numbered, `comments` back-refs sit between `prov` and `orig`, and a
3255    /// chart picture carries `classification` plus `tabular_chart`.
3256    #[test]
3257    fn deleted_items_comment_refs_and_chart_meta_in_the_tree() {
3258        use crate::tree::{ItemTree, TreeKind};
3259        let mut t = ItemTree::default();
3260        let text = |txt: &str| TreeKind::Text {
3261            label: "text".into(),
3262            text: txt.into(),
3263            orig: None,
3264            formatting: None,
3265            hyperlink: None,
3266            level: None,
3267            list: None,
3268        };
3269        let a = t.add(None, None, text("a"));
3270        let blank = t.add(None, None, text(""));
3271        let b = t.add(None, None, text("b"));
3272        t.delete(blank);
3273        let group = t.add(
3274            None,
3275            Some(ContentLayer::Notes),
3276            TreeKind::Group {
3277                label: "comment_section".into(),
3278                name: "comment-0".into(),
3279            },
3280        );
3281        t.add(Some(group), Some(ContentLayer::Notes), text("note"));
3282        t.items[a].comments.push(group);
3283        t.add(
3284            None,
3285            None,
3286            TreeKind::Picture {
3287                captions: Vec::new(),
3288                image: None,
3289                classification: Some("bar_chart".into()),
3290                description: None,
3291                confidence: None,
3292                chart: Some(Table {
3293                    rows: vec![vec!["".into(), "s".into()], vec!["c".into(), "1".into()]],
3294                    ..Table::default()
3295                }),
3296                dpi: None,
3297            },
3298        );
3299        assert_eq!(t.last_text(), Some(4), "the note; the blank is skipped");
3300        assert_eq!(t.bucket_index(b), 1, "numbered past the deleted item");
3301        let mut doc = DoclingDocument::new("t");
3302        doc.tree = Some(t);
3303        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3304        let texts = v["texts"].as_array().unwrap();
3305        assert_eq!(texts.len(), 3);
3306        assert_eq!(texts[1]["text"], "b");
3307        assert_eq!(texts[1]["self_ref"], "#/texts/1");
3308        assert_eq!(
3309            v["body"]["children"],
3310            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}, {"$ref": "#/groups/0"}, {"$ref": "#/pictures/0"}])
3311        );
3312        let keys: Vec<&str> = texts[0]
3313            .as_object()
3314            .unwrap()
3315            .keys()
3316            .map(String::as_str)
3317            .collect();
3318        assert_eq!(
3319            keys,
3320            vec![
3321                "self_ref",
3322                "parent",
3323                "children",
3324                "content_layer",
3325                "label",
3326                "prov",
3327                "comments",
3328                "orig",
3329                "text"
3330            ]
3331        );
3332        assert_eq!(
3333            texts[0]["comments"],
3334            serde_json::json!([{"$ref": "#/groups/0"}])
3335        );
3336        assert!(texts[1].get("comments").is_none());
3337        let meta = &v["pictures"][0]["meta"];
3338        assert_eq!(
3339            meta["classification"]["predictions"][0]["class_name"],
3340            "bar_chart"
3341        );
3342        assert_eq!(meta["tabular_chart"]["chart_data"]["num_rows"], 2);
3343    }
3344
3345    /// A tree item's `TreeProv` is written verbatim — a raw EMU box with
3346    /// whatever origin tag the backend set (`BOTTOMLEFT` here) and a per-item
3347    /// charspan, a note's zero `TOPLEFT` box — a picture's `image.dpi` is the file's when the backend
3348    /// read one, an item without provenance writes `prov: []`, and the page
3349    /// map still comes from the flat stream's markers.
3350    #[test]
3351    fn tree_items_carry_exact_provenance_and_dpi() {
3352        use crate::tree::{ItemTree, TreeKind, TreeProv};
3353        let text = |label: &str, t: &str| TreeKind::Text {
3354            label: label.into(),
3355            text: t.into(),
3356            orig: None,
3357            formatting: None,
3358            hyperlink: None,
3359            level: None,
3360            list: None,
3361        };
3362        let mut t = ItemTree::default();
3363        let slide = t.add(
3364            None,
3365            None,
3366            TreeKind::Group {
3367                label: "chapter".into(),
3368                name: "slide-0".into(),
3369            },
3370        );
3371        t.add_with_prov(
3372            Some(slide),
3373            None,
3374            text("paragraph", "héllo"),
3375            TreeProv {
3376                page_no: 1,
3377                bbox: [914400.0, 1828800.0, 2743200.0, 457200.0],
3378                bottom_left: true,
3379                charspan: [0, 5],
3380            },
3381        );
3382        t.add_with_prov(
3383            Some(slide),
3384            None,
3385            TreeKind::Picture {
3386                captions: Vec::new(),
3387                image: Some(crate::PictureImage {
3388                    dpi: crate::PictureImage::DEFAULT_DPI,
3389                    mimetype: "image/png".into(),
3390                    width: 2,
3391                    height: 2,
3392                    data: vec![0],
3393                }),
3394                classification: None,
3395                description: None,
3396                confidence: None,
3397                chart: None,
3398                dpi: Some(300),
3399            },
3400            TreeProv {
3401                page_no: 1,
3402                bbox: [0.0; 4],
3403                bottom_left: false,
3404                charspan: [0, 0],
3405            },
3406        );
3407        t.add(
3408            Some(slide),
3409            Some(ContentLayer::Notes),
3410            text("text", "no geometry"),
3411        );
3412        let mut doc = DoclingDocument::new("t");
3413        doc.push(Node::PageInfo {
3414            page_no: 1,
3415            width: 9144000.0,
3416            height: 6858000.0,
3417        });
3418        doc.tree = Some(t);
3419        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3420        assert_eq!(
3421            v["texts"][0]["prov"],
3422            serde_json::json!([{
3423                "page_no": 1,
3424                "bbox": { "l": 914400.0, "t": 1828800.0, "r": 2743200.0, "b": 457200.0, "coord_origin": "BOTTOMLEFT" },
3425                "charspan": [0, 5],
3426            }])
3427        );
3428        assert_eq!(v["texts"][0]["label"], "paragraph");
3429        assert_eq!(
3430            v["pictures"][0]["prov"][0]["bbox"]["coord_origin"],
3431            "TOPLEFT"
3432        );
3433        assert_eq!(v["pictures"][0]["image"]["dpi"], 300);
3434        assert_eq!(v["texts"][1]["prov"], serde_json::json!([]));
3435        assert_eq!(v["texts"][1]["content_layer"], "notes");
3436        assert_eq!(v["pages"]["1"]["size"]["width"], 9144000.0);
3437        assert_eq!(v["pages"]["1"]["page_no"], 1);
3438    }
3439
3440    /// docling-core's `validate_document` clamps every provenance box (and a
3441    /// one-page table's cell boxes) into its page — the state every
3442    /// `ConversionResult` leaves a document in, so the state docling's JSON
3443    /// shows. A box on a page the document does not describe is left alone.
3444    #[test]
3445    fn provenance_boxes_are_clamped_to_their_page() {
3446        let mut doc = DoclingDocument::new("t");
3447        doc.push(Node::PageInfo {
3448            page_no: 1,
3449            width: 10.0,
3450            height: 8.0,
3451        });
3452        doc.push(Node::Prov {
3453            page_no: 1,
3454            bbox: [-1.0, 2.0, 12.0, 9.5],
3455            charspan: [0, 1],
3456            seq: None,
3457            inner: Box::new(Node::Paragraph { text: "x".into() }),
3458        });
3459        let mut table = Table {
3460            rows: vec![vec!["a".into()]],
3461            ..Table::default()
3462        };
3463        table.cells = Some(vec![crate::TableCell {
3464            text: "a".into(),
3465            bbox: Some([1.0, 1.0, 11.0, 9.0]),
3466            start_row: 0,
3467            start_col: 0,
3468            row_span: 1,
3469            col_span: 1,
3470            column_header: false,
3471            row_header: false,
3472            row_section: false,
3473        }]);
3474        doc.push(Node::Prov {
3475            page_no: 1,
3476            bbox: [0.0, 0.0, 10.0, 8.0],
3477            charspan: [0, 0],
3478            seq: None,
3479            inner: Box::new(Node::Table(table)),
3480        });
3481        doc.push(Node::Prov {
3482            page_no: 7,
3483            bbox: [-5.0, 0.0, 50.0, 50.0],
3484            charspan: [0, 1],
3485            seq: None,
3486            inner: Box::new(Node::Paragraph { text: "y".into() }),
3487        });
3488        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3489        assert_eq!(
3490            v["texts"][0]["prov"][0]["bbox"],
3491            serde_json::json!({ "l": 0.0, "t": 2.0, "r": 10.0, "b": 8.0, "coord_origin": "TOPLEFT" })
3492        );
3493        let cell = &v["tables"][0]["data"]["table_cells"][0]["bbox"];
3494        assert_eq!(
3495            (cell["l"].as_f64(), cell["r"].as_f64(), cell["b"].as_f64()),
3496            (Some(1.0), Some(10.0), Some(8.0))
3497        );
3498        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["bbox"]["r"], 10.0);
3499        assert_eq!(
3500            v["texts"][1]["prov"][0]["bbox"]["r"], 50.0,
3501            "page 7 is not described"
3502        );
3503    }
3504
3505    /// docling-core's `validate_misplaced_list_items`, as it re-homes list
3506    /// items outside a `list` group (#527) — the refs below are what
3507    /// docling-core 2.99 writes for the same document: the two consecutive
3508    /// items on the body share a group, each item in another group gets its
3509    /// own, the runs are handled last-first (new groups and re-added texts
3510    /// numbered in that order), and a well-placed item is left alone.
3511    #[test]
3512    fn misplaced_list_items_are_wrapped_like_docling_core() {
3513        use crate::tree::{ItemTree, ListMeta, TreeKind};
3514        let mut t = ItemTree::default();
3515        let li = |txt: &str| TreeKind::Text {
3516            label: "list_item".into(),
3517            text: txt.into(),
3518            orig: None,
3519            formatting: None,
3520            hyperlink: None,
3521            level: None,
3522            list: Some(ListMeta {
3523                enumerated: false,
3524                marker: "-".into(),
3525            }),
3526        };
3527        let group = |label: &str, name: &str| TreeKind::Group {
3528            label: label.into(),
3529            name: name.into(),
3530        };
3531        t.add(None, None, li("A"));
3532        t.add(None, None, li("B"));
3533        t.add(
3534            None,
3535            None,
3536            TreeKind::Text {
3537                label: "text".into(),
3538                text: "plain".into(),
3539                orig: None,
3540                formatting: None,
3541                hyperlink: None,
3542                level: None,
3543                list: None,
3544            },
3545        );
3546        let cell = t.add(None, None, group("unspecified", "cell"));
3547        t.add(Some(cell), None, li("C"));
3548        t.add(Some(cell), None, li("D"));
3549        let list = t.add(None, None, group("list", "list"));
3550        t.add(Some(list), None, li("E"));
3551        let doc = DoclingDocument {
3552            tree: Some(t),
3553            ..DoclingDocument::new("t")
3554        };
3555        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3556        let refs = |x: &Value| -> Vec<String> {
3557            x.as_array()
3558                .unwrap()
3559                .iter()
3560                .map(|r| r["$ref"].as_str().unwrap().to_string())
3561                .collect()
3562        };
3563        assert_eq!(
3564            refs(&v["body"]["children"]),
3565            ["#/groups/4", "#/texts/0", "#/groups/0", "#/groups/1"]
3566        );
3567        assert_eq!(
3568            refs(&v["groups"][0]["children"]),
3569            ["#/groups/3", "#/groups/2"]
3570        );
3571        for (g, name, kids) in [
3572            (2, "group", vec!["#/texts/2"]),
3573            (3, "group", vec!["#/texts/3"]),
3574            (4, "group", vec!["#/texts/4", "#/texts/5"]),
3575            (1, "list", vec!["#/texts/1"]),
3576        ] {
3577            assert_eq!(v["groups"][g]["label"], "list");
3578            assert_eq!(v["groups"][g]["name"], name);
3579            assert_eq!(refs(&v["groups"][g]["children"]), kids);
3580        }
3581        let texts: Vec<&str> = v["texts"]
3582            .as_array()
3583            .unwrap()
3584            .iter()
3585            .map(|t| t["text"].as_str().unwrap())
3586            .collect();
3587        assert_eq!(texts, ["plain", "E", "D", "C", "A", "B"]);
3588        assert_eq!(v["texts"][2]["parent"]["$ref"], "#/groups/2");
3589    }
3590
3591    /// A child the tree still lists after deleting it is left out instead
3592    /// of written as `"$ref": ""`, which docling-core rejects (#527).
3593    #[test]
3594    fn a_deleted_child_is_not_written_as_an_empty_ref() {
3595        use crate::tree::{ItemTree, TreeKind};
3596        let mut t = ItemTree::default();
3597        let text = |txt: &str| TreeKind::Text {
3598            label: "text".into(),
3599            text: txt.into(),
3600            orig: None,
3601            formatting: None,
3602            hyperlink: None,
3603            level: None,
3604            list: None,
3605        };
3606        let g = t.add(
3607            None,
3608            None,
3609            TreeKind::Group {
3610                label: "unspecified".into(),
3611                name: "g".into(),
3612            },
3613        );
3614        let gone = t.add(Some(g), None, text(""));
3615        t.add(Some(g), None, text("kept"));
3616        t.items[gone].deleted = true; // still in `g`'s children
3617        let doc = DoclingDocument {
3618            tree: Some(t),
3619            ..DoclingDocument::new("t")
3620        };
3621        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3622        assert_eq!(
3623            v["groups"][0]["children"],
3624            serde_json::json!([{"$ref": "#/texts/0"}])
3625        );
3626    }
3627
3628    #[test]
3629    fn a_backend_item_tree_is_written_verbatim() {
3630        use crate::tree::{Formatting, ItemTree, ListMeta, TreeKind};
3631        let mut t = ItemTree::default();
3632        let text = |label: &str, txt: &str| TreeKind::Text {
3633            label: label.into(),
3634            text: txt.into(),
3635            orig: None,
3636            formatting: None,
3637            hyperlink: None,
3638            level: None,
3639            list: None,
3640        };
3641        let title = t.add(None, Some(ContentLayer::Furniture), text("title", "Page"));
3642        let h = t.add(None, None, text("title", "Heading"));
3643        let group = t.add(
3644            Some(h),
3645            None,
3646            TreeKind::Group {
3647                label: "inline".into(),
3648                name: "group".into(),
3649            },
3650        );
3651        t.add(
3652            Some(group),
3653            None,
3654            TreeKind::Text {
3655                label: "text".into(),
3656                text: "bold".into(),
3657                orig: None,
3658                formatting: Some(Formatting {
3659                    bold: true,
3660                    ..Formatting::default()
3661                }),
3662                hyperlink: Some("https://example.com/".into()),
3663                level: None,
3664                list: None,
3665            },
3666        );
3667        t.add(
3668            Some(group),
3669            None,
3670            TreeKind::Code {
3671                text: "x = 1".into(),
3672                orig: None,
3673                language: Some("python".into()),
3674                formatting: None,
3675                hyperlink: None,
3676            },
3677        );
3678        let sub = t.add(
3679            Some(h),
3680            None,
3681            TreeKind::Text {
3682                label: "section_header".into(),
3683                text: "Sub".into(),
3684                orig: Some("Sub\u{2019}".into()),
3685                formatting: None,
3686                hyperlink: None,
3687                level: Some(1),
3688                list: None,
3689            },
3690        );
3691        let item = t.add(
3692            Some(sub),
3693            None,
3694            TreeKind::Text {
3695                label: "list_item".into(),
3696                text: "item".into(),
3697                orig: None,
3698                formatting: None,
3699                hyperlink: None,
3700                level: None,
3701                list: Some(ListMeta {
3702                    enumerated: true,
3703                    marker: "3.".into(),
3704                }),
3705            },
3706        );
3707        let _region = t.add(
3708            Some(sub),
3709            None,
3710            TreeKind::FieldRegion {
3711                items: vec![crate::FieldItem {
3712                    marker: None,
3713                    key: Some("Name".into()),
3714                    value: Some("Duck".into()),
3715                    value_kind: Some("read_only".into()),
3716                }],
3717            },
3718        );
3719        let table = t.add(
3720            Some(sub),
3721            None,
3722            TreeKind::Table {
3723                table: Table {
3724                    rows: vec![vec!["a  \n&lt;".into(), "b".into()]],
3725                    cells: Some(vec![
3726                        crate::TableCell {
3727                            text: "a  \n&lt;".into(),
3728                            bbox: None,
3729                            start_row: 0,
3730                            start_col: 0,
3731                            row_span: 3,
3732                            col_span: 1,
3733                            column_header: false,
3734                            row_header: true,
3735                            row_section: false,
3736                        },
3737                        crate::TableCell {
3738                            text: "b".into(),
3739                            bbox: None,
3740                            start_row: 0,
3741                            start_col: 1,
3742                            row_span: 1,
3743                            col_span: 1,
3744                            column_header: false,
3745                            row_header: false,
3746                            row_section: false,
3747                        },
3748                    ]),
3749                    ..Table::default()
3750                },
3751                rich_cells: vec![(0, 1, 0)], // patched below
3752                captions: Vec::new(),
3753            },
3754        );
3755        let cell_group = t.add(
3756            Some(table),
3757            None,
3758            TreeKind::Group {
3759                label: "unspecified".into(),
3760                name: "rich_cell_group_1_0_0".into(),
3761            },
3762        );
3763        if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
3764            *rich_cells = vec![(0, 1, cell_group)];
3765        }
3766        let after = t.add(Some(sub), None, text("text", "after the region"));
3767        let _ = (title, after);
3768        // A list item belongs in a `list` group — one outside it would be
3769        // re-homed by `wrap_misplaced_list_items` (created last, so the
3770        // other groups keep their numbers).
3771        let list = t.add(
3772            Some(sub),
3773            None,
3774            TreeKind::Group {
3775                label: "list".into(),
3776                name: "list".into(),
3777            },
3778        );
3779        t.reparent(item, Some(list));
3780
3781        let doc = DoclingDocument {
3782            tree: Some(t),
3783            ..DoclingDocument::new("t")
3784        };
3785        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3786        // Creation order: Page, Heading, bold, x = 1, Sub, item, [Name, Duck], after.
3787        let texts: Vec<&str> = v["texts"]
3788            .as_array()
3789            .unwrap()
3790            .iter()
3791            .map(|t| t["text"].as_str().unwrap())
3792            .collect();
3793        assert_eq!(
3794            texts,
3795            [
3796                "Page",
3797                "Heading",
3798                "bold",
3799                "x = 1",
3800                "Sub",
3801                "item",
3802                "Name",
3803                "Duck",
3804                "after the region"
3805            ]
3806        );
3807        assert_eq!(
3808            v["body"]["children"],
3809            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}])
3810        );
3811        assert_eq!(v["texts"][0]["content_layer"], "furniture");
3812        assert_eq!(
3813            v["texts"][1]["children"],
3814            serde_json::json!([{"$ref": "#/groups/0"}, {"$ref": "#/texts/4"}])
3815        );
3816        let bold = &v["texts"][2];
3817        assert_eq!(bold["parent"]["$ref"], "#/groups/0");
3818        let keys: Vec<&str> = bold
3819            .as_object()
3820            .unwrap()
3821            .keys()
3822            .map(String::as_str)
3823            .collect();
3824        assert_eq!(
3825            keys,
3826            [
3827                "self_ref",
3828                "parent",
3829                "children",
3830                "content_layer",
3831                "label",
3832                "prov",
3833                "orig",
3834                "text",
3835                "formatting",
3836                "hyperlink"
3837            ]
3838        );
3839        assert_eq!(
3840            bold["formatting"],
3841            serde_json::json!({"bold": true, "italic": false, "underline": false, "strikethrough": false, "script": "baseline"})
3842        );
3843        let code = &v["texts"][3];
3844        assert_eq!(code["label"], "code");
3845        assert_eq!(code["code_language"], "Python");
3846        let sub = &v["texts"][4];
3847        assert_eq!(sub["orig"], "Sub\u{2019}");
3848        assert_eq!(sub["level"], 1);
3849        let item = &v["texts"][5];
3850        let keys: Vec<&str> = item
3851            .as_object()
3852            .unwrap()
3853            .keys()
3854            .map(String::as_str)
3855            .collect();
3856        assert_eq!(
3857            keys,
3858            [
3859                "self_ref",
3860                "parent",
3861                "children",
3862                "content_layer",
3863                "label",
3864                "prov",
3865                "orig",
3866                "text",
3867                "enumerated",
3868                "marker"
3869            ]
3870        );
3871        assert_eq!(item["marker"], "3.");
3872        assert_eq!(v["texts"][7]["kind"], "read_only");
3873        assert_eq!(v["field_regions"][0]["parent"]["$ref"], "#/texts/4");
3874        let table = &v["tables"][0];
3875        assert_eq!(
3876            table["children"],
3877            serde_json::json!([{"$ref": "#/groups/1"}])
3878        );
3879        let cells = table["data"]["table_cells"].as_array().unwrap();
3880        assert_eq!(
3881            cells[0]["text"], "a  \n&lt;",
3882            "raw cell text is written verbatim"
3883        );
3884        assert_eq!(
3885            cells[0]["end_row_offset_idx"], 3,
3886            "declared spans are not clamped"
3887        );
3888        assert_eq!(cells[1]["ref"], serde_json::json!({"$ref": "#/groups/1"}));
3889        assert!(cells[0].get("ref").is_none());
3890        assert!(
3891            table["data"]["grid"][0][1].get("ref").is_none(),
3892            "the grid shows plain cells"
3893        );
3894        assert_eq!(v["groups"][1]["name"], "rich_cell_group_1_0_0");
3895    }
3896
3897    /// A comment section that links its note text rather than its group — the
3898    /// spreadsheet shape, where docling-core's `add_comment` appends the text
3899    /// item's ref to each target.
3900    #[test]
3901    fn a_comment_section_can_be_referenced_by_its_note_text() {
3902        let doc = DoclingDocument {
3903            name: "c".into(),
3904            nodes: vec![
3905                Node::Commented {
3906                    comments: vec![0],
3907                    inner: Box::new(Node::Paragraph {
3908                        text: "annotated".into(),
3909                    }),
3910                },
3911                Node::CommentSection {
3912                    name: "comment-Sheet1-A1".into(),
3913                    text: "[author: A]: note".into(),
3914                    refs_note_text: true,
3915                    grouped: true,
3916                },
3917            ],
3918            ..DoclingDocument::new("c")
3919        };
3920        let v = crate::json::to_json(&doc);
3921        assert_eq!(v["groups"][0]["name"], "comment-Sheet1-A1");
3922        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/texts/1");
3923    }
3924
3925    /// docx reviewer comments: a `comment_section` group on the notes layer
3926    /// holding the note text, and a `comments` back-ref on the annotated item —
3927    /// keyed between `prov` and `orig`, the slot docling emits it in.
3928    #[test]
3929    fn comment_sections_link_back_to_their_items() {
3930        let doc = DoclingDocument {
3931            name: "c".into(),
3932            nodes: vec![
3933                Node::Commented {
3934                    comments: vec![0],
3935                    inner: Box::new(Node::Paragraph {
3936                        text: "annotated".into(),
3937                    }),
3938                },
3939                Node::Paragraph {
3940                    text: "plain".into(),
3941                },
3942                Node::CommentSection {
3943                    name: "comment-7".into(),
3944                    text: "[time: t]: note".into(),
3945                    refs_note_text: false,
3946                    grouped: true,
3947                },
3948            ],
3949            ..DoclingDocument::new("c")
3950        };
3951        let v = crate::json::to_json(&doc);
3952        // The group is the comment section; its only child is the notes text.
3953        assert_eq!(v["groups"][0]["label"], "comment_section");
3954        assert_eq!(v["groups"][0]["name"], "comment-7");
3955        assert_eq!(v["groups"][0]["content_layer"], "notes");
3956        assert_eq!(v["groups"][0]["children"][0]["$ref"], "#/texts/2");
3957        assert_eq!(v["texts"][2]["content_layer"], "notes");
3958        // The annotated item points back at the group; the plain one has no key.
3959        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/groups/0");
3960        assert!(v["texts"][1].get("comments").is_none());
3961        // docling's key order: … prov, comments, orig, text.
3962        let keys: Vec<&str> = v["texts"][0]
3963            .as_object()
3964            .unwrap()
3965            .keys()
3966            .map(String::as_str)
3967            .collect();
3968        assert_eq!(
3969            &keys[keys.len() - 4..],
3970            &["prov", "comments", "orig", "text"]
3971        );
3972    }
3973
3974    fn picture(caption: &str, caption_parent: CaptionParent) -> Node {
3975        Node::Picture {
3976            caption: Some(caption.into()),
3977            caption_href: None,
3978            image: None,
3979            classification: None,
3980            description: None,
3981            caption_parent,
3982            caption_location: None,
3983        }
3984    }
3985
3986    fn group(children: Vec<Node>) -> Node {
3987        Node::Group {
3988            label: "section".into(),
3989            name: None,
3990            layer: None,
3991            children,
3992        }
3993    }
3994
3995    fn refs(v: &Value) -> Vec<&str> {
3996        v.as_array()
3997            .unwrap()
3998            .iter()
3999            .map(|r| r["$ref"].as_str().unwrap())
4000            .collect()
4001    }
4002
4003    /// #390: a declarative backend's caption is docling's `add_text` default —
4004    /// a body child, appended as it is created — wherever the picture sits:
4005    /// ahead of a top-level picture, behind the top-level item enclosing a
4006    /// nested one. The picture references it either way and has no children.
4007    #[test]
4008    fn a_body_caption_follows_the_enclosing_top_level_item() {
4009        let mut doc = DoclingDocument::new("t");
4010        doc.push(picture("top", CaptionParent::Body));
4011        doc.push(group(vec![
4012            Node::Paragraph { text: "p".into() },
4013            picture("nested", CaptionParent::Body),
4014        ]));
4015        doc.push(Node::Paragraph {
4016            text: "after".into(),
4017        });
4018        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4019        assert_eq!(
4020            refs(&v["body"]["children"]),
4021            [
4022                "#/texts/0",
4023                "#/pictures/0",
4024                "#/groups/0",
4025                "#/texts/2",
4026                "#/texts/3"
4027            ]
4028        );
4029        assert_eq!(
4030            refs(&v["groups"][0]["children"]),
4031            ["#/texts/1", "#/pictures/1"]
4032        );
4033        for (cap, pic) in [(0, 0), (2, 1)] {
4034            assert_eq!(v["texts"][cap]["label"], "caption");
4035            assert_eq!(v["texts"][cap]["parent"]["$ref"], "#/body");
4036            assert_eq!(
4037                refs(&v["pictures"][pic]["captions"]),
4038                [format!("#/texts/{cap}")]
4039            );
4040            assert_eq!(v["pictures"][pic]["children"], serde_json::json!([]));
4041        }
4042    }
4043
4044    /// The PDF pipeline's caption is the picture's (or table's) own child,
4045    /// as docling attaches a layout caption.
4046    #[test]
4047    fn an_item_caption_is_the_items_first_child() {
4048        let mut doc = DoclingDocument::new("t");
4049        doc.push(picture("fig", CaptionParent::Item));
4050        doc.push(Node::Table(Table {
4051            rows: vec![vec!["a".into()]],
4052            caption: Some("tab".into()),
4053            caption_parent: CaptionParent::Item,
4054            ..Table::default()
4055        }));
4056        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4057        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/tables/0"]);
4058        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/pictures/0");
4059        assert_eq!(refs(&v["pictures"][0]["children"]), ["#/texts/0"]);
4060        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
4061        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/tables/0");
4062        assert_eq!(refs(&v["tables"][0]["children"]), ["#/texts/1"]);
4063        assert_eq!(refs(&v["tables"][0]["captions"]), ["#/texts/1"]);
4064    }
4065
4066    /// A PDF picture's contained text (`Node::PictureChildren`) is written as
4067    /// docling's `_add_child_elements` writes it: items parented to the
4068    /// picture, after its caption in the picture's `children`, never in the
4069    /// body; a list item opens its own list group under the picture. The
4070    /// Markdown and the chunker's refs are unaffected.
4071    #[test]
4072    fn picture_children_hang_off_the_picture_after_its_caption() {
4073        let mut doc = DoclingDocument::new("t");
4074        doc.push(picture("fig", CaptionParent::Item));
4075        doc.push(Node::PictureChildren(vec![
4076            Node::Heading {
4077                level: 2,
4078                text: "in-figure title".into(),
4079            },
4080            Node::Paragraph {
4081                text: "axis label".into(),
4082            },
4083            Node::ListItem {
4084                ordered: false,
4085                number: 0,
4086                first_in_list: true,
4087                text: "callout".into(),
4088                level: 0,
4089                marker: None,
4090                location: None,
4091                dclx: None,
4092                href: None,
4093                layer: None,
4094            },
4095        ]));
4096        doc.push(Node::Paragraph {
4097            text: "after".into(),
4098        });
4099        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4100        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/texts/4"]);
4101        assert_eq!(
4102            refs(&v["pictures"][0]["children"]),
4103            ["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"]
4104        );
4105        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
4106        assert_eq!(v["texts"][1]["label"], "section_header");
4107        assert_eq!(v["texts"][2]["label"], "text");
4108        for t in 1..=2 {
4109            assert_eq!(v["texts"][t]["parent"]["$ref"], "#/pictures/0");
4110            assert_eq!(v["texts"][t]["content_layer"], "body");
4111        }
4112        assert_eq!(v["groups"][0]["parent"]["$ref"], "#/pictures/0");
4113        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
4114        let md = doc.export_to_markdown();
4115        assert!(
4116            !md.contains("axis label") && !md.contains("callout"),
4117            "{md}"
4118        );
4119        assert!(md.contains("after"), "{md}");
4120    }
4121
4122    /// A container caption sits beside its item under the item's parent —
4123    /// ahead of it (an office chart's title) or behind it (an HTML
4124    /// `<figure>`'s table, whose figcaption docling adds after the table).
4125    #[test]
4126    fn a_container_caption_is_the_items_sibling() {
4127        let mut doc = DoclingDocument::new("t");
4128        doc.push(group(vec![
4129            picture("chart", CaptionParent::Container),
4130            Node::Table(Table {
4131                rows: vec![vec!["a".into()]],
4132                caption: Some("figcaption".into()),
4133                caption_parent: CaptionParent::ContainerAfter,
4134                ..Table::default()
4135            }),
4136        ]));
4137        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4138        assert_eq!(refs(&v["body"]["children"]), ["#/groups/0"]);
4139        assert_eq!(
4140            refs(&v["groups"][0]["children"]),
4141            ["#/texts/0", "#/pictures/0", "#/tables/0", "#/texts/1"]
4142        );
4143        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/groups/0");
4144        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/groups/0");
4145        assert_eq!(v["pictures"][0]["children"], serde_json::json!([]));
4146        assert_eq!(v["tables"][0]["children"], serde_json::json!([]));
4147    }
4148
4149    /// A PDF footnote (#620 follow-up): the JSON keeps docling's
4150    /// `footnote` label, the raw text and the link as `hyperlink`; the
4151    /// Markdown still wraps the whole item as `[text](uri)`, and the plain
4152    /// text drops the link like docling-core's `export_to_text`.
4153    #[test]
4154    fn a_labeled_text_keeps_its_label_and_hyperlink_out_of_the_text() {
4155        let mut doc = DoclingDocument::new("t");
4156        doc.push(Node::LabeledText {
4157            label: "footnote".into(),
4158            text: "1 https://example.com/a\\_b".into(),
4159            href: Some("https://example.com/a_b".into()),
4160        });
4161        doc.push(Node::LabeledText {
4162            label: "footnote".into(),
4163            text: "© 2022 the authors".into(),
4164            href: None,
4165        });
4166        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
4167        let t = &v["texts"][0];
4168        assert_eq!(t["label"], "footnote");
4169        assert_eq!(t["text"], "1 https://example.com/a_b");
4170        assert_eq!(t["orig"], "1 https://example.com/a_b");
4171        assert_eq!(t["hyperlink"], "https://example.com/a_b");
4172        assert_eq!(v["texts"][1]["label"], "footnote");
4173        assert!(v["texts"][1].get("hyperlink").is_none());
4174        assert_eq!(
4175            doc.export_to_markdown(),
4176            "[1 https://example.com/a\\_b](https://example.com/a_b)\n\n© 2022 the authors\n"
4177        );
4178        assert_eq!(
4179            doc.export_to_text(),
4180            "1 https://example.com/a_b\n\n© 2022 the authors"
4181        );
4182    }
4183}