Skip to main content

docling_core/
json.rs

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