Skip to main content

docling_core/
json.rs

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