Skip to main content

docling_core/
json.rs

1//! Export a [`DoclingDocument`] to docling-core's native JSON wire format
2//! (`DoclingDocument` schema v1.10.0) — the same shape `export_to_dict()` /
3//! `save_as_json()` produce in Python docling, and the inverse of the
4//! JSON-docling reader.
5//!
6//! The crate's [`Node`] model bakes Markdown escaping (and inline markers) into
7//! its text, whereas docling stores raw text and escapes at render time. We
8//! therefore *un-escape* on the way out so a docling-core round-trip
9//! (`load_from_json().export_to_markdown()`) reproduces the same Markdown.
10
11use serde_json::{json, Value};
12
13use crate::document::{CaptionParent, ContentLayer, DoclingDocument, Node, Table};
14
15const SCHEMA_VERSION: &str = "1.10.0";
16
17/// docling-core's `CodeLanguageLabel` values (anything else serializes as
18/// `unknown`, which the model requires for code items).
19const CODE_LANGUAGES: &[&str] = &[
20    "Ada",
21    "Awk",
22    "Bash",
23    "bc",
24    "C",
25    "C#",
26    "C++",
27    "CMake",
28    "COBOL",
29    "CSS",
30    "Ceylon",
31    "Clojure",
32    "Crystal",
33    "Cuda",
34    "Cython",
35    "D",
36    "Dart",
37    "dc",
38    "Dockerfile",
39    "DocLang",
40    "Elixir",
41    "Erlang",
42    "FORTRAN",
43    "Forth",
44    "Go",
45    "HTML",
46    "Haskell",
47    "Haxe",
48    "Java",
49    "JavaScript",
50    "JSON",
51    "Julia",
52    "Kotlin",
53    "Latex",
54    "Lisp",
55    "Lua",
56    "Matlab",
57    "MoonScript",
58    "Nim",
59    "OCaml",
60    "ObjectiveC",
61    "Octave",
62    "PHP",
63    "Pascal",
64    "Perl",
65    "Prolog",
66    "Python",
67    "Racket",
68    "Ruby",
69    "Rust",
70    "SML",
71    "SQL",
72    "Scala",
73    "Scheme",
74    "Swift",
75    "Tikz",
76    "TypeScript",
77    "VisualBasic",
78    "XML",
79    "YAML",
80];
81
82/// Map a fence language to docling's `CodeLanguageLabel` (case-insensitive), else
83/// `unknown`.
84/// docling's `CodeLanguageLabel` for a language name (`unknown` when it is
85/// not one docling knows) — the mapping the JSON `code_language` field uses,
86/// for a backend that wants to test a hint before storing it.
87pub fn code_language_label(lang: &str) -> &'static str {
88    code_language(Some(lang))
89}
90
91pub(crate) fn code_language(lang: Option<&str>) -> &'static str {
92    match lang {
93        Some(l) => CODE_LANGUAGES
94            .iter()
95            .find(|c| c.eq_ignore_ascii_case(l))
96            .copied()
97            .unwrap_or("unknown"),
98        None => "unknown",
99    }
100}
101
102/// An item's exact provenance, written verbatim (see [`Builder::prov_json`]):
103/// a [`Node::Prov`] wrapper's top-left page box, or a tree item's
104/// [`TreeProv`](crate::tree::TreeProv).
105#[derive(Clone, Copy)]
106struct ExactProv {
107    page_no: usize,
108    bbox: [f64; 4],
109    bottom_left: bool,
110    charspan: [usize; 2],
111}
112
113impl From<&crate::tree::TreeProv> for ExactProv {
114    fn from(p: &crate::tree::TreeProv) -> Self {
115        ExactProv {
116            page_no: p.page_no,
117            bbox: p.bbox,
118            bottom_left: p.bottom_left,
119            charspan: p.charspan,
120        }
121    }
122}
123
124/// docling-core's `_clamp_provenance_bboxes_to_pages`: each `prov` box of
125/// every item on a known page is clamped to `[0, width] × [0, height]`
126/// (whatever its `coord_origin` — docling clamps the stored numbers), and a
127/// table whose provenance sits on one page has its cell boxes clamped too.
128fn clamp_boxes_to_pages(out: &mut Value, pages: &[(usize, f64, f64)]) {
129    if pages.is_empty() {
130        return;
131    }
132    let size = |page_no: &Value| -> Option<(f64, f64)> {
133        let n = page_no.as_u64()? as usize;
134        pages
135            .iter()
136            .find(|(p, _, _)| *p == n)
137            .map(|(_, w, h)| (*w, *h))
138    };
139    let r2 = |v: f64| (v * 100.0).round() / 100.0;
140    let clamp_bbox = |bbox: &mut Value, (w, h): (f64, f64)| {
141        for (key, hi) in [("l", w), ("r", w), ("t", h), ("b", h)] {
142            if let Some(v) = bbox.get(key).and_then(Value::as_f64) {
143                bbox[key] = json!(r2(v.clamp(0.0, hi.max(0.0))));
144            }
145        }
146    };
147    for bucket in [
148        "texts",
149        "pictures",
150        "tables",
151        "key_value_items",
152        "form_items",
153        "field_regions",
154        "field_items",
155    ] {
156        let Some(items) = out.get_mut(bucket).and_then(Value::as_array_mut) else {
157            continue;
158        };
159        for item in items {
160            let mut table_page: Option<Option<(f64, f64)>> = None;
161            if let Some(provs) = item.get_mut("prov").and_then(Value::as_array_mut) {
162                let mut page_nos: Vec<u64> = Vec::new();
163                for prov in provs.iter_mut() {
164                    if let Some(n) = prov.get("page_no").and_then(Value::as_u64) {
165                        page_nos.push(n);
166                    }
167                    let Some(sz) = prov.get("page_no").and_then(size) else {
168                        continue;
169                    };
170                    if let Some(bbox) = prov.get_mut("bbox") {
171                        clamp_bbox(bbox, sz);
172                    }
173                }
174                page_nos.sort_unstable();
175                page_nos.dedup();
176                if let [only] = page_nos[..] {
177                    table_page = Some(size(&json!(only)));
178                }
179            }
180            // A table's cells (and the `grid` docling derives from them).
181            if let (Some(Some(sz)), Some(data)) = (table_page, item.get_mut("data")) {
182                for key in ["table_cells", "grid"] {
183                    let Some(rows) = data.get_mut(key).and_then(Value::as_array_mut) else {
184                        continue;
185                    };
186                    for entry in rows.iter_mut() {
187                        let cells: Vec<&mut Value> = match entry {
188                            Value::Array(row) => row.iter_mut().collect(),
189                            other => vec![other],
190                        };
191                        for cell in cells {
192                            if let Some(bbox) = cell.get_mut("bbox").filter(|b| b.is_object()) {
193                                clamp_bbox(bbox, sz);
194                            }
195                        }
196                    }
197                }
198            }
199        }
200    }
201}
202
203/// docling-core's `Formatting` model, every field written.
204fn formatting_json(f: &crate::tree::Formatting) -> Value {
205    json!({
206        "bold": f.bold,
207        "italic": f.italic,
208        "underline": f.underline,
209        "strikethrough": f.strikethrough,
210        "script": match f.script {
211            crate::Script::Baseline => "baseline",
212            crate::Script::Sub => "sub",
213            crate::Script::Super => "super",
214        },
215    })
216}
217
218/// Build the docling-core JSON object for `doc`.
219pub fn to_json(doc: &DoclingDocument) -> Value {
220    let mut b = Builder::default();
221    // A backend that built docling's item tree ([`crate::tree`]) has already
222    // decided every parent, child and creation index; serialize that. The
223    // flat nodes are for the other serializers.
224    let body = match &doc.tree {
225        Some(tree) => {
226            // The page map still comes from the flat stream's markers (a
227            // PPTX slide's EMU size): the tree holds items, not pages.
228            for n in &doc.nodes {
229                if let Node::PageInfo {
230                    page_no,
231                    width,
232                    height,
233                } = n
234                {
235                    if *page_no > 0 {
236                        b.pages.push((*page_no, *width as f64, *height as f64));
237                    }
238                }
239            }
240            b.write_tree(tree)
241        }
242        None => b.walk_into(&doc.nodes, "#/body"),
243    };
244    b.link_comments();
245
246    let mut out = json!({
247        "schema_name": "DoclingDocument",
248        "version": SCHEMA_VERSION,
249        "name": doc.name,
250        "origin": {
251            "mimetype": "text/plain",
252            "binary_hash": fnv1a(&doc.name),
253            "filename": doc.name,
254        },
255        "furniture": {
256            "self_ref": "#/furniture",
257            "children": [],
258            "content_layer": "furniture",
259            "name": "_root_",
260            "label": "unspecified",
261        },
262        "body": {
263            "self_ref": "#/body",
264            "children": body,
265            "content_layer": "body",
266            "name": "_root_",
267            "label": "unspecified",
268        },
269        "groups": b.groups,
270        "texts": b.texts,
271        "pictures": b.pictures,
272        "tables": b.tables,
273        "key_value_items": [],
274        "form_items": [],
275        "pages": b.pages.iter().map(|(n, w, h)| {
276            let r2 = |v: f64| (v * 100.0).round() / 100.0;
277            (n.to_string(), json!({
278                "size": { "width": r2(*w), "height": r2(*h) },
279                "page_no": n,
280            }))
281        }).collect::<serde_json::Map<String, Value>>(),
282    });
283
284    // docling-core's `validate_document` — a pydantic `model_validator` every
285    // `DoclingDocument` passes through when a `ConversionResult` (or a
286    // serializer) is built around it — clamps every provenance box, and a
287    // table's cell boxes, into its page's bounds in place
288    // (`_clamp_provenance_bboxes_to_pages`). The JSON docling writes therefore
289    // carries the clamped boxes: a spreadsheet region whose page was sized
290    // `right − left` × `bottom − top` loses its offset, a slide shape hanging
291    // off the slide edge is cut at it. Reproduce that on the finished items.
292    clamp_boxes_to_pages(&mut out, &b.pages);
293
294    // docling only emits `field_regions` / `field_items` when a document has
295    // form fields, and places them just before `pages`. Insert them in that slot
296    // (re-appending `pages` afterwards, since `preserve_order` keeps insertion
297    // order) so non-KVP documents' JSON is byte-identical to before.
298    if !b.field_regions.is_empty() {
299        if let Some(obj) = out.as_object_mut() {
300            let pages = obj.remove("pages");
301            obj.insert("field_regions".into(), Value::Array(b.field_regions));
302            obj.insert("field_items".into(), Value::Array(b.field_items));
303            if let Some(pages) = pages {
304                obj.insert("pages".into(), pages);
305            }
306        }
307    }
308    out
309}
310
311/// A DocumentPictureClassifier's predictions as the picture's `meta` — they
312/// land twice, exactly like docling 2.x writes them: the newer
313/// `meta.classification` field (pydantic field order: confidence, created_by,
314/// class_name) and the deprecated-but-still-emitted `classification`
315/// annotation, carried here under an `annotations` key that `add_picture`
316/// lifts onto the item.
317fn classification_meta(classes: &[crate::PictureClass]) -> Value {
318    json!({
319        "classification": {
320            "predictions": classes.iter().map(|c| json!({
321                "confidence": c.confidence as f64,
322                "created_by": "DocumentPictureClassifier",
323                "class_name": c.class_name,
324            })).collect::<Vec<_>>(),
325        },
326        "annotations": [{
327            "kind": "classification",
328            "provenance": "DocumentPictureClassifier",
329            "predicted_classes": classes.iter().map(|c| json!({
330                "class_name": c.class_name,
331                "confidence": c.confidence as f64,
332            })).collect::<Vec<_>>(),
333        }],
334    })
335}
336
337/// docling's `TableData` for a table: `table_cells`, `num_rows`/`num_cols`
338/// and the `grid` that repeats each cell at every position it covers. Shared
339/// by table items and a chart picture's `meta.tabular_chart.chart_data`.
340/// One table cell as docling's `TableCell` JSON: the eleven fields in
341/// pydantic order, `bbox` appended when the cell carries one. Built straight
342/// into a `Map` sized for the entries — the table grid repeats every cell
343/// object once per spanned slot, so on a table-heavy document (a patent's
344/// claims tables, an EBCDIC dump) this constructor and its clones *are* the
345/// JSON export's cost; the `json!` macro built the same object through a
346/// growing map with a rehash per doubling.
347#[allow(clippy::too_many_arguments)]
348fn cell_value(
349    row_span: usize,
350    col_span: usize,
351    start_row: usize,
352    end_row: usize,
353    start_col: usize,
354    end_col: usize,
355    text: String,
356    column_header: bool,
357    row_header: bool,
358    row_section: bool,
359    bbox: Option<[f32; 4]>,
360) -> Value {
361    let mut m = serde_json::Map::with_capacity(12);
362    m.insert("row_span".into(), row_span.into());
363    m.insert("col_span".into(), col_span.into());
364    m.insert("start_row_offset_idx".into(), start_row.into());
365    m.insert("end_row_offset_idx".into(), end_row.into());
366    m.insert("start_col_offset_idx".into(), start_col.into());
367    m.insert("end_col_offset_idx".into(), end_col.into());
368    m.insert("text".into(), Value::String(text));
369    m.insert("column_header".into(), column_header.into());
370    m.insert("row_header".into(), row_header.into());
371    m.insert("row_section".into(), row_section.into());
372    m.insert("fillable".into(), false.into());
373    if let Some(b) = bbox {
374        m.insert(
375            "bbox".into(),
376            json!({
377                "l": b[0], "t": b[1], "r": b[2], "b": b[3],
378                "coord_origin": "TOPLEFT",
379            }),
380        );
381    }
382    Value::Object(m)
383}
384
385/// `TableData` from a table whose cell text is Markdown-flavoured (the flat
386/// nodes: escapes to undo, GFM hard-break markers to strip).
387fn table_data(t: &Table) -> Value {
388    table_data_with(t, false)
389}
390
391/// `TableData`; with `raw` the cell text is docling's own raw cell text (a
392/// backend-built tree) and is written verbatim.
393fn table_data_with(t: &Table, raw: bool) -> Value {
394    let cell_text = |s: &str| {
395        if raw {
396            s.to_string()
397        } else {
398            unescape_text(&crate::markdown::strip_hard_breaks(s))
399        }
400    };
401    let num_rows = t.rows.len();
402    let num_cols = t.rows.iter().map(Vec::len).max().unwrap_or(0);
403    let mut grid = Vec::with_capacity(num_rows);
404    let mut cells = Vec::new();
405    // Grid slot → index into `cells` (the anchor cell covering it). A flat
406    // row-major table instead of a `HashMap<(r, c), Value>` of clones: the
407    // grid is filled from it with one clone per slot, and nothing is hashed.
408    let mut slot: Vec<Option<usize>> = vec![None; num_rows * num_cols];
409    if let Some(first_class) = t.cells.as_ref().filter(|c| !c.is_empty()) {
410        for c in first_class {
411            let idx = cells.len();
412            cells.push(cell_value(
413                c.row_span,
414                c.col_span,
415                c.start_row,
416                c.start_row + c.row_span,
417                c.start_col,
418                c.start_col + c.col_span,
419                cell_text(&c.text),
420                c.column_header,
421                c.row_header,
422                c.row_section,
423                c.bbox,
424            ));
425            for r in c.start_row..(c.start_row + c.row_span).min(num_rows) {
426                for k in c.start_col..(c.start_col + c.col_span).min(num_cols) {
427                    slot[r * num_cols + k] = Some(idx);
428                }
429            }
430        }
431        for r in 0..num_rows {
432            let mut grid_row = Vec::with_capacity(num_cols);
433            for c in 0..num_cols {
434                grid_row.push(match slot[r * num_cols + c] {
435                    Some(i) => cells[i].clone(),
436                    None => cell_value(
437                        1,
438                        1,
439                        r,
440                        r + 1,
441                        c,
442                        c + 1,
443                        String::new(),
444                        false,
445                        false,
446                        false,
447                        None,
448                    ),
449                });
450            }
451            grid.push(grid_row);
452        }
453    } else {
454        let s = t.structure.as_ref();
455        let flag = |grid: Option<&Vec<Vec<bool>>>, r: usize, c: usize| -> bool {
456            grid.and_then(|g| g.get(r))
457                .and_then(|row| row.get(c))
458                .copied()
459                .unwrap_or(false)
460        };
461        let anchor_of = |r: usize, c: usize| -> (usize, usize) {
462            let (mut r0, mut c0) = (r, c);
463            while c0 > 0 && flag(s.map(|s| &s.col_continuation), r, c0) {
464                c0 -= 1;
465            }
466            while r0 > 0 && flag(s.map(|s| &s.row_continuation), r0, c0) {
467                r0 -= 1;
468            }
469            (r0, c0)
470        };
471        // Each slot's anchor, computed once; the anchor's extent is the
472        // farthest slot that resolves to it.
473        let anchors: Vec<(usize, usize)> = (0..num_rows)
474            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
475            .map(|(r, c)| anchor_of(r, c))
476            .collect();
477        let mut extent: Vec<(usize, usize)> = (0..num_rows)
478            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
479            .collect();
480        for (i, &(ar, ac)) in anchors.iter().enumerate() {
481            let (r, c) = (i / num_cols.max(1), i % num_cols.max(1));
482            let e = &mut extent[ar * num_cols + ac];
483            e.0 = e.0.max(r);
484            e.1 = e.1.max(c);
485        }
486        for (r, row) in t.rows.iter().enumerate() {
487            let mut grid_row = Vec::with_capacity(num_cols);
488            for c in 0..num_cols {
489                let (ar, ac) = anchors[r * num_cols + c];
490                if (ar, ac) == (r, c) {
491                    let (er, ec) = extent[r * num_cols + c];
492                    let text = row.get(c).map(|s| cell_text(s)).unwrap_or_default();
493                    let column_header = match s.filter(|s| !s.col_header.is_empty()) {
494                        Some(s) => flag(Some(&s.col_header), r, c),
495                        None => r == 0,
496                    };
497                    slot[r * num_cols + c] = Some(cells.len());
498                    cells.push(cell_value(
499                        er - r + 1,
500                        ec - c + 1,
501                        r,
502                        er + 1,
503                        c,
504                        ec + 1,
505                        text,
506                        column_header,
507                        flag(s.map(|s| &s.row_header), r, c),
508                        false,
509                        None,
510                    ));
511                }
512                grid_row.push(match slot[ar * num_cols + ac] {
513                    Some(i) => cells[i].clone(),
514                    None => Value::Null,
515                });
516            }
517            grid.push(grid_row);
518        }
519    }
520    json!({
521        "table_cells": cells,
522        "num_rows": num_rows,
523        "num_cols": num_cols,
524        "orientation": "rot_0",
525        "grid": grid,
526    })
527}
528
529#[derive(Default)]
530struct Builder {
531    texts: Vec<Value>,
532    groups: Vec<Value>,
533    tables: Vec<Value>,
534    pictures: Vec<Value>,
535    field_regions: Vec<Value>,
536    field_items: Vec<Value>,
537    /// Pages seen so far (`page_no`, width, height in points) — from the
538    /// [`Node::PageInfo`] markers the PDF paths emit; empty for every other
539    /// backend, which keeps their JSON byte-identical (`"pages": {}`, no prov).
540    pages: Vec<(usize, f64, f64)>,
541    /// The page the walk is currently on (0 before the first marker).
542    cur_page: usize,
543    cur_w: f64,
544    cur_h: f64,
545    /// The enclosing [`Node::Located`] wrapper's 0–511 grid box, waiting to be
546    /// consumed as the next item's provenance.
547    pending_loc: Option<[u16; 4]>,
548    /// The enclosing [`Node::Prov`] wrapper's (or the tree item's) exact
549    /// provenance, which takes precedence over the grid box.
550    pending_exact: Option<ExactProv>,
551    /// `$ref`s an item wants placed in its parent's `children` *before* its
552    /// own — a chart's caption item, which docling's office backends add to
553    /// the container ahead of the picture that references it.
554    pending_siblings: Vec<Value>,
555    /// `$ref`s an item wants placed in its parent's `children` right *after*
556    /// its own — an HTML `<figure>`-wrapped table's caption
557    /// ([`CaptionParent::ContainerAfter`]).
558    pending_after: Vec<Value>,
559    /// Caption `$ref`s that hang off `#/body` while their item sits deeper
560    /// ([`CaptionParent::Body`]): docling appends them to the body's children
561    /// as they are created, so they follow the top-level item being walked.
562    pending_body: Vec<Value>,
563    /// What each [`Node::CommentSection`] is referenced by, in document order —
564    /// its group `$ref`, or its note text's when the section says so. The index
565    /// is what a [`Node::Commented`] annotation carries.
566    comment_groups: Vec<String>,
567    /// Annotated items awaiting their refs: comments are usually emitted
568    /// *after* the body they annotate (docx appends them), so the link is
569    /// patched in once the whole document has been walked.
570    pending_comments: Vec<(String, Vec<usize>)>,
571}
572
573impl Builder {
574    /// Consume the pending location (if any) into a docling `prov` array: the
575    /// 0-511 grid denormalized against the current page into BOTTOMLEFT
576    /// points, rounded to 2 decimals like docling's own export. `char_len` is
577    /// the item's text length in characters (0 for tables and pictures, whose
578    /// charspan docling emits as `[0, 0]`).
579    fn take_prov(&mut self, char_len: usize) -> Value {
580        let prov = self.prov_json(char_len, false);
581        self.pending_exact = None;
582        self.pending_loc = None;
583        prov
584    }
585
586    /// The pending provenance without consuming it. An exact [`Node::Prov`]
587    /// box wins over the grid; its own `charspan` is used unless
588    /// `span_over_text` asks for `[0, char_len]` (a chart caption's span
589    /// covers the caption text where the chart's is `[0, 0]`).
590    fn prov_json(&self, char_len: usize, span_over_text: bool) -> Value {
591        let r2 = |v: f64| (v * 100.0).round() / 100.0;
592        if let Some(ExactProv {
593            page_no,
594            bbox: [l, t, r, b],
595            bottom_left,
596            charspan,
597        }) = self.pending_exact
598        {
599            let charspan = if span_over_text {
600                [0, char_len]
601            } else {
602                charspan
603            };
604            return json!([{
605                "page_no": page_no,
606                "bbox": {
607                    "l": r2(l), "t": r2(t), "r": r2(r), "b": r2(b),
608                    "coord_origin": if bottom_left { "BOTTOMLEFT" } else { "TOPLEFT" },
609                },
610                "charspan": charspan,
611            }]);
612        }
613        let Some([x0, y0, x1, y1]) = self.pending_loc else {
614            return json!([]);
615        };
616        // An all-zero grid box is the sentinel for "this item has no geometry"
617        // (a slide's speaker notes, say). docling writes a zero bbox for it,
618        // not a box spanning the page, which is what denormalizing would give.
619        if [x0, y0, x1, y1] == [0, 0, 0, 0] {
620            return json!([{
621                "page_no": self.cur_page,
622                "bbox": { "l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT" },
623                "charspan": [0, char_len],
624            }]);
625        }
626        json!([{
627            "page_no": self.cur_page,
628            "bbox": {
629                "l": r2(x0 as f64 * self.cur_w / 512.0),
630                "t": r2(self.cur_h - y0 as f64 * self.cur_h / 512.0),
631                "r": r2(x1 as f64 * self.cur_w / 512.0),
632                "b": r2(self.cur_h - y1 as f64 * self.cur_h / 512.0),
633                "coord_origin": "BOTTOMLEFT",
634            },
635            "charspan": [0, char_len],
636        }])
637    }
638
639    /// Adopt a node's own location field (tables, formulas, list items carry
640    /// one instead of a [`Node::Located`] wrapper) when no wrapper is pending.
641    fn adopt_loc(&mut self, loc: Option<[u16; 4]>) {
642        if self.pending_loc.is_none() && self.cur_page > 0 {
643            self.pending_loc = loc;
644        }
645    }
646
647    /// Resolve the [`Node::Commented`] annotations collected during the walk
648    /// into docling's `comments: [{"$ref": "#/groups/N"}]` key on the annotated
649    /// item. It has to run after the whole walk: docx appends its comment
650    /// bodies, so the groups they live in are usually allocated *later* than
651    /// the paragraphs pointing at them. docling emits the key between `prov`
652    /// and `orig`, so insert it in place rather than appending (serde_json runs
653    /// with `preserve_order`, i.e. key order is output order).
654    fn link_comments(&mut self) {
655        let refs: Vec<(String, Vec<Value>)> = std::mem::take(&mut self.pending_comments)
656            .into_iter()
657            .map(|(item, comments)| {
658                let refs = comments
659                    .iter()
660                    .filter_map(|i| self.comment_groups.get(*i))
661                    .map(|r| json!({ "$ref": r }))
662                    .collect();
663                (item, refs)
664            })
665            .collect();
666        for (item, comment_refs) in refs {
667            if comment_refs.is_empty() {
668                continue;
669            }
670            let Some(target) = self.item_mut(&item) else {
671                continue;
672            };
673            let Some(obj) = target.as_object_mut() else {
674                continue;
675            };
676            let tail: Vec<(String, Value)> = obj
677                .iter()
678                .skip_while(|(k, _)| k.as_str() != "prov")
679                .skip(1)
680                .map(|(k, v)| (k.clone(), v.clone()))
681                .collect();
682            for (k, _) in &tail {
683                obj.shift_remove(k);
684            }
685            obj.insert("comments".into(), Value::Array(comment_refs));
686            for (k, v) in tail {
687                obj.insert(k, v);
688            }
689        }
690    }
691
692    /// The stored JSON object a `#/texts/N`-style self-ref points at.
693    fn item_mut(&mut self, self_ref: &str) -> Option<&mut Value> {
694        let idx = ref_index(self_ref)?;
695        let bucket = if self_ref.starts_with("#/texts/") {
696            &mut self.texts
697        } else if self_ref.starts_with("#/tables/") {
698            &mut self.tables
699        } else if self_ref.starts_with("#/pictures/") {
700            &mut self.pictures
701        } else if self_ref.starts_with("#/groups/") {
702            &mut self.groups
703        } else {
704            return None;
705        };
706        bucket.get_mut(idx)
707    }
708
709    /// Serialize a backend-built [`ItemTree`](crate::tree::ItemTree): every
710    /// item in creation order into its bucket, with the parent / children /
711    /// layer the tree recorded. Returns the body's `children` refs.
712    fn write_tree(&mut self, tree: &crate::tree::ItemTree) -> Vec<Value> {
713        use crate::tree::TreeKind;
714        // Every item's `self_ref` first: children may be listed before they
715        // are written (a rich cell's group is created after its content).
716        let mut refs: Vec<String> = Vec::with_capacity(tree.items.len());
717        let (mut nt, mut ng, mut ntb, mut np, mut nf) = (0, 0, 0, 0, 0);
718        for item in &tree.items {
719            if item.deleted {
720                refs.push(String::new());
721                continue;
722            }
723            let r = match &item.kind {
724                TreeKind::Text { .. } | TreeKind::Code { .. } => {
725                    nt += 1;
726                    format!("#/texts/{}", nt - 1)
727                }
728                TreeKind::Group { .. } => {
729                    ng += 1;
730                    format!("#/groups/{}", ng - 1)
731                }
732                TreeKind::Table { .. } => {
733                    ntb += 1;
734                    format!("#/tables/{}", ntb - 1)
735                }
736                TreeKind::Picture { .. } => {
737                    np += 1;
738                    format!("#/pictures/{}", np - 1)
739                }
740                TreeKind::FieldRegion { items } => {
741                    // A region's marker / key / value parts are text items
742                    // too, numbered where docling creates them.
743                    nt += items
744                        .iter()
745                        .map(|i| {
746                            [&i.marker, &i.key, &i.value]
747                                .iter()
748                                .filter(|p| p.is_some())
749                                .count()
750                        })
751                        .sum::<usize>();
752                    nf += 1;
753                    format!("#/field_regions/{}", nf - 1)
754                }
755            };
756            refs.push(r);
757        }
758        let ref_of = |id: usize| json!({ "$ref": refs[id] });
759        for (id, item) in tree.items.iter().enumerate() {
760            if item.deleted {
761                continue;
762            }
763            let parent = item.parent.map_or("#/body", |p| refs[p].as_str());
764            let children: Vec<Value> = item.children.iter().map(|&c| ref_of(c)).collect();
765            let layer = item.layer.map_or("body", |l| l.value());
766            // The item's own provenance, consumed by the writer below
767            // (`take_prov`); an item without one writes `prov: []`.
768            self.pending_exact = item.prov.as_ref().map(ExactProv::from);
769            let self_ref = match &item.kind {
770                TreeKind::Text {
771                    label,
772                    text,
773                    orig,
774                    formatting,
775                    hyperlink,
776                    level,
777                    list,
778                } => {
779                    // docling's field order: …, text, formatting, hyperlink,
780                    // then the subclass fields (`level`; `enumerated`, `marker`).
781                    let mut tail = serde_json::Map::new();
782                    if let Some(f) = formatting {
783                        tail.insert("formatting".into(), formatting_json(f));
784                    }
785                    if let Some(h) = hyperlink {
786                        tail.insert("hyperlink".into(), json!(h));
787                    }
788                    if let Some(l) = level {
789                        tail.insert("level".into(), json!(l));
790                    }
791                    if let Some(l) = list {
792                        tail.insert("enumerated".into(), json!(l.enumerated));
793                        tail.insert("marker".into(), json!(l.marker));
794                    }
795                    let r = format!("#/texts/{}", self.texts.len());
796                    let prov = self.take_prov(text.chars().count());
797                    let mut item_json = json!({
798                        "self_ref": r,
799                        "parent": { "$ref": parent },
800                        "children": children,
801                        "content_layer": layer,
802                        "label": label,
803                        "prov": prov,
804                    });
805                    // docling's `comments` back-refs sit between `prov` and
806                    // `orig`, and are written only when set.
807                    if !item.comments.is_empty() {
808                        item_json["comments"] =
809                            Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
810                    }
811                    merge(
812                        &mut item_json,
813                        json!({
814                            "orig": orig.as_deref().unwrap_or(text),
815                            "text": text,
816                        }),
817                    );
818                    merge(&mut item_json, Value::Object(tail));
819                    self.texts.push(item_json);
820                    r
821                }
822                TreeKind::Code {
823                    text,
824                    orig,
825                    language,
826                    formatting,
827                    hyperlink,
828                } => {
829                    let r = format!("#/texts/{}", self.texts.len());
830                    let prov = self.take_prov(text.chars().count());
831                    let mut item_json = json!({
832                        "self_ref": r,
833                        "parent": { "$ref": parent },
834                        "children": children,
835                        "content_layer": layer,
836                        "label": "code",
837                        "prov": prov,
838                    });
839                    if !item.comments.is_empty() {
840                        item_json["comments"] =
841                            Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
842                    }
843                    merge(
844                        &mut item_json,
845                        json!({
846                            "orig": orig.as_deref().unwrap_or(text),
847                            "text": text,
848                        }),
849                    );
850                    if let Some(f) = formatting {
851                        item_json["formatting"] = formatting_json(f);
852                    }
853                    if let Some(h) = hyperlink {
854                        item_json["hyperlink"] = json!(h);
855                    }
856                    merge(
857                        &mut item_json,
858                        json!({
859                            "captions": [],
860                            "references": [],
861                            "footnotes": [],
862                            "code_language": code_language(language.as_deref()),
863                        }),
864                    );
865                    self.texts.push(item_json);
866                    r
867                }
868                TreeKind::Group { label, name } => {
869                    self.pending_exact = None;
870                    let r = format!("#/groups/{}", self.groups.len());
871                    self.groups.push(json!({
872                        "self_ref": r,
873                        "parent": { "$ref": parent },
874                        "children": children,
875                        "content_layer": layer,
876                        "name": name,
877                        "label": label,
878                    }));
879                    r
880                }
881                TreeKind::Table {
882                    table,
883                    rich_cells,
884                    captions,
885                } => {
886                    // docling's raw cell text: nothing to unescape or strip.
887                    let r = self.add_table_with(table, parent, true);
888                    let idx = ref_index(&r).expect("table ref");
889                    let t = &mut self.tables[idx];
890                    t["children"] = Value::Array(children);
891                    t["content_layer"] = json!(layer);
892                    t["captions"] = Value::Array(captions.iter().map(|&c| ref_of(c)).collect());
893                    // A `RichTableCell` is the plain cell plus a `ref` to the
894                    // group holding its content — on `table_cells` only; the
895                    // derived `grid` shows plain cells.
896                    for &(row, col, group) in rich_cells {
897                        let cell_ref = ref_of(group);
898                        let hit = |c: &Value| {
899                            c["start_row_offset_idx"] == json!(row)
900                                && c["start_col_offset_idx"] == json!(col)
901                        };
902                        if let Some(cells) = t["data"]["table_cells"].as_array_mut() {
903                            for c in cells.iter_mut().filter(|c| hit(c)) {
904                                c["ref"] = cell_ref.clone();
905                            }
906                        }
907                    }
908                    r
909                }
910                TreeKind::Picture {
911                    captions,
912                    image,
913                    classification,
914                    chart,
915                    dpi,
916                } => {
917                    // A chart's meta: the kind as the one classification
918                    // prediction, then the reconstructed data grid (#405).
919                    let mut meta = classification.as_ref().map(
920                        |c| json!({ "classification": { "predictions": [{ "class_name": c }] } }),
921                    );
922                    if let (Some(m), Some(t)) = (meta.as_mut(), chart) {
923                        if !t.rows.is_empty() {
924                            m["tabular_chart"] = json!({ "chart_data": table_data(t) });
925                        }
926                    }
927                    let prov = self.take_prov(0);
928                    let r = self.push_picture(
929                        prov,
930                        captions.iter().map(|&c| ref_of(c)).collect(),
931                        children,
932                        image.as_ref(),
933                        meta,
934                        parent,
935                    );
936                    if let Some(idx) = ref_index(&r) {
937                        self.pictures[idx]["content_layer"] = json!(layer);
938                        // The image's dpi is the file's when the backend read
939                        // it (python-pptx does); the default 72 otherwise.
940                        if let (Some(dpi), Some(img)) = (dpi, self.pictures[idx].get_mut("image")) {
941                            img["dpi"] = json!(dpi);
942                        }
943                    }
944                    r
945                }
946                TreeKind::FieldRegion { items } => {
947                    self.pending_exact = None;
948                    let r = self.add_field_region(items, parent);
949                    if let Some(region) = self.field_regions.last_mut() {
950                        region["content_layer"] = json!(layer);
951                    }
952                    r
953                }
954            };
955            debug_assert_eq!(self_ref, refs[id], "tree item {id} numbered out of order");
956        }
957        tree.body.iter().map(|&c| ref_of(c)).collect()
958    }
959
960    fn add_node(&mut self, node: &Node, parent: &str) -> Option<String> {
961        match node {
962            Node::Heading { level: 1, text } => {
963                Some(self.add_text("title", text, parent, json!({})))
964            }
965            Node::Heading { level, text } => Some(self.add_text(
966                "section_header",
967                text,
968                parent,
969                json!({ "level": level.saturating_sub(1) }),
970            )),
971            Node::Caption { text, href } => {
972                let extra = match href {
973                    Some(url) => json!({ "hyperlink": url }),
974                    None => json!({}),
975                };
976                Some(self.add_text("caption", text, parent, extra))
977            }
978            Node::Paragraph { text } => {
979                // A whole-paragraph display equation is a formula item (docling
980                // wraps it in `$$…$$` and, unlike a text item, never escapes it).
981                let t = text.trim();
982                match t.strip_prefix("$$").and_then(|s| s.strip_suffix("$$")) {
983                    Some(inner) if !inner.is_empty() => Some(self.add_formula(inner, parent)),
984                    _ => Some(self.add_text("text", text, parent, json!({}))),
985                }
986            }
987            Node::CheckboxItem { checked, text } => {
988                // JSON keeps the task-list form as a plain text item (the
989                // `checkbox_selected`/`checkbox_unselected` label is DocLang-only).
990                let mark = if *checked { "- [x] " } else { "- [ ] " };
991                Some(self.add_text("text", &format!("{mark}{text}"), parent, json!({})))
992            }
993            Node::Code {
994                language,
995                text,
996                orig,
997                ..
998            } => Some(self.add_code(text, language.as_deref(), orig.as_deref(), parent)),
999            // A CodeFormula-decoded display formula: `text` carries the LaTeX,
1000            // `orig` the raw glyph extraction (docling's enriched shape).
1001            Node::Formula {
1002                latex,
1003                orig,
1004                location,
1005            } => {
1006                self.adopt_loc(*location);
1007                Some(self.add_formula_item(latex, orig, parent))
1008            }
1009            // docling's notes-layer `comment_section` group holding the
1010            // comment's text item. What the annotated items point at differs
1011            // upstream: the docx backend links the group (so a comment's
1012            // replies group together), everything going through
1013            // docling-core's `add_comment` links the note text itself.
1014            Node::CommentSection {
1015                name,
1016                text,
1017                refs_note_text,
1018                grouped,
1019            } => {
1020                if !*grouped {
1021                    // docling-core's bare `add_comment`: the note text sits
1022                    // directly under the parent and is what the back-refs
1023                    // point at.
1024                    let child =
1025                        self.add_text("text", text, parent, json!({ "content_layer": "notes" }));
1026                    self.comment_groups.push(child.clone());
1027                    return Some(child);
1028                }
1029                let self_ref = format!("#/groups/{}", self.groups.len());
1030                self.groups.push(Value::Null);
1031                let child =
1032                    self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
1033                self.groups[group_index(&self_ref)] = json!({
1034                    "self_ref": self_ref,
1035                    "parent": { "$ref": parent },
1036                    "children": [{ "$ref": child }],
1037                    "content_layer": "notes",
1038                    "name": name,
1039                    "label": "comment_section",
1040                });
1041                self.comment_groups.push(if *refs_note_text {
1042                    child
1043                } else {
1044                    self_ref.clone()
1045                });
1046                Some(self_ref)
1047            }
1048            // The annotation itself is a cross-reference: emit the item, then
1049            // remember it so the group refs can be filled in at the end.
1050            Node::Commented { comments, inner } => {
1051                let item = self.add_node(inner, parent)?;
1052                if !comments.is_empty() {
1053                    self.pending_comments.push((item.clone(), comments.clone()));
1054                }
1055                Some(item)
1056            }
1057            Node::Table(t) => Some(self.add_table(t, parent)),
1058            Node::Picture {
1059                caption,
1060                caption_href,
1061                image,
1062                classification,
1063                caption_parent,
1064            } => Some(self.add_picture(
1065                caption.as_deref(),
1066                caption_href.as_deref(),
1067                image.as_ref(),
1068                classification.as_deref().map(classification_meta),
1069                parent,
1070                *caption_parent,
1071            )),
1072            // A chart is a picture item in the JSON with docling's chart
1073            // meta — `classification` (the chart kind, as the one prediction)
1074            // and `tabular_chart.chart_data`, the series reconstructed as a
1075            // `TableData` (#405) — and no image payload.
1076            Node::Chart {
1077                kind,
1078                table,
1079                caption,
1080                location,
1081            } => {
1082                self.adopt_loc(*location);
1083                let mut meta = json!({
1084                    "classification": { "predictions": [{ "class_name": kind }] },
1085                });
1086                if !table.rows.is_empty() {
1087                    meta["tabular_chart"] = json!({ "chart_data": table_data(table) });
1088                }
1089                // docling's office backends add the chart's title as a caption
1090                // item of the *container* (the sheet group, the slide), listed
1091                // before the picture that references it, with the chart's own
1092                // box and a charspan over the caption text — not as a child
1093                // of the picture, which is where a PDF caption lives.
1094                let mut captions = Vec::new();
1095                if let Some(cap) = caption.as_deref().filter(|c| !c.is_empty()) {
1096                    let prov = self.prov_json(unescape_text(cap).chars().count(), true);
1097                    let cap_ref = self.add_text_with("caption", cap, parent, json!({}), prov);
1098                    self.pending_siblings.push(json!({ "$ref": cap_ref }));
1099                    captions.push(json!({ "$ref": cap_ref }));
1100                }
1101                let prov = self.take_prov(0);
1102                Some(self.push_picture(prov, captions, Vec::new(), None, Some(meta), parent))
1103            }
1104            // A DocLang-only node is omitted from the JSON body.
1105            Node::DoclangOnly(_) => None,
1106            Node::Group {
1107                label,
1108                name,
1109                layer,
1110                children,
1111            } => Some(self.add_group(label, name.as_deref(), *layer, children, parent)),
1112            Node::FieldRegion { items } => Some(self.add_field_region(items, parent)),
1113            // A rich inline group is a text item over its Markdown text; the
1114            // structured runs are DocLang-only, so the JSON matches a paragraph.
1115            Node::InlineGroup { md_text, .. } => {
1116                Some(self.add_text("text", md_text, parent, json!({})))
1117            }
1118            // A plain-text backend dump is a single text item over the file body.
1119            Node::TextDump(text) => Some(self.add_text("text", text, parent, json!({}))),
1120            // Speaker notes are content a deck carries, and docling puts them
1121            // in the JSON on their own layer, so a consumer reading only JSON
1122            // can pick them (#402). Page furniture stays out of the flat
1123            // path; a backend that builds docling's item tree (HTML) puts its
1124            // furniture-layer items in the JSON through `write_tree`.
1125            Node::Furniture {
1126                layer: ContentLayer::Notes,
1127                inner,
1128            } => {
1129                let item = self.add_node(inner, parent)?;
1130                self.set_layer(&item, "notes");
1131                Some(item)
1132            }
1133            Node::Furniture { .. } => None,
1134            Node::PageFurniture { .. } => None,
1135            // A location wrapper turns into the wrapped item's `prov` entry —
1136            // but only on pages the PDF paths described with a PageInfo marker
1137            // (other geometry-bearing backends, e.g. PPTX shapes, keep their
1138            // pre-#171 provenance-less JSON until they emit markers too).
1139            Node::Located { location, inner } => {
1140                if self.cur_page > 0 {
1141                    self.pending_loc = Some(*location);
1142                }
1143                let r = self.add_node(inner, parent);
1144                self.pending_loc = None;
1145                r
1146            }
1147            Node::Prov {
1148                page_no,
1149                bbox,
1150                charspan,
1151                inner,
1152                ..
1153            } => {
1154                self.pending_exact = Some(ExactProv {
1155                    page_no: *page_no,
1156                    bbox: bbox.map(f64::from),
1157                    bottom_left: false,
1158                    charspan: *charspan,
1159                });
1160                let r = self.add_node(inner, parent);
1161                self.pending_exact = None;
1162                r
1163            }
1164            // Page breaks are DocLang-only; docling omits them from the JSON body.
1165            Node::PageBreak => None,
1166            // The page marker: record the page's number and size for the
1167            // `pages` map, and denormalize every following location against it.
1168            Node::PageInfo {
1169                page_no,
1170                width,
1171                height,
1172            } => {
1173                self.cur_page = *page_no;
1174                self.cur_w = *width as f64;
1175                self.cur_h = *height as f64;
1176                if *page_no > 0 {
1177                    self.pages.push((*page_no, self.cur_w, self.cur_h));
1178                }
1179                None
1180            }
1181            // Handled by `add_list` in `walk`.
1182            Node::ListItem { .. } => None,
1183        }
1184    }
1185
1186    /// A form key-value region: `field_regions/N` holds the region, each field is
1187    /// a `field_items/M` whose children are its `marker` / `field_key` /
1188    /// `field_value` texts (absent parts are simply omitted).
1189    fn add_field_region(&mut self, items: &[crate::FieldItem], parent: &str) -> String {
1190        let self_ref = format!("#/field_regions/{}", self.field_regions.len());
1191        self.field_regions.push(Value::Null);
1192        let region_index = self.field_regions.len() - 1;
1193        let mut item_refs = Vec::new();
1194        for item in items {
1195            item_refs.push(json!({ "$ref": self.add_field_item(item, &self_ref) }));
1196        }
1197        self.field_regions[region_index] = json!({
1198            "self_ref": self_ref,
1199            "parent": { "$ref": parent },
1200            "children": item_refs,
1201            "content_layer": "body",
1202            "label": "field_region",
1203            "prov": [],
1204        });
1205        self_ref
1206    }
1207
1208    fn add_field_item(&mut self, item: &crate::FieldItem, parent: &str) -> String {
1209        let self_ref = format!("#/field_items/{}", self.field_items.len());
1210        self.field_items.push(Value::Null);
1211        let item_index = self.field_items.len() - 1;
1212        let mut child_refs = Vec::new();
1213        for (label, text) in [
1214            ("marker", &item.marker),
1215            ("field_key", &item.key),
1216            ("field_value", &item.value),
1217        ] {
1218            if let Some(text) = text {
1219                // A value's `kind` (docling's `read_only` / `fillable`)
1220                // follows its text.
1221                let extra = match (label, &item.value_kind) {
1222                    ("field_value", Some(kind)) => json!({ "kind": kind }),
1223                    _ => json!({}),
1224                };
1225                child_refs.push(json!({ "$ref": self.add_text(label, text, &self_ref, extra) }));
1226            }
1227        }
1228        self.field_items[item_index] = json!({
1229            "self_ref": self_ref,
1230            "parent": { "$ref": parent },
1231            "children": child_refs,
1232            "content_layer": "body",
1233            "label": "field_item",
1234            "prov": [],
1235        });
1236        self_ref
1237    }
1238
1239    /// Move an already-emitted item onto a content layer. Notes are single
1240    /// text items today; a deeper notes subtree would need its children moved
1241    /// too, and no backend builds one.
1242    fn set_layer(&mut self, self_ref: &str, layer: &str) {
1243        let bucket = match self_ref.split('/').nth(1) {
1244            Some("texts") => &mut self.texts,
1245            Some("tables") => &mut self.tables,
1246            Some("pictures") => &mut self.pictures,
1247            Some("groups") => &mut self.groups,
1248            _ => return,
1249        };
1250        if let Some(item) = self_ref
1251            .rsplit('/')
1252            .next()
1253            .and_then(|i| i.parse::<usize>().ok())
1254            .and_then(|i| bucket.get_mut(i))
1255        {
1256            item["content_layer"] = json!(layer);
1257        }
1258    }
1259
1260    fn add_text(&mut self, label: &str, text: &str, parent: &str, extra: Value) -> String {
1261        let prov = self.take_prov(unescape_text(text).chars().count());
1262        self.add_text_with(label, text, parent, extra, prov)
1263    }
1264
1265    /// [`Self::add_text`] with an explicit `prov` (a chart caption shares the
1266    /// chart's box without consuming it).
1267    fn add_text_with(
1268        &mut self,
1269        label: &str,
1270        text: &str,
1271        parent: &str,
1272        extra: Value,
1273        prov: Value,
1274    ) -> String {
1275        let self_ref = format!("#/texts/{}", self.texts.len());
1276        let raw = unescape_text(text);
1277        let mut item = json!({
1278            "self_ref": self_ref,
1279            "parent": { "$ref": parent },
1280            "children": [],
1281            "content_layer": "body",
1282            "label": label,
1283            "prov": prov,
1284            "orig": raw,
1285            "text": raw,
1286        });
1287        merge(&mut item, extra);
1288        self.texts.push(item);
1289        self_ref
1290    }
1291
1292    /// A display-math formula item. `latex` is the raw content (no `$$`); docling
1293    /// re-wraps it and never escapes it.
1294    fn add_formula(&mut self, latex: &str, parent: &str) -> String {
1295        let self_ref = format!("#/texts/{}", self.texts.len());
1296        let prov = self.take_prov(latex.chars().count());
1297        self.texts.push(json!({
1298            "self_ref": self_ref,
1299            "parent": { "$ref": parent },
1300            "children": [],
1301            "content_layer": "body",
1302            "label": "formula",
1303            "prov": prov,
1304            "orig": latex,
1305            "text": latex,
1306        }));
1307        self_ref
1308    }
1309
1310    /// A CodeFormula-enriched display formula: `text` is the model's LaTeX
1311    /// while `orig` keeps the raw glyph extraction (docling's enriched shape;
1312    /// the plain [`Self::add_formula`] above sets both to the same string).
1313    fn add_formula_item(&mut self, latex: &str, orig: &str, parent: &str) -> String {
1314        let self_ref = format!("#/texts/{}", self.texts.len());
1315        let prov = self.take_prov(latex.chars().count());
1316        self.texts.push(json!({
1317            "self_ref": self_ref,
1318            "parent": { "$ref": parent },
1319            "children": [],
1320            "content_layer": "body",
1321            "label": "formula",
1322            "prov": prov,
1323            "orig": orig,
1324            "text": latex,
1325        }));
1326        self_ref
1327    }
1328
1329    fn add_code(
1330        &mut self,
1331        text: &str,
1332        language: Option<&str>,
1333        orig: Option<&str>,
1334        parent: &str,
1335    ) -> String {
1336        let self_ref = format!("#/texts/{}", self.texts.len());
1337        let raw = unescape_text(text);
1338        let prov = self.take_prov(raw.chars().count());
1339        self.texts.push(json!({
1340            "self_ref": self_ref,
1341            "parent": { "$ref": parent },
1342            "children": [],
1343            "content_layer": "body",
1344            "label": "code",
1345            "prov": prov,
1346            // With code enrichment, `text` is the model's rewrite while `orig`
1347            // keeps the raw extraction; otherwise both are the same string.
1348            "orig": orig.map(unescape_text).unwrap_or_else(|| raw.clone()),
1349            "text": raw,
1350            "captions": [],
1351            "references": [],
1352            "footnotes": [],
1353            "code_language": code_language(language),
1354        }));
1355        self_ref
1356    }
1357
1358    /// Build a list group from a run of (possibly multi-level) list items. A
1359    /// deeper level starts a nested list under the preceding item.
1360    fn add_list(&mut self, items: &[Node], parent: &str) -> String {
1361        let self_ref = format!("#/groups/{}", self.groups.len());
1362        // reserve the slot so nested groups get later indices
1363        self.groups.push(Value::Null);
1364        let base = level_of(&items[0]);
1365        let mut children = Vec::new();
1366        let mut i = 0;
1367        while i < items.len() {
1368            // Empty paragraphs absorbed into the run (blank lines between items)
1369            // are not list items — skip them.
1370            if !matches!(items[i], Node::ListItem { .. }) {
1371                i += 1;
1372                continue;
1373            }
1374            let lvl = level_of(&items[i]);
1375            if lvl > base {
1376                // shouldn't happen at the head; skip defensively
1377                i += 1;
1378                continue;
1379            }
1380            let item_ref = self.add_list_item(&items[i], &self_ref);
1381            // collect any deeper items that nest under this one
1382            let mut j = i + 1;
1383            while j < items.len() && level_of(&items[j]) > base {
1384                j += 1;
1385            }
1386            if j > i + 1 {
1387                let mut nested = Vec::new();
1388                self.add_sibling_lists(&items[i + 1..j], &item_ref, &mut nested);
1389                // the nested list group(s) are children of this item
1390                if let Some(idx) = ref_index(&item_ref) {
1391                    self.texts[idx]["children"]
1392                        .as_array_mut()
1393                        .unwrap()
1394                        .extend(nested);
1395                }
1396            }
1397            children.push(json!({ "$ref": item_ref }));
1398            i = j;
1399        }
1400        self.groups[group_index(&self_ref)] = json!({
1401            "self_ref": self_ref,
1402            "parent": { "$ref": parent },
1403            "children": children,
1404            "content_layer": "body",
1405            "name": "list",
1406            "label": "list",
1407        });
1408        self_ref
1409    }
1410
1411    fn add_list_item(&mut self, node: &Node, parent: &str) -> String {
1412        let Node::ListItem {
1413            ordered,
1414            number,
1415            text,
1416            location,
1417            ..
1418        } = node
1419        else {
1420            unreachable!()
1421        };
1422        self.adopt_loc(*location);
1423        let self_ref = format!("#/texts/{}", self.texts.len());
1424        let raw = unescape_text(text);
1425        let prov = self.take_prov(raw.chars().count());
1426        let marker = if *ordered {
1427            format!("{number}.")
1428        } else {
1429            "-".to_string()
1430        };
1431        self.texts.push(json!({
1432            "self_ref": self_ref,
1433            "parent": { "$ref": parent },
1434            "children": [],
1435            "content_layer": "body",
1436            "label": "list_item",
1437            "prov": prov,
1438            "orig": raw,
1439            "text": raw,
1440            "enumerated": ordered,
1441            "marker": marker,
1442        }));
1443        self_ref
1444    }
1445
1446    fn add_table(&mut self, t: &Table, parent: &str) -> String {
1447        self.add_table_with(t, parent, false)
1448    }
1449
1450    /// [`Self::add_table`]; `raw` cell text is written verbatim (see
1451    /// [`table_data_with`]).
1452    fn add_table_with(&mut self, t: &Table, parent: &str, raw: bool) -> String {
1453        let self_ref = format!("#/tables/{}", self.tables.len());
1454        self.adopt_loc(t.location);
1455        let prov = self.take_prov(0);
1456        // The caption is a separate text item the table references (docling's
1457        // `TableItem.captions`), added before the grid so its box isn't
1458        // inherited by a later item.
1459        let (captions, children) = match t.caption.as_deref().filter(|c| !c.is_empty()) {
1460            Some(cap) => self.add_caption(cap, json!({}), &self_ref, parent, t.caption_parent),
1461            None => (Vec::new(), Vec::new()),
1462        };
1463        let data = table_data_with(t, raw);
1464        self.tables.push(json!({
1465            "self_ref": self_ref,
1466            "parent": { "$ref": parent },
1467            "children": children,
1468            "content_layer": "body",
1469            "label": "table",
1470            "prov": prov,
1471            "captions": captions,
1472            "references": [],
1473            "footnotes": [],
1474            "data": data,
1475            "annotations": [],
1476        }));
1477        self_ref
1478    }
1479
1480    /// Add a picture's or table's caption text item where `choice` says it
1481    /// hangs (#390), returning the `captions` entry for the item and the
1482    /// item's own `children` (the caption, when it is the item's child).
1483    /// The caption never consumes the item's pending provenance — the item
1484    /// takes its box first.
1485    fn add_caption(
1486        &mut self,
1487        text: &str,
1488        extra: Value,
1489        self_ref: &str,
1490        parent: &str,
1491        choice: CaptionParent,
1492    ) -> (Vec<Value>, Vec<Value>) {
1493        // docling's PDF pipeline parents the caption to the item; every
1494        // declarative backend leaves `add_text`'s default — the body — even
1495        // for an item inside a group; the office backends and HTML's
1496        // `<figure>` hang it off the item's container.
1497        let cap_parent = match choice {
1498            CaptionParent::Item => self_ref,
1499            CaptionParent::Container | CaptionParent::ContainerAfter => parent,
1500            CaptionParent::Body => "#/body",
1501        };
1502        let cap_ref = json!({ "$ref": self.add_text("caption", text, cap_parent, extra) });
1503        match choice {
1504            CaptionParent::Item => return (vec![cap_ref.clone()], vec![cap_ref]),
1505            // Created ahead of the item, so it precedes the item in the
1506            // container's children — and, on the body, in the body's.
1507            CaptionParent::Container => self.pending_siblings.push(cap_ref.clone()),
1508            CaptionParent::Body if parent == "#/body" => {
1509                self.pending_siblings.push(cap_ref.clone())
1510            }
1511            CaptionParent::ContainerAfter => self.pending_after.push(cap_ref.clone()),
1512            // The item sits deeper: the body's children get the caption after
1513            // the top-level item under walk, where docling appended it.
1514            CaptionParent::Body => self.pending_body.push(cap_ref.clone()),
1515        }
1516        (vec![cap_ref], Vec::new())
1517    }
1518
1519    /// `meta` is the picture's docling `PictureMeta` (a classifier's
1520    /// predictions, a chart's kind and data), `None` for a plain picture.
1521    fn add_picture(
1522        &mut self,
1523        caption: Option<&str>,
1524        caption_href: Option<&str>,
1525        image: Option<&crate::PictureImage>,
1526        meta: Option<Value>,
1527        parent: &str,
1528        caption_parent: CaptionParent,
1529    ) -> String {
1530        let self_ref = format!("#/pictures/{}", self.pictures.len());
1531        // Take the picture's own provenance before the caption text is added —
1532        // the caption is a separate item and must not inherit the crop's box.
1533        let prov = self.take_prov(0);
1534        let (captions, children) = match caption.filter(|c| !c.is_empty()) {
1535            Some(cap) => {
1536                // Emit the caption as a text item that the picture references. A
1537                // wrapping `<a href>`'s link rides as docling's `hyperlink` field
1538                // on the caption item (#328).
1539                let extra = match caption_href {
1540                    Some(href) => json!({ "hyperlink": href }),
1541                    None => json!({}),
1542                };
1543                self.add_caption(cap, extra, &self_ref, parent, caption_parent)
1544            }
1545            None => (Vec::new(), Vec::new()),
1546        };
1547        self.push_picture(prov, captions, children, image, meta, parent)
1548    }
1549
1550    /// Append the picture item itself — `prov`, `captions` and `children`
1551    /// (a PDF caption is the picture's child) already settled.
1552    fn push_picture(
1553        &mut self,
1554        prov: Value,
1555        captions: Vec<Value>,
1556        children: Vec<Value>,
1557        image: Option<&crate::PictureImage>,
1558        meta: Option<Value>,
1559        parent: &str,
1560    ) -> String {
1561        let self_ref = format!("#/pictures/{}", self.pictures.len());
1562        // The legacy `classification` annotation rides along with a
1563        // classifier's `meta` (see `classification_meta`); a chart's meta has
1564        // none, like docling's.
1565        let annotations = meta
1566            .as_ref()
1567            .and_then(|m| m.get("annotations").cloned())
1568            .unwrap_or_else(|| json!([]));
1569        let meta = meta.map(|mut m| {
1570            if let Some(obj) = m.as_object_mut() {
1571                obj.remove("annotations");
1572            }
1573            m
1574        });
1575        // `meta` sits between `content_layer` and `label` in docling's field
1576        // order (and `preserve_order` keeps ours byte-compatible), so the item
1577        // is built in one shot per shape rather than patched afterwards.
1578        let mut item = match meta {
1579            Some(meta) => json!({
1580                "self_ref": self_ref,
1581                "parent": { "$ref": parent },
1582                "children": children,
1583                "content_layer": "body",
1584                "meta": meta,
1585                "label": "picture",
1586                "prov": prov,
1587                "captions": captions,
1588                "references": [],
1589                "footnotes": [],
1590                "annotations": annotations,
1591            }),
1592            None => json!({
1593                "self_ref": self_ref,
1594                "parent": { "$ref": parent },
1595                "children": children,
1596                "content_layer": "body",
1597                "label": "picture",
1598                "prov": prov,
1599                "captions": captions,
1600                "references": [],
1601                "footnotes": [],
1602                "annotations": annotations,
1603            }),
1604        };
1605        // docling stores the extracted image as an `ImageRef` (data URI + size,
1606        // the size as floats) between `footnotes` and `annotations` — pydantic
1607        // field order, which `preserve_order` lets us reproduce by rebuilding
1608        // the tail.
1609        if let Some(img) = image {
1610            let image = json!({
1611                "mimetype": img.mimetype,
1612                "dpi": 72,
1613                "size": { "width": img.width as f64, "height": img.height as f64 },
1614                "uri": img.data_uri(),
1615            });
1616            if let Some(obj) = item.as_object_mut() {
1617                let annotations = obj.remove("annotations").unwrap_or_else(|| json!([]));
1618                obj.insert("image".into(), image);
1619                obj.insert("annotations".into(), annotations);
1620            }
1621        }
1622        self.pictures.push(item);
1623        self_ref
1624    }
1625
1626    fn add_group(
1627        &mut self,
1628        label: &str,
1629        name: Option<&str>,
1630        layer: Option<ContentLayer>,
1631        nodes: &[Node],
1632        parent: &str,
1633    ) -> String {
1634        let self_ref = format!("#/groups/{}", self.groups.len());
1635        self.groups.push(Value::Null);
1636        // Everything the walk creates belongs to this group, so a non-body
1637        // layer (a hidden sheet) is stamped on the whole subtree afterwards —
1638        // docling puts the layer on the group *and* on every item under it.
1639        let mark = (
1640            self.texts.len(),
1641            self.tables.len(),
1642            self.pictures.len(),
1643            self.groups.len(),
1644        );
1645        let children = self.walk_into(nodes, &self_ref);
1646        let name = name.unwrap_or(if label == "inline" { "group" } else { label });
1647        let content_layer = layer.map_or("body", |l| l.value());
1648        self.groups[group_index(&self_ref)] = json!({
1649            "self_ref": self_ref,
1650            "parent": { "$ref": parent },
1651            "children": children,
1652            "content_layer": content_layer,
1653            "name": name,
1654            "label": label,
1655        });
1656        if layer.is_some() {
1657            let (t, tb, p, g) = mark;
1658            for item in self.texts[t..]
1659                .iter_mut()
1660                .chain(self.tables[tb..].iter_mut())
1661                .chain(self.pictures[p..].iter_mut())
1662                .chain(self.groups[g..].iter_mut())
1663            {
1664                if let Some(obj) = item.as_object_mut() {
1665                    obj.insert("content_layer".into(), json!(content_layer));
1666                }
1667            }
1668        }
1669        self_ref
1670    }
1671
1672    /// Walk a slice of sibling nodes, returning each child's `$ref`; runs of
1673    /// list items are folded into list groups (one per sibling list).
1674    fn walk_into(&mut self, nodes: &[Node], parent: &str) -> Vec<Value> {
1675        // Siblings that all carry a creation rank (an XLSX sheet's items) are
1676        // *added* in that order — so `#/tables/N` and friends are numbered as
1677        // docling numbers them — while their refs keep the node order, which
1678        // is docling's position-sorted `children`.
1679        let seqs: Option<Vec<usize>> = nodes
1680            .iter()
1681            .map(|n| match n {
1682                Node::Prov { seq: Some(s), .. } => Some(*s),
1683                _ => None,
1684            })
1685            .collect();
1686        if let Some(seqs) = seqs.filter(|s| !s.is_empty()) {
1687            let mut order: Vec<usize> = (0..nodes.len()).collect();
1688            order.sort_by_key(|&i| seqs[i]);
1689            let mut slots: Vec<Vec<Value>> = vec![Vec::new(); nodes.len()];
1690            for i in order {
1691                if let Some(r) = self.add_node(&nodes[i], parent) {
1692                    slots[i].append(&mut self.pending_siblings);
1693                    slots[i].push(json!({ "$ref": r }));
1694                    slots[i].append(&mut self.pending_after);
1695                }
1696                if parent == "#/body" {
1697                    slots[i].append(&mut self.pending_body);
1698                }
1699            }
1700            return slots.into_iter().flatten().collect();
1701        }
1702        let mut children = Vec::new();
1703        let mut i = 0;
1704        while i < nodes.len() {
1705            if matches!(nodes[i], Node::ListItem { .. }) {
1706                let start = i;
1707                i += 1;
1708                loop {
1709                    match nodes.get(i) {
1710                        Some(Node::ListItem { .. }) => i += 1,
1711                        // Absorb an empty paragraph sitting between two list
1712                        // items (docling keeps the ListGroup contiguous).
1713                        Some(Node::Paragraph { text })
1714                            if text.is_empty()
1715                                && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
1716                        {
1717                            i += 1
1718                        }
1719                        _ => break,
1720                    }
1721                }
1722                self.add_sibling_lists(&nodes[start..i], parent, &mut children);
1723            } else {
1724                if let Some(r) = self.add_node(&nodes[i], parent) {
1725                    children.append(&mut self.pending_siblings);
1726                    children.push(json!({ "$ref": r }));
1727                    children.append(&mut self.pending_after);
1728                }
1729                i += 1;
1730            }
1731            // Body-parented captions of items deeper in the tree follow the
1732            // top-level item they were created under (#390).
1733            if parent == "#/body" {
1734                children.append(&mut self.pending_body);
1735            }
1736        }
1737        children
1738    }
1739
1740    /// A run of list items may hold several *sibling* lists; emit one list group
1741    /// per sibling. The boundary is the backend's `first_in_list` flag on a
1742    /// base-level item — the same rule as the Markdown serializer's blank line
1743    /// (#385; the kind-flip and number-gap guesses are gone).
1744    fn add_sibling_lists(&mut self, run: &[Node], parent: &str, out: &mut Vec<Value>) {
1745        let base = level_of(&run[0]);
1746        let mut seg = 0;
1747        for k in 0..run.len() {
1748            let Node::ListItem {
1749                first_in_list,
1750                level,
1751                ..
1752            } = &run[k]
1753            else {
1754                continue;
1755            };
1756            if *level != base {
1757                continue; // nested item — handled inside add_list
1758            }
1759            if k > seg && *first_in_list {
1760                out.push(json!({ "$ref": self.add_list(&run[seg..k], parent) }));
1761                seg = k;
1762            }
1763        }
1764        out.push(json!({ "$ref": self.add_list(&run[seg..], parent) }));
1765    }
1766}
1767
1768fn level_of(node: &Node) -> u8 {
1769    match node {
1770        Node::ListItem { level, .. } => *level,
1771        _ => 0,
1772    }
1773}
1774
1775fn group_index(self_ref: &str) -> usize {
1776    self_ref.rsplit('/').next().unwrap().parse().unwrap()
1777}
1778
1779fn ref_index(self_ref: &str) -> Option<usize> {
1780    self_ref.rsplit('/').next()?.parse().ok()
1781}
1782
1783/// Merge the key/values of `extra` (an object) into `target` (an object).
1784fn merge(target: &mut Value, extra: Value) {
1785    if let (Some(t), Some(e)) = (target.as_object_mut(), extra.as_object()) {
1786        for (k, v) in e {
1787            t.insert(k.clone(), v.clone());
1788        }
1789    }
1790}
1791
1792/// Reverse [`crate`]'s Markdown text escaping (HTML entities + `\_`).
1793fn unescape_text(s: &str) -> String {
1794    s.replace("&lt;", "<")
1795        .replace("&gt;", ">")
1796        .replace("&amp;", "&")
1797        .replace("\\_", "_")
1798}
1799
1800/// 64-bit FNV-1a, a stand-in for docling's `binary_hash` (we lack the source bytes
1801/// at export time; the value only needs to be a stable u64).
1802fn fnv1a(s: &str) -> u64 {
1803    let mut h: u64 = 0xcbf29ce484222325;
1804    for b in s.bytes() {
1805        h ^= b as u64;
1806        h = h.wrapping_mul(0x100000001b3);
1807    }
1808    h
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813    use crate::{
1814        CaptionParent, ContentLayer, DoclingDocument, ImageMode, Node, PictureImage, Table,
1815    };
1816    use serde_json::Value;
1817
1818    fn doc_with_image() -> DoclingDocument {
1819        let mut doc = DoclingDocument::new("t");
1820        doc.push(Node::Picture {
1821            caption: Some("Fig 1".into()),
1822            caption_href: None,
1823            image: Some(PictureImage {
1824                mimetype: "image/png".into(),
1825                width: 4,
1826                height: 2,
1827                data: b"foobar".to_vec(),
1828            }),
1829            classification: None,
1830            caption_parent: Default::default(),
1831        });
1832        doc
1833    }
1834
1835    /// #402: a deck's speaker notes are content, and docling puts them in the
1836    /// JSON on the `notes` layer so a consumer reading only JSON can pick them
1837    /// out. Markdown still serializes the body layer alone, and page furniture
1838    /// stays out of the JSON, where docling does keep it.
1839    #[test]
1840    fn notes_layer_items_reach_the_json_but_furniture_does_not() {
1841        let mut doc = DoclingDocument::new("t");
1842        doc.push(Node::Heading {
1843            level: 1,
1844            text: "Slide One".into(),
1845        });
1846        doc.push(Node::Furniture {
1847            layer: ContentLayer::Notes,
1848            inner: Box::new(Node::Located {
1849                location: [0, 0, 0, 0],
1850                inner: Box::new(Node::Paragraph {
1851                    text: "Speaker note for slide 1.".into(),
1852                }),
1853            }),
1854        });
1855        doc.push(Node::Furniture {
1856            layer: ContentLayer::Furniture,
1857            inner: Box::new(Node::Paragraph {
1858                text: "page header".into(),
1859            }),
1860        });
1861
1862        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1863        let texts = v["texts"].as_array().unwrap();
1864        assert_eq!(
1865            texts
1866                .iter()
1867                .map(|t| (
1868                    t["label"].as_str().unwrap(),
1869                    t["content_layer"].as_str().unwrap(),
1870                    t["text"].as_str().unwrap()
1871                ))
1872                .collect::<Vec<_>>(),
1873            vec![
1874                ("title", "body", "Slide One"),
1875                ("text", "notes", "Speaker note for slide 1."),
1876            ],
1877            "the note is carried on its own layer; the furniture is not carried"
1878        );
1879        // The body layer is what Markdown serializes, so it does not change.
1880        assert_eq!(doc.export_to_markdown(), "# Slide One\n");
1881    }
1882
1883    /// #410: a backend that describes merged ranges only as continuation
1884    /// flags (xlsx `<mergeCell>`, docx `gridSpan`/`vMerge`) gets docling's
1885    /// one-`TableCell`-per-range JSON: the anchor's offsets and spans, the
1886    /// entry repeated across the grid positions it covers — not a 1×1 cell
1887    /// per position with the text copied into each.
1888    #[test]
1889    fn continuation_flags_become_spanning_cells() {
1890        let mut doc = DoclingDocument::new("t");
1891        // A1:C2 merged ("merged"), then a plain row underneath.
1892        let rows = vec![
1893            vec!["merged".to_string(), "merged".into(), "merged".into()],
1894            vec!["merged".to_string(), "merged".into(), "merged".into()],
1895            vec!["a".to_string(), "b".into(), "c".into()],
1896        ];
1897        doc.push(Node::Table(crate::Table {
1898            rows,
1899            location: None,
1900            structure: Some(crate::TableStructure {
1901                header_row: vec![true, false, false],
1902                col_continuation: vec![
1903                    vec![false, true, true],
1904                    vec![false, true, true],
1905                    vec![false, false, false],
1906                ],
1907                row_continuation: vec![
1908                    vec![false, false, false],
1909                    vec![true, true, true],
1910                    vec![false, false, false],
1911                ],
1912                row_header: Vec::new(),
1913                col_header: Vec::new(),
1914            }),
1915            cell_blocks: None,
1916            cells: None,
1917            caption: None,
1918            caption_parent: Default::default(),
1919        }));
1920        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1921        let data = &v["tables"][0]["data"];
1922        assert_eq!(data["num_rows"], 3);
1923        assert_eq!(data["num_cols"], 3);
1924        let cells = data["table_cells"].as_array().unwrap();
1925        assert_eq!(
1926            cells.len(),
1927            4,
1928            "one cell for the range, three for the plain row"
1929        );
1930        assert_eq!(
1931            cells[0],
1932            serde_json::json!({
1933                "row_span": 2, "col_span": 3,
1934                "start_row_offset_idx": 0, "end_row_offset_idx": 2,
1935                "start_col_offset_idx": 0, "end_col_offset_idx": 3,
1936                "text": "merged", "column_header": true, "row_header": false,
1937                "row_section": false, "fillable": false,
1938            })
1939        );
1940        assert_eq!(cells[1]["text"], "a");
1941        assert_eq!(cells[1]["row_span"], 1);
1942        assert_eq!(cells[1]["column_header"], false);
1943        // The grid repeats the range's entry at every position it covers.
1944        let grid = data["grid"].as_array().unwrap();
1945        assert_eq!(grid.len(), 3);
1946        for (r, row) in grid.iter().take(2).enumerate() {
1947            for (c, cell) in row.as_array().unwrap().iter().enumerate() {
1948                assert_eq!(*cell, cells[0], "grid[{r}][{c}]");
1949            }
1950        }
1951        assert_eq!(grid[2][2]["text"], "c");
1952    }
1953
1954    /// A [`Node::Prov`] wrapper is docling's provenance verbatim — the exact
1955    /// box in a top-left origin, the backend's charspan — and the page marker
1956    /// before it sizes the page; a chart's caption becomes a sibling of the
1957    /// picture in the container, listed first, sharing the chart's box with
1958    /// a charspan over its text. That is the JSON shape of an XLSX sheet.
1959    #[test]
1960    fn exact_provenance_pages_and_chart_captions_follow_docling() {
1961        let mut doc = DoclingDocument::new("t");
1962        doc.push(Node::PageInfo {
1963            page_no: 1,
1964            width: 3.0,
1965            height: 4.0,
1966        });
1967        let table = crate::Table {
1968            rows: vec![vec!["a".to_string(), "b".into()]],
1969            ..Default::default()
1970        };
1971        doc.push(Node::Group {
1972            label: "sheet".into(),
1973            name: Some("Data".into()),
1974            layer: None,
1975            children: vec![
1976                // Node order is the position-sorted one; creation order (the
1977                // `seq`) had the chart first — so the chart is `#/pictures/0`
1978                // *and* its caption `#/texts/0`, while the table stays the
1979                // group's first child.
1980                Node::Prov {
1981                    page_no: 1,
1982                    bbox: [0.0, 0.0, 3.0, 4.0],
1983                    charspan: [0, 0],
1984                    seq: Some(1),
1985                    inner: Box::new(Node::Table(table.clone())),
1986                },
1987                Node::Prov {
1988                    page_no: 1,
1989                    bbox: [0.0, 1.0, 1.0, 1.0],
1990                    charspan: [0, 0],
1991                    seq: Some(0),
1992                    inner: Box::new(Node::Chart {
1993                        kind: "bar_chart".into(),
1994                        table,
1995                        caption: Some("Sales".into()),
1996                        location: Some([0, 128, 170, 128]),
1997                    }),
1998                },
1999            ],
2000        });
2001        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2002        assert_eq!(
2003            v["pages"],
2004            serde_json::json!({"1": {"size": {"width": 3.0, "height": 4.0}, "page_no": 1}})
2005        );
2006        assert_eq!(
2007            v["tables"][0]["prov"],
2008            serde_json::json!([{
2009                "page_no": 1,
2010                "bbox": {"l": 0.0, "t": 0.0, "r": 3.0, "b": 4.0, "coord_origin": "TOPLEFT"},
2011                "charspan": [0, 0],
2012            }])
2013        );
2014        assert_eq!(v["tables"][0]["data"]["orientation"], "rot_0");
2015        // The caption is the group's child *before* the picture, parented to
2016        // the group, and referenced by the picture.
2017        let sheet = &v["groups"][0];
2018        assert_eq!(
2019            sheet["children"],
2020            serde_json::json!([
2021                {"$ref": "#/tables/0"}, {"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}
2022            ])
2023        );
2024        let cap = &v["texts"][0];
2025        assert_eq!(cap["label"], "caption");
2026        assert_eq!(cap["parent"], serde_json::json!({"$ref": "#/groups/0"}));
2027        assert_eq!(cap["prov"][0]["charspan"], serde_json::json!([0, 5]));
2028        assert_eq!(cap["prov"][0]["bbox"]["b"], 1.0);
2029        let pic = &v["pictures"][0];
2030        assert_eq!(pic["captions"], serde_json::json!([{"$ref": "#/texts/0"}]));
2031        assert_eq!(pic["prov"][0]["charspan"], serde_json::json!([0, 0]));
2032        assert_eq!(pic["prov"][0]["bbox"]["coord_origin"], "TOPLEFT");
2033        assert_eq!(
2034            pic["meta"]["classification"]["predictions"][0]["class_name"],
2035            "bar_chart"
2036        );
2037        assert_eq!(pic["meta"]["tabular_chart"]["chart_data"]["num_cols"], 2);
2038    }
2039
2040    /// An all-zero location is the "no geometry" sentinel — a slide's speaker
2041    /// notes carry one — and docling writes it as a zero bbox, not as a box
2042    /// spanning the whole page, which is what denormalizing the grid gives.
2043    #[test]
2044    fn a_zero_location_is_a_zero_bbox_not_the_whole_page() {
2045        let mut doc = DoclingDocument::new("t");
2046        doc.push(Node::PageInfo {
2047            page_no: 1,
2048            width: 12192000.0,
2049            height: 6858000.0,
2050        });
2051        doc.push(Node::Furniture {
2052            layer: ContentLayer::Notes,
2053            inner: Box::new(Node::Located {
2054                location: [0, 0, 0, 0],
2055                inner: Box::new(Node::Paragraph {
2056                    text: "a note".into(),
2057                }),
2058            }),
2059        });
2060        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2061        let prov = &v["texts"][0]["prov"][0];
2062        assert_eq!(prov["page_no"], 1);
2063        assert_eq!(prov["charspan"], serde_json::json!([0, 6]));
2064        assert_eq!(
2065            prov["bbox"],
2066            serde_json::json!({"l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT"})
2067        );
2068        // The page itself is recorded at its true size.
2069        assert_eq!(
2070            v["pages"]["1"]["size"],
2071            serde_json::json!({"width": 12192000.0, "height": 6858000.0})
2072        );
2073    }
2074
2075    /// #171: PageInfo markers become the `pages` map, and `Located` wrappers /
2076    /// node-level locations become per-item `prov` — the 0–511 grid
2077    /// denormalized against the page into BOTTOMLEFT points. Without markers
2078    /// (every declarative backend) the JSON stays exactly as before: empty
2079    /// `pages`, `prov: []` even for located nodes.
2080    #[test]
2081    fn page_markers_produce_pages_and_prov() {
2082        let mut doc = DoclingDocument::new("t");
2083        doc.push(Node::PageInfo {
2084            page_no: 1,
2085            width: 512.0,
2086            height: 1024.0,
2087        });
2088        doc.push(Node::Located {
2089            location: [128, 64, 256, 128], // quarter/eighth points of the grid
2090            inner: Box::new(Node::Paragraph {
2091                text: "hello".into(),
2092            }),
2093        });
2094        doc.push(Node::Table(Table {
2095            rows: vec![vec!["a".into()]],
2096            location: Some([0, 0, 512, 512]),
2097            ..Table::default()
2098        }));
2099        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2100        assert_eq!(v["pages"]["1"]["page_no"], 1);
2101        assert_eq!(v["pages"]["1"]["size"]["width"], 512.0);
2102        assert_eq!(v["pages"]["1"]["size"]["height"], 1024.0);
2103        // 512-wide page: grid x scales 1:1; 1024-high: grid y doubles, then
2104        // flips to the BOTTOMLEFT origin (t from grid-top 64 → 1024-128=896).
2105        let prov = &v["texts"][0]["prov"][0];
2106        assert_eq!(prov["page_no"], 1);
2107        assert_eq!(prov["bbox"]["l"], 128.0);
2108        assert_eq!(prov["bbox"]["t"], 896.0);
2109        assert_eq!(prov["bbox"]["r"], 256.0);
2110        assert_eq!(prov["bbox"]["b"], 768.0);
2111        assert_eq!(prov["bbox"]["coord_origin"], "BOTTOMLEFT");
2112        assert_eq!(prov["charspan"][1], 5);
2113        // The table adopts its own location field; charspan is [0, 0].
2114        let tprov = &v["tables"][0]["prov"][0];
2115        assert_eq!(tprov["bbox"]["t"], 1024.0);
2116        assert_eq!(tprov["bbox"]["b"], 0.0);
2117        assert_eq!(tprov["charspan"][1], 0);
2118
2119        // No markers → the pre-#171 shape, byte for byte.
2120        let mut plain = DoclingDocument::new("t");
2121        plain.push(Node::Located {
2122            location: [1, 2, 3, 4],
2123            inner: Box::new(Node::Paragraph { text: "x".into() }),
2124        });
2125        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
2126        assert_eq!(v["pages"], serde_json::json!({}));
2127        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
2128    }
2129
2130    #[test]
2131    fn picture_image_in_markdown_modes_and_json() {
2132        let doc = doc_with_image();
2133        // placeholder (default) ignores the image
2134        assert!(doc.export_to_markdown().contains("<!-- image -->"));
2135        // embedded → base64 data URI (b"foobar" → "Zm9vYmFy")
2136        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Embedded, "artifacts");
2137        assert!(
2138            md.contains("![Image](data:image/png;base64,Zm9vYmFy)"),
2139            "got:\n{md}"
2140        );
2141        assert!(files.is_empty());
2142        // referenced → file link + collected bytes
2143        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Referenced, "artifacts");
2144        assert!(
2145            md.contains("![Image](artifacts/image_000000.png)"),
2146            "got:\n{md}"
2147        );
2148        assert_eq!(
2149            files,
2150            vec![("artifacts/image_000000.png".to_string(), b"foobar".to_vec())]
2151        );
2152        // JSON carries the ImageRef (data URI + size — floats, as docling's
2153        // `Size` is — placed before `annotations`).
2154        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2155        assert_eq!(v["pictures"][0]["image"]["mimetype"], "image/png");
2156        assert_eq!(v["pictures"][0]["image"]["size"]["width"], 4.0);
2157        let keys: Vec<&str> = v["pictures"][0]
2158            .as_object()
2159            .unwrap()
2160            .keys()
2161            .map(String::as_str)
2162            .collect();
2163        assert_eq!(&keys[keys.len() - 2..], ["image", "annotations"]);
2164        assert_eq!(
2165            v["pictures"][0]["image"]["uri"],
2166            "data:image/png;base64,Zm9vYmFy"
2167        );
2168    }
2169
2170    #[test]
2171    fn exports_docling_schema() {
2172        let mut doc = DoclingDocument::new("t");
2173        doc.push(Node::Heading {
2174            level: 1,
2175            text: "Title".into(),
2176        });
2177        doc.push(Node::Heading {
2178            level: 2,
2179            text: "Sec".into(),
2180        });
2181        doc.push(Node::Paragraph {
2182            text: "Body &amp; more".into(),
2183        }); // markdown-escaped
2184        doc.push(Node::ListItem {
2185            ordered: false,
2186            number: 0,
2187            first_in_list: true,
2188            text: "one".into(),
2189            level: 0,
2190            marker: None,
2191            location: None,
2192            dclx: None,
2193            href: None,
2194            layer: None,
2195        });
2196        doc.push(Node::ListItem {
2197            ordered: false,
2198            number: 0,
2199            first_in_list: false,
2200            text: "two".into(),
2201            level: 0,
2202            marker: None,
2203            location: None,
2204            dclx: None,
2205            href: None,
2206            layer: None,
2207        });
2208        doc.push(Node::Table(Table {
2209            rows: vec![vec!["A".into(), "B".into()]],
2210            location: None,
2211            structure: None,
2212            cell_blocks: None,
2213            cells: None,
2214            caption: None,
2215            caption_parent: Default::default(),
2216        }));
2217
2218        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2219        assert_eq!(v["schema_name"], "DoclingDocument");
2220        assert_eq!(v["version"], "1.10.0");
2221        assert_eq!(v["texts"][0]["label"], "title");
2222        assert_eq!(v["texts"][1]["label"], "section_header");
2223        assert_eq!(v["texts"][1]["level"], 1); // heading level 2 → docling level 1
2224        assert_eq!(v["texts"][2]["text"], "Body & more"); // un-escaped for the wire format
2225                                                          // consecutive list items fold into one list group, parented to it
2226        assert_eq!(v["groups"][0]["label"], "list");
2227        assert_eq!(v["groups"][0]["children"].as_array().unwrap().len(), 2);
2228        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
2229        assert_eq!(v["texts"][3]["marker"], "-");
2230        // table grid + header flag
2231        assert_eq!(v["tables"][0]["data"]["num_cols"], 2);
2232        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["column_header"], true);
2233    }
2234    /// A named group on a non-body layer — docling's hidden spreadsheet sheet:
2235    /// the group carries the sheet's name and the `invisible` layer, and every
2236    /// item inside it carries the layer too.
2237    #[test]
2238    fn a_layered_group_stamps_its_whole_subtree() {
2239        let doc = DoclingDocument {
2240            name: "s".into(),
2241            nodes: vec![
2242                Node::Group {
2243                    label: "sheet".into(),
2244                    name: Some("Sheet1".into()),
2245                    layer: None,
2246                    children: vec![Node::Paragraph {
2247                        text: "visible".into(),
2248                    }],
2249                },
2250                Node::Group {
2251                    label: "sheet".into(),
2252                    name: Some("Sheet2".into()),
2253                    layer: Some(ContentLayer::Invisible),
2254                    children: vec![Node::Paragraph {
2255                        text: "hidden".into(),
2256                    }],
2257                },
2258            ],
2259            ..DoclingDocument::new("s")
2260        };
2261        let v = crate::json::to_json(&doc);
2262        assert_eq!(v["groups"][0]["label"], "sheet");
2263        assert_eq!(v["groups"][0]["name"], "Sheet1");
2264        assert_eq!(v["groups"][0]["content_layer"], "body");
2265        assert_eq!(v["texts"][0]["content_layer"], "body");
2266        assert_eq!(v["groups"][1]["name"], "Sheet2");
2267        assert_eq!(v["groups"][1]["content_layer"], "invisible");
2268        assert_eq!(v["texts"][1]["content_layer"], "invisible");
2269        // The group's children are the items, and the body holds the groups.
2270        assert_eq!(v["groups"][1]["children"][0]["$ref"], "#/texts/1");
2271        assert_eq!(v["body"]["children"][1]["$ref"], "#/groups/1");
2272    }
2273
2274    /// A backend-built item tree is serialized as it is: items numbered in
2275    /// creation order per bucket (a field region's part texts included), the
2276    /// tree's parents / children / layers, docling's field order for
2277    /// `formatting`, `hyperlink`, `level`, `enumerated`/`marker`, a rich
2278    /// cell's `ref` on `table_cells` only, raw cell text.
2279    /// The DOCX tree's extras: an item `delete`d (docling's `delete_items`,
2280    /// the spacer between two items of a resumed list) is neither written nor
2281    /// numbered, `comments` back-refs sit between `prov` and `orig`, and a
2282    /// chart picture carries `classification` plus `tabular_chart`.
2283    #[test]
2284    fn deleted_items_comment_refs_and_chart_meta_in_the_tree() {
2285        use crate::tree::{ItemTree, TreeKind};
2286        let mut t = ItemTree::default();
2287        let text = |txt: &str| TreeKind::Text {
2288            label: "text".into(),
2289            text: txt.into(),
2290            orig: None,
2291            formatting: None,
2292            hyperlink: None,
2293            level: None,
2294            list: None,
2295        };
2296        let a = t.add(None, None, text("a"));
2297        let blank = t.add(None, None, text(""));
2298        let b = t.add(None, None, text("b"));
2299        t.delete(blank);
2300        let group = t.add(
2301            None,
2302            Some(ContentLayer::Notes),
2303            TreeKind::Group {
2304                label: "comment_section".into(),
2305                name: "comment-0".into(),
2306            },
2307        );
2308        t.add(Some(group), Some(ContentLayer::Notes), text("note"));
2309        t.items[a].comments.push(group);
2310        t.add(
2311            None,
2312            None,
2313            TreeKind::Picture {
2314                captions: Vec::new(),
2315                image: None,
2316                classification: Some("bar_chart".into()),
2317                chart: Some(Table {
2318                    rows: vec![vec!["".into(), "s".into()], vec!["c".into(), "1".into()]],
2319                    ..Table::default()
2320                }),
2321                dpi: None,
2322            },
2323        );
2324        assert_eq!(t.last_text(), Some(4), "the note; the blank is skipped");
2325        assert_eq!(t.bucket_index(b), 1, "numbered past the deleted item");
2326        let mut doc = DoclingDocument::new("t");
2327        doc.tree = Some(t);
2328        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2329        let texts = v["texts"].as_array().unwrap();
2330        assert_eq!(texts.len(), 3);
2331        assert_eq!(texts[1]["text"], "b");
2332        assert_eq!(texts[1]["self_ref"], "#/texts/1");
2333        assert_eq!(
2334            v["body"]["children"],
2335            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}, {"$ref": "#/groups/0"}, {"$ref": "#/pictures/0"}])
2336        );
2337        let keys: Vec<&str> = texts[0]
2338            .as_object()
2339            .unwrap()
2340            .keys()
2341            .map(String::as_str)
2342            .collect();
2343        assert_eq!(
2344            keys,
2345            vec![
2346                "self_ref",
2347                "parent",
2348                "children",
2349                "content_layer",
2350                "label",
2351                "prov",
2352                "comments",
2353                "orig",
2354                "text"
2355            ]
2356        );
2357        assert_eq!(
2358            texts[0]["comments"],
2359            serde_json::json!([{"$ref": "#/groups/0"}])
2360        );
2361        assert!(texts[1].get("comments").is_none());
2362        let meta = &v["pictures"][0]["meta"];
2363        assert_eq!(
2364            meta["classification"]["predictions"][0]["class_name"],
2365            "bar_chart"
2366        );
2367        assert_eq!(meta["tabular_chart"]["chart_data"]["num_rows"], 2);
2368    }
2369
2370    /// A tree item's `TreeProv` is written verbatim — the PPTX backend's raw
2371    /// EMU box with its `BOTTOMLEFT` tag and per-item charspan, a note's zero
2372    /// `TOPLEFT` box — a picture's `image.dpi` is the file's when the backend
2373    /// read one, an item without provenance writes `prov: []`, and the page
2374    /// map still comes from the flat stream's markers.
2375    #[test]
2376    fn tree_items_carry_exact_provenance_and_dpi() {
2377        use crate::tree::{ItemTree, TreeKind, TreeProv};
2378        let text = |label: &str, t: &str| TreeKind::Text {
2379            label: label.into(),
2380            text: t.into(),
2381            orig: None,
2382            formatting: None,
2383            hyperlink: None,
2384            level: None,
2385            list: None,
2386        };
2387        let mut t = ItemTree::default();
2388        let slide = t.add(
2389            None,
2390            None,
2391            TreeKind::Group {
2392                label: "chapter".into(),
2393                name: "slide-0".into(),
2394            },
2395        );
2396        t.add_with_prov(
2397            Some(slide),
2398            None,
2399            text("paragraph", "héllo"),
2400            TreeProv {
2401                page_no: 1,
2402                bbox: [914400.0, 1828800.0, 2743200.0, 457200.0],
2403                bottom_left: true,
2404                charspan: [0, 5],
2405            },
2406        );
2407        t.add_with_prov(
2408            Some(slide),
2409            None,
2410            TreeKind::Picture {
2411                captions: Vec::new(),
2412                image: Some(crate::PictureImage {
2413                    mimetype: "image/png".into(),
2414                    width: 2,
2415                    height: 2,
2416                    data: vec![0],
2417                }),
2418                classification: None,
2419                chart: None,
2420                dpi: Some(300),
2421            },
2422            TreeProv {
2423                page_no: 1,
2424                bbox: [0.0; 4],
2425                bottom_left: false,
2426                charspan: [0, 0],
2427            },
2428        );
2429        t.add(
2430            Some(slide),
2431            Some(ContentLayer::Notes),
2432            text("text", "no geometry"),
2433        );
2434        let mut doc = DoclingDocument::new("t");
2435        doc.push(Node::PageInfo {
2436            page_no: 1,
2437            width: 9144000.0,
2438            height: 6858000.0,
2439        });
2440        doc.tree = Some(t);
2441        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2442        assert_eq!(
2443            v["texts"][0]["prov"],
2444            serde_json::json!([{
2445                "page_no": 1,
2446                "bbox": { "l": 914400.0, "t": 1828800.0, "r": 2743200.0, "b": 457200.0, "coord_origin": "BOTTOMLEFT" },
2447                "charspan": [0, 5],
2448            }])
2449        );
2450        assert_eq!(v["texts"][0]["label"], "paragraph");
2451        assert_eq!(
2452            v["pictures"][0]["prov"][0]["bbox"]["coord_origin"],
2453            "TOPLEFT"
2454        );
2455        assert_eq!(v["pictures"][0]["image"]["dpi"], 300);
2456        assert_eq!(v["texts"][1]["prov"], serde_json::json!([]));
2457        assert_eq!(v["texts"][1]["content_layer"], "notes");
2458        assert_eq!(v["pages"]["1"]["size"]["width"], 9144000.0);
2459        assert_eq!(v["pages"]["1"]["page_no"], 1);
2460    }
2461
2462    /// docling-core's `validate_document` clamps every provenance box (and a
2463    /// one-page table's cell boxes) into its page — the state every
2464    /// `ConversionResult` leaves a document in, so the state docling's JSON
2465    /// shows. A box on a page the document does not describe is left alone.
2466    #[test]
2467    fn provenance_boxes_are_clamped_to_their_page() {
2468        let mut doc = DoclingDocument::new("t");
2469        doc.push(Node::PageInfo {
2470            page_no: 1,
2471            width: 10.0,
2472            height: 8.0,
2473        });
2474        doc.push(Node::Prov {
2475            page_no: 1,
2476            bbox: [-1.0, 2.0, 12.0, 9.5],
2477            charspan: [0, 1],
2478            seq: None,
2479            inner: Box::new(Node::Paragraph { text: "x".into() }),
2480        });
2481        let mut table = Table {
2482            rows: vec![vec!["a".into()]],
2483            ..Table::default()
2484        };
2485        table.cells = Some(vec![crate::TableCell {
2486            text: "a".into(),
2487            bbox: Some([1.0, 1.0, 11.0, 9.0]),
2488            start_row: 0,
2489            start_col: 0,
2490            row_span: 1,
2491            col_span: 1,
2492            column_header: false,
2493            row_header: false,
2494            row_section: false,
2495        }]);
2496        doc.push(Node::Prov {
2497            page_no: 1,
2498            bbox: [0.0, 0.0, 10.0, 8.0],
2499            charspan: [0, 0],
2500            seq: None,
2501            inner: Box::new(Node::Table(table)),
2502        });
2503        doc.push(Node::Prov {
2504            page_no: 7,
2505            bbox: [-5.0, 0.0, 50.0, 50.0],
2506            charspan: [0, 1],
2507            seq: None,
2508            inner: Box::new(Node::Paragraph { text: "y".into() }),
2509        });
2510        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2511        assert_eq!(
2512            v["texts"][0]["prov"][0]["bbox"],
2513            serde_json::json!({ "l": 0.0, "t": 2.0, "r": 10.0, "b": 8.0, "coord_origin": "TOPLEFT" })
2514        );
2515        let cell = &v["tables"][0]["data"]["table_cells"][0]["bbox"];
2516        assert_eq!(
2517            (cell["l"].as_f64(), cell["r"].as_f64(), cell["b"].as_f64()),
2518            (Some(1.0), Some(10.0), Some(8.0))
2519        );
2520        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["bbox"]["r"], 10.0);
2521        assert_eq!(
2522            v["texts"][1]["prov"][0]["bbox"]["r"], 50.0,
2523            "page 7 is not described"
2524        );
2525    }
2526
2527    #[test]
2528    fn a_backend_item_tree_is_written_verbatim() {
2529        use crate::tree::{Formatting, ItemTree, ListMeta, TreeKind};
2530        let mut t = ItemTree::default();
2531        let text = |label: &str, txt: &str| TreeKind::Text {
2532            label: label.into(),
2533            text: txt.into(),
2534            orig: None,
2535            formatting: None,
2536            hyperlink: None,
2537            level: None,
2538            list: None,
2539        };
2540        let title = t.add(None, Some(ContentLayer::Furniture), text("title", "Page"));
2541        let h = t.add(None, None, text("title", "Heading"));
2542        let group = t.add(
2543            Some(h),
2544            None,
2545            TreeKind::Group {
2546                label: "inline".into(),
2547                name: "group".into(),
2548            },
2549        );
2550        t.add(
2551            Some(group),
2552            None,
2553            TreeKind::Text {
2554                label: "text".into(),
2555                text: "bold".into(),
2556                orig: None,
2557                formatting: Some(Formatting {
2558                    bold: true,
2559                    ..Formatting::default()
2560                }),
2561                hyperlink: Some("https://example.com/".into()),
2562                level: None,
2563                list: None,
2564            },
2565        );
2566        t.add(
2567            Some(group),
2568            None,
2569            TreeKind::Code {
2570                text: "x = 1".into(),
2571                orig: None,
2572                language: Some("python".into()),
2573                formatting: None,
2574                hyperlink: None,
2575            },
2576        );
2577        let sub = t.add(
2578            Some(h),
2579            None,
2580            TreeKind::Text {
2581                label: "section_header".into(),
2582                text: "Sub".into(),
2583                orig: Some("Sub\u{2019}".into()),
2584                formatting: None,
2585                hyperlink: None,
2586                level: Some(1),
2587                list: None,
2588            },
2589        );
2590        t.add(
2591            Some(sub),
2592            None,
2593            TreeKind::Text {
2594                label: "list_item".into(),
2595                text: "item".into(),
2596                orig: None,
2597                formatting: None,
2598                hyperlink: None,
2599                level: None,
2600                list: Some(ListMeta {
2601                    enumerated: true,
2602                    marker: "3.".into(),
2603                }),
2604            },
2605        );
2606        let _region = t.add(
2607            Some(sub),
2608            None,
2609            TreeKind::FieldRegion {
2610                items: vec![crate::FieldItem {
2611                    marker: None,
2612                    key: Some("Name".into()),
2613                    value: Some("Duck".into()),
2614                    value_kind: Some("read_only".into()),
2615                }],
2616            },
2617        );
2618        let table = t.add(
2619            Some(sub),
2620            None,
2621            TreeKind::Table {
2622                table: Table {
2623                    rows: vec![vec!["a  \n&lt;".into(), "b".into()]],
2624                    cells: Some(vec![
2625                        crate::TableCell {
2626                            text: "a  \n&lt;".into(),
2627                            bbox: None,
2628                            start_row: 0,
2629                            start_col: 0,
2630                            row_span: 3,
2631                            col_span: 1,
2632                            column_header: false,
2633                            row_header: true,
2634                            row_section: false,
2635                        },
2636                        crate::TableCell {
2637                            text: "b".into(),
2638                            bbox: None,
2639                            start_row: 0,
2640                            start_col: 1,
2641                            row_span: 1,
2642                            col_span: 1,
2643                            column_header: false,
2644                            row_header: false,
2645                            row_section: false,
2646                        },
2647                    ]),
2648                    ..Table::default()
2649                },
2650                rich_cells: vec![(0, 1, 0)], // patched below
2651                captions: Vec::new(),
2652            },
2653        );
2654        let cell_group = t.add(
2655            Some(table),
2656            None,
2657            TreeKind::Group {
2658                label: "unspecified".into(),
2659                name: "rich_cell_group_1_0_0".into(),
2660            },
2661        );
2662        if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
2663            *rich_cells = vec![(0, 1, cell_group)];
2664        }
2665        let after = t.add(Some(sub), None, text("text", "after the region"));
2666        let _ = (title, after);
2667
2668        let doc = DoclingDocument {
2669            tree: Some(t),
2670            ..DoclingDocument::new("t")
2671        };
2672        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2673        // Creation order: Page, Heading, bold, x = 1, Sub, item, [Name, Duck], after.
2674        let texts: Vec<&str> = v["texts"]
2675            .as_array()
2676            .unwrap()
2677            .iter()
2678            .map(|t| t["text"].as_str().unwrap())
2679            .collect();
2680        assert_eq!(
2681            texts,
2682            [
2683                "Page",
2684                "Heading",
2685                "bold",
2686                "x = 1",
2687                "Sub",
2688                "item",
2689                "Name",
2690                "Duck",
2691                "after the region"
2692            ]
2693        );
2694        assert_eq!(
2695            v["body"]["children"],
2696            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}])
2697        );
2698        assert_eq!(v["texts"][0]["content_layer"], "furniture");
2699        assert_eq!(
2700            v["texts"][1]["children"],
2701            serde_json::json!([{"$ref": "#/groups/0"}, {"$ref": "#/texts/4"}])
2702        );
2703        let bold = &v["texts"][2];
2704        assert_eq!(bold["parent"]["$ref"], "#/groups/0");
2705        let keys: Vec<&str> = bold
2706            .as_object()
2707            .unwrap()
2708            .keys()
2709            .map(String::as_str)
2710            .collect();
2711        assert_eq!(
2712            keys,
2713            [
2714                "self_ref",
2715                "parent",
2716                "children",
2717                "content_layer",
2718                "label",
2719                "prov",
2720                "orig",
2721                "text",
2722                "formatting",
2723                "hyperlink"
2724            ]
2725        );
2726        assert_eq!(
2727            bold["formatting"],
2728            serde_json::json!({"bold": true, "italic": false, "underline": false, "strikethrough": false, "script": "baseline"})
2729        );
2730        let code = &v["texts"][3];
2731        assert_eq!(code["label"], "code");
2732        assert_eq!(code["code_language"], "Python");
2733        let sub = &v["texts"][4];
2734        assert_eq!(sub["orig"], "Sub\u{2019}");
2735        assert_eq!(sub["level"], 1);
2736        let item = &v["texts"][5];
2737        let keys: Vec<&str> = item
2738            .as_object()
2739            .unwrap()
2740            .keys()
2741            .map(String::as_str)
2742            .collect();
2743        assert_eq!(
2744            keys,
2745            [
2746                "self_ref",
2747                "parent",
2748                "children",
2749                "content_layer",
2750                "label",
2751                "prov",
2752                "orig",
2753                "text",
2754                "enumerated",
2755                "marker"
2756            ]
2757        );
2758        assert_eq!(item["marker"], "3.");
2759        assert_eq!(v["texts"][7]["kind"], "read_only");
2760        assert_eq!(v["field_regions"][0]["parent"]["$ref"], "#/texts/4");
2761        let table = &v["tables"][0];
2762        assert_eq!(
2763            table["children"],
2764            serde_json::json!([{"$ref": "#/groups/1"}])
2765        );
2766        let cells = table["data"]["table_cells"].as_array().unwrap();
2767        assert_eq!(
2768            cells[0]["text"], "a  \n&lt;",
2769            "raw cell text is written verbatim"
2770        );
2771        assert_eq!(
2772            cells[0]["end_row_offset_idx"], 3,
2773            "declared spans are not clamped"
2774        );
2775        assert_eq!(cells[1]["ref"], serde_json::json!({"$ref": "#/groups/1"}));
2776        assert!(cells[0].get("ref").is_none());
2777        assert!(
2778            table["data"]["grid"][0][1].get("ref").is_none(),
2779            "the grid shows plain cells"
2780        );
2781        assert_eq!(v["groups"][1]["name"], "rich_cell_group_1_0_0");
2782    }
2783
2784    /// A comment section that links its note text rather than its group — the
2785    /// spreadsheet shape, where docling-core's `add_comment` appends the text
2786    /// item's ref to each target.
2787    #[test]
2788    fn a_comment_section_can_be_referenced_by_its_note_text() {
2789        let doc = DoclingDocument {
2790            name: "c".into(),
2791            nodes: vec![
2792                Node::Commented {
2793                    comments: vec![0],
2794                    inner: Box::new(Node::Paragraph {
2795                        text: "annotated".into(),
2796                    }),
2797                },
2798                Node::CommentSection {
2799                    name: "comment-Sheet1-A1".into(),
2800                    text: "[author: A]: note".into(),
2801                    refs_note_text: true,
2802                    grouped: true,
2803                },
2804            ],
2805            ..DoclingDocument::new("c")
2806        };
2807        let v = crate::json::to_json(&doc);
2808        assert_eq!(v["groups"][0]["name"], "comment-Sheet1-A1");
2809        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/texts/1");
2810    }
2811
2812    /// docx reviewer comments: a `comment_section` group on the notes layer
2813    /// holding the note text, and a `comments` back-ref on the annotated item —
2814    /// keyed between `prov` and `orig`, the slot docling emits it in.
2815    #[test]
2816    fn comment_sections_link_back_to_their_items() {
2817        let doc = DoclingDocument {
2818            name: "c".into(),
2819            nodes: vec![
2820                Node::Commented {
2821                    comments: vec![0],
2822                    inner: Box::new(Node::Paragraph {
2823                        text: "annotated".into(),
2824                    }),
2825                },
2826                Node::Paragraph {
2827                    text: "plain".into(),
2828                },
2829                Node::CommentSection {
2830                    name: "comment-7".into(),
2831                    text: "[time: t]: note".into(),
2832                    refs_note_text: false,
2833                    grouped: true,
2834                },
2835            ],
2836            ..DoclingDocument::new("c")
2837        };
2838        let v = crate::json::to_json(&doc);
2839        // The group is the comment section; its only child is the notes text.
2840        assert_eq!(v["groups"][0]["label"], "comment_section");
2841        assert_eq!(v["groups"][0]["name"], "comment-7");
2842        assert_eq!(v["groups"][0]["content_layer"], "notes");
2843        assert_eq!(v["groups"][0]["children"][0]["$ref"], "#/texts/2");
2844        assert_eq!(v["texts"][2]["content_layer"], "notes");
2845        // The annotated item points back at the group; the plain one has no key.
2846        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/groups/0");
2847        assert!(v["texts"][1].get("comments").is_none());
2848        // docling's key order: … prov, comments, orig, text.
2849        let keys: Vec<&str> = v["texts"][0]
2850            .as_object()
2851            .unwrap()
2852            .keys()
2853            .map(String::as_str)
2854            .collect();
2855        assert_eq!(
2856            &keys[keys.len() - 4..],
2857            &["prov", "comments", "orig", "text"]
2858        );
2859    }
2860
2861    fn picture(caption: &str, caption_parent: CaptionParent) -> Node {
2862        Node::Picture {
2863            caption: Some(caption.into()),
2864            caption_href: None,
2865            image: None,
2866            classification: None,
2867            caption_parent,
2868        }
2869    }
2870
2871    fn group(children: Vec<Node>) -> Node {
2872        Node::Group {
2873            label: "section".into(),
2874            name: None,
2875            layer: None,
2876            children,
2877        }
2878    }
2879
2880    fn refs(v: &Value) -> Vec<&str> {
2881        v.as_array()
2882            .unwrap()
2883            .iter()
2884            .map(|r| r["$ref"].as_str().unwrap())
2885            .collect()
2886    }
2887
2888    /// #390: a declarative backend's caption is docling's `add_text` default —
2889    /// a body child, appended as it is created — wherever the picture sits:
2890    /// ahead of a top-level picture, behind the top-level item enclosing a
2891    /// nested one. The picture references it either way and has no children.
2892    #[test]
2893    fn a_body_caption_follows_the_enclosing_top_level_item() {
2894        let mut doc = DoclingDocument::new("t");
2895        doc.push(picture("top", CaptionParent::Body));
2896        doc.push(group(vec![
2897            Node::Paragraph { text: "p".into() },
2898            picture("nested", CaptionParent::Body),
2899        ]));
2900        doc.push(Node::Paragraph {
2901            text: "after".into(),
2902        });
2903        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2904        assert_eq!(
2905            refs(&v["body"]["children"]),
2906            [
2907                "#/texts/0",
2908                "#/pictures/0",
2909                "#/groups/0",
2910                "#/texts/2",
2911                "#/texts/3"
2912            ]
2913        );
2914        assert_eq!(
2915            refs(&v["groups"][0]["children"]),
2916            ["#/texts/1", "#/pictures/1"]
2917        );
2918        for (cap, pic) in [(0, 0), (2, 1)] {
2919            assert_eq!(v["texts"][cap]["label"], "caption");
2920            assert_eq!(v["texts"][cap]["parent"]["$ref"], "#/body");
2921            assert_eq!(
2922                refs(&v["pictures"][pic]["captions"]),
2923                [format!("#/texts/{cap}")]
2924            );
2925            assert_eq!(v["pictures"][pic]["children"], serde_json::json!([]));
2926        }
2927    }
2928
2929    /// The PDF pipeline's caption is the picture's (or table's) own child,
2930    /// as docling attaches a layout caption.
2931    #[test]
2932    fn an_item_caption_is_the_items_first_child() {
2933        let mut doc = DoclingDocument::new("t");
2934        doc.push(picture("fig", CaptionParent::Item));
2935        doc.push(Node::Table(Table {
2936            rows: vec![vec!["a".into()]],
2937            caption: Some("tab".into()),
2938            caption_parent: CaptionParent::Item,
2939            ..Table::default()
2940        }));
2941        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2942        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/tables/0"]);
2943        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/pictures/0");
2944        assert_eq!(refs(&v["pictures"][0]["children"]), ["#/texts/0"]);
2945        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
2946        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/tables/0");
2947        assert_eq!(refs(&v["tables"][0]["children"]), ["#/texts/1"]);
2948        assert_eq!(refs(&v["tables"][0]["captions"]), ["#/texts/1"]);
2949    }
2950
2951    /// A container caption sits beside its item under the item's parent —
2952    /// ahead of it (an office chart's title) or behind it (an HTML
2953    /// `<figure>`'s table, whose figcaption docling adds after the table).
2954    #[test]
2955    fn a_container_caption_is_the_items_sibling() {
2956        let mut doc = DoclingDocument::new("t");
2957        doc.push(group(vec![
2958            picture("chart", CaptionParent::Container),
2959            Node::Table(Table {
2960                rows: vec![vec!["a".into()]],
2961                caption: Some("figcaption".into()),
2962                caption_parent: CaptionParent::ContainerAfter,
2963                ..Table::default()
2964            }),
2965        ]));
2966        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2967        assert_eq!(refs(&v["body"]["children"]), ["#/groups/0"]);
2968        assert_eq!(
2969            refs(&v["groups"][0]["children"]),
2970            ["#/texts/0", "#/pictures/0", "#/tables/0", "#/texts/1"]
2971        );
2972        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/groups/0");
2973        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/groups/0");
2974        assert_eq!(v["pictures"][0]["children"], serde_json::json!([]));
2975        assert_eq!(v["tables"][0]["children"], serde_json::json!([]));
2976    }
2977}