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