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