Skip to main content

docling_core/
json.rs

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