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