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