Skip to main content

docling_core/
tree.rs

1//! docling's **item tree**, for a backend that knows the exact shape upstream
2//! gives a document and wants the JSON export to reproduce it.
3//!
4//! [`DoclingDocument::nodes`](crate::DoclingDocument::nodes) is a flat,
5//! reading-order stream tuned for Markdown / DocLang / LaTeX; the JSON export
6//! rebuilds docling's parent/child structure from it with generic rules (runs
7//! of list items become list groups, a heading is a flat sibling of the text
8//! that follows it). Upstream's backends do not all agree on that structure:
9//! the HTML backend nests everything after a heading *under* the heading,
10//! splits a paragraph of mixed formatting into an `inline` group of one text
11//! item per formatting run, parents a rich table cell's content to a group
12//! under the table, keeps site chrome on the `furniture` layer… and numbers
13//! every item in the order it *creates* them. A backend that ports those
14//! rules call-for-call (HTML's `html_tree.rs`, DOCX's `docx_tree.rs`) records
15//! the result here — an arena of items in
16//! creation order, each with its parent and children — and the JSON export
17//! ([`DoclingDocument::export_to_json`](crate::DoclingDocument::export_to_json))
18//! serializes this tree instead of deriving one from the nodes. Every other
19//! serializer keeps reading the flat nodes, so their output is unaffected.
20
21use crate::{ContentLayer, FieldItem, PictureImage, Script, Table};
22
23/// docling-core's `Formatting`: the inline styles an item carries in JSON.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct Formatting {
26    pub bold: bool,
27    pub italic: bool,
28    pub underline: bool,
29    pub strikethrough: bool,
30    pub script: Script,
31}
32
33/// A `list_item`'s docling fields.
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub struct ListMeta {
36    pub enumerated: bool,
37    /// docling's `marker` — the HTML backend writes `""` unless an ordered
38    /// list carries an explicit `start`, then `"{n}."`.
39    pub marker: String,
40}
41
42/// What an item in the tree is. Mirrors the docling-core item classes the
43/// JSON `texts` / `groups` / `tables` / `pictures` / `field_regions` buckets
44/// hold.
45#[derive(Debug, Clone, PartialEq)]
46pub enum TreeKind {
47    /// A `TextItem` / `TitleItem` / `SectionHeaderItem` / `ListItem`, told
48    /// apart by `label` (`text`, `title`, `section_header`, `list_item`,
49    /// `caption`, `checkbox_selected`, `checkbox_unselected`, …).
50    Text {
51        label: String,
52        text: String,
53        /// docling's `orig` when it differs from `text` (the heading text
54        /// before unicode cleanup, say); `None` = same as `text`.
55        orig: Option<String>,
56        formatting: Option<Formatting>,
57        hyperlink: Option<String>,
58        /// `section_header` only: docling's heading level.
59        level: Option<u8>,
60        /// `list_item` only.
61        list: Option<ListMeta>,
62    },
63    /// A `CodeItem`.
64    Code {
65        text: String,
66        orig: Option<String>,
67        /// The language hint (a highlighter class token such as `python`),
68        /// mapped onto docling's `CodeLanguageLabel` at export; `None` →
69        /// `unknown`.
70        language: Option<String>,
71        formatting: Option<Formatting>,
72        hyperlink: Option<String>,
73        /// Caption text items in the tree — docling's `add_code(caption=…)`
74        /// (an AsciiDoc block title in front of a literal block).
75        captions: Vec<usize>,
76    },
77    /// A `GroupItem`: `label` is docling's `GroupLabel` value (`inline`,
78    /// `list`, `section`, `unspecified`, …), `name` its name (`group`, `list`,
79    /// `ordered list`, `header-2`, `rich_cell_group_1_0_3`, …).
80    Group { label: String, name: String },
81    /// A `TableItem`. `rich_cells` marks the cells docling serialized as a
82    /// `RichTableCell`: `(row, col)` grid anchor → the group item (a child of
83    /// the table) that holds the cell's content. `captions` are caption text
84    /// items in the tree.
85    Table {
86        table: Table,
87        rich_cells: Vec<(usize, usize, usize)>,
88        captions: Vec<usize>,
89    },
90    /// A `PictureItem`, its caption text items and optional payload.
91    /// `classification` is a `PictureClassificationLabel` value written as
92    /// the picture's `meta.classification` (an HTML `<stamp>` / `<signature>`).
93    Picture {
94        captions: Vec<usize>,
95        image: Option<PictureImage>,
96        classification: Option<String>,
97        /// Further predictions after `classification`, in order: the other
98        /// plot types of a PPTX combination chart (docling#4773).
99        extra_classes: Vec<String>,
100        /// The picture-OCR enrichment's text (#645), serialized like the flat
101        /// node's: `meta.description` + the `description` annotation.
102        description: Option<crate::PictureDescription>,
103        /// The prediction's `confidence`, when the backend writes one: the
104        /// DocLang deserializer stamps `1.0`; the office and HTML backends
105        /// leave it out (`None`).
106        confidence: Option<f64>,
107        /// A native chart's data grid (docling's `meta.tabular_chart.chart_data`,
108        /// the series reconstructed as a `TableData`), for a DOCX chart drawing.
109        chart: Option<Table>,
110        /// The `ImageRef.dpi` docling writes for `image` when the backend
111        /// read one from the file (python-pptx's `Image.dpi`: PIL's `dpi`
112        /// info, rounded, 72 when absent or out of 1–2048); `None` → 72,
113        /// which is what upstream's other office backends pass.
114        dpi: Option<u32>,
115    },
116    /// A form key-value region (`field_regions` / `field_items`).
117    FieldRegion { items: Vec<FieldItem> },
118    /// A `KeyValueItem` (`key_value_items`): docling's `GraphData` of key and
119    /// value cells and their links, written verbatim.
120    KeyValueGraph {
121        cells: Vec<crate::GraphCell>,
122        links: Vec<crate::GraphLink>,
123    },
124}
125
126/// docling's `ProvenanceItem` for a tree item, written verbatim: the
127/// backend's own geometry in the page's units — a PPTX shape's EMU box,
128/// whose `pages` entry is the slide size in EMU — rather than the 0–511
129/// DocLang grid the flat [`Node::Located`](crate::Node::Located) carries
130/// (which cannot round-trip those integers).
131#[derive(Debug, Clone, PartialEq)]
132pub struct TreeProv {
133    /// 1-based page (slide) number.
134    pub page_no: usize,
135    /// `[l, t, r, b]`, exactly as docling computed them.
136    pub bbox: [f64; 4],
137    /// docling's `coord_origin` tag. The office backends tag their
138    /// top-left-based boxes `TOPLEFT` (the PPTX backend since docling#4294 —
139    /// it used to tag them `BOTTOMLEFT`, which read the tuple as `(l, b, r,
140    /// t)` and swapped the vertical edges); a backend that really works in a
141    /// bottom-left space sets this.
142    pub bottom_left: bool,
143    /// `[0, len(text)]` in characters for a text item, `[0, 0]` for a table
144    /// or picture.
145    pub charspan: [usize; 2],
146}
147
148/// docling's `TrackSource` — where in a time-based track (a WebVTT cue) a
149/// text item came from. Written as the item's `source: [{"kind": "track", …}]`.
150#[derive(Debug, Clone, PartialEq)]
151pub struct TreeTrack {
152    /// The cue's start offset in seconds (docling's `WebVTTTimestamp.seconds`:
153    /// `h*3600 + m*60 + s + millis/1000.0`, so the float is bit-identical).
154    pub start_time: f64,
155    /// The cue's end offset in seconds.
156    pub end_time: f64,
157    /// The cue identifier line, when the cue has one.
158    pub identifier: Option<String>,
159    /// The `<v …>` voice annotation the text sits in, when any.
160    pub voice: Option<String>,
161}
162
163/// One item of an [`ItemTree`].
164#[derive(Debug, Clone, PartialEq)]
165pub struct TreeItem {
166    /// The parent item's index; `None` = the document body.
167    pub parent: Option<usize>,
168    /// Child item indices, in docling's `children` order.
169    pub children: Vec<usize>,
170    /// The content layer; `None` = `body`.
171    pub layer: Option<ContentLayer>,
172    pub kind: TreeKind,
173    /// The item's `prov` entry, when the backend has page geometry for it
174    /// (`None` → `prov: []`, what the HTML and DOCX backends write).
175    pub prov: Option<TreeProv>,
176    /// docling's `DocItem.comments`: the `comment_section` groups (or note
177    /// text items) annotating this item, as item indices — written after
178    /// `prov` when non-empty.
179    pub comments: Vec<usize>,
180    /// docling's `DocItem.source`: the track segment a text item was taken
181    /// from (WebVTT cues) — written after `prov` when set.
182    pub source: Option<TreeTrack>,
183    /// Removed by [`ItemTree::delete`] (docling's `delete_items`): the slot
184    /// stays so every other index keeps its meaning, but the item is not
185    /// numbered or written.
186    pub deleted: bool,
187    /// Footnotes / endnotes referenced from inside this text item (#538): the
188    /// note call's position (chars into the item's text) and the note's text.
189    /// docling keeps notes as unlinked furniture `footnote` items, so the
190    /// JSON never shows this; the Pandoc AST writes each as a `Note` there.
191    pub notes: Vec<TreeNote>,
192    /// This furniture `footnote` item is the body of a note some text item
193    /// calls (it travels in that item's [`Self::notes`]); the Pandoc AST then
194    /// leaves it out as a standalone block.
195    pub note_body: bool,
196    /// Link destinations at this item (#849): the identifiers a document's
197    /// internal links (`hyperlink: "#id"`) point at — a DOCX bookmark in
198    /// a heading, say. docling's model has no slot for them, so the JSON
199    /// shows them only on the Pandoc path (`_anchors`), where a heading's
200    /// first one is its `Header` id and the rest empty `Span .anchor`s.
201    pub anchors: Vec<String>,
202    /// The generated numbering `text` opens with, when the backend put one
203    /// there (#850: a DOCX heading numbered through `w:numPr` is docling's
204    /// `1.2 Title`): `"1.2"` — the authored text follows it after one
205    /// space. docling's JSON keeps the numbered text; the Pandoc AST writes
206    /// the authored text alone (`_number` on that path), since Pandoc
207    /// numbers sections itself (`--number-sections`) and its docx reader
208    /// never writes the number into the `Header`.
209    pub number: Option<String>,
210    /// The tracked change this run carries under
211    /// [`TrackChanges::All`](crate::TrackChanges::All) (#851): the Pandoc
212    /// AST wraps the run in `Span .insertion` / `.deletion` (`_revision`
213    /// on that path); docling's JSON has no field for it.
214    pub revision: Option<crate::Revision>,
215    /// A picture's `meta.code` — docling's `CodeMetaField` (`text`,
216    /// `language`): the LaTeX backend keeps a `tikzpicture`'s source there
217    /// when no engine renders it (`("…", "Tikz")`).
218    pub code_meta: Option<(String, String)>,
219}
220
221/// A note call inside a text item ([`TreeItem::notes`]).
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct TreeNote {
224    /// The call's position, in chars into the item's `text`.
225    pub offset: usize,
226    /// The note's plain text.
227    pub text: String,
228}
229
230impl ItemTree {
231    /// Where each note call of one paragraph lands (#538): `full_text` is the
232    /// paragraph's text, `offsets` the calls' positions in it (chars), and
233    /// the paragraph's items are those created from `first_new` on. Each text
234    /// item is a trimmed slice of the paragraph (a formatting run, a link, or
235    /// the whole heading / list item), matched in order; a call inside an
236    /// item lands there, one between items ends the earlier (or starts the
237    /// first). When no item matches, the call ends the paragraph's last text
238    /// item; with no text item at all it is `None`.
239    pub fn place_note_calls(
240        &self,
241        first_new: usize,
242        full_text: &str,
243        offsets: &[usize],
244    ) -> Vec<Option<(usize, usize)>> {
245        let texts: Vec<(usize, &str)> = (first_new..self.items.len())
246            .filter(|&i| !self.items[i].deleted)
247            .filter_map(|i| match &self.items[i].kind {
248                TreeKind::Text { text, .. } | TreeKind::Code { text, .. } => {
249                    Some((i, text.as_str()))
250                }
251                _ => None,
252            })
253            .collect();
254        // Each found item's (id, start, end) in chars of `full_text`.
255        let mut spans: Vec<(usize, usize, usize)> = Vec::new();
256        let mut cursor = 0usize; // bytes
257        for &(item, text) in &texts {
258            if text.is_empty() {
259                continue;
260            }
261            if let Some(pos) = full_text[cursor..].find(text) {
262                let start_b = cursor + pos;
263                let start = full_text[..start_b].chars().count();
264                spans.push((item, start, start + text.chars().count()));
265                cursor = start_b + text.len();
266            }
267        }
268        offsets
269            .iter()
270            .map(|&offset| {
271                spans
272                    .iter()
273                    .find(|&&(_, start, end)| offset >= start && offset <= end)
274                    .map(|&(item, start, _)| (item, offset - start))
275                    .or_else(
276                        || match spans.iter().rev().find(|&&(_, _, end)| end <= offset) {
277                            Some(&(item, start, end)) => Some((item, end - start)),
278                            None => spans.first().map(|&(item, _, _)| (item, 0)),
279                        },
280                    )
281                    .or_else(|| {
282                        texts
283                            .last()
284                            .map(|&(item, text)| (item, text.chars().count()))
285                    })
286            })
287            .collect()
288    }
289}
290
291/// docling's item tree in creation order (see the [module docs](self)).
292#[derive(Debug, Clone, PartialEq, Default)]
293pub struct ItemTree {
294    /// Every item, indexed by creation order — which is how docling numbers
295    /// `#/texts/N`, `#/groups/N`, … within each bucket.
296    pub items: Vec<TreeItem>,
297    /// The body's `children`, as item indices.
298    pub body: Vec<usize>,
299}
300
301impl ItemTree {
302    /// Append an item under `parent` (`None` = body) on `layer`, registering
303    /// it as its parent's last child — docling's `add_*` calls do exactly that.
304    pub fn add(
305        &mut self,
306        parent: Option<usize>,
307        layer: Option<ContentLayer>,
308        kind: TreeKind,
309    ) -> usize {
310        let id = self.items.len();
311        self.items.push(TreeItem {
312            parent,
313            children: Vec::new(),
314            layer,
315            kind,
316            prov: None,
317            comments: Vec::new(),
318            source: None,
319            deleted: false,
320            notes: Vec::new(),
321            note_body: false,
322            anchors: Vec::new(),
323            number: None,
324            revision: None,
325            code_meta: None,
326        });
327        match parent {
328            Some(p) => self.items[p].children.push(id),
329            None => self.body.push(id),
330        }
331        id
332    }
333
334    /// [`add`](Self::add) with the item's provenance — docling's
335    /// `add_text(…, prov=prov)`.
336    pub fn add_with_prov(
337        &mut self,
338        parent: Option<usize>,
339        layer: Option<ContentLayer>,
340        kind: TreeKind,
341        prov: TreeProv,
342    ) -> usize {
343        let id = self.add(parent, layer, kind);
344        self.items[id].prov = Some(prov);
345        id
346    }
347
348    /// Append every item of `other` after this tree's, renumbering its
349    /// indices (parents, children, comments, table/picture caption and
350    /// rich-cell refs) and adding its body children to this body — so a
351    /// backend can build independent fragments in parallel (one per PPTX
352    /// slide) and still hand the export one tree in creation order, exactly
353    /// as if it had been built sequentially.
354    pub fn append(&mut self, other: ItemTree) {
355        let off = self.items.len();
356        let shift = |i: usize| i + off;
357        for mut item in other.items {
358            item.parent = item.parent.map(shift);
359            for c in item.children.iter_mut().chain(item.comments.iter_mut()) {
360                *c = shift(*c);
361            }
362            match &mut item.kind {
363                TreeKind::Table {
364                    rich_cells,
365                    captions,
366                    ..
367                } => {
368                    for (_, _, g) in rich_cells.iter_mut() {
369                        *g = shift(*g);
370                    }
371                    for c in captions.iter_mut() {
372                        *c = shift(*c);
373                    }
374                }
375                TreeKind::Picture { captions, .. } | TreeKind::Code { captions, .. } => {
376                    for c in captions.iter_mut() {
377                        *c = shift(*c);
378                    }
379                }
380                _ => {}
381            }
382            self.items.push(item);
383        }
384        self.body.extend(other.body.into_iter().map(shift));
385    }
386
387    /// Move `id` under `new_parent`, dropping it from its current parent's
388    /// children and appending it to the new one's — docling's
389    /// `group_cell_elements` re-parenting of a rich cell's items.
390    pub fn reparent(&mut self, id: usize, new_parent: Option<usize>) {
391        let old = self.items[id].parent;
392        let siblings = match old {
393            Some(p) => &mut self.items[p].children,
394            None => &mut self.body,
395        };
396        siblings.retain(|&c| c != id);
397        self.items[id].parent = new_parent;
398        match new_parent {
399            Some(p) => self.items[p].children.push(id),
400            None => self.body.push(id),
401        }
402    }
403
404    /// Re-number the items in traversal order — a pre-order walk of the body
405    /// through every layer, which is how docling's
406    /// `DoclingDocument.concatenate` (and `_normalize_references`) re-creates
407    /// a document's items: a group created after the content it was later
408    /// wrapped around comes before that content afterwards. Items the walk
409    /// does not reach (deleted ones) are dropped. Returns each old id's new
410    /// id.
411    pub fn renumber_in_traversal_order(&mut self) -> Vec<Option<usize>> {
412        let mut order = Vec::with_capacity(self.items.len());
413        let mut stack: Vec<usize> = self.body.iter().rev().copied().collect();
414        while let Some(id) = stack.pop() {
415            if self.items[id].deleted {
416                continue;
417            }
418            order.push(id);
419            stack.extend(self.items[id].children.iter().rev());
420        }
421        let mut new_of: Vec<Option<usize>> = vec![None; self.items.len()];
422        for (new, &old) in order.iter().enumerate() {
423            new_of[old] = Some(new);
424        }
425        let remap = |ids: &mut Vec<usize>| {
426            *ids = ids.iter().filter_map(|&i| new_of[i]).collect();
427        };
428        let mut old_items: Vec<Option<TreeItem>> = std::mem::take(&mut self.items)
429            .into_iter()
430            .map(Some)
431            .collect();
432        for &old in &order {
433            let mut item = old_items[old].take().expect("each item visited once");
434            item.parent = item.parent.and_then(|p| new_of[p]);
435            remap(&mut item.children);
436            remap(&mut item.comments);
437            match &mut item.kind {
438                TreeKind::Table {
439                    rich_cells,
440                    captions,
441                    ..
442                } => {
443                    rich_cells.retain_mut(|(_, _, g)| match new_of[*g] {
444                        Some(n) => {
445                            *g = n;
446                            true
447                        }
448                        None => false,
449                    });
450                    remap(captions);
451                }
452                TreeKind::Picture { captions, .. } | TreeKind::Code { captions, .. } => {
453                    remap(captions)
454                }
455                _ => {}
456            }
457            self.items.push(item);
458        }
459        remap(&mut self.body);
460        new_of
461    }
462
463    /// Remove `id` from the tree — docling's `delete_items`, which the DOCX
464    /// backend uses to drop the empty text item a blank spacer paragraph left
465    /// between two items of a resumed list. The item leaves its parent's
466    /// children and is neither numbered nor written; its slot stays so the
467    /// indices held elsewhere stay valid.
468    pub fn delete(&mut self, id: usize) {
469        match self.items[id].parent {
470            Some(p) => self.items[p].children.retain(|&c| c != id),
471            None => self.body.retain(|&c| c != id),
472        }
473        self.items[id].deleted = true;
474    }
475
476    /// docling-core's `DoclingDocument.validate_misplaced_list_items` (a
477    /// model validator, so it runs whenever docling-core serializes or loads
478    /// a document): every `list_item` whose parent is not a `list` group is
479    /// re-homed into a new one. A pre-order walk of the body (groups
480    /// included) collects them; consecutive misplaced items directly on the
481    /// body share one group, any other misplaced item gets its own. Working
482    /// from the last run back, each run gets a `ListGroup` (name `group`)
483    /// inserted where its first item stood, the items are deleted and
484    /// re-added under the group as fresh items, so they move to the end of
485    /// the text numbering (#527: the DOCX backend leaves such items in rich
486    /// table cells). A no-op for a well-formed tree.
487    ///
488    /// One deliberate divergence (#586): docling-core deletes each item *with
489    /// its children* and re-adds it from its text alone, so a mixed-format
490    /// item — an empty `list_item` over an `inline` group of text runs —
491    /// comes back empty and its text is gone from the JSON (and from the
492    /// Markdown/HTML docling serializes after that validator ran; the JSON
493    /// docling saves *before* exporting still has it). Here the children
494    /// follow the item into the group: the structure docling-core requires,
495    /// the content the document had.
496    pub fn wrap_misplaced_list_items(&mut self) {
497        let is_list_item = |t: &Self, id: usize| matches!(&t.items[id].kind, TreeKind::Text { label, .. } if label == "list_item");
498        let in_list_group = |t: &Self, id: usize| {
499            t.items[id].parent.is_some_and(
500                |p| matches!(&t.items[p].kind, TreeKind::Group { label, .. } if label == "list"),
501            )
502        };
503        let mut runs: Vec<Vec<usize>> = Vec::new();
504        // `None` = the body itself, which the walk yields first.
505        let mut prev: Option<usize> = None;
506        let mut stack: Vec<usize> = self.body.iter().rev().copied().collect();
507        while let Some(id) = stack.pop() {
508            if self.items[id].deleted {
509                continue;
510            }
511            if is_list_item(self, id) && !in_list_group(self, id) {
512                let continues =
513                    prev.is_some_and(|p| is_list_item(self, p) && self.items[p].parent.is_none());
514                match runs.last_mut() {
515                    Some(run) if continues => run.push(id),
516                    _ => runs.push(vec![id]),
517                }
518            }
519            prev = Some(id);
520            stack.extend(self.items[id].children.iter().rev().copied());
521        }
522        for run in runs.into_iter().rev() {
523            let parent = self.items[run[0]].parent;
524            let group = self.add(
525                parent,
526                None,
527                TreeKind::Group {
528                    label: "list".into(),
529                    name: "group".into(),
530                },
531            );
532            let siblings = match parent {
533                Some(p) => &mut self.items[p].children,
534                None => &mut self.body,
535            };
536            siblings.pop();
537            let at = siblings
538                .iter()
539                .position(|&c| c == run[0])
540                .unwrap_or(siblings.len());
541            siblings.insert(at, group);
542            for &li in &run {
543                // Not `delete_subtree`: the children move to the copy (#586).
544                self.delete(li);
545            }
546            // `add_list_item` keeps the text, marker, formatting, hyperlink
547            // and first provenance — not comments or a source. The children
548            // (the inline group of a mixed-format item) are carried over.
549            for &li in &run {
550                let children = std::mem::take(&mut self.items[li].children);
551                let copy = TreeItem {
552                    parent: Some(group),
553                    children: children.clone(),
554                    comments: Vec::new(),
555                    source: None,
556                    deleted: false,
557                    ..self.items[li].clone()
558                };
559                let id = self.items.len();
560                self.items.push(copy);
561                for c in children {
562                    self.items[c].parent = Some(id);
563                }
564                self.items[group].children.push(id);
565            }
566        }
567    }
568
569    /// The last live text-bucket item (docling's `doc.texts[-1]`).
570    pub fn last_text(&self) -> Option<usize> {
571        self.items.iter().rposition(|it| {
572            !it.deleted && matches!(it.kind, TreeKind::Text { .. } | TreeKind::Code { .. })
573        })
574    }
575
576    /// How many items of a bucket precede `id` — its `#/{bucket}/N` index.
577    pub fn bucket_index(&self, id: usize) -> usize {
578        let same = |k: &TreeKind| {
579            std::mem::discriminant(k) == std::mem::discriminant(&self.items[id].kind)
580                || matches!(
581                    (k, &self.items[id].kind),
582                    (TreeKind::Text { .. }, TreeKind::Code { .. })
583                        | (TreeKind::Code { .. }, TreeKind::Text { .. })
584                )
585        };
586        self.items[..id]
587            .iter()
588            .filter(|it| !it.deleted && same(&it.kind))
589            .count()
590    }
591
592    /// The number of tables created so far (docling's `len(doc.tables)`).
593    pub fn table_count(&self) -> usize {
594        self.items
595            .iter()
596            .filter(|it| !it.deleted && matches!(it.kind, TreeKind::Table { .. }))
597            .count()
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    fn text(t: &str) -> TreeKind {
606        TreeKind::Text {
607            label: "text".into(),
608            text: t.into(),
609            orig: None,
610            formatting: None,
611            hyperlink: None,
612            level: None,
613            list: None,
614        }
615    }
616
617    /// `add` registers the item as its parent's (or the body's) last child;
618    /// `reparent` moves it — a rich cell's items leave the heading they were
619    /// created under for the table's group.
620    #[test]
621    fn add_and_reparent_keep_docling_children_order() {
622        let mut t = ItemTree::default();
623        let title = t.add(None, None, text("Title"));
624        let a = t.add(Some(title), None, text("a"));
625        let b = t.add(Some(title), None, text("b"));
626        let table = t.add(
627            Some(title),
628            None,
629            TreeKind::Table {
630                table: Table::default(),
631                rich_cells: Vec::new(),
632                captions: Vec::new(),
633            },
634        );
635        let group = t.add(
636            Some(table),
637            None,
638            TreeKind::Group {
639                label: "unspecified".into(),
640                name: "rich_cell_group_1_0_0".into(),
641            },
642        );
643        assert_eq!(t.body, vec![title]);
644        assert_eq!(t.items[title].children, vec![a, b, table]);
645        t.reparent(a, Some(group));
646        assert_eq!(t.items[title].children, vec![b, table]);
647        assert_eq!(t.items[group].children, vec![a]);
648        assert_eq!(t.items[a].parent, Some(group));
649        assert_eq!(t.table_count(), 1);
650        // Text and code share the `texts` bucket.
651        let code = t.add(
652            None,
653            None,
654            TreeKind::Code {
655                text: "x".into(),
656                orig: None,
657                language: None,
658                formatting: None,
659                hyperlink: None,
660                captions: Vec::new(),
661            },
662        );
663        assert_eq!(t.bucket_index(code), 3, "title, a, b precede it in `texts`");
664        assert_eq!(t.bucket_index(group), 0);
665        assert_eq!(t.body, vec![title, code]);
666    }
667
668    /// `append` renumbers a fragment built on its own (a slide converted in
669    /// parallel) so the merged tree reads as if built in one pass: parents,
670    /// children, comment back-refs and caption refs all shift together.
671    #[test]
672    fn append_renumbers_a_fragment_into_creation_order() {
673        let mut whole = ItemTree::default();
674        let slide0 = whole.add(
675            None,
676            None,
677            TreeKind::Group {
678                label: "chapter".into(),
679                name: "slide-0".into(),
680            },
681        );
682        whole.add(Some(slide0), None, text("first"));
683
684        let mut frag = ItemTree::default();
685        let slide1 = frag.add(
686            None,
687            None,
688            TreeKind::Group {
689                label: "chapter".into(),
690                name: "slide-1".into(),
691            },
692        );
693        let cap = frag.add_with_prov(
694            Some(slide1),
695            None,
696            TreeKind::Text {
697                label: "caption".into(),
698                text: "Title".into(),
699                orig: None,
700                formatting: None,
701                hyperlink: None,
702                level: None,
703                list: None,
704            },
705            TreeProv {
706                page_no: 2,
707                bbox: [1.0, 2.0, 3.0, 4.0],
708                bottom_left: true,
709                charspan: [0, 5],
710            },
711        );
712        let pic = frag.add(
713            Some(slide1),
714            None,
715            TreeKind::Picture {
716                captions: vec![cap],
717                image: None,
718                extra_classes: Vec::new(),
719                classification: Some("bar_chart".into()),
720                description: None,
721                confidence: None,
722                chart: None,
723                dpi: None,
724            },
725        );
726        let note = frag.add(
727            None,
728            Some(ContentLayer::Notes),
729            TreeKind::Group {
730                label: "comment_section".into(),
731                name: "comment-slide2-1".into(),
732            },
733        );
734        frag.items[pic].comments.push(note);
735
736        whole.append(frag);
737        assert_eq!(whole.body, vec![slide0, 2, 5]);
738        assert_eq!(whole.items[2].children, vec![3, 4]);
739        assert_eq!(whole.items[3].parent, Some(2));
740        assert_eq!(whole.items[3].prov.as_ref().map(|p| p.page_no), Some(2));
741        assert!(
742            matches!(&whole.items[4].kind, TreeKind::Picture { captions, .. } if captions == &[3])
743        );
744        assert_eq!(whole.items[4].comments, vec![5]);
745        assert_eq!(whole.items[5].parent, None);
746        assert_eq!(
747            whole.bucket_index(4),
748            0,
749            "the fragment's picture is #/pictures/0"
750        );
751        assert_eq!(
752            whole.bucket_index(5),
753            2,
754            "slide-0, slide-1 precede it in `groups`"
755        );
756    }
757
758    /// `renumber_in_traversal_order`: docling's `concatenate` re-creates the
759    /// items as a pre-order walk meets them, so a group created after the
760    /// content it was wrapped around (a rich cell's group) comes first, and a
761    /// deleted item disappears; every cross-reference follows.
762    #[test]
763    fn renumbering_follows_the_traversal() {
764        let mut t = ItemTree::default();
765        let a = t.add(None, None, text("a"));
766        let table = t.add(
767            None,
768            None,
769            TreeKind::Table {
770                table: Table::default(),
771                rich_cells: Vec::new(),
772                captions: Vec::new(),
773            },
774        );
775        let cell_text = t.add(None, None, text("cell"));
776        let group = t.add(
777            Some(table),
778            None,
779            TreeKind::Group {
780                label: "unspecified".into(),
781                name: "rich_cell_group_1_0_0".into(),
782            },
783        );
784        t.reparent(cell_text, Some(group));
785        if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
786            rich_cells.push((0, 0, group));
787        }
788        let gone = t.add(None, None, text("gone"));
789        t.delete(gone);
790        let z = t.add(None, None, text("z"));
791
792        let new_of = t.renumber_in_traversal_order();
793        assert_eq!(
794            new_of,
795            vec![Some(0), Some(1), Some(3), Some(2), None, Some(4)]
796        );
797        assert_eq!(t.items.len(), 5);
798        assert_eq!(t.body, vec![0, 1, 4]);
799        assert_eq!(t.items[1].children, vec![2], "the group follows its table");
800        assert_eq!(t.items[2].parent, Some(1));
801        assert_eq!(
802            t.items[3].parent,
803            Some(2),
804            "the cell text follows its group"
805        );
806        assert!(matches!(&t.items[3].kind, TreeKind::Text { text, .. } if text == "cell"));
807        assert!(
808            matches!(&t.items[1].kind, TreeKind::Table { rich_cells, .. } if rich_cells == &[(0, 0, 2)])
809        );
810        let _ = (a, z);
811    }
812
813    /// #586: a misplaced `list_item` keeps its children when it is re-homed.
814    /// docling-core's `validate_misplaced_list_items` re-adds the item from
815    /// its text alone, so a mixed-format item (empty `list_item` over an
816    /// `inline` group of runs) lost every run — a DOCX table cell whose list
817    /// paragraph followed a `numId 0` spacer came out as an empty bullet in
818    /// the JSON and the HTML. The group goes where the item stood, the copy
819    /// is numbered last, the inline group and its texts move under it.
820    #[test]
821    fn wrapping_a_misplaced_list_item_keeps_its_runs() {
822        let mut t = ItemTree::default();
823        let cell = t.add(
824            None,
825            None,
826            TreeKind::Group {
827                label: "unspecified".into(),
828                name: "rich_cell_group_1_0_1".into(),
829            },
830        );
831        let before = t.add(Some(cell), None, text("before"));
832        let item = t.add(
833            Some(cell),
834            None,
835            TreeKind::Text {
836                label: "list_item".into(),
837                text: String::new(),
838                orig: None,
839                formatting: None,
840                hyperlink: None,
841                level: None,
842                list: Some(ListMeta {
843                    enumerated: false,
844                    marker: String::new(),
845                }),
846            },
847        );
848        let inline = t.add(
849            Some(item),
850            None,
851            TreeKind::Group {
852                label: "inline".into(),
853                name: "group".into(),
854            },
855        );
856        let run_a = t.add(Some(inline), None, text("Second item text"));
857        let run_b = t.add(Some(inline), None, text("[Optional]"));
858        let after = t.add(Some(cell), None, text("after"));
859
860        t.wrap_misplaced_list_items();
861
862        let group = t.items[cell].children[1];
863        assert_eq!(t.items[cell].children, vec![before, group, after]);
864        assert!(
865            matches!(&t.items[group].kind, TreeKind::Group { label, name } if label == "list" && name == "group")
866        );
867        assert!(t.items[item].deleted, "the original item is deleted");
868        let copy = t.items[group].children[0];
869        assert_ne!(copy, item);
870        assert!(
871            copy > after,
872            "the copy is numbered after every existing item"
873        );
874        assert_eq!(t.items[copy].children, vec![inline]);
875        assert_eq!(t.items[inline].parent, Some(copy));
876        assert_eq!(t.items[inline].children, vec![run_a, run_b]);
877        for id in [inline, run_a, run_b] {
878            assert!(!t.items[id].deleted, "item {id} must survive the re-homing");
879        }
880    }
881}