Skip to main content

docling_core/
chunker.rs

1//! Document chunking for RAG pipelines — the Rust port of docling-core's
2//! `docling_core.transforms.chunker`.
3//!
4//! Two chunkers, matching docling's semantics output-for-output:
5//!
6//! * [`HierarchicalChunker`] walks the document tree and yields one chunk per
7//!   top-level item (paragraph, whole list, table, picture caption, …), each
8//!   carrying the heading path it sits under. Tables are serialized in
9//!   docling's *triplet* form (`row, column = value`), pictures contribute
10//!   their captions.
11//! * [`HybridChunker`] refines the hierarchical chunks with a tokenizer:
12//!   oversized chunks are split (at item boundaries first, then within the
13//!   text by docling's `semchunk` algorithm), and undersized neighbours that
14//!   share the same headings are merged back together.
15//!
16//! [`contextualize`] renders a chunk to the string an embedding model should
17//! see: the heading path plus the chunk text.
18//!
19//! Anything tokenizer-related is abstracted behind [`ChunkTokenizer`]; a
20//! HuggingFace `tokenizers` implementation ships behind the `chunking` cargo
21//! feature as [`HuggingFaceTokenizer`].
22
23use std::collections::{BTreeMap, VecDeque};
24
25use crate::document::{DoclingDocument, Node, Table};
26
27/// What kind of document item a [`ChunkItem`] points at — only what the
28/// hybrid splitting logic needs to know.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ChunkItemKind {
31    /// A text-ish item (paragraph, list item, code, formula, caption, …).
32    Text,
33    /// A table item (single-item oversized chunks re-split per line, repeating
34    /// docling's header handling).
35    Table,
36    /// A picture item.
37    Picture,
38}
39
40/// One document item contributing to a chunk — the analogue of an entry in
41/// docling's `DocMeta.doc_items`.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ChunkItem {
44    /// The item's ref in [`DoclingDocument::export_to_json`] output
45    /// (`#/texts/12`, `#/tables/0`, `#/pictures/1`, …).
46    pub self_ref: String,
47    pub kind: ChunkItemKind,
48    /// The item serialized *standalone* (docling re-serializes individual
49    /// items when splitting an oversized multi-item chunk — e.g. a nested
50    /// list item flattens to `- text` with no indentation).
51    pub text: String,
52}
53
54/// One chunk — the analogue of docling's `DocChunk` (text + `DocMeta`).
55#[derive(Debug, Clone, PartialEq)]
56pub struct DocChunk {
57    /// The chunk body (markdown-flavoured, unescaped — same text docling puts
58    /// in `DocChunk.text`).
59    pub text: String,
60    /// The heading path above this chunk, outermost first (`DocMeta.headings`;
61    /// `None` when the chunk sits above any heading).
62    pub headings: Option<Vec<String>>,
63    /// The document items the chunk was built from (`DocMeta.doc_items`).
64    pub doc_items: Vec<ChunkItem>,
65}
66
67/// Render a chunk for embedding: the heading path, then the text, joined with
68/// newlines — docling's `BaseChunker.contextualize()`.
69pub fn contextualize(chunk: &DocChunk) -> String {
70    let mut parts: Vec<&str> = Vec::new();
71    if let Some(h) = &chunk.headings {
72        parts.extend(h.iter().map(String::as_str));
73    }
74    parts.push(&chunk.text);
75    parts.join("\n")
76}
77
78// ---------------------------------------------------------------------------
79// Hierarchical chunker
80// ---------------------------------------------------------------------------
81
82/// Structure-driven chunker: one chunk per document item, lists and inline
83/// groups kept whole, heading path tracked as metadata — docling-core's
84/// `HierarchicalChunker` with default parameters.
85#[derive(Debug, Clone, Default)]
86pub struct HierarchicalChunker;
87
88impl HierarchicalChunker {
89    /// Chunk the document.
90    pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
91        let mut chunks = Vec::new();
92        self.chunk_with(doc, &mut |c| {
93            chunks.push(c);
94            true
95        });
96        chunks
97    }
98
99    /// Stream the chunks: `sink` is called with each chunk as the document
100    /// walk produces it, so a consumer can process (embed, forward) chunks
101    /// without materializing the whole `Vec` first. A `false` return from
102    /// `sink` cancels the walk. [`Self::chunk`] is this with a collecting
103    /// sink — the chunks and their order are identical.
104    pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
105        let mut w = Walker {
106            alloc: Alloc::default(),
107            headings: BTreeMap::new(),
108            stopped: false,
109            sink,
110        };
111        w.walk(&doc.nodes);
112    }
113}
114
115/// Ref allocator mirroring the numbering `json.rs` gives every item, so
116/// `ChunkItem::self_ref` matches the document's JSON export.
117#[derive(Debug, Default)]
118struct Alloc {
119    texts: usize,
120    groups: usize,
121    tables: usize,
122    pictures: usize,
123    field_regions: usize,
124    field_items: usize,
125}
126
127impl Alloc {
128    fn text(&mut self) -> String {
129        let r = format!("#/texts/{}", self.texts);
130        self.texts += 1;
131        r
132    }
133    fn group(&mut self) -> String {
134        let r = format!("#/groups/{}", self.groups);
135        self.groups += 1;
136        r
137    }
138    fn table(&mut self) -> String {
139        let r = format!("#/tables/{}", self.tables);
140        self.tables += 1;
141        r
142    }
143    fn picture(&mut self) -> String {
144        let r = format!("#/pictures/{}", self.pictures);
145        self.pictures += 1;
146        r
147    }
148    fn field_region(&mut self) -> String {
149        let r = format!("#/field_regions/{}", self.field_regions);
150        self.field_regions += 1;
151        r
152    }
153    fn field_item(&mut self) -> String {
154        let r = format!("#/field_items/{}", self.field_items);
155        self.field_items += 1;
156        r
157    }
158}
159
160struct Walker<'s> {
161    alloc: Alloc,
162    /// Active heading per docling level (title = 0, `section_header` = its
163    /// `level`), pruned like docling's `heading_by_level`.
164    headings: BTreeMap<u8, String>,
165    /// Set once the sink refuses a chunk; the walk unwinds without emitting.
166    stopped: bool,
167    sink: &'s mut dyn FnMut(DocChunk) -> bool,
168}
169
170impl Walker<'_> {
171    fn emit(&mut self, text: String, doc_items: Vec<ChunkItem>) {
172        if self.stopped || text.is_empty() {
173            return;
174        }
175        let headings: Vec<String> = self.headings.values().cloned().collect();
176        self.stopped = !(self.sink)(DocChunk {
177            text,
178            headings: (!headings.is_empty()).then_some(headings),
179            doc_items,
180        });
181    }
182
183    /// Emit a text chunk whose doc items follow docling's inline granularity:
184    /// mixed inline content (a paragraph docling represents as an inline group)
185    /// contributes one item per span, plain text one item.
186    fn emit_inline(&mut self, md_text: &str, self_ref: String) {
187        self.emit_inline_with_runs(md_text, self_ref, &[]);
188    }
189
190    fn emit_inline_with_runs(
191        &mut self,
192        md_text: &str,
193        self_ref: String,
194        runs: &[crate::InlineRun],
195    ) {
196        let body = unescape_text(md_text);
197        if body.is_empty() {
198            return;
199        }
200        let segments: Vec<String> = inline_segments_tagged(md_text)
201            .into_iter()
202            .flat_map(|(text, is_plain)| {
203                if is_plain {
204                    if let Some(split) = split_plain_by_runs(&text, runs) {
205                        return split;
206                    }
207                }
208                vec![text]
209            })
210            .collect();
211        let items: Vec<ChunkItem> = if segments.len() <= 1 {
212            vec![ChunkItem {
213                self_ref,
214                kind: ChunkItemKind::Text,
215                text: body.clone(),
216            }]
217        } else {
218            segments
219                .into_iter()
220                .map(|text| ChunkItem {
221                    self_ref: self_ref.clone(),
222                    kind: ChunkItemKind::Text,
223                    text,
224                })
225                .collect()
226        };
227        self.emit(body, items);
228    }
229
230    fn set_heading(&mut self, doc_level: u8, text: String) {
231        self.headings.retain(|k, _| *k < doc_level);
232        self.headings.insert(doc_level, text);
233    }
234
235    fn walk(&mut self, nodes: &[Node]) {
236        let mut i = 0;
237        while i < nodes.len() {
238            if self.stopped {
239                return;
240            }
241            if matches!(nodes[i], Node::ListItem { .. }) {
242                let start = i;
243                i += 1;
244                loop {
245                    match nodes.get(i) {
246                        Some(Node::ListItem { .. }) => i += 1,
247                        // An empty paragraph between two list items is absorbed
248                        // into the run (mirrors json.rs / markdown.rs).
249                        Some(Node::Paragraph { text })
250                            if text.is_empty()
251                                && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
252                        {
253                            i += 1
254                        }
255                        _ => break,
256                    }
257                }
258                self.sibling_lists(&nodes[start..i]);
259            } else {
260                self.one(&nodes[i]);
261                i += 1;
262            }
263        }
264    }
265
266    /// Split a run of list items into sibling lists exactly like
267    /// `json.rs::add_sibling_lists`, chunking each list separately (docling
268    /// yields one chunk per `ListGroup`).
269    fn sibling_lists(&mut self, run: &[Node]) {
270        let base = level_of(&run[0]);
271        let mut seg = 0;
272        for k in 0..run.len() {
273            let Node::ListItem {
274                first_in_list,
275                level,
276                ..
277            } = &run[k]
278            else {
279                continue;
280            };
281            if *level != base {
282                continue; // nested item — handled inside `list`
283            }
284            // The backend's list boundary, as in `json.rs::add_sibling_lists`.
285            if k > seg && *first_in_list {
286                self.list(&run[seg..k]);
287                seg = k;
288            }
289        }
290        self.list(&run[seg..]);
291    }
292
293    /// One `ListGroup`: allocate refs in `json.rs::add_list` order (group,
294    /// then per top item its text ref followed by any nested groups) and emit
295    /// a single chunk whose text is the indented markdown list.
296    fn list(&mut self, items: &[Node]) {
297        self.alloc.group();
298        let mut chunk_items = Vec::new();
299        self.list_refs(items, &mut chunk_items);
300        let text = render_list(items);
301        self.emit(text, chunk_items);
302    }
303
304    /// Allocate refs for one list's items (and nested sibling lists), mirroring
305    /// `json.rs::add_list` / `add_sibling_lists` recursion, collecting the
306    /// non-furniture items in allocation (= document) order.
307    fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
308        let base = level_of(&items[0]);
309        let mut i = 0;
310        while i < items.len() {
311            let Node::ListItem {
312                ordered,
313                number,
314                text,
315                level,
316                layer,
317                ..
318            } = &items[i]
319            else {
320                i += 1;
321                continue;
322            };
323            if *level > base {
324                i += 1;
325                continue;
326            }
327            let item_ref = self.alloc.text();
328            let mut j = i + 1;
329            while j < items.len() && level_of(&items[j]) > base {
330                j += 1;
331            }
332            let has_nested = j > i + 1;
333            if layer.is_none() {
334                let marker = if *ordered {
335                    format!("{number}.")
336                } else {
337                    "-".to_string()
338                };
339                // An item that carries both inline spans and a nested list is an
340                // empty list item wrapping an inline group in docling's model:
341                // its marker and each inline span are separate doc items.
342                // docling represents a list item whose content is not pure
343                // inline text (it carries a nested list or child pictures) as
344                // an empty item wrapping an inline group: its marker and each
345                // inline span become separate doc items.
346                // docling represents a list item whose content is not pure
347                // inline text (it carries a nested list or its own images) as
348                // an empty item wrapping an inline group: its marker and each
349                // inline span become separate doc items. The chunker's
350                // image placeholder is empty, so the markers are stripped from
351                // the text before segmentation.
352                let has_pics = text.contains("<!-- image -->");
353                let text = strip_image_markers(text);
354                let text = text.as_str();
355                let segments = inline_segments(text);
356                if (has_nested || has_pics) && segments.len() > 1 && text.contains("](") {
357                    out.push(ChunkItem {
358                        self_ref: item_ref.clone(),
359                        kind: ChunkItemKind::Text,
360                        text: format!("{marker} "),
361                    });
362                    for seg in segments {
363                        out.push(ChunkItem {
364                            self_ref: item_ref.clone(),
365                            kind: ChunkItemKind::Text,
366                            text: seg,
367                        });
368                    }
369                } else {
370                    out.push(ChunkItem {
371                        self_ref: item_ref.clone(),
372                        kind: ChunkItemKind::Text,
373                        text: format!("{marker} {}", unescape_text(text)),
374                    });
375                }
376            }
377            // nested items group under this one; each nested sibling list is a
378            // fresh group ref
379            if j > i + 1 {
380                self.nested_sibling_lists(&items[i + 1..j], out);
381            }
382            i = j;
383        }
384    }
385
386    fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
387        let base = level_of(&run[0]);
388        let mut seg = 0;
389        for k in 0..run.len() {
390            let Node::ListItem {
391                first_in_list,
392                level,
393                ..
394            } = &run[k]
395            else {
396                continue;
397            };
398            if *level != base {
399                continue;
400            }
401            if k > seg && *first_in_list {
402                self.alloc.group();
403                self.list_refs(&run[seg..k], out);
404                seg = k;
405            }
406        }
407        self.alloc.group();
408        self.list_refs(&run[seg..], out);
409    }
410
411    fn one(&mut self, node: &Node) {
412        match node {
413            Node::Heading { level, text } => {
414                let doc_level = if *level == 1 {
415                    0
416                } else {
417                    level.saturating_sub(1)
418                };
419                let self_ref = self.alloc.text();
420                // docling stores heading text unformatted: a heading that is one
421                // uniformly formatted span keeps its plain text; a *partially*
422                // formatted heading becomes an empty heading whose content is an
423                // inline group — which the chunker then yields as a chunk of its
424                // own (under the freshly-set empty heading).
425                let runs = crate::inline_runs_from_markdown(text);
426                if runs.len() <= 1 {
427                    let plain = runs
428                        .first()
429                        .map(|r| r.text.clone())
430                        .unwrap_or_else(|| text.clone());
431                    self.set_heading(doc_level, unescape_text(&plain));
432                } else {
433                    self.set_heading(doc_level, String::new());
434                    let body = unescape_text(text);
435                    self.emit(
436                        body.clone(),
437                        vec![ChunkItem {
438                            self_ref,
439                            kind: ChunkItemKind::Text,
440                            text: body,
441                        }],
442                    );
443                }
444            }
445            Node::Paragraph { text } | Node::ListText { text, .. } => {
446                let t = text.trim();
447                let self_ref = self.alloc.text();
448                // A whole-paragraph display equation is a formula item; docling's
449                // chunk serializer re-wraps the raw latex in `$$…$$`.
450                if let Some(inner) = t
451                    .strip_prefix("$$")
452                    .and_then(|s| s.strip_suffix("$$"))
453                    .filter(|s| !s.is_empty())
454                {
455                    let body = format!("$${inner}$$");
456                    self.emit(
457                        body.clone(),
458                        vec![ChunkItem {
459                            self_ref,
460                            kind: ChunkItemKind::Text,
461                            text: body,
462                        }],
463                    );
464                    return;
465                }
466                self.emit_inline(text, self_ref);
467            }
468            // A standalone caption chunks like any text item (docling's chunker
469            // does not treat `caption` specially outside a table/picture).
470            Node::Caption { text, .. } => {
471                let self_ref = self.alloc.text();
472                self.emit_inline(text, self_ref);
473            }
474            Node::LabeledText { .. } => self.one(&node.labeled_as_paragraph()),
475            Node::CheckboxItem { checked, text } => {
476                let self_ref = self.alloc.text();
477                let mark = if *checked { "- [x] " } else { "- [ ] " };
478                let body = format!("{mark}{}", unescape_text(text));
479                self.emit(
480                    body.clone(),
481                    vec![ChunkItem {
482                        self_ref,
483                        kind: ChunkItemKind::Text,
484                        text: body,
485                    }],
486                );
487            }
488            // An enriched display formula chunks like docling's formula item:
489            // the LaTeX is the chunk text.
490            Node::Formula { latex, .. } => {
491                let self_ref = self.alloc.text();
492                let body = format!("$${}$$", latex);
493                self.emit(
494                    body.clone(),
495                    vec![ChunkItem {
496                        self_ref,
497                        kind: ChunkItemKind::Text,
498                        text: body,
499                    }],
500                );
501            }
502            Node::Code { text, .. } => {
503                let self_ref = self.alloc.text();
504                let body = crate::markdown::code_fence("", &unescape_text(text));
505                self.emit(
506                    body.clone(),
507                    vec![ChunkItem {
508                        self_ref,
509                        kind: ChunkItemKind::Text,
510                        text: body,
511                    }],
512                );
513            }
514            Node::Table(t) => {
515                let self_ref = self.alloc.table();
516                let body = triplet_table_text(t);
517                self.emit(
518                    body.clone(),
519                    vec![ChunkItem {
520                        self_ref,
521                        kind: ChunkItemKind::Table,
522                        text: body,
523                    }],
524                );
525            }
526            Node::Picture {
527                caption,
528                description,
529                ..
530            } => {
531                let cap = caption.as_deref().filter(|c| !c.is_empty());
532                let cap_item = cap.map(|c| ChunkItem {
533                    self_ref: self.alloc.text(),
534                    kind: ChunkItemKind::Text,
535                    text: unescape_text(c),
536                });
537                let pic_ref = self.alloc.picture();
538                // The picture itself serializes to the (empty) chunking image
539                // placeholder, and its caption is already consumed by the
540                // caption chunk — so only the caption text is emitted. A
541                // description (the picture-OCR text, #645) is the picture's
542                // own annotation text — docling's chunker serializes the
543                // picture through the Markdown picture serializer, so the
544                // description lands in the chunk after the caption.
545                let desc = description
546                    .as_ref()
547                    .map(|d| d.text.as_str())
548                    .filter(|t| !t.is_empty());
549                match (cap_item, desc) {
550                    (Some(cap_item), None) => {
551                        let body = cap_item.text.clone();
552                        self.emit(body, vec![cap_item]);
553                    }
554                    (Some(cap_item), Some(desc)) => {
555                        let body = format!("{}\n\n{desc}", cap_item.text);
556                        let pic_item = ChunkItem {
557                            self_ref: pic_ref,
558                            kind: ChunkItemKind::Picture,
559                            text: desc.to_string(),
560                        };
561                        self.emit(body, vec![cap_item, pic_item]);
562                    }
563                    (None, Some(desc)) => {
564                        let pic_item = ChunkItem {
565                            self_ref: pic_ref,
566                            kind: ChunkItemKind::Picture,
567                            text: desc.to_string(),
568                        };
569                        self.emit(desc.to_string(), vec![pic_item]);
570                    }
571                    (None, None) => {}
572                }
573            }
574            Node::Chart {
575                kind,
576                table,
577                caption,
578                ..
579            } => {
580                let cap = caption.as_deref().filter(|c| !c.is_empty());
581                let cap_item = cap.map(|c| ChunkItem {
582                    self_ref: self.alloc.text(),
583                    kind: ChunkItemKind::Text,
584                    text: unescape_text(c),
585                });
586                let pic_ref = self.alloc.picture();
587                // caption, humanized classification, then the chart's data grid
588                // as a (padded) markdown table — docling's picture serializer
589                // parts, joined with blank lines.
590                let mut parts: Vec<String> = Vec::new();
591                if let Some(ci) = &cap_item {
592                    parts.push(ci.text.clone());
593                }
594                parts.push(humanize_label(kind));
595                let grid = crate::markdown::render_table(table, false);
596                if !grid.is_empty() {
597                    parts.push(unescape_text(&grid));
598                }
599                let body = parts.join("\n\n");
600                // Re-serialized standalone (the hybrid window join), the picture
601                // carries its caption itself, while the caption *item* renders
602                // empty — docling's markdown serializer emits caption-label text
603                // only through the picture.
604                let pic_item = ChunkItem {
605                    self_ref: pic_ref,
606                    kind: ChunkItemKind::Picture,
607                    text: body.clone(),
608                };
609                let items = match cap_item {
610                    Some(mut ci) => {
611                        ci.text = String::new();
612                        vec![ci, pic_item]
613                    }
614                    None => vec![pic_item],
615                };
616                self.emit(body, items);
617            }
618            // A group on a non-body layer (a hidden spreadsheet sheet) carries
619            // no chunkable content, like every other non-body item.
620            Node::Group { layer: Some(_), .. } => {}
621            Node::Group { children, .. } => {
622                // A generic group is a structural container: docling recurses
623                // into it rather than chunking it whole.
624                self.alloc.group();
625                self.walk(children);
626            }
627            // A key-value graph holds no text items, so no chunk comes of it.
628            Node::KeyValueGraph { .. } => {}
629            Node::FieldRegion { items } => {
630                // Each field part (marker / key / value) is its own text item,
631                // and docling chunks each one individually.
632                self.alloc.field_region();
633                for item in items {
634                    self.alloc.field_item();
635                    for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
636                        let self_ref = self.alloc.text();
637                        let body = unescape_text(part);
638                        self.emit(
639                            body.clone(),
640                            vec![ChunkItem {
641                                self_ref,
642                                kind: ChunkItemKind::Text,
643                                text: body,
644                            }],
645                        );
646                    }
647                }
648            }
649            Node::InlineGroup { md_text, runs, .. } => {
650                let self_ref = self.alloc.text();
651                self.emit_inline_with_runs(md_text, self_ref, runs);
652            }
653            Node::TextDump(text) => {
654                let self_ref = self.alloc.text();
655                let body = unescape_text(text);
656                self.emit(
657                    body.clone(),
658                    vec![ChunkItem {
659                        self_ref,
660                        kind: ChunkItemKind::Text,
661                        text: body,
662                    }],
663                );
664            }
665            // Layout provenance and comment annotations are transparent.
666            Node::Located { inner, .. }
667            | Node::Prov { inner, .. }
668            | Node::Track { inner, .. }
669            | Node::Commented { inner, .. } => self.one(inner),
670            // A PDF page header/footer is a furniture-layer JSON text item:
671            // never chunked (docling's chunker reads the body layer), but it
672            // takes a `#/texts/N` number, so later refs keep matching the JSON.
673            Node::PageFurniture { .. } | Node::FurnitureText { .. } => {
674                self.alloc.text();
675            }
676            // A picture's children are JSON items docling's chunker never
677            // reaches (it iterates without `traverse_pictures`): allocate
678            // their refs only — one text each, plus the list group `json.rs`
679            // opens for each list item (each starts its own list) — so later
680            // refs keep matching the JSON.
681            Node::PictureChildren(children) => {
682                for child in children {
683                    match child {
684                        // A header/footer child is numbered exactly as a
685                        // top-level one is, whatever the JSON does with it.
686                        Node::PageFurniture { .. } => self.one(child),
687                        Node::ListItem { .. } => {
688                            self.alloc.group();
689                            self.alloc.text();
690                        }
691                        _ => {
692                            self.alloc.text();
693                        }
694                    }
695                }
696            }
697            // Non-body layers and doclang-only nodes don't reach the chunker
698            // (nor the JSON body).
699            Node::CommentSection { .. }
700            | Node::Furniture { .. }
701            | Node::PageBreak
702            | Node::PageInfo { .. }
703            | Node::Anchor { .. }
704            | Node::DoclangOnly(_) => {}
705            // Runs are grouped by `walk`; a stray single item (hand-built
706            // document, `Located` wrapper) still chunks instead of panicking.
707            Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
708        }
709    }
710}
711
712fn level_of(node: &Node) -> u8 {
713    match node {
714        Node::ListItem { level, .. } => *level,
715        _ => 0,
716    }
717}
718
719/// Render one sibling list as its markdown chunk text (indented items, same
720/// rules as the full markdown serializer's list rendering).
721fn render_list(items: &[Node]) -> String {
722    let mut lines: Vec<String> = Vec::new();
723    for item in items {
724        let Node::ListItem {
725            ordered,
726            number,
727            text,
728            level,
729            layer,
730            ..
731        } = item
732        else {
733            continue;
734        };
735        if layer.is_some() {
736            continue;
737        }
738        let indent = "    ".repeat(*level as usize);
739        let marker = if *ordered {
740            format!("{number}.")
741        } else {
742            "-".to_string()
743        };
744        lines.push(format!(
745            "{indent}{marker} {}",
746            unescape_text(&strip_image_markers(text))
747        ));
748    }
749    lines.join("\n")
750}
751
752/// Strip the Markdown image placeholders a list item's own images fold into
753/// its text (the chunking serializer's `image_placeholder` is empty, so
754/// docling's chunk text carries no marker), collapsing the newlines that
755/// carried them.
756fn strip_image_markers(text: &str) -> String {
757    if !text.contains("<!-- image -->") {
758        return text.to_string();
759    }
760    let cleaned: Vec<&str> = text
761        .split('\n')
762        .map(str::trim_end)
763        .filter(|l| *l != "<!-- image -->")
764        .collect();
765    cleaned.join("\n").trim_end().to_string()
766}
767
768/// docling-core's `_humanize_text`: underscores to spaces, first letter
769/// capitalized (`line_chart` → `Line chart`).
770fn humanize_label(label: &str) -> String {
771    let text = label.replace('_', " ");
772    let mut chars = text.chars();
773    match chars.next() {
774        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
775        None => text,
776    }
777}
778
779/// docling's `TripletTableSerializer` over `export_to_dataframe` semantics: the
780/// leading rows carrying column-header cells become the dataframe's column
781/// names (multiple header rows join per column with `.`; no header rows at all
782/// yield pandas' integer column names), the rest are data rows; the dataframe
783/// is then rendered as `row, column = value` sentences (with the header-only /
784/// single-column special cases and the plain-text flatten fallback).
785fn triplet_table_text(t: &Table) -> String {
786    let anchors = t.span_anchors();
787    let rows: Vec<Vec<String>> = t
788        .rows
789        .iter()
790        .enumerate()
791        .map(|(ri, r)| {
792            (0..r.len())
793                .map(|ci| cell_chunk_text(t, &anchors, ri, ci))
794                .collect()
795        })
796        .collect();
797    let num_rows = rows.len();
798    let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
799    if num_rows == 0 || num_cols == 0 {
800        return String::new();
801    }
802    let cell = |r: usize, c: usize| -> &str {
803        rows.get(r)
804            .and_then(|row| row.get(c))
805            .map(String::as_str)
806            .unwrap_or("")
807    };
808
809    // The header block is the one the Markdown serializer uses
810    // (`Table::column_header_row_count`, docling-core#767), without its
811    // "no flags -> row 0" fallback: pandas then gets integer column names.
812    let num_headers = t.column_header_row_count().min(num_rows);
813
814    // Column names: header-row texts joined per column with '.', or the integer
815    // positions when there are no header rows.
816    let columns: Vec<String> = if num_headers > 0 {
817        (0..num_cols)
818            .map(|c| {
819                let mut name = String::new();
820                for r in 0..num_headers {
821                    if !name.is_empty() {
822                        name.push('.');
823                    }
824                    name.push_str(cell(r, c));
825                }
826                name
827            })
828            .collect()
829    } else {
830        (0..num_cols).map(|c| c.to_string()).collect()
831    };
832    let data_rows = num_headers..num_rows;
833    let n_data = data_rows.len();
834
835    // Header-only table: emit the header texts directly.
836    if n_data == 0 {
837        return columns
838            .iter()
839            .map(|s| s.trim())
840            .filter(|s| !s.is_empty())
841            .collect::<Vec<_>>()
842            .join(". ");
843    }
844
845    let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
846    let text = if num_cols == 1 {
847        // Single-column: the first data row is the column name, the rest are
848        // values (a single data row emits its cell text alone).
849        let col_name = data(0, 0).trim().to_string();
850        if n_data == 1 {
851            col_name
852        } else {
853            (1..n_data)
854                .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
855                .collect::<Vec<_>>()
856                .join(". ")
857        }
858    } else {
859        // Triplets over the dataframe with the column names copied as row 0.
860        let mut parts = Vec::new();
861        for r in 0..n_data {
862            for (c, col_name) in columns.iter().enumerate().skip(1) {
863                parts.push(format!(
864                    "{}, {} = {}",
865                    data(r, 0).trim(),
866                    col_name.trim(),
867                    data(r, c).trim()
868                ));
869            }
870        }
871        parts.join(". ")
872    };
873    if !text.is_empty() {
874        return text;
875    }
876
877    // Last-resort flatten: the data rows' non-blank cells joined with '. '
878    // (the header rows are the dataframe's columns, so they are not included).
879    (0..n_data)
880        .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
881        .map(|(r, c)| data(r, c).trim())
882        .filter(|s| !s.is_empty())
883        .collect::<Vec<_>>()
884        .join(". ")
885}
886
887/// Split a markdown-flavoured text into docling's inline-item granularity: a
888/// hyperlink / formatted span / inline formula is its own document item in
889/// docling's model, with plain text runs between them. Returns the standalone
890/// serialization of each item (`[text](url)`, `**bold**`, a formula re-wrapped
891/// as `$$latex$$`, plain text), or a single-element vector when the text is one
892/// uniform item. The single space docling's serializer inserts between inline
893/// items is stripped from the adjacent plain runs.
894fn inline_segments(md: &str) -> Vec<String> {
895    inline_segments_tagged(md)
896        .into_iter()
897        .map(|(t, _)| t)
898        .collect()
899}
900
901/// Like [`inline_segments`], with each segment tagged `true` when it came from
902/// plain (unmarked) text — those may still need splitting at run boundaries
903/// invisible in markdown (underline, soft breaks).
904fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
905    let chars: Vec<char> = md.chars().collect();
906    let n = chars.len();
907    let find = |from: usize, pat: &str| -> Option<usize> {
908        let hay: String = chars[from..].iter().collect();
909        hay.find(pat).map(|p| from + hay[..p].chars().count())
910    };
911    let mut out: Vec<(String, bool)> = Vec::new();
912    let mut plain = String::new();
913    let mut after_span = false;
914
915    fn flush(
916        out: &mut Vec<(String, bool)>,
917        plain: &mut String,
918        before_span: bool,
919        after_span: bool,
920    ) {
921        let mut p = std::mem::take(plain);
922        if after_span {
923            if let Some(rest) = p.strip_prefix(' ') {
924                p = rest.to_string();
925            }
926        }
927        if before_span {
928            if let Some(rest) = p.strip_suffix(' ') {
929                p = rest.to_string();
930            }
931        }
932        if !p.is_empty() {
933            out.push((unescape_text(&p), true));
934        }
935    }
936
937    let mut i = 0;
938    while i < n {
939        let rest: String = chars[i..].iter().collect();
940        // A hyperlink span (not an image): the whole `[text](url)` is one item.
941        // The URL may itself contain balanced parentheses (`/Duck_(film)`),
942        // so the closing `)` is found by paren depth, not first match.
943        if chars[i] == '[' && !rest.starts_with("[](") {
944            // The label may itself contain balanced brackets (`[[ 1 ]](#ref)`
945            // has label `[ 1 ]`), but an *unbalanced* `[` means this bracket is
946            // plain text preceding a real link (`[ [*note*](url) ]`).
947            let balanced = |c: usize| {
948                let mut d = 0i32;
949                for &ch in &chars[i + 1..c] {
950                    match ch {
951                        '[' => d += 1,
952                        ']' => d -= 1,
953                        _ => {}
954                    }
955                }
956                d == 0
957            };
958            if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
959                let mut depth = 0usize;
960                let mut url_end = None;
961                for (k, &c) in chars.iter().enumerate().skip(close + 2) {
962                    match c {
963                        '(' => depth += 1,
964                        ')' => {
965                            if depth == 0 {
966                                url_end = Some(k);
967                                break;
968                            }
969                            depth -= 1;
970                        }
971                        _ => {}
972                    }
973                }
974                if let Some(endp) = url_end {
975                    // An unsafe destination (`javascript:`, #860) is plain
976                    // text, not a hyperlink item.
977                    let url: String = chars[close + 2..endp].iter().collect();
978                    if !crate::hyperlink::is_safe_hyperlink(&url) {
979                        plain.extend(&chars[i + 1..close]);
980                        i = endp + 1;
981                        continue;
982                    }
983                    flush(&mut out, &mut plain, true, after_span);
984                    out.push((
985                        unescape_text(&chars[i..=endp].iter().collect::<String>()),
986                        false,
987                    ));
988                    i = endp + 1;
989                    after_span = true;
990                    continue;
991                }
992            }
993        }
994        // An inline code span is a code item in docling's model, whose
995        // standalone form is a fenced block; its backticks may run longer
996        // than one (#859).
997        if chars[i] == '`' {
998            if let Some((s, e, after)) = crate::markdown::code_span_bounds(&chars, i) {
999                if e > s && !chars[s..e].iter().all(|c| c.is_whitespace()) {
1000                    flush(&mut out, &mut plain, true, after_span);
1001                    let inner: String = chars[s..e].iter().collect();
1002                    out.push((
1003                        crate::markdown::code_fence("", &unescape_text(&inner)),
1004                        false,
1005                    ));
1006                    i = after;
1007                    after_span = true;
1008                    continue;
1009                }
1010            }
1011        }
1012        // A formatted span; longest markers first.
1013        let mut matched = false;
1014        for marker in ["***", "**", "*", "~~"] {
1015            if rest.starts_with(marker) {
1016                let mlen = marker.chars().count();
1017                if let Some(end) = find(i + mlen, marker) {
1018                    // A whitespace-only span (`* *` from a literal asterisk in
1019                    // running text next to a real italic) is not a docling run:
1020                    // treat the marker as plain text.
1021                    let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
1022                    if end > i + mlen && !inner_blank {
1023                        flush(&mut out, &mut plain, true, after_span);
1024                        out.push((
1025                            unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
1026                            false,
1027                        ));
1028                        i = end + mlen;
1029                        after_span = true;
1030                        matched = true;
1031                    }
1032                }
1033                break;
1034            }
1035        }
1036        if matched {
1037            continue;
1038        }
1039        // A literal `$$` inside running text is not an inline formula: copy it
1040        // through as plain characters.
1041        if rest.starts_with("$$") {
1042            plain.push_str("$$");
1043            i += 2;
1044            continue;
1045        }
1046        // An inline formula: standalone it re-serializes in display form.
1047        if chars[i] == '$' {
1048            if let Some(end) = find(i + 1, "$") {
1049                if end > i + 1 {
1050                    flush(&mut out, &mut plain, true, after_span);
1051                    let latex: String = chars[i + 1..end].iter().collect();
1052                    out.push((format!("$${latex}$$"), false));
1053                    i = end + 1;
1054                    after_span = true;
1055                    continue;
1056                }
1057            }
1058        }
1059        plain.push(chars[i]);
1060        i += 1;
1061    }
1062    flush(&mut out, &mut plain, false, after_span);
1063    if out.is_empty() {
1064        out.push((unescape_text(md), true));
1065    }
1066    out
1067}
1068
1069/// Split a plain markdown segment at run boundaries the markdown cannot show
1070/// (an underlined run, a `<sub>`/`<sup>` run): when a consecutive window of
1071/// two or more unmarked runs exactly covers the segment, each run is its own
1072/// document item.
1073fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1074    let target = segment.trim();
1075    if target.is_empty() {
1076        return None;
1077    }
1078    let plainish =
1079        |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1080    let fully_plain =
1081        |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1082    let unmarked: Vec<(&str, bool)> = runs
1083        .iter()
1084        .filter(|r| plainish(r))
1085        .map(|r| (r.text.as_str(), fully_plain(r)))
1086        .collect();
1087    for start in 0..unmarked.len() {
1088        let mut rest = target;
1089        let mut taken: Vec<(String, bool)> = Vec::new();
1090        for (t, fully) in &unmarked[start..] {
1091            let t = t.trim();
1092            if t.is_empty() {
1093                continue;
1094            }
1095            match rest.strip_prefix(t) {
1096                Some(r) => {
1097                    taken.push((unescape_text(t), *fully));
1098                    rest = r.trim_start();
1099                    if rest.is_empty() {
1100                        break;
1101                    }
1102                }
1103                None => break,
1104            }
1105        }
1106        if rest.is_empty() && taken.len() >= 2 {
1107            // Consecutive fully-plain pieces (no underline / sub / sup) are one
1108            // annotation in docling's model — `simplify_text_elements` merges
1109            // them joined with a space, so they never split between themselves.
1110            // Only an underline / sub / sup run is a genuine boundary.
1111            let mut merged: Vec<(String, bool)> = Vec::new();
1112            for (t, fully) in taken {
1113                match merged.last_mut() {
1114                    Some((last, true)) if fully => {
1115                        last.push(' ');
1116                        last.push_str(&t);
1117                    }
1118                    _ => merged.push((t, fully)),
1119                }
1120            }
1121            if merged.len() >= 2 {
1122                return Some(merged.into_iter().map(|(t, _)| t).collect());
1123            }
1124            return None;
1125        }
1126    }
1127    None
1128}
1129
1130/// A table cell's text for the triplet serializer. A *rich* cell (one carrying
1131/// block content) is re-serialized the way docling's chunking serializer sees
1132/// it: paragraphs joined with blank lines, a nested table as its own triplet
1133/// sentences, pictures as an empty placeholder. Plain cells use the flat text
1134/// with the markdown image placeholder stripped (the chunking serializer's
1135/// `image_placeholder` is empty).
1136fn cell_chunk_text(t: &Table, anchors: &[Vec<(usize, usize)>], r: usize, c: usize) -> String {
1137    // A rich cell's blocks live on its anchor; every position it covers
1138    // reads them (docling-core#857, #777).
1139    let (ar, ac) = anchors
1140        .get(r)
1141        .and_then(|row| row.get(c))
1142        .copied()
1143        .unwrap_or((r, c));
1144    if let Some(blocks) = t
1145        .cell_blocks
1146        .as_ref()
1147        .and_then(|b| b.get(ar))
1148        .and_then(|row| row.get(ac))
1149        .filter(|b| !b.is_empty())
1150    {
1151        let mut parts: Vec<String> = Vec::new();
1152        for node in blocks.iter() {
1153            let part = block_chunk_text(node);
1154            if !part.is_empty() {
1155                parts.push(part);
1156            }
1157        }
1158        return parts.join("\n\n");
1159    }
1160    let flat = t
1161        .rows
1162        .get(r)
1163        .and_then(|row| row.get(c))
1164        .map(String::as_str)
1165        .unwrap_or("");
1166    unescape_text(flat)
1167        .replace("<!-- image -->", "")
1168        .trim()
1169        .to_string()
1170}
1171
1172/// One block of a rich cell, serialized for chunking.
1173fn block_chunk_text(node: &Node) -> String {
1174    match node {
1175        Node::Paragraph { text } | Node::ListText { text, .. } => unescape_text(text),
1176        Node::LabeledText { .. } => block_chunk_text(&node.labeled_as_paragraph()),
1177        Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1178        Node::Code { text, .. } => crate::markdown::code_fence("", &unescape_text(text)),
1179        Node::Table(inner) => triplet_table_text(inner),
1180        Node::Picture { caption, .. } => caption
1181            .as_deref()
1182            .filter(|c| !c.is_empty())
1183            .map(unescape_text)
1184            .unwrap_or_default(),
1185        Node::ListItem {
1186            ordered,
1187            number,
1188            text,
1189            ..
1190        } => {
1191            let marker = if *ordered {
1192                format!("{number}.")
1193            } else {
1194                "-".to_string()
1195            };
1196            format!("{marker} {}", unescape_text(text))
1197        }
1198        Node::CheckboxItem { checked, text } => {
1199            let mark = if *checked { "- [x] " } else { "- [ ] " };
1200            format!("{mark}{}", unescape_text(text))
1201        }
1202        Node::Heading { text, .. } => unescape_text(text),
1203        Node::Located { inner, .. }
1204        | Node::Prov { inner, .. }
1205        | Node::Track { inner, .. }
1206        | Node::Commented { inner, .. } => block_chunk_text(inner),
1207        Node::Group { layer: Some(_), .. } => String::new(),
1208        Node::Group { children, .. } => children
1209            .iter()
1210            .map(block_chunk_text)
1211            .filter(|s| !s.is_empty())
1212            .collect::<Vec<_>>()
1213            .join("\n"),
1214        _ => String::new(),
1215    }
1216}
1217
1218/// Reverse the model's baked markdown text escaping — same mapping as the JSON
1219/// exporter (docling chunks carry raw text).
1220fn unescape_text(s: &str) -> String {
1221    s.replace("&lt;", "<")
1222        .replace("&gt;", ">")
1223        .replace("&amp;", "&")
1224        .replace("\\_", "_")
1225}
1226
1227// ---------------------------------------------------------------------------
1228// Hybrid chunker
1229// ---------------------------------------------------------------------------
1230
1231/// Token counting for [`HybridChunker`] — docling's `BaseTokenizer`.
1232pub trait ChunkTokenizer {
1233    /// Number of tokens in `text` (no special tokens).
1234    fn count_tokens(&self, text: &str) -> usize;
1235    /// The chunk budget (docling's `max_tokens`, e.g. 256 for MiniLM).
1236    fn max_tokens(&self) -> usize;
1237}
1238
1239/// Tokenization-aware chunker on top of [`HierarchicalChunker`] — docling's
1240/// `HybridChunker` with default parameters (`merge_peers`,
1241/// `repeat_table_header` on; `omit_header_on_overflow` off).
1242pub struct HybridChunker<T: ChunkTokenizer> {
1243    tokenizer: T,
1244    merge_peers: bool,
1245}
1246
1247impl<T: ChunkTokenizer> HybridChunker<T> {
1248    pub fn new(tokenizer: T) -> Self {
1249        Self {
1250            tokenizer,
1251            merge_peers: true,
1252        }
1253    }
1254
1255    /// Disable merging of undersized same-heading neighbours.
1256    pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1257        self.merge_peers = merge_peers;
1258        self
1259    }
1260
1261    pub fn max_tokens(&self) -> usize {
1262        self.tokenizer.max_tokens()
1263    }
1264
1265    /// Chunk the document.
1266    pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1267        let mut chunks = Vec::new();
1268        self.chunk_with(doc, &mut |c| {
1269            chunks.push(c);
1270            true
1271        });
1272        chunks
1273    }
1274
1275    /// Stream the chunks: each hierarchical chunk is split against the token
1276    /// budget as the document walk produces it, and the peer merge flushes a
1277    /// merged chunk to `sink` as soon as its window closes (a chunk with
1278    /// different headings arrives, or the budget fills). A `false` return from
1279    /// `sink` cancels the chunking. [`Self::chunk`] is this with a collecting
1280    /// sink — the chunks and their order are identical.
1281    pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1282        let mut merger = PeerMerger::default();
1283        let mut alive = true;
1284        HierarchicalChunker.chunk_with(doc, &mut |c| {
1285            for split in self.split_by_doc_items(c) {
1286                for chunk in self.split_using_plain_text(split) {
1287                    if !alive {
1288                        return false;
1289                    }
1290                    alive = if self.merge_peers {
1291                        self.merge_push(&mut merger, chunk, sink)
1292                    } else {
1293                        sink(chunk)
1294                    };
1295                }
1296            }
1297            alive
1298        });
1299        if alive {
1300            self.merge_flush(&mut merger, sink);
1301        }
1302    }
1303
1304    fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1305        self.tokenizer.count_tokens(&contextualize(chunk))
1306    }
1307
1308    /// docling's `_make_chunk_from_doc_items`: single-item chunks keep their
1309    /// text; multi-item windows re-join the items' standalone serializations.
1310    fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1311        let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1312        let text = if chunk.doc_items.len() == 1 {
1313            chunk.text.clone()
1314        } else {
1315            doc_items
1316                .iter()
1317                .filter(|it| !it.text.is_empty())
1318                .map(|it| it.text.as_str())
1319                .collect::<Vec<_>>()
1320                .join("\n")
1321        };
1322        DocChunk {
1323            text,
1324            headings: chunk.headings.clone(),
1325            doc_items,
1326        }
1327    }
1328
1329    fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1330        if chunk.doc_items.is_empty() {
1331            return vec![chunk];
1332        }
1333        let max = self.max_tokens();
1334        let num_items = chunk.doc_items.len();
1335        let mut chunks = Vec::new();
1336        let mut window_start = 0usize;
1337        let mut window_end = 0usize; // inclusive
1338        while window_end < num_items {
1339            let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1340            if self.count_chunk_tokens(&new_chunk) <= max {
1341                if window_end < num_items - 1 {
1342                    window_end += 1;
1343                    continue;
1344                } else {
1345                    window_end = num_items; // last loop
1346                }
1347            } else if window_start == window_end {
1348                // One item that doesn't fit: keep it; the plain-text splitter
1349                // takes over.
1350                window_end += 1;
1351                window_start = window_end;
1352            } else {
1353                // The window without its last item fit; flush that and start a
1354                // new window at the current item.
1355                new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1356                window_start = window_end;
1357            }
1358            chunks.push(new_chunk);
1359        }
1360        chunks
1361    }
1362
1363    fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1364        let total = self.count_chunk_tokens(&chunk);
1365        let max = self.max_tokens();
1366        if total <= max {
1367            return vec![chunk];
1368        }
1369        let text_len = self.tokenizer.count_tokens(&chunk.text);
1370        let other_len = total - text_len;
1371        if other_len >= max {
1372            // Headings alone exceed the budget: drop them and retry.
1373            let stripped = DocChunk {
1374                headings: None,
1375                ..chunk
1376            };
1377            return self.split_using_plain_text(stripped);
1378        }
1379        let available = max - other_len;
1380
1381        let segments =
1382            if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1383                // Table: split line-based, repeating headers. The triplet
1384                // serializer has no header lines, so this is a line-preserving
1385                // split of the table text. (docling constructs the line chunker
1386                // with the *tokenizer's* max_tokens — the `max_tokens=available`
1387                // argument is silently dropped by pydantic — so the line budget is
1388                // the full window, not `available`.)
1389                let lines: Vec<String> = chunk
1390                    .text
1391                    .split('\n')
1392                    .filter(|l| !l.trim().is_empty())
1393                    .map(|l| l.to_string())
1394                    .collect();
1395                line_chunk_text(&lines, &self.tokenizer, max)
1396            } else {
1397                semchunk(&chunk.text, available, &self.tokenizer)
1398            };
1399        segments
1400            .into_iter()
1401            .map(|s| DocChunk {
1402                text: s,
1403                headings: chunk.headings.clone(),
1404                doc_items: chunk.doc_items.clone(),
1405            })
1406            .collect()
1407    }
1408
1409    /// One step of docling's `_merge_chunks_with_matching_metadata`, streamed:
1410    /// extend the window with `chunk` when its headings match the window's and
1411    /// the merged window stays within budget, otherwise flush the window to
1412    /// `sink` and start a new one at `chunk`. Returns `false` once the sink
1413    /// cancels.
1414    ///
1415    /// Linear, as docling-core 2.102 has it (docling-core#823, #782): each
1416    /// chunk's text is counted once, the window grows on a running estimate
1417    /// (text tokens, the delimiter's cost, the headings' overhead), an exact
1418    /// count of the merged window runs only where the estimate overflows, and
1419    /// the final window is verified exactly — shrunk back to the largest one
1420    /// that fits when the estimate under-counted, the chunks it gives up
1421    /// starting the next window. Rebuilding and re-tokenizing the whole window
1422    /// for every chunk was quadratic in the window's length.
1423    fn merge_push(
1424        &self,
1425        m: &mut PeerMerger,
1426        chunk: DocChunk,
1427        sink: &mut dyn FnMut(DocChunk) -> bool,
1428    ) -> bool {
1429        self.merge_feed(m, VecDeque::from([(chunk, None)]), sink)
1430    }
1431
1432    /// Feed chunks (with their text token count when already known) to the
1433    /// merge window, closing it whenever a chunk cannot join.
1434    fn merge_feed(
1435        &self,
1436        m: &mut PeerMerger,
1437        mut pending: VecDeque<(DocChunk, Option<usize>)>,
1438        sink: &mut dyn FnMut(DocChunk) -> bool,
1439    ) -> bool {
1440        while let Some((chunk, tokens)) = pending.pop_front() {
1441            let tokens = tokens.unwrap_or_else(|| self.tokenizer.count_tokens(&chunk.text));
1442            if let Err(chunk) = self.merge_extend(m, chunk, tokens) {
1443                let (alive, rest) = self.merge_close(m, sink);
1444                if !alive {
1445                    return false;
1446                }
1447                pending.push_front((chunk, Some(tokens)));
1448                for item in rest.into_iter().rev() {
1449                    pending.push_front(item);
1450                }
1451            }
1452        }
1453        true
1454    }
1455
1456    /// Add `chunk` (of `tokens` text tokens) to the window, or hand it back
1457    /// when its headings differ or the window would overflow.
1458    fn merge_extend(
1459        &self,
1460        m: &mut PeerMerger,
1461        chunk: DocChunk,
1462        tokens: usize,
1463    ) -> Result<(), DocChunk> {
1464        let max = self.max_tokens() as i64;
1465        if m.window.is_empty() {
1466            // The headings' overhead around the joined text is shared by every
1467            // candidate of this window (it may be negative).
1468            m.overhead = self.count_chunk_tokens(&chunk) as i64 - tokens as i64;
1469            m.running = tokens as i64;
1470            m.window.push((chunk, tokens));
1471            return Ok(());
1472        }
1473        if chunk.headings != m.window[0].0.headings {
1474            return Err(chunk);
1475        }
1476        let delim = *m.delim.get_or_insert_with(|| {
1477            // `_estimate_delimiter_tokens`: what joining two texts adds.
1478            let t = &self.tokenizer;
1479            t.count_tokens("a\nb") as i64 - t.count_tokens("a") as i64 - t.count_tokens("b") as i64
1480        });
1481        m.running += tokens as i64 + delim;
1482        if m.running + m.overhead > max {
1483            let exact = self.count_chunk_tokens(&merged_chunk(
1484                m.window.iter().map(|(c, _)| c).chain([&chunk]),
1485            )) as i64;
1486            if exact > max {
1487                return Err(chunk);
1488            }
1489            // The estimate overshot; re-anchor it on the exact count.
1490            m.running = exact - m.overhead;
1491        }
1492        m.window.push((chunk, tokens));
1493        Ok(())
1494    }
1495
1496    /// Close the window: verify it exactly, shrinking it back to the largest
1497    /// prefix that fits, emit that (a single chunk unchanged), and return the
1498    /// chunks given up, which start the next window.
1499    fn merge_close(
1500        &self,
1501        m: &mut PeerMerger,
1502        sink: &mut dyn FnMut(DocChunk) -> bool,
1503    ) -> (bool, Vec<(DocChunk, Option<usize>)>) {
1504        if m.window.is_empty() {
1505            return (true, Vec::new());
1506        }
1507        let max = self.max_tokens();
1508        let mut fit = m.window.len() - 1;
1509        while fit > 0
1510            && self.count_chunk_tokens(&merged_chunk(m.window[..=fit].iter().map(|(c, _)| c))) > max
1511        {
1512            fit -= 1;
1513        }
1514        let rest = m
1515            .window
1516            .split_off(fit + 1)
1517            .into_iter()
1518            .map(|(c, t)| (c, Some(t)))
1519            .collect();
1520        let out = if m.window.len() == 1 {
1521            m.window.pop().expect("single-chunk window").0
1522        } else {
1523            let merged = merged_chunk(m.window.iter().map(|(c, _)| c));
1524            m.window.clear();
1525            merged
1526        };
1527        (sink(out), rest)
1528    }
1529
1530    /// Flush the merge window at the end of the stream (feeding back what a
1531    /// shrunk window gave up). Returns `false` once the sink cancels.
1532    fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1533        while !m.window.is_empty() {
1534            let (alive, rest) = self.merge_close(m, sink);
1535            if !alive || !self.merge_feed(m, rest.into(), sink) {
1536                return false;
1537            }
1538        }
1539        true
1540    }
1541}
1542
1543/// docling's merged peer chunk: the texts joined by `\n`, every chunk's doc
1544/// items, the first chunk's headings.
1545fn merged_chunk<'a>(chunks: impl Iterator<Item = &'a DocChunk> + Clone) -> DocChunk {
1546    let mut first = chunks.clone();
1547    DocChunk {
1548        text: chunks
1549            .clone()
1550            .map(|c| c.text.as_str())
1551            .collect::<Vec<_>>()
1552            .join("\n"),
1553        headings: first.next().and_then(|c| c.headings.clone()),
1554        doc_items: chunks.flat_map(|c| c.doc_items.iter().cloned()).collect(),
1555    }
1556}
1557
1558/// The in-flight peer-merge window of [`HybridChunker::chunk_with`]: the
1559/// window's chunks with their text token counts, the running token estimate
1560/// of the joined text, the window's headings overhead, and the delimiter's
1561/// token cost (measured once).
1562#[derive(Default)]
1563struct PeerMerger {
1564    window: Vec<(DocChunk, usize)>,
1565    running: i64,
1566    overhead: i64,
1567    delim: Option<i64>,
1568}
1569
1570// ---------------------------------------------------------------------------
1571// Line-based token chunking (docling's LineBasedTokenChunker, empty prefix)
1572// ---------------------------------------------------------------------------
1573
1574/// Pack lines into chunks of at most `max_tokens`, splitting a line only when
1575/// it exceeds the budget on its own — docling's `LineBasedTokenChunker
1576/// .chunk_text` with an empty prefix (which is what the triplet table
1577/// serializer yields). Reproduces its exact output, including the `\n` it
1578/// prepends to a carried-over segment of an oversized line.
1579fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1580    let mut chunks: Vec<String> = Vec::new();
1581    let mut current = String::new();
1582    let mut current_len = 0usize;
1583
1584    for line in lines {
1585        let mut remaining: Vec<char> = line.chars().collect();
1586        loop {
1587            let rem_str: String = remaining.iter().collect();
1588            let line_tokens = tok.count_tokens(&rem_str);
1589            let available = max_tokens.saturating_sub(current_len);
1590
1591            if line_tokens <= available {
1592                current.push_str(&rem_str);
1593                current_len += line_tokens;
1594                break;
1595            }
1596            if line_tokens <= max_tokens {
1597                chunks.push(std::mem::take(&mut current));
1598                current_len = 0;
1599                continue;
1600            }
1601            // Too large even for an empty chunk: split off what fits.
1602            let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1603            let mut rest = rest;
1604            if take.is_empty() {
1605                if rest.is_empty() {
1606                    break;
1607                }
1608                take = rest[..1].iter().collect();
1609                rest = rest[1..].to_vec();
1610            }
1611            current.push('\n');
1612            current.push_str(&take);
1613            chunks.push(std::mem::take(&mut current));
1614            current_len = 0;
1615            remaining = rest;
1616        }
1617    }
1618    if !current.is_empty() {
1619        chunks.push(current);
1620    }
1621    chunks
1622}
1623
1624/// Binary-search the longest char-prefix of `text` within `token_limit`
1625/// tokens, preferring to break at the last ASCII space — docling's
1626/// `split_by_token_limit`.
1627fn split_by_token_limit<T: ChunkTokenizer>(
1628    text: &[char],
1629    token_limit: usize,
1630    tok: &T,
1631) -> (String, Vec<char>) {
1632    if token_limit == 0 || text.is_empty() {
1633        return (String::new(), text.to_vec());
1634    }
1635    let full: String = text.iter().collect();
1636    if tok.count_tokens(&full) <= token_limit {
1637        return (full, Vec::new());
1638    }
1639    let (mut lo, mut hi) = (0usize, text.len());
1640    let mut best: Option<usize> = None;
1641    while lo <= hi {
1642        let mid = (lo + hi) / 2;
1643        let head: String = text[..mid].iter().collect();
1644        if tok.count_tokens(&head) <= token_limit {
1645            best = Some(mid);
1646            lo = mid + 1;
1647        } else {
1648            if mid == 0 {
1649                break;
1650            }
1651            hi = mid - 1;
1652        }
1653    }
1654    let mut best_idx = match best {
1655        Some(b) if b > 0 => b,
1656        _ => return (String::new(), text.to_vec()),
1657    };
1658    // Snap back to the last space, if that leaves a non-empty head.
1659    if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1660        if pos > 0 {
1661            best_idx = pos;
1662        }
1663    }
1664    (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1665}
1666
1667// ---------------------------------------------------------------------------
1668// semchunk port (the plain-text splitter HybridChunker delegates to)
1669// ---------------------------------------------------------------------------
1670
1671/// Semantically meaningful non-whitespace splitters, most desirable first.
1672const NON_WS_SPLITTERS: &[&str] = &[
1673    ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1674    "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1675];
1676
1677/// Split `text` into chunks of at most `chunk_size` tokens using the most
1678/// semantically meaningful splitter available — the `semchunk` algorithm
1679/// docling's HybridChunker delegates plain-text splitting to.
1680pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1681    let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1682    let mut counter = |s: &str| -> usize {
1683        if let Some(n) = cache.get(s) {
1684            return *n;
1685        }
1686        let n = tok.count_tokens(s);
1687        cache.insert(s.to_string(), n);
1688        n
1689    };
1690    let chunks = semchunk_rec(text, chunk_size, &mut counter);
1691    // top-level: drop empty / all-whitespace chunks
1692    chunks
1693        .into_iter()
1694        .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1695        .collect()
1696}
1697
1698/// One recursion level of semchunk: split, merge windows back up to size, and
1699/// recurse into oversized splits.
1700fn semchunk_rec(
1701    text: &str,
1702    chunk_size: usize,
1703    counter: &mut dyn FnMut(&str) -> usize,
1704) -> Vec<String> {
1705    let (splitter, splitter_is_ws, splits) = split_text(text);
1706
1707    let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1708    let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1709    cum_lens.push(0usize);
1710    for l in &split_lens {
1711        cum_lens.push(cum_lens.last().unwrap() + l);
1712    }
1713    let num_splits_plus_one = splits.len() + 1;
1714
1715    let mut chunks: Vec<String> = Vec::new();
1716    let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1717
1718    for i in 0..splits.len() {
1719        if skips.contains(&i) {
1720            continue;
1721        }
1722        let split = &splits[i];
1723        if counter(split) > chunk_size {
1724            let inner = semchunk_rec(split, chunk_size, counter);
1725            chunks.extend(inner);
1726        } else {
1727            let (end, merged) = merge_splits(
1728                &splits,
1729                &cum_lens,
1730                chunk_size,
1731                &splitter,
1732                counter,
1733                i,
1734                num_splits_plus_one,
1735            );
1736            for j in (i + 1)..end {
1737                skips.insert(j);
1738            }
1739            chunks.push(merged);
1740        }
1741        // Re-attach a non-whitespace splitter to the last chunk (or emit it as
1742        // its own chunk if it doesn't fit).
1743        let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1744        if !splitter_is_ws && !is_last {
1745            let with_splitter = format!(
1746                "{}{}",
1747                chunks.last().map(String::as_str).unwrap_or(""),
1748                splitter
1749            );
1750            if counter(&with_splitter) <= chunk_size {
1751                if let Some(last) = chunks.last_mut() {
1752                    *last = with_splitter;
1753                } else {
1754                    chunks.push(with_splitter);
1755                }
1756            } else {
1757                chunks.push(splitter.clone());
1758            }
1759        }
1760    }
1761    chunks
1762}
1763
1764/// docling/semchunk's `merge_splits`: extend the window with a cum-length-guided
1765/// binary search until the token budget is hit.
1766fn merge_splits(
1767    splits: &[String],
1768    cum_lens: &[usize],
1769    chunk_size: usize,
1770    splitter: &str,
1771    counter: &mut dyn FnMut(&str) -> usize,
1772    start: usize,
1773    high_init: usize,
1774) -> (usize, String) {
1775    let mut average = 0.2f64;
1776    let mut low = start;
1777    let mut high = high_init;
1778    let offset = cum_lens[start];
1779    let mut target = offset as f64 + (chunk_size as f64 * average);
1780
1781    while low < high {
1782        let i = bisect_left(cum_lens, target, low, high);
1783        let midpoint = i.min(high - 1);
1784        let joined = splits[start..midpoint.max(start)].join(splitter);
1785        let tokens = counter(&joined);
1786        let local_cum = cum_lens[midpoint] - offset;
1787        if local_cum > 0 && tokens > 0 {
1788            average = local_cum as f64 / tokens as f64;
1789            target = offset as f64 + (chunk_size as f64 * average);
1790        }
1791        if tokens > chunk_size {
1792            high = midpoint;
1793        } else {
1794            low = midpoint + 1;
1795        }
1796    }
1797    let end = low - 1;
1798    (end, splits[start..end.max(start)].join(splitter))
1799}
1800
1801fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1802    while low < high {
1803        let mid = (low + high) / 2;
1804        if (sorted[mid] as f64) < target {
1805            low = mid + 1;
1806        } else {
1807            high = mid;
1808        }
1809    }
1810    low
1811}
1812
1813/// semchunk's `_split_text`: pick the most desirable splitter present.
1814fn split_text(text: &str) -> (String, bool, Vec<String>) {
1815    // Longest run of newlines/carriage returns.
1816    if text.contains('\n') || text.contains('\r') {
1817        let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1818        return (splitter.clone(), true, split_on(text, &splitter));
1819    }
1820    // Longest run of tabs.
1821    if text.contains('\t') {
1822        let splitter = longest_run(text, |c| c == '\t');
1823        return (splitter.clone(), true, split_on(text, &splitter));
1824    }
1825    // Longest run of whitespace.
1826    if text.chars().any(char::is_whitespace) {
1827        let splitter = longest_run(text, char::is_whitespace);
1828        if splitter.chars().count() == 1 {
1829            // Prefer a whitespace char preceded by a semantic splitter.
1830            for preceder in NON_WS_SPLITTERS {
1831                if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1832                    return (ws, true, parts);
1833                }
1834            }
1835        }
1836        return (splitter.clone(), true, split_on(text, &splitter));
1837    }
1838    // Most desirable semantic splitter present.
1839    for s in NON_WS_SPLITTERS {
1840        if text.contains(s) {
1841            return (s.to_string(), false, split_on(text, s));
1842        }
1843    }
1844    // No splitter at all: split into characters.
1845    (
1846        String::new(),
1847        true,
1848        text.chars().map(|c| c.to_string()).collect(),
1849    )
1850}
1851
1852/// The longest maximal run of chars matching `pred` (first one wins ties).
1853fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1854    let mut best = String::new();
1855    let mut cur = String::new();
1856    for c in text.chars() {
1857        if pred(c) {
1858            cur.push(c);
1859        } else {
1860            if cur.chars().count() > best.chars().count() {
1861                best = cur.clone();
1862            }
1863            cur.clear();
1864        }
1865    }
1866    if cur.chars().count() > best.chars().count() {
1867        best = cur;
1868    }
1869    best
1870}
1871
1872fn split_on(text: &str, splitter: &str) -> Vec<String> {
1873    text.split(splitter).map(str::to_string).collect()
1874}
1875
1876/// Python: `re.search(rf'{p}(\s)', text)` → the first whitespace char preceded
1877/// by `p`; then `re.split(rf'(?<={p}){s}', text)` — split at every occurrence
1878/// of that whitespace char immediately preceded by `p`.
1879fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1880    let chars: Vec<char> = text.chars().collect();
1881    let p: Vec<char> = preceder.chars().collect();
1882    let mut ws: Option<char> = None;
1883    for i in p.len()..chars.len() {
1884        if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1885            ws = Some(chars[i]);
1886            break;
1887        }
1888    }
1889    let ws = ws?;
1890    let mut parts = Vec::new();
1891    let mut cur = String::new();
1892    let mut i = 0usize;
1893    while i < chars.len() {
1894        if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1895            parts.push(std::mem::take(&mut cur));
1896            i += 1;
1897            continue;
1898        }
1899        cur.push(chars[i]);
1900        i += 1;
1901    }
1902    parts.push(cur);
1903    Some((ws.to_string(), parts))
1904}
1905
1906// ---------------------------------------------------------------------------
1907// HuggingFace tokenizer (feature `chunking`)
1908// ---------------------------------------------------------------------------
1909
1910#[cfg(feature = "chunking")]
1911mod hf {
1912    use super::ChunkTokenizer;
1913
1914    /// Where `scripts/install/download_dependencies.sh` puts the hybrid
1915    /// chunker's default tokenizer (all-MiniLM-L6-v2's `tokenizer.json`),
1916    /// relative to the process's working directory — the same convention as
1917    /// the `.models/` ONNX files.
1918    pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1919
1920    /// Resolve the tokenizer path for the hybrid chunker: an explicit path
1921    /// wins; otherwise fall back to [`DEFAULT_TOKENIZER_PATH`] when it exists
1922    /// on disk. Errors with the download instructions when neither is
1923    /// available.
1924    pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1925        if let Some(p) = explicit {
1926            return Ok(p.to_string());
1927        }
1928        let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1929        if std::path::Path::new(&resolved).exists() {
1930            return Ok(resolved);
1931        }
1932        Err(format!(
1933            "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1934             {DEFAULT_TOKENIZER_PATH} does not exist — run \
1935             scripts/install/download_dependencies.sh (or pass an explicit path)"
1936        ))
1937    }
1938
1939    /// [`ChunkTokenizer`] backed by a HuggingFace `tokenizer.json` — the Rust
1940    /// analogue of docling's `HuggingFaceTokenizer` (whose default is
1941    /// `sentence-transformers/all-MiniLM-L6-v2` with `max_tokens` 256).
1942    pub struct HuggingFaceTokenizer {
1943        tok: tokenizers::Tokenizer,
1944        max_tokens: usize,
1945    }
1946
1947    impl HuggingFaceTokenizer {
1948        /// Load the tokenizer from an explicit path, or from
1949        /// [`DEFAULT_TOKENIZER_PATH`] when `path` is `None` (see
1950        /// [`resolve_tokenizer_path`]).
1951        pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1952            Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1953        }
1954
1955        /// Load a `tokenizer.json`. `max_tokens` is the chunk budget (docling
1956        /// resolves it from the model's `sentence_bert_config.json`; for the
1957        /// default MiniLM model that is 256).
1958        pub fn from_file(
1959            path: impl AsRef<std::path::Path>,
1960            max_tokens: usize,
1961        ) -> Result<Self, String> {
1962            let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1963                .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1964            // Counting must see the full text (docling tokenizes without
1965            // truncation, padding, or special tokens — MiniLM's tokenizer.json
1966            // ships with fixed-length padding enabled, which would make every
1967            // short string count as the padded length).
1968            let _ = tok.with_truncation(None);
1969            tok.with_padding(None);
1970            Ok(Self { tok, max_tokens })
1971        }
1972    }
1973
1974    impl ChunkTokenizer for HuggingFaceTokenizer {
1975        fn count_tokens(&self, text: &str) -> usize {
1976            self.tok
1977                .encode(text, false)
1978                .map(|e| e.get_tokens().len())
1979                .unwrap_or(0)
1980        }
1981        fn max_tokens(&self) -> usize {
1982            self.max_tokens
1983        }
1984    }
1985}
1986
1987#[cfg(feature = "chunking")]
1988pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1989
1990// ---------------------------------------------------------------------------
1991// Window chunker (feature `chunking`) — docling-rag's Markdown window chunker
1992// ---------------------------------------------------------------------------
1993
1994#[cfg(feature = "chunking")]
1995mod window {
1996    use super::DocChunk;
1997    use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1998
1999    /// A contiguous run of body text under a heading path.
2000    #[derive(Debug, Clone, Default)]
2001    pub struct Section {
2002        /// The heading stack in effect for this section, outermost first
2003        /// (e.g. `["Guide", "Setup"]`). Empty for pre-heading / body-only text.
2004        pub heading_path: Vec<String>,
2005        /// The plain words of the section body, markup stripped.
2006        pub words: Vec<String>,
2007    }
2008
2009    impl Section {
2010        /// The heading path rendered as a single context line, e.g.
2011        /// `# Guide > Setup`. Empty string when there is no heading.
2012        pub fn heading_context(&self) -> String {
2013            if self.heading_path.is_empty() {
2014                String::new()
2015            } else {
2016                format!("# {}", self.heading_path.join(" > "))
2017            }
2018        }
2019    }
2020
2021    fn level_index(level: HeadingLevel) -> usize {
2022        match level {
2023            HeadingLevel::H1 => 1,
2024            HeadingLevel::H2 => 2,
2025            HeadingLevel::H3 => 3,
2026            HeadingLevel::H4 => 4,
2027            HeadingLevel::H5 => 5,
2028            HeadingLevel::H6 => 6,
2029        }
2030    }
2031
2032    /// Parse Markdown into heading-bounded sections. A new section starts at
2033    /// every heading; the heading path is maintained as a stack keyed by
2034    /// heading level.
2035    pub fn parse_sections(markdown: &str) -> Vec<Section> {
2036        parse_sections_with_stack(markdown, Vec::new()).0
2037    }
2038
2039    /// [`parse_sections`] with an explicit initial heading stack, returning the
2040    /// final stack — lets a streaming caller carry heading context across
2041    /// pieces. `heading_stack[i]` holds the current heading text at level `i+1`
2042    /// (may be empty when a level was skipped).
2043    pub fn parse_sections_with_stack(
2044        markdown: &str,
2045        initial_stack: Vec<String>,
2046    ) -> (Vec<Section>, Vec<String>) {
2047        let mut heading_stack: Vec<String> = initial_stack;
2048        let mut sections: Vec<Section> = Vec::new();
2049        // Text before the first heading of this piece continues the carried-over
2050        // section, so it keeps the heading path in effect at the split point.
2051        let mut current = Section {
2052            heading_path: heading_stack
2053                .iter()
2054                .filter(|h| !h.is_empty())
2055                .cloned()
2056                .collect(),
2057            words: Vec::new(),
2058        };
2059
2060        let mut in_heading = false;
2061        let mut heading_level = 0usize;
2062        let mut heading_buf = String::new();
2063
2064        let push_words = |section: &mut Section, text: &str| {
2065            for w in text.split_whitespace() {
2066                section.words.push(w.to_string());
2067            }
2068        };
2069
2070        let flush = |sections: &mut Vec<Section>, section: &mut Section| {
2071            if !section.words.is_empty() {
2072                sections.push(std::mem::take(section));
2073            } else {
2074                *section = Section::default();
2075            }
2076        };
2077
2078        for event in Parser::new(markdown) {
2079            match event {
2080                Event::Start(Tag::Heading { level, .. }) => {
2081                    in_heading = true;
2082                    heading_level = level_index(level);
2083                    heading_buf.clear();
2084                }
2085                Event::End(TagEnd::Heading(_)) => {
2086                    in_heading = false;
2087                    // Update the heading stack: set this level, drop anything deeper.
2088                    let idx = heading_level.saturating_sub(1);
2089                    if heading_stack.len() <= idx {
2090                        heading_stack.resize(idx + 1, String::new());
2091                    } else {
2092                        heading_stack.truncate(idx + 1);
2093                    }
2094                    heading_stack[idx] = heading_buf.trim().to_string();
2095                    // A heading begins a new section.
2096                    flush(&mut sections, &mut current);
2097                    current.heading_path = heading_stack
2098                        .iter()
2099                        .filter(|h| !h.is_empty())
2100                        .cloned()
2101                        .collect();
2102                }
2103                Event::Text(t) | Event::Code(t) => {
2104                    if in_heading {
2105                        if !heading_buf.is_empty() {
2106                            heading_buf.push(' ');
2107                        }
2108                        heading_buf.push_str(&t);
2109                    } else {
2110                        push_words(&mut current, &t);
2111                    }
2112                }
2113                // Treat hard/soft breaks and rules as whitespace (words already split).
2114                Event::SoftBreak | Event::HardBreak | Event::Rule => {}
2115                _ => {}
2116            }
2117        }
2118        flush(&mut sections, &mut current);
2119        (sections, heading_stack)
2120    }
2121
2122    /// docling-rag's Markdown **window chunker**: the document is split into
2123    /// heading-bounded [`Section`]s of plain words (markup stripped), and a
2124    /// fixed-size window of [`Self::max_words`] words slides over each section
2125    /// with [`Self::overlap`] fractional overlap. A chunk never crosses a
2126    /// heading boundary; [`Self::contextualize`] prefixes the heading path.
2127    #[derive(Debug, Clone)]
2128    pub struct WindowChunker {
2129        /// Window size in words (docling-rag's default 300).
2130        pub max_words: usize,
2131        /// Fractional overlap between consecutive windows (default 0.05 = 5%).
2132        pub overlap: f32,
2133    }
2134
2135    impl Default for WindowChunker {
2136        fn default() -> Self {
2137            WindowChunker {
2138                max_words: 300,
2139                overlap: 0.05,
2140            }
2141        }
2142    }
2143
2144    impl WindowChunker {
2145        pub fn new(max_words: usize, overlap: f32) -> Self {
2146            WindowChunker { max_words, overlap }
2147        }
2148
2149        /// The window size, kept ≥ 1.
2150        fn word_budget(&self) -> usize {
2151            self.max_words.max(1)
2152        }
2153
2154        /// Number of words carried from one window into the next; capped so
2155        /// the window always advances.
2156        fn overlap_words(&self, budget: usize) -> usize {
2157            let o = (budget as f32 * self.overlap).round() as usize;
2158            o.min(budget.saturating_sub(1))
2159        }
2160
2161        /// Chunk a Markdown document.
2162        pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2163            let mut chunks = Vec::new();
2164            self.chunk_with(markdown, &mut |c| {
2165                chunks.push(c);
2166                true
2167            });
2168            chunks
2169        }
2170
2171        /// Stream the chunks: `sink` receives each window as it is cut, and a
2172        /// `false` return cancels. [`Self::chunk`] is this with a collecting
2173        /// sink — the chunks and their order are identical.
2174        pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2175            let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2176            for section in &sections {
2177                if !self.pack_section(section, sink) {
2178                    return;
2179                }
2180            }
2181        }
2182
2183        /// Slide the window over one completed section, feeding each chunk to
2184        /// `sink`. Returns `false` once the sink cancels — exposed so a
2185        /// streaming caller (docling-rag) can pack sections as they complete.
2186        pub fn pack_section(
2187            &self,
2188            section: &Section,
2189            sink: &mut dyn FnMut(DocChunk) -> bool,
2190        ) -> bool {
2191            let words = &section.words;
2192            if words.is_empty() {
2193                return true;
2194            }
2195            let budget = self.word_budget();
2196            let step = budget - self.overlap_words(budget); // ≥ 1 by construction
2197            let mut start = 0;
2198            loop {
2199                let end = (start + budget).min(words.len());
2200                let chunk = DocChunk {
2201                    text: words[start..end].join(" "),
2202                    headings: (!section.heading_path.is_empty())
2203                        .then(|| section.heading_path.clone()),
2204                    doc_items: Vec::new(),
2205                };
2206                if !sink(chunk) {
2207                    return false;
2208                }
2209                if end >= words.len() {
2210                    return true;
2211                }
2212                start += step;
2213            }
2214        }
2215
2216        /// Render a window chunk for embedding — docling-rag's rendering: the
2217        /// heading path as a `# Outer > Inner` context line, a blank line,
2218        /// then the body (just the body above any heading). Note this differs
2219        /// from the docling chunkers' [`contextualize`](super::contextualize),
2220        /// matching docling-rag instead.
2221        pub fn contextualize(chunk: &DocChunk) -> String {
2222            match &chunk.headings {
2223                Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2224                _ => chunk.text.clone(),
2225            }
2226        }
2227    }
2228
2229    #[cfg(test)]
2230    mod tests {
2231        use super::*;
2232
2233        #[test]
2234        fn splits_on_headings_and_tracks_path() {
2235            let md = "\
2236intro words
2237# Chapter 1
2238para one
2239## Section 1.1
2240para two
2241# Chapter 2
2242para three";
2243            let secs = parse_sections(md);
2244            // pre-heading intro, Chapter 1, Section 1.1, Chapter 2.
2245            assert_eq!(secs.len(), 4);
2246            assert!(secs[0].heading_path.is_empty());
2247            assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2248            assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2249            // A deeper heading is dropped when we return to H1.
2250            assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2251        }
2252
2253        #[test]
2254        fn strips_markup_to_plain_words() {
2255            let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2256            let secs = parse_sections(md);
2257            let words = &secs[0].words;
2258            assert!(words.contains(&"bold".to_string()));
2259            assert!(words.contains(&"code".to_string()));
2260            assert!(words.contains(&"link".to_string()));
2261            // No markdown punctuation survives as its own token.
2262            assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2263        }
2264
2265        #[test]
2266        fn windows_overlap_and_never_cross_headings() {
2267            let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2268            let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2269            let chunker = WindowChunker::new(10, 0.2); // step 8, overlap 2
2270            let chunks = chunker.chunk(&md);
2271            // Section A: 25 words → windows [0..10), [8..18), [16..25).
2272            let a: Vec<_> = chunks
2273                .iter()
2274                .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2275                .collect();
2276            assert_eq!(a.len(), 3);
2277            assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2278            assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2279            assert!(a[2].text.ends_with(" w24"));
2280            // Section B stays its own chunk; nothing crosses the heading.
2281            let b: Vec<_> = chunks
2282                .iter()
2283                .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2284                .collect();
2285            assert_eq!(b.len(), 1);
2286            assert_eq!(b[0].text, "short tail");
2287            assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2288        }
2289
2290        #[test]
2291        fn sink_false_cancels_the_window_walk() {
2292            let md = format!(
2293                "# A\n\n{}\n",
2294                (0..50)
2295                    .map(|i| format!("w{i}"))
2296                    .collect::<Vec<_>>()
2297                    .join(" ")
2298            );
2299            let chunker = WindowChunker::new(10, 0.0);
2300            let mut n = 0;
2301            chunker.chunk_with(&md, &mut |_| {
2302                n += 1;
2303                false
2304            });
2305            assert_eq!(n, 1);
2306        }
2307    }
2308}
2309
2310#[cfg(feature = "chunking")]
2311pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2312
2313#[cfg(test)]
2314mod tests {
2315    use super::*;
2316
2317    /// A whitespace "tokenizer" for algorithm tests.
2318    struct WordTok(usize);
2319    impl ChunkTokenizer for WordTok {
2320        fn count_tokens(&self, text: &str) -> usize {
2321            text.split_whitespace().count()
2322        }
2323        fn max_tokens(&self) -> usize {
2324            self.0
2325        }
2326    }
2327
2328    fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2329        let mut d = DoclingDocument::new("t");
2330        for n in nodes {
2331            d.push(n);
2332        }
2333        d
2334    }
2335
2336    #[test]
2337    fn hierarchical_headings_and_items() {
2338        let doc = doc_with(vec![
2339            Node::Heading {
2340                level: 1,
2341                text: "Title".into(),
2342            },
2343            Node::Paragraph {
2344                text: "Intro".into(),
2345            },
2346            Node::Heading {
2347                level: 2,
2348                text: "Sec".into(),
2349            },
2350            Node::Paragraph {
2351                text: "Body".into(),
2352            },
2353        ]);
2354        let chunks = HierarchicalChunker.chunk(&doc);
2355        assert_eq!(chunks.len(), 2);
2356        assert_eq!(chunks[0].text, "Intro");
2357        assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2358        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2359        assert_eq!(
2360            chunks[1].headings.as_deref(),
2361            Some(&["Title".into(), "Sec".into()][..])
2362        );
2363        assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2364    }
2365
2366    /// A page footer is a JSON text item (furniture layer) the chunker never
2367    /// chunks; the refs of the items after it still match the JSON.
2368    #[test]
2369    fn page_furniture_keeps_refs_aligned_with_json() {
2370        let doc = doc_with(vec![
2371            Node::Paragraph {
2372                text: "Before".into(),
2373            },
2374            Node::PageFurniture {
2375                footer: true,
2376                location: [0, 490, 100, 512],
2377                text: "1.10.2".into(),
2378            },
2379            Node::Paragraph {
2380                text: "After".into(),
2381            },
2382        ]);
2383        let chunks = HierarchicalChunker.chunk(&doc);
2384        assert_eq!(chunks.len(), 2);
2385        assert_eq!(chunks[1].text, "After");
2386        let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2387        assert_eq!(json["texts"][2]["text"], "After");
2388        assert_eq!(chunks[1].doc_items[0].self_ref, "#/texts/2");
2389    }
2390
2391    /// A picture's children are never chunked, but they take `#/texts/N`
2392    /// (and list-group) slots in the JSON — the refs after them must still
2393    /// point at the right items.
2394    #[test]
2395    fn picture_children_are_not_chunked_but_keep_refs_aligned() {
2396        let doc = doc_with(vec![
2397            Node::Picture {
2398                caption: None,
2399                caption_href: None,
2400                image: None,
2401                classification: None,
2402                description: None,
2403                caption_parent: crate::CaptionParent::Item,
2404                caption_location: None,
2405            },
2406            Node::PictureChildren(vec![
2407                Node::Paragraph {
2408                    text: "axis label".into(),
2409                },
2410                Node::ListItem {
2411                    ordered: false,
2412                    number: 0,
2413                    first_in_list: true,
2414                    text: "callout".into(),
2415                    level: 0,
2416                    marker: None,
2417                    location: None,
2418                    dclx: None,
2419                    href: None,
2420                    layer: None,
2421                },
2422            ]),
2423            Node::Paragraph {
2424                text: "Body".into(),
2425            },
2426        ]);
2427        let chunks = HierarchicalChunker.chunk(&doc);
2428        assert_eq!(chunks.len(), 1);
2429        assert_eq!(chunks[0].text, "Body");
2430        let json = doc.export_to_json_value();
2431        assert_eq!(json["texts"][2]["text"], "Body");
2432        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/2");
2433    }
2434
2435    #[test]
2436    fn heading_shadowing_prunes_deeper_levels() {
2437        let doc = doc_with(vec![
2438            Node::Heading {
2439                level: 2,
2440                text: "A".into(),
2441            },
2442            Node::Heading {
2443                level: 3,
2444                text: "A.1".into(),
2445            },
2446            Node::Heading {
2447                level: 2,
2448                text: "B".into(),
2449            },
2450            Node::Paragraph { text: "p".into() },
2451        ]);
2452        let chunks = HierarchicalChunker.chunk(&doc);
2453        assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2454    }
2455
2456    #[test]
2457    fn triplet_table() {
2458        let t = Table {
2459            rows: vec![
2460                vec!["".into(), "Col1".into()],
2461                vec!["Row1".into(), "v".into()],
2462            ],
2463            ..Default::default()
2464        };
2465        assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2466        // Single-column: row 0 is the dataframe header, the first data row
2467        // becomes the column name, the rest the values.
2468        let single = Table {
2469            rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2470            ..Default::default()
2471        };
2472        assert_eq!(triplet_table_text(&single), "a = b");
2473    }
2474
2475    /// The peer merge before docling-core#823 (#782): rebuild and re-count
2476    /// the whole window for every chunk. The linear merge must give the
2477    /// same chunks.
2478    fn quadratic_merge<T: ChunkTokenizer>(
2479        h: &HybridChunker<T>,
2480        chunks: Vec<DocChunk>,
2481    ) -> Vec<DocChunk> {
2482        let mut out = Vec::new();
2483        let mut window: Vec<DocChunk> = Vec::new();
2484        let flush = |window: &mut Vec<DocChunk>, out: &mut Vec<DocChunk>| match window.len() {
2485            0 => {}
2486            1 => out.push(window.pop().unwrap()),
2487            _ => {
2488                out.push(merged_chunk(window.iter()));
2489                window.clear();
2490            }
2491        };
2492        for chunk in chunks {
2493            if window.is_empty() {
2494                window.push(chunk);
2495                continue;
2496            }
2497            let candidate = merged_chunk(window.iter().chain([&chunk]));
2498            if chunk.headings == window[0].headings
2499                && h.count_chunk_tokens(&candidate) <= h.max_tokens()
2500            {
2501                window.push(chunk);
2502            } else {
2503                flush(&mut window, &mut out);
2504                window.push(chunk);
2505            }
2506        }
2507        flush(&mut window, &mut out);
2508        out
2509    }
2510
2511    fn linear_merge<T: ChunkTokenizer>(
2512        h: &HybridChunker<T>,
2513        chunks: Vec<DocChunk>,
2514    ) -> Vec<DocChunk> {
2515        let mut out = Vec::new();
2516        let mut sink = |c| {
2517            out.push(c);
2518            true
2519        };
2520        let mut m = PeerMerger::default();
2521        for c in chunks {
2522            assert!(h.merge_push(&mut m, c, &mut sink));
2523        }
2524        assert!(h.merge_flush(&mut m, &mut sink));
2525        out
2526    }
2527
2528    /// A non-additive "tokenizer": a token per started 4 characters, so the
2529    /// joined text counts differently from the sum of its parts and the
2530    /// delimiter's measured cost is negative — the running estimate is
2531    /// re-anchored and the final window shrunk back.
2532    struct QuadCharTok(usize);
2533    impl ChunkTokenizer for QuadCharTok {
2534        fn count_tokens(&self, text: &str) -> usize {
2535            text.chars().count().div_ceil(4)
2536        }
2537        fn max_tokens(&self) -> usize {
2538            self.0
2539        }
2540    }
2541
2542    /// Pseudo-random chunks: short texts of 1–12 words under one of three
2543    /// heading paths (runs of equal headings, as the walk yields them).
2544    fn random_chunks(seed: u64, n: usize) -> Vec<DocChunk> {
2545        let mut state = seed;
2546        let mut next = move |m: u64| {
2547            state = state
2548                .wrapping_mul(6364136223846793005)
2549                .wrapping_add(1442695040888963407);
2550            (state >> 33) % m
2551        };
2552        let headings = [
2553            None,
2554            Some(vec!["A".to_string()]),
2555            Some(vec!["A".into(), "B".into()]),
2556        ];
2557        let mut current = 0;
2558        (0..n)
2559            .map(|i| {
2560                if next(4) == 0 {
2561                    current = next(3) as usize;
2562                }
2563                let words = 1 + next(12) as usize;
2564                let text = (0..words)
2565                    .map(|w| ["x", "lorem", "ipsum", "dolor", "sit"][(w + i) % 5])
2566                    .collect::<Vec<_>>()
2567                    .join(" ");
2568                DocChunk {
2569                    text,
2570                    headings: headings[current].clone(),
2571                    doc_items: vec![ChunkItem {
2572                        self_ref: format!("#/texts/{i}"),
2573                        kind: ChunkItemKind::Text,
2574                        text: String::new(),
2575                    }],
2576                }
2577            })
2578            .collect()
2579    }
2580
2581    /// #782 (docling-core#823): the linear peer merge gives exactly the
2582    /// chunks of the quadratic one, with an additive and a non-additive
2583    /// tokenizer, over many budgets.
2584    #[test]
2585    fn linear_peer_merge_matches_the_quadratic_one() {
2586        for seed in 0..40 {
2587            let chunks = random_chunks(seed, 60);
2588            for max in [3, 8, 16, 40, 200] {
2589                let h = HybridChunker::new(WordTok(max));
2590                assert_eq!(
2591                    linear_merge(&h, chunks.clone()),
2592                    quadratic_merge(&h, chunks.clone()),
2593                    "words, seed {seed}, max {max}"
2594                );
2595                let h = HybridChunker::new(QuadCharTok(max));
2596                assert_eq!(
2597                    linear_merge(&h, chunks.clone()),
2598                    quadratic_merge(&h, chunks.clone()),
2599                    "chars/4, seed {seed}, max {max}"
2600                );
2601            }
2602        }
2603    }
2604
2605    /// 10k tiny paragraphs at an 8192-token budget — docling-core#823's
2606    /// benchmark. `cargo test -p docling-core --release -- --ignored
2607    /// peer_merge_bench --nocapture` prints both timings.
2608    #[test]
2609    #[ignore]
2610    fn peer_merge_bench() {
2611        let chunks: Vec<DocChunk> = (0..10_000)
2612            .map(|i| DocChunk {
2613                text: format!("Paragraph number {i} is short."),
2614                headings: Some(vec!["Section".into()]),
2615                doc_items: Vec::new(),
2616            })
2617            .collect();
2618        let h = HybridChunker::new(WordTok(8192));
2619        let t = std::time::Instant::now();
2620        let fast = linear_merge(&h, chunks.clone());
2621        let linear = t.elapsed();
2622        let t = std::time::Instant::now();
2623        let slow = quadratic_merge(&h, chunks);
2624        let quadratic = t.elapsed();
2625        assert_eq!(fast, slow);
2626        println!(
2627            "linear {linear:?}, quadratic {quadratic:?}, {} chunks",
2628            fast.len()
2629        );
2630    }
2631
2632    /// A table from first-class cells `(text, row, col, row_span,
2633    /// column_header)`, its grid repeating a spanning cell's text.
2634    fn cell_table(
2635        num_rows: usize,
2636        num_cols: usize,
2637        cells: &[(&str, usize, usize, usize, bool)],
2638    ) -> Table {
2639        let mut rows = vec![vec![String::new(); num_cols]; num_rows];
2640        for &(text, r, c, span, _) in cells {
2641            for row in rows.iter_mut().skip(r).take(span) {
2642                row[c] = text.into();
2643            }
2644        }
2645        Table {
2646            rows,
2647            cells: Some(
2648                cells
2649                    .iter()
2650                    .map(|&(text, r, c, span, ched)| crate::TableCell {
2651                        text: text.into(),
2652                        bbox: None,
2653                        start_row: r,
2654                        start_col: c,
2655                        row_span: span,
2656                        col_span: 1,
2657                        column_header: ched,
2658                        row_header: false,
2659                        row_section: false,
2660                    })
2661                    .collect(),
2662            ),
2663            ..Default::default()
2664        }
2665    }
2666
2667    /// #778 (docling-core#767, 2.102): the Markdown table and the triplet
2668    /// chunk agree on the header rows — upstream's parametrized
2669    /// `test_table_header_rows_in_markdown_and_dataframe`, plus the issue's
2670    /// two tables. Expected texts are docling-core 2.102.0's (compact
2671    /// Markdown, `HierarchicalChunker` chunk text).
2672    #[test]
2673    fn markdown_and_triplets_share_the_header_rows() {
2674        type Case<'a> = (
2675            &'a str,
2676            usize,
2677            usize,
2678            Vec<(&'a str, usize, usize, usize, bool)>,
2679            &'a str,
2680            &'a str,
2681        );
2682        let cases: Vec<Case> = vec![
2683            (
2684                "rows under a row-spanning header stay in the body",
2685                2,
2686                2,
2687                vec![("Price", 0, 0, 1, true), ("Category", 0, 1, 2, true), ("10.00", 1, 0, 1, false)],
2688                "| Price | Category |\n| - | - |\n| 10.00 | Category |",
2689                "10.00, Category = Category",
2690            ),
2691            (
2692                "a row header flagged as column header stays in the body",
2693                3,
2694                3,
2695                vec![
2696                    ("Year", 0, 0, 1, true),
2697                    ("Month", 0, 1, 1, true),
2698                    ("Revenue", 0, 2, 1, true),
2699                    ("2025", 1, 0, 2, true),
2700                    ("January", 1, 1, 1, false),
2701                    ("$134", 1, 2, 1, false),
2702                    ("February", 2, 1, 1, false),
2703                    ("$150", 2, 2, 1, false),
2704                ],
2705                "| Year | Month | Revenue |\n| - | - | - |\n| 2025 | January | $134 |\n| 2025 | February | $150 |",
2706                "2025, Month = January. 2025, Revenue = $134. 2025, Month = February. 2025, Revenue = $150",
2707            ),
2708            (
2709                "stacked header with an unmarked row label",
2710                3,
2711                3,
2712                vec![
2713                    ("", 0, 0, 1, true),
2714                    ("% of Total", 0, 1, 1, true),
2715                    ("% of Total", 0, 2, 1, true),
2716                    ("class label", 1, 0, 1, false),
2717                    ("Train", 1, 1, 1, true),
2718                    ("Test", 1, 2, 1, true),
2719                    ("Caption", 2, 0, 1, false),
2720                    ("2.5", 2, 1, 1, false),
2721                    ("2.4", 2, 2, 1, false),
2722                ],
2723                "| class label | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.5 | 2.4 |",
2724                "Caption, % of Total.Train = 2.5. Caption, % of Total.Test = 2.4",
2725            ),
2726            (
2727                "a partially flagged first row stays the header",
2728                2,
2729                2,
2730                vec![("Name", 0, 0, 1, true), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2731                "| Name | Value |\n| - | - |\n| A | 1 |",
2732                "A, Value = 1",
2733            ),
2734            (
2735                "no flags: Markdown promotes row 0, triplets do not",
2736                2,
2737                2,
2738                vec![("Name", 0, 0, 1, false), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2739                "| Name | Value |\n| - | - |\n| A | 1 |",
2740                "Name, 1 = Value. A, 1 = 1",
2741            ),
2742            (
2743                "row headers flagged as column headers stay in the body",
2744                2,
2745                2,
2746                vec![("2025", 0, 0, 1, true), ("$134", 0, 1, 1, false), ("2026", 1, 0, 1, true), ("$150", 1, 1, 1, false)],
2747                "|  |  |\n| - | - |\n| 2025 | $134 |\n| 2026 | $150 |",
2748                "2025, 1 = $134. 2026, 1 = $150",
2749            ),
2750            (
2751                "a form with flagged first-column labels is all data",
2752                3,
2753                2,
2754                vec![
2755                    ("Name", 0, 0, 1, true),
2756                    ("Bob", 0, 1, 1, false),
2757                    ("Age", 1, 0, 1, true),
2758                    ("42", 1, 1, 1, false),
2759                    ("City", 2, 0, 1, true),
2760                    ("Paris", 2, 1, 1, false),
2761                ],
2762                "|  |  |\n| - | - |\n| Name | Bob |\n| Age | 42 |\n| City | Paris |",
2763                "Name, 1 = Bob. Age, 1 = 42. City, 1 = Paris",
2764            ),
2765            (
2766                "a partly flagged row 0 above a stacked header row",
2767                3,
2768                3,
2769                vec![
2770                    ("Region", 0, 0, 1, true),
2771                    ("2025", 0, 1, 1, false),
2772                    ("", 0, 2, 1, false),
2773                    ("", 1, 0, 1, false),
2774                    ("Q1", 1, 1, 1, true),
2775                    ("Q2", 1, 2, 1, true),
2776                    ("North", 2, 0, 1, false),
2777                    ("1", 2, 1, 1, false),
2778                    ("2", 2, 2, 1, false),
2779                ],
2780                "| Region | 2025 - Q1 | Q2 |\n| - | - | - |\n| North | 1 | 2 |",
2781                "North, 2025.Q1 = 1. North, Q2 = 2",
2782            ),
2783        ];
2784        for (name, rows, cols, cells, md, triplets) in cases {
2785            let t = cell_table(rows, cols, &cells);
2786            assert_eq!(
2787                crate::markdown::render_table(&t, true),
2788                md,
2789                "{name}: markdown"
2790            );
2791            assert_eq!(triplet_table_text(&t), triplets, "{name}: triplets");
2792        }
2793    }
2794
2795    /// #777 (docling-core#857, 2.102): a rich header cell spanning two
2796    /// columns repeats in both — upstream's `col_span_rich_table_doc`. Its
2797    /// blocks sit on the anchor only and the covered position's flat text is
2798    /// blank, so LaTeX and the triplets must resolve the anchor themselves.
2799    #[test]
2800    fn a_column_spanning_rich_cell_repeats_in_every_column() {
2801        let mut t = cell_table(
2802            2,
2803            3,
2804            &[
2805                ("", 0, 0, 1, true),
2806                ("Partner", 0, 1, 1, true),
2807                ("Status", 1, 0, 1, false),
2808                ("Done", 1, 1, 1, false),
2809                ("Open", 1, 2, 1, false),
2810            ],
2811        );
2812        if let Some(cells) = t.cells.as_mut() {
2813            cells[1].col_span = 2;
2814        }
2815        let mut blocks = vec![vec![Vec::new(); 3]; 2];
2816        blocks[0][1] = vec![Node::Paragraph {
2817            text: "Partner".into(),
2818        }];
2819        t.cell_blocks = Some(blocks);
2820        assert_eq!(
2821            t.rows[0][2], "",
2822            "the covered position carries no text of its own"
2823        );
2824        assert_eq!(
2825            triplet_table_text(&t),
2826            "Status, Partner = Done. Status, Partner = Open"
2827        );
2828        let latex = crate::latex::render_table(&t);
2829        assert!(
2830            latex.contains(" & Partner & Partner \\\\ \\hline\nStatus & Done & Open \\\\ \\hline"),
2831            "{latex}"
2832        );
2833        // Markdown reads the flat text, which the backends now fill.
2834        t.rows[0][2] = "Partner".into();
2835        assert_eq!(
2836            crate::markdown::render_table(&t, true),
2837            "|  | Partner | Partner |\n| - | - | - |\n| Status | Done | Open |"
2838        );
2839    }
2840
2841    #[test]
2842    fn hybrid_merges_small_peers_and_splits_large() {
2843        let doc = doc_with(vec![
2844            Node::Heading {
2845                level: 2,
2846                text: "S".into(),
2847            },
2848            Node::Paragraph { text: "a b".into() },
2849            Node::Paragraph { text: "c d".into() },
2850        ]);
2851        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2852        assert_eq!(chunks.len(), 1, "peers under one heading merge");
2853        assert_eq!(chunks[0].text, "a b\nc d");
2854
2855        let long = "w ".repeat(40).trim().to_string();
2856        let doc = doc_with(vec![Node::Paragraph { text: long }]);
2857        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2858        assert!(chunks.len() > 1, "oversized paragraph splits");
2859        for c in &chunks {
2860            assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2861        }
2862    }
2863
2864    #[test]
2865    fn semchunk_prefers_newlines_then_sentences() {
2866        let tok = WordTok(4);
2867        let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2868        assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2869    }
2870}