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 /// The DocLang export serializes this tree too (#884) — docling-core's
300 /// `DocLangDocSerializer` ported over the tree (`doclang_tree.rs`) —
301 /// instead of the flat nodes, which cannot carry a footnote's label, a
302 /// caption's or a cell's inline formulas, or text docling never wrote
303 /// as Markdown. Set by a backend whose tree is upstream's exact
304 /// structure (LaTeX); a tree the port does not cover falls back to the
305 /// nodes.
306 pub doclang: bool,
307}
308
309impl ItemTree {
310 /// Append an item under `parent` (`None` = body) on `layer`, registering
311 /// it as its parent's last child — docling's `add_*` calls do exactly that.
312 pub fn add(
313 &mut self,
314 parent: Option<usize>,
315 layer: Option<ContentLayer>,
316 kind: TreeKind,
317 ) -> usize {
318 let id = self.items.len();
319 self.items.push(TreeItem {
320 parent,
321 children: Vec::new(),
322 layer,
323 kind,
324 prov: None,
325 comments: Vec::new(),
326 source: None,
327 deleted: false,
328 notes: Vec::new(),
329 note_body: false,
330 anchors: Vec::new(),
331 number: None,
332 revision: None,
333 code_meta: None,
334 });
335 match parent {
336 Some(p) => self.items[p].children.push(id),
337 None => self.body.push(id),
338 }
339 id
340 }
341
342 /// [`add`](Self::add) with the item's provenance — docling's
343 /// `add_text(…, prov=prov)`.
344 pub fn add_with_prov(
345 &mut self,
346 parent: Option<usize>,
347 layer: Option<ContentLayer>,
348 kind: TreeKind,
349 prov: TreeProv,
350 ) -> usize {
351 let id = self.add(parent, layer, kind);
352 self.items[id].prov = Some(prov);
353 id
354 }
355
356 /// Append every item of `other` after this tree's, renumbering its
357 /// indices (parents, children, comments, table/picture caption and
358 /// rich-cell refs) and adding its body children to this body — so a
359 /// backend can build independent fragments in parallel (one per PPTX
360 /// slide) and still hand the export one tree in creation order, exactly
361 /// as if it had been built sequentially.
362 pub fn append(&mut self, other: ItemTree) {
363 let off = self.items.len();
364 let shift = |i: usize| i + off;
365 for mut item in other.items {
366 item.parent = item.parent.map(shift);
367 for c in item.children.iter_mut().chain(item.comments.iter_mut()) {
368 *c = shift(*c);
369 }
370 match &mut item.kind {
371 TreeKind::Table {
372 rich_cells,
373 captions,
374 ..
375 } => {
376 for (_, _, g) in rich_cells.iter_mut() {
377 *g = shift(*g);
378 }
379 for c in captions.iter_mut() {
380 *c = shift(*c);
381 }
382 }
383 TreeKind::Picture { captions, .. } | TreeKind::Code { captions, .. } => {
384 for c in captions.iter_mut() {
385 *c = shift(*c);
386 }
387 }
388 _ => {}
389 }
390 self.items.push(item);
391 }
392 self.body.extend(other.body.into_iter().map(shift));
393 }
394
395 /// Move `id` under `new_parent`, dropping it from its current parent's
396 /// children and appending it to the new one's — docling's
397 /// `group_cell_elements` re-parenting of a rich cell's items.
398 pub fn reparent(&mut self, id: usize, new_parent: Option<usize>) {
399 let old = self.items[id].parent;
400 let siblings = match old {
401 Some(p) => &mut self.items[p].children,
402 None => &mut self.body,
403 };
404 siblings.retain(|&c| c != id);
405 self.items[id].parent = new_parent;
406 match new_parent {
407 Some(p) => self.items[p].children.push(id),
408 None => self.body.push(id),
409 }
410 }
411
412 /// Re-number the items in traversal order — a pre-order walk of the body
413 /// through every layer, which is how docling's
414 /// `DoclingDocument.concatenate` (and `_normalize_references`) re-creates
415 /// a document's items: a group created after the content it was later
416 /// wrapped around comes before that content afterwards. Items the walk
417 /// does not reach (deleted ones) are dropped. Returns each old id's new
418 /// id.
419 pub fn renumber_in_traversal_order(&mut self) -> Vec<Option<usize>> {
420 let mut order = Vec::with_capacity(self.items.len());
421 let mut stack: Vec<usize> = self.body.iter().rev().copied().collect();
422 while let Some(id) = stack.pop() {
423 if self.items[id].deleted {
424 continue;
425 }
426 order.push(id);
427 stack.extend(self.items[id].children.iter().rev());
428 }
429 let mut new_of: Vec<Option<usize>> = vec![None; self.items.len()];
430 for (new, &old) in order.iter().enumerate() {
431 new_of[old] = Some(new);
432 }
433 let remap = |ids: &mut Vec<usize>| {
434 *ids = ids.iter().filter_map(|&i| new_of[i]).collect();
435 };
436 let mut old_items: Vec<Option<TreeItem>> = std::mem::take(&mut self.items)
437 .into_iter()
438 .map(Some)
439 .collect();
440 for &old in &order {
441 let mut item = old_items[old].take().expect("each item visited once");
442 item.parent = item.parent.and_then(|p| new_of[p]);
443 remap(&mut item.children);
444 remap(&mut item.comments);
445 match &mut item.kind {
446 TreeKind::Table {
447 rich_cells,
448 captions,
449 ..
450 } => {
451 rich_cells.retain_mut(|(_, _, g)| match new_of[*g] {
452 Some(n) => {
453 *g = n;
454 true
455 }
456 None => false,
457 });
458 remap(captions);
459 }
460 TreeKind::Picture { captions, .. } | TreeKind::Code { captions, .. } => {
461 remap(captions)
462 }
463 _ => {}
464 }
465 self.items.push(item);
466 }
467 remap(&mut self.body);
468 new_of
469 }
470
471 /// Remove `id` from the tree — docling's `delete_items`, which the DOCX
472 /// backend uses to drop the empty text item a blank spacer paragraph left
473 /// between two items of a resumed list. The item leaves its parent's
474 /// children and is neither numbered nor written; its slot stays so the
475 /// indices held elsewhere stay valid.
476 pub fn delete(&mut self, id: usize) {
477 match self.items[id].parent {
478 Some(p) => self.items[p].children.retain(|&c| c != id),
479 None => self.body.retain(|&c| c != id),
480 }
481 self.items[id].deleted = true;
482 }
483
484 /// docling-core's `DoclingDocument.validate_misplaced_list_items` (a
485 /// model validator, so it runs whenever docling-core serializes or loads
486 /// a document): every `list_item` whose parent is not a `list` group is
487 /// re-homed into a new one. A pre-order walk of the body (groups
488 /// included) collects them; consecutive misplaced items directly on the
489 /// body share one group, any other misplaced item gets its own. Working
490 /// from the last run back, each run gets a `ListGroup` (name `group`)
491 /// inserted where its first item stood, the items are deleted and
492 /// re-added under the group as fresh items, so they move to the end of
493 /// the text numbering (#527: the DOCX backend leaves such items in rich
494 /// table cells). A no-op for a well-formed tree.
495 ///
496 /// One deliberate divergence (#586): docling-core deletes each item *with
497 /// its children* and re-adds it from its text alone, so a mixed-format
498 /// item — an empty `list_item` over an `inline` group of text runs —
499 /// comes back empty and its text is gone from the JSON (and from the
500 /// Markdown/HTML docling serializes after that validator ran; the JSON
501 /// docling saves *before* exporting still has it). Here the children
502 /// follow the item into the group: the structure docling-core requires,
503 /// the content the document had.
504 pub fn wrap_misplaced_list_items(&mut self) {
505 let is_list_item = |t: &Self, id: usize| matches!(&t.items[id].kind, TreeKind::Text { label, .. } if label == "list_item");
506 let in_list_group = |t: &Self, id: usize| {
507 t.items[id].parent.is_some_and(
508 |p| matches!(&t.items[p].kind, TreeKind::Group { label, .. } if label == "list"),
509 )
510 };
511 let mut runs: Vec<Vec<usize>> = Vec::new();
512 // `None` = the body itself, which the walk yields first.
513 let mut prev: Option<usize> = None;
514 let mut stack: Vec<usize> = self.body.iter().rev().copied().collect();
515 while let Some(id) = stack.pop() {
516 if self.items[id].deleted {
517 continue;
518 }
519 if is_list_item(self, id) && !in_list_group(self, id) {
520 let continues =
521 prev.is_some_and(|p| is_list_item(self, p) && self.items[p].parent.is_none());
522 match runs.last_mut() {
523 Some(run) if continues => run.push(id),
524 _ => runs.push(vec![id]),
525 }
526 }
527 prev = Some(id);
528 stack.extend(self.items[id].children.iter().rev().copied());
529 }
530 for run in runs.into_iter().rev() {
531 let parent = self.items[run[0]].parent;
532 let group = self.add(
533 parent,
534 None,
535 TreeKind::Group {
536 label: "list".into(),
537 name: "group".into(),
538 },
539 );
540 let siblings = match parent {
541 Some(p) => &mut self.items[p].children,
542 None => &mut self.body,
543 };
544 siblings.pop();
545 let at = siblings
546 .iter()
547 .position(|&c| c == run[0])
548 .unwrap_or(siblings.len());
549 siblings.insert(at, group);
550 for &li in &run {
551 // Not `delete_subtree`: the children move to the copy (#586).
552 self.delete(li);
553 }
554 // `add_list_item` keeps the text, marker, formatting, hyperlink
555 // and first provenance — not comments or a source. The children
556 // (the inline group of a mixed-format item) are carried over.
557 for &li in &run {
558 let children = std::mem::take(&mut self.items[li].children);
559 let copy = TreeItem {
560 parent: Some(group),
561 children: children.clone(),
562 comments: Vec::new(),
563 source: None,
564 deleted: false,
565 ..self.items[li].clone()
566 };
567 let id = self.items.len();
568 self.items.push(copy);
569 for c in children {
570 self.items[c].parent = Some(id);
571 }
572 self.items[group].children.push(id);
573 }
574 }
575 }
576
577 /// The last live text-bucket item (docling's `doc.texts[-1]`).
578 pub fn last_text(&self) -> Option<usize> {
579 self.items.iter().rposition(|it| {
580 !it.deleted && matches!(it.kind, TreeKind::Text { .. } | TreeKind::Code { .. })
581 })
582 }
583
584 /// How many items of a bucket precede `id` — its `#/{bucket}/N` index.
585 pub fn bucket_index(&self, id: usize) -> usize {
586 let same = |k: &TreeKind| {
587 std::mem::discriminant(k) == std::mem::discriminant(&self.items[id].kind)
588 || matches!(
589 (k, &self.items[id].kind),
590 (TreeKind::Text { .. }, TreeKind::Code { .. })
591 | (TreeKind::Code { .. }, TreeKind::Text { .. })
592 )
593 };
594 self.items[..id]
595 .iter()
596 .filter(|it| !it.deleted && same(&it.kind))
597 .count()
598 }
599
600 /// The number of tables created so far (docling's `len(doc.tables)`).
601 pub fn table_count(&self) -> usize {
602 self.items
603 .iter()
604 .filter(|it| !it.deleted && matches!(it.kind, TreeKind::Table { .. }))
605 .count()
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 fn text(t: &str) -> TreeKind {
614 TreeKind::Text {
615 label: "text".into(),
616 text: t.into(),
617 orig: None,
618 formatting: None,
619 hyperlink: None,
620 level: None,
621 list: None,
622 }
623 }
624
625 /// `add` registers the item as its parent's (or the body's) last child;
626 /// `reparent` moves it — a rich cell's items leave the heading they were
627 /// created under for the table's group.
628 #[test]
629 fn add_and_reparent_keep_docling_children_order() {
630 let mut t = ItemTree::default();
631 let title = t.add(None, None, text("Title"));
632 let a = t.add(Some(title), None, text("a"));
633 let b = t.add(Some(title), None, text("b"));
634 let table = t.add(
635 Some(title),
636 None,
637 TreeKind::Table {
638 table: Table::default(),
639 rich_cells: Vec::new(),
640 captions: Vec::new(),
641 },
642 );
643 let group = t.add(
644 Some(table),
645 None,
646 TreeKind::Group {
647 label: "unspecified".into(),
648 name: "rich_cell_group_1_0_0".into(),
649 },
650 );
651 assert_eq!(t.body, vec![title]);
652 assert_eq!(t.items[title].children, vec![a, b, table]);
653 t.reparent(a, Some(group));
654 assert_eq!(t.items[title].children, vec![b, table]);
655 assert_eq!(t.items[group].children, vec![a]);
656 assert_eq!(t.items[a].parent, Some(group));
657 assert_eq!(t.table_count(), 1);
658 // Text and code share the `texts` bucket.
659 let code = t.add(
660 None,
661 None,
662 TreeKind::Code {
663 text: "x".into(),
664 orig: None,
665 language: None,
666 formatting: None,
667 hyperlink: None,
668 captions: Vec::new(),
669 },
670 );
671 assert_eq!(t.bucket_index(code), 3, "title, a, b precede it in `texts`");
672 assert_eq!(t.bucket_index(group), 0);
673 assert_eq!(t.body, vec![title, code]);
674 }
675
676 /// `append` renumbers a fragment built on its own (a slide converted in
677 /// parallel) so the merged tree reads as if built in one pass: parents,
678 /// children, comment back-refs and caption refs all shift together.
679 #[test]
680 fn append_renumbers_a_fragment_into_creation_order() {
681 let mut whole = ItemTree::default();
682 let slide0 = whole.add(
683 None,
684 None,
685 TreeKind::Group {
686 label: "chapter".into(),
687 name: "slide-0".into(),
688 },
689 );
690 whole.add(Some(slide0), None, text("first"));
691
692 let mut frag = ItemTree::default();
693 let slide1 = frag.add(
694 None,
695 None,
696 TreeKind::Group {
697 label: "chapter".into(),
698 name: "slide-1".into(),
699 },
700 );
701 let cap = frag.add_with_prov(
702 Some(slide1),
703 None,
704 TreeKind::Text {
705 label: "caption".into(),
706 text: "Title".into(),
707 orig: None,
708 formatting: None,
709 hyperlink: None,
710 level: None,
711 list: None,
712 },
713 TreeProv {
714 page_no: 2,
715 bbox: [1.0, 2.0, 3.0, 4.0],
716 bottom_left: true,
717 charspan: [0, 5],
718 },
719 );
720 let pic = frag.add(
721 Some(slide1),
722 None,
723 TreeKind::Picture {
724 captions: vec![cap],
725 image: None,
726 extra_classes: Vec::new(),
727 classification: Some("bar_chart".into()),
728 description: None,
729 confidence: None,
730 chart: None,
731 dpi: None,
732 },
733 );
734 let note = frag.add(
735 None,
736 Some(ContentLayer::Notes),
737 TreeKind::Group {
738 label: "comment_section".into(),
739 name: "comment-slide2-1".into(),
740 },
741 );
742 frag.items[pic].comments.push(note);
743
744 whole.append(frag);
745 assert_eq!(whole.body, vec![slide0, 2, 5]);
746 assert_eq!(whole.items[2].children, vec![3, 4]);
747 assert_eq!(whole.items[3].parent, Some(2));
748 assert_eq!(whole.items[3].prov.as_ref().map(|p| p.page_no), Some(2));
749 assert!(
750 matches!(&whole.items[4].kind, TreeKind::Picture { captions, .. } if captions == &[3])
751 );
752 assert_eq!(whole.items[4].comments, vec![5]);
753 assert_eq!(whole.items[5].parent, None);
754 assert_eq!(
755 whole.bucket_index(4),
756 0,
757 "the fragment's picture is #/pictures/0"
758 );
759 assert_eq!(
760 whole.bucket_index(5),
761 2,
762 "slide-0, slide-1 precede it in `groups`"
763 );
764 }
765
766 /// `renumber_in_traversal_order`: docling's `concatenate` re-creates the
767 /// items as a pre-order walk meets them, so a group created after the
768 /// content it was wrapped around (a rich cell's group) comes first, and a
769 /// deleted item disappears; every cross-reference follows.
770 #[test]
771 fn renumbering_follows_the_traversal() {
772 let mut t = ItemTree::default();
773 let a = t.add(None, None, text("a"));
774 let table = t.add(
775 None,
776 None,
777 TreeKind::Table {
778 table: Table::default(),
779 rich_cells: Vec::new(),
780 captions: Vec::new(),
781 },
782 );
783 let cell_text = t.add(None, None, text("cell"));
784 let group = t.add(
785 Some(table),
786 None,
787 TreeKind::Group {
788 label: "unspecified".into(),
789 name: "rich_cell_group_1_0_0".into(),
790 },
791 );
792 t.reparent(cell_text, Some(group));
793 if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
794 rich_cells.push((0, 0, group));
795 }
796 let gone = t.add(None, None, text("gone"));
797 t.delete(gone);
798 let z = t.add(None, None, text("z"));
799
800 let new_of = t.renumber_in_traversal_order();
801 assert_eq!(
802 new_of,
803 vec![Some(0), Some(1), Some(3), Some(2), None, Some(4)]
804 );
805 assert_eq!(t.items.len(), 5);
806 assert_eq!(t.body, vec![0, 1, 4]);
807 assert_eq!(t.items[1].children, vec![2], "the group follows its table");
808 assert_eq!(t.items[2].parent, Some(1));
809 assert_eq!(
810 t.items[3].parent,
811 Some(2),
812 "the cell text follows its group"
813 );
814 assert!(matches!(&t.items[3].kind, TreeKind::Text { text, .. } if text == "cell"));
815 assert!(
816 matches!(&t.items[1].kind, TreeKind::Table { rich_cells, .. } if rich_cells == &[(0, 0, 2)])
817 );
818 let _ = (a, z);
819 }
820
821 /// #586: a misplaced `list_item` keeps its children when it is re-homed.
822 /// docling-core's `validate_misplaced_list_items` re-adds the item from
823 /// its text alone, so a mixed-format item (empty `list_item` over an
824 /// `inline` group of runs) lost every run — a DOCX table cell whose list
825 /// paragraph followed a `numId 0` spacer came out as an empty bullet in
826 /// the JSON and the HTML. The group goes where the item stood, the copy
827 /// is numbered last, the inline group and its texts move under it.
828 #[test]
829 fn wrapping_a_misplaced_list_item_keeps_its_runs() {
830 let mut t = ItemTree::default();
831 let cell = t.add(
832 None,
833 None,
834 TreeKind::Group {
835 label: "unspecified".into(),
836 name: "rich_cell_group_1_0_1".into(),
837 },
838 );
839 let before = t.add(Some(cell), None, text("before"));
840 let item = t.add(
841 Some(cell),
842 None,
843 TreeKind::Text {
844 label: "list_item".into(),
845 text: String::new(),
846 orig: None,
847 formatting: None,
848 hyperlink: None,
849 level: None,
850 list: Some(ListMeta {
851 enumerated: false,
852 marker: String::new(),
853 }),
854 },
855 );
856 let inline = t.add(
857 Some(item),
858 None,
859 TreeKind::Group {
860 label: "inline".into(),
861 name: "group".into(),
862 },
863 );
864 let run_a = t.add(Some(inline), None, text("Second item text"));
865 let run_b = t.add(Some(inline), None, text("[Optional]"));
866 let after = t.add(Some(cell), None, text("after"));
867
868 t.wrap_misplaced_list_items();
869
870 let group = t.items[cell].children[1];
871 assert_eq!(t.items[cell].children, vec![before, group, after]);
872 assert!(
873 matches!(&t.items[group].kind, TreeKind::Group { label, name } if label == "list" && name == "group")
874 );
875 assert!(t.items[item].deleted, "the original item is deleted");
876 let copy = t.items[group].children[0];
877 assert_ne!(copy, item);
878 assert!(
879 copy > after,
880 "the copy is numbered after every existing item"
881 );
882 assert_eq!(t.items[copy].children, vec![inline]);
883 assert_eq!(t.items[inline].parent, Some(copy));
884 assert_eq!(t.items[inline].children, vec![run_a, run_b]);
885 for id in [inline, run_a, run_b] {
886 assert!(!t.items[id].deleted, "item {id} must survive the re-homing");
887 }
888 }
889}