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