Skip to main content

docling_core/
json.rs

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