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 = format!("```\n{}\n```", 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                    flush(&mut out, &mut plain, true, after_span);
976                    out.push((
977                        unescape_text(&chars[i..=endp].iter().collect::<String>()),
978                        false,
979                    ));
980                    i = endp + 1;
981                    after_span = true;
982                    continue;
983                }
984            }
985        }
986        // A formatted span; longest markers first. An inline code span is a
987        // code item in docling's model, whose standalone form is a fenced block.
988        let mut matched = false;
989        for marker in ["***", "**", "*", "~~", "`"] {
990            if rest.starts_with(marker) {
991                let mlen = marker.chars().count();
992                if let Some(end) = find(i + mlen, marker) {
993                    // A whitespace-only span (`* *` from a literal asterisk in
994                    // running text next to a real italic) is not a docling run:
995                    // treat the marker as plain text.
996                    let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
997                    if end > i + mlen && !inner_blank {
998                        flush(&mut out, &mut plain, true, after_span);
999                        if marker == "`" {
1000                            let inner: String = chars[i + 1..end].iter().collect();
1001                            out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
1002                        } else {
1003                            out.push((
1004                                unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
1005                                false,
1006                            ));
1007                        }
1008                        i = end + mlen;
1009                        after_span = true;
1010                        matched = true;
1011                    }
1012                }
1013                break;
1014            }
1015        }
1016        if matched {
1017            continue;
1018        }
1019        // A literal `$$` inside running text is not an inline formula: copy it
1020        // through as plain characters.
1021        if rest.starts_with("$$") {
1022            plain.push_str("$$");
1023            i += 2;
1024            continue;
1025        }
1026        // An inline formula: standalone it re-serializes in display form.
1027        if chars[i] == '$' {
1028            if let Some(end) = find(i + 1, "$") {
1029                if end > i + 1 {
1030                    flush(&mut out, &mut plain, true, after_span);
1031                    let latex: String = chars[i + 1..end].iter().collect();
1032                    out.push((format!("$${latex}$$"), false));
1033                    i = end + 1;
1034                    after_span = true;
1035                    continue;
1036                }
1037            }
1038        }
1039        plain.push(chars[i]);
1040        i += 1;
1041    }
1042    flush(&mut out, &mut plain, false, after_span);
1043    if out.is_empty() {
1044        out.push((unescape_text(md), true));
1045    }
1046    out
1047}
1048
1049/// Split a plain markdown segment at run boundaries the markdown cannot show
1050/// (an underlined run, a `<sub>`/`<sup>` run): when a consecutive window of
1051/// two or more unmarked runs exactly covers the segment, each run is its own
1052/// document item.
1053fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1054    let target = segment.trim();
1055    if target.is_empty() {
1056        return None;
1057    }
1058    let plainish =
1059        |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1060    let fully_plain =
1061        |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1062    let unmarked: Vec<(&str, bool)> = runs
1063        .iter()
1064        .filter(|r| plainish(r))
1065        .map(|r| (r.text.as_str(), fully_plain(r)))
1066        .collect();
1067    for start in 0..unmarked.len() {
1068        let mut rest = target;
1069        let mut taken: Vec<(String, bool)> = Vec::new();
1070        for (t, fully) in &unmarked[start..] {
1071            let t = t.trim();
1072            if t.is_empty() {
1073                continue;
1074            }
1075            match rest.strip_prefix(t) {
1076                Some(r) => {
1077                    taken.push((unescape_text(t), *fully));
1078                    rest = r.trim_start();
1079                    if rest.is_empty() {
1080                        break;
1081                    }
1082                }
1083                None => break,
1084            }
1085        }
1086        if rest.is_empty() && taken.len() >= 2 {
1087            // Consecutive fully-plain pieces (no underline / sub / sup) are one
1088            // annotation in docling's model — `simplify_text_elements` merges
1089            // them joined with a space, so they never split between themselves.
1090            // Only an underline / sub / sup run is a genuine boundary.
1091            let mut merged: Vec<(String, bool)> = Vec::new();
1092            for (t, fully) in taken {
1093                match merged.last_mut() {
1094                    Some((last, true)) if fully => {
1095                        last.push(' ');
1096                        last.push_str(&t);
1097                    }
1098                    _ => merged.push((t, fully)),
1099                }
1100            }
1101            if merged.len() >= 2 {
1102                return Some(merged.into_iter().map(|(t, _)| t).collect());
1103            }
1104            return None;
1105        }
1106    }
1107    None
1108}
1109
1110/// A table cell's text for the triplet serializer. A *rich* cell (one carrying
1111/// block content) is re-serialized the way docling's chunking serializer sees
1112/// it: paragraphs joined with blank lines, a nested table as its own triplet
1113/// sentences, pictures as an empty placeholder. Plain cells use the flat text
1114/// with the markdown image placeholder stripped (the chunking serializer's
1115/// `image_placeholder` is empty).
1116fn cell_chunk_text(t: &Table, anchors: &[Vec<(usize, usize)>], r: usize, c: usize) -> String {
1117    // A rich cell's blocks live on its anchor; every position it covers
1118    // reads them (docling-core#857, #777).
1119    let (ar, ac) = anchors
1120        .get(r)
1121        .and_then(|row| row.get(c))
1122        .copied()
1123        .unwrap_or((r, c));
1124    if let Some(blocks) = t
1125        .cell_blocks
1126        .as_ref()
1127        .and_then(|b| b.get(ar))
1128        .and_then(|row| row.get(ac))
1129        .filter(|b| !b.is_empty())
1130    {
1131        let mut parts: Vec<String> = Vec::new();
1132        for node in blocks.iter() {
1133            let part = block_chunk_text(node);
1134            if !part.is_empty() {
1135                parts.push(part);
1136            }
1137        }
1138        return parts.join("\n\n");
1139    }
1140    let flat = t
1141        .rows
1142        .get(r)
1143        .and_then(|row| row.get(c))
1144        .map(String::as_str)
1145        .unwrap_or("");
1146    unescape_text(flat)
1147        .replace("<!-- image -->", "")
1148        .trim()
1149        .to_string()
1150}
1151
1152/// One block of a rich cell, serialized for chunking.
1153fn block_chunk_text(node: &Node) -> String {
1154    match node {
1155        Node::Paragraph { text } | Node::ListText { text, .. } => unescape_text(text),
1156        Node::LabeledText { .. } => block_chunk_text(&node.labeled_as_paragraph()),
1157        Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1158        Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1159        Node::Table(inner) => triplet_table_text(inner),
1160        Node::Picture { caption, .. } => caption
1161            .as_deref()
1162            .filter(|c| !c.is_empty())
1163            .map(unescape_text)
1164            .unwrap_or_default(),
1165        Node::ListItem {
1166            ordered,
1167            number,
1168            text,
1169            ..
1170        } => {
1171            let marker = if *ordered {
1172                format!("{number}.")
1173            } else {
1174                "-".to_string()
1175            };
1176            format!("{marker} {}", unescape_text(text))
1177        }
1178        Node::CheckboxItem { checked, text } => {
1179            let mark = if *checked { "- [x] " } else { "- [ ] " };
1180            format!("{mark}{}", unescape_text(text))
1181        }
1182        Node::Heading { text, .. } => unescape_text(text),
1183        Node::Located { inner, .. }
1184        | Node::Prov { inner, .. }
1185        | Node::Track { inner, .. }
1186        | Node::Commented { inner, .. } => block_chunk_text(inner),
1187        Node::Group { layer: Some(_), .. } => String::new(),
1188        Node::Group { children, .. } => children
1189            .iter()
1190            .map(block_chunk_text)
1191            .filter(|s| !s.is_empty())
1192            .collect::<Vec<_>>()
1193            .join("\n"),
1194        _ => String::new(),
1195    }
1196}
1197
1198/// Reverse the model's baked markdown text escaping — same mapping as the JSON
1199/// exporter (docling chunks carry raw text).
1200fn unescape_text(s: &str) -> String {
1201    s.replace("&lt;", "<")
1202        .replace("&gt;", ">")
1203        .replace("&amp;", "&")
1204        .replace("\\_", "_")
1205}
1206
1207// ---------------------------------------------------------------------------
1208// Hybrid chunker
1209// ---------------------------------------------------------------------------
1210
1211/// Token counting for [`HybridChunker`] — docling's `BaseTokenizer`.
1212pub trait ChunkTokenizer {
1213    /// Number of tokens in `text` (no special tokens).
1214    fn count_tokens(&self, text: &str) -> usize;
1215    /// The chunk budget (docling's `max_tokens`, e.g. 256 for MiniLM).
1216    fn max_tokens(&self) -> usize;
1217}
1218
1219/// Tokenization-aware chunker on top of [`HierarchicalChunker`] — docling's
1220/// `HybridChunker` with default parameters (`merge_peers`,
1221/// `repeat_table_header` on; `omit_header_on_overflow` off).
1222pub struct HybridChunker<T: ChunkTokenizer> {
1223    tokenizer: T,
1224    merge_peers: bool,
1225}
1226
1227impl<T: ChunkTokenizer> HybridChunker<T> {
1228    pub fn new(tokenizer: T) -> Self {
1229        Self {
1230            tokenizer,
1231            merge_peers: true,
1232        }
1233    }
1234
1235    /// Disable merging of undersized same-heading neighbours.
1236    pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1237        self.merge_peers = merge_peers;
1238        self
1239    }
1240
1241    pub fn max_tokens(&self) -> usize {
1242        self.tokenizer.max_tokens()
1243    }
1244
1245    /// Chunk the document.
1246    pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1247        let mut chunks = Vec::new();
1248        self.chunk_with(doc, &mut |c| {
1249            chunks.push(c);
1250            true
1251        });
1252        chunks
1253    }
1254
1255    /// Stream the chunks: each hierarchical chunk is split against the token
1256    /// budget as the document walk produces it, and the peer merge flushes a
1257    /// merged chunk to `sink` as soon as its window closes (a chunk with
1258    /// different headings arrives, or the budget fills). A `false` return from
1259    /// `sink` cancels the chunking. [`Self::chunk`] is this with a collecting
1260    /// sink — the chunks and their order are identical.
1261    pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1262        let mut merger = PeerMerger::default();
1263        let mut alive = true;
1264        HierarchicalChunker.chunk_with(doc, &mut |c| {
1265            for split in self.split_by_doc_items(c) {
1266                for chunk in self.split_using_plain_text(split) {
1267                    if !alive {
1268                        return false;
1269                    }
1270                    alive = if self.merge_peers {
1271                        self.merge_push(&mut merger, chunk, sink)
1272                    } else {
1273                        sink(chunk)
1274                    };
1275                }
1276            }
1277            alive
1278        });
1279        if alive {
1280            self.merge_flush(&mut merger, sink);
1281        }
1282    }
1283
1284    fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1285        self.tokenizer.count_tokens(&contextualize(chunk))
1286    }
1287
1288    /// docling's `_make_chunk_from_doc_items`: single-item chunks keep their
1289    /// text; multi-item windows re-join the items' standalone serializations.
1290    fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1291        let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1292        let text = if chunk.doc_items.len() == 1 {
1293            chunk.text.clone()
1294        } else {
1295            doc_items
1296                .iter()
1297                .filter(|it| !it.text.is_empty())
1298                .map(|it| it.text.as_str())
1299                .collect::<Vec<_>>()
1300                .join("\n")
1301        };
1302        DocChunk {
1303            text,
1304            headings: chunk.headings.clone(),
1305            doc_items,
1306        }
1307    }
1308
1309    fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1310        if chunk.doc_items.is_empty() {
1311            return vec![chunk];
1312        }
1313        let max = self.max_tokens();
1314        let num_items = chunk.doc_items.len();
1315        let mut chunks = Vec::new();
1316        let mut window_start = 0usize;
1317        let mut window_end = 0usize; // inclusive
1318        while window_end < num_items {
1319            let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1320            if self.count_chunk_tokens(&new_chunk) <= max {
1321                if window_end < num_items - 1 {
1322                    window_end += 1;
1323                    continue;
1324                } else {
1325                    window_end = num_items; // last loop
1326                }
1327            } else if window_start == window_end {
1328                // One item that doesn't fit: keep it; the plain-text splitter
1329                // takes over.
1330                window_end += 1;
1331                window_start = window_end;
1332            } else {
1333                // The window without its last item fit; flush that and start a
1334                // new window at the current item.
1335                new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1336                window_start = window_end;
1337            }
1338            chunks.push(new_chunk);
1339        }
1340        chunks
1341    }
1342
1343    fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1344        let total = self.count_chunk_tokens(&chunk);
1345        let max = self.max_tokens();
1346        if total <= max {
1347            return vec![chunk];
1348        }
1349        let text_len = self.tokenizer.count_tokens(&chunk.text);
1350        let other_len = total - text_len;
1351        if other_len >= max {
1352            // Headings alone exceed the budget: drop them and retry.
1353            let stripped = DocChunk {
1354                headings: None,
1355                ..chunk
1356            };
1357            return self.split_using_plain_text(stripped);
1358        }
1359        let available = max - other_len;
1360
1361        let segments =
1362            if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1363                // Table: split line-based, repeating headers. The triplet
1364                // serializer has no header lines, so this is a line-preserving
1365                // split of the table text. (docling constructs the line chunker
1366                // with the *tokenizer's* max_tokens — the `max_tokens=available`
1367                // argument is silently dropped by pydantic — so the line budget is
1368                // the full window, not `available`.)
1369                let lines: Vec<String> = chunk
1370                    .text
1371                    .split('\n')
1372                    .filter(|l| !l.trim().is_empty())
1373                    .map(|l| l.to_string())
1374                    .collect();
1375                line_chunk_text(&lines, &self.tokenizer, max)
1376            } else {
1377                semchunk(&chunk.text, available, &self.tokenizer)
1378            };
1379        segments
1380            .into_iter()
1381            .map(|s| DocChunk {
1382                text: s,
1383                headings: chunk.headings.clone(),
1384                doc_items: chunk.doc_items.clone(),
1385            })
1386            .collect()
1387    }
1388
1389    /// One step of docling's `_merge_chunks_with_matching_metadata`, streamed:
1390    /// extend the window with `chunk` when its headings match the window's and
1391    /// the merged window stays within budget, otherwise flush the window to
1392    /// `sink` and start a new one at `chunk`. Returns `false` once the sink
1393    /// cancels.
1394    ///
1395    /// Linear, as docling-core 2.102 has it (docling-core#823, #782): each
1396    /// chunk's text is counted once, the window grows on a running estimate
1397    /// (text tokens, the delimiter's cost, the headings' overhead), an exact
1398    /// count of the merged window runs only where the estimate overflows, and
1399    /// the final window is verified exactly — shrunk back to the largest one
1400    /// that fits when the estimate under-counted, the chunks it gives up
1401    /// starting the next window. Rebuilding and re-tokenizing the whole window
1402    /// for every chunk was quadratic in the window's length.
1403    fn merge_push(
1404        &self,
1405        m: &mut PeerMerger,
1406        chunk: DocChunk,
1407        sink: &mut dyn FnMut(DocChunk) -> bool,
1408    ) -> bool {
1409        self.merge_feed(m, VecDeque::from([(chunk, None)]), sink)
1410    }
1411
1412    /// Feed chunks (with their text token count when already known) to the
1413    /// merge window, closing it whenever a chunk cannot join.
1414    fn merge_feed(
1415        &self,
1416        m: &mut PeerMerger,
1417        mut pending: VecDeque<(DocChunk, Option<usize>)>,
1418        sink: &mut dyn FnMut(DocChunk) -> bool,
1419    ) -> bool {
1420        while let Some((chunk, tokens)) = pending.pop_front() {
1421            let tokens = tokens.unwrap_or_else(|| self.tokenizer.count_tokens(&chunk.text));
1422            if let Err(chunk) = self.merge_extend(m, chunk, tokens) {
1423                let (alive, rest) = self.merge_close(m, sink);
1424                if !alive {
1425                    return false;
1426                }
1427                pending.push_front((chunk, Some(tokens)));
1428                for item in rest.into_iter().rev() {
1429                    pending.push_front(item);
1430                }
1431            }
1432        }
1433        true
1434    }
1435
1436    /// Add `chunk` (of `tokens` text tokens) to the window, or hand it back
1437    /// when its headings differ or the window would overflow.
1438    fn merge_extend(
1439        &self,
1440        m: &mut PeerMerger,
1441        chunk: DocChunk,
1442        tokens: usize,
1443    ) -> Result<(), DocChunk> {
1444        let max = self.max_tokens() as i64;
1445        if m.window.is_empty() {
1446            // The headings' overhead around the joined text is shared by every
1447            // candidate of this window (it may be negative).
1448            m.overhead = self.count_chunk_tokens(&chunk) as i64 - tokens as i64;
1449            m.running = tokens as i64;
1450            m.window.push((chunk, tokens));
1451            return Ok(());
1452        }
1453        if chunk.headings != m.window[0].0.headings {
1454            return Err(chunk);
1455        }
1456        let delim = *m.delim.get_or_insert_with(|| {
1457            // `_estimate_delimiter_tokens`: what joining two texts adds.
1458            let t = &self.tokenizer;
1459            t.count_tokens("a\nb") as i64 - t.count_tokens("a") as i64 - t.count_tokens("b") as i64
1460        });
1461        m.running += tokens as i64 + delim;
1462        if m.running + m.overhead > max {
1463            let exact = self.count_chunk_tokens(&merged_chunk(
1464                m.window.iter().map(|(c, _)| c).chain([&chunk]),
1465            )) as i64;
1466            if exact > max {
1467                return Err(chunk);
1468            }
1469            // The estimate overshot; re-anchor it on the exact count.
1470            m.running = exact - m.overhead;
1471        }
1472        m.window.push((chunk, tokens));
1473        Ok(())
1474    }
1475
1476    /// Close the window: verify it exactly, shrinking it back to the largest
1477    /// prefix that fits, emit that (a single chunk unchanged), and return the
1478    /// chunks given up, which start the next window.
1479    fn merge_close(
1480        &self,
1481        m: &mut PeerMerger,
1482        sink: &mut dyn FnMut(DocChunk) -> bool,
1483    ) -> (bool, Vec<(DocChunk, Option<usize>)>) {
1484        if m.window.is_empty() {
1485            return (true, Vec::new());
1486        }
1487        let max = self.max_tokens();
1488        let mut fit = m.window.len() - 1;
1489        while fit > 0
1490            && self.count_chunk_tokens(&merged_chunk(m.window[..=fit].iter().map(|(c, _)| c))) > max
1491        {
1492            fit -= 1;
1493        }
1494        let rest = m
1495            .window
1496            .split_off(fit + 1)
1497            .into_iter()
1498            .map(|(c, t)| (c, Some(t)))
1499            .collect();
1500        let out = if m.window.len() == 1 {
1501            m.window.pop().expect("single-chunk window").0
1502        } else {
1503            let merged = merged_chunk(m.window.iter().map(|(c, _)| c));
1504            m.window.clear();
1505            merged
1506        };
1507        (sink(out), rest)
1508    }
1509
1510    /// Flush the merge window at the end of the stream (feeding back what a
1511    /// shrunk window gave up). Returns `false` once the sink cancels.
1512    fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1513        while !m.window.is_empty() {
1514            let (alive, rest) = self.merge_close(m, sink);
1515            if !alive || !self.merge_feed(m, rest.into(), sink) {
1516                return false;
1517            }
1518        }
1519        true
1520    }
1521}
1522
1523/// docling's merged peer chunk: the texts joined by `\n`, every chunk's doc
1524/// items, the first chunk's headings.
1525fn merged_chunk<'a>(chunks: impl Iterator<Item = &'a DocChunk> + Clone) -> DocChunk {
1526    let mut first = chunks.clone();
1527    DocChunk {
1528        text: chunks
1529            .clone()
1530            .map(|c| c.text.as_str())
1531            .collect::<Vec<_>>()
1532            .join("\n"),
1533        headings: first.next().and_then(|c| c.headings.clone()),
1534        doc_items: chunks.flat_map(|c| c.doc_items.iter().cloned()).collect(),
1535    }
1536}
1537
1538/// The in-flight peer-merge window of [`HybridChunker::chunk_with`]: the
1539/// window's chunks with their text token counts, the running token estimate
1540/// of the joined text, the window's headings overhead, and the delimiter's
1541/// token cost (measured once).
1542#[derive(Default)]
1543struct PeerMerger {
1544    window: Vec<(DocChunk, usize)>,
1545    running: i64,
1546    overhead: i64,
1547    delim: Option<i64>,
1548}
1549
1550// ---------------------------------------------------------------------------
1551// Line-based token chunking (docling's LineBasedTokenChunker, empty prefix)
1552// ---------------------------------------------------------------------------
1553
1554/// Pack lines into chunks of at most `max_tokens`, splitting a line only when
1555/// it exceeds the budget on its own — docling's `LineBasedTokenChunker
1556/// .chunk_text` with an empty prefix (which is what the triplet table
1557/// serializer yields). Reproduces its exact output, including the `\n` it
1558/// prepends to a carried-over segment of an oversized line.
1559fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1560    let mut chunks: Vec<String> = Vec::new();
1561    let mut current = String::new();
1562    let mut current_len = 0usize;
1563
1564    for line in lines {
1565        let mut remaining: Vec<char> = line.chars().collect();
1566        loop {
1567            let rem_str: String = remaining.iter().collect();
1568            let line_tokens = tok.count_tokens(&rem_str);
1569            let available = max_tokens.saturating_sub(current_len);
1570
1571            if line_tokens <= available {
1572                current.push_str(&rem_str);
1573                current_len += line_tokens;
1574                break;
1575            }
1576            if line_tokens <= max_tokens {
1577                chunks.push(std::mem::take(&mut current));
1578                current_len = 0;
1579                continue;
1580            }
1581            // Too large even for an empty chunk: split off what fits.
1582            let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1583            let mut rest = rest;
1584            if take.is_empty() {
1585                if rest.is_empty() {
1586                    break;
1587                }
1588                take = rest[..1].iter().collect();
1589                rest = rest[1..].to_vec();
1590            }
1591            current.push('\n');
1592            current.push_str(&take);
1593            chunks.push(std::mem::take(&mut current));
1594            current_len = 0;
1595            remaining = rest;
1596        }
1597    }
1598    if !current.is_empty() {
1599        chunks.push(current);
1600    }
1601    chunks
1602}
1603
1604/// Binary-search the longest char-prefix of `text` within `token_limit`
1605/// tokens, preferring to break at the last ASCII space — docling's
1606/// `split_by_token_limit`.
1607fn split_by_token_limit<T: ChunkTokenizer>(
1608    text: &[char],
1609    token_limit: usize,
1610    tok: &T,
1611) -> (String, Vec<char>) {
1612    if token_limit == 0 || text.is_empty() {
1613        return (String::new(), text.to_vec());
1614    }
1615    let full: String = text.iter().collect();
1616    if tok.count_tokens(&full) <= token_limit {
1617        return (full, Vec::new());
1618    }
1619    let (mut lo, mut hi) = (0usize, text.len());
1620    let mut best: Option<usize> = None;
1621    while lo <= hi {
1622        let mid = (lo + hi) / 2;
1623        let head: String = text[..mid].iter().collect();
1624        if tok.count_tokens(&head) <= token_limit {
1625            best = Some(mid);
1626            lo = mid + 1;
1627        } else {
1628            if mid == 0 {
1629                break;
1630            }
1631            hi = mid - 1;
1632        }
1633    }
1634    let mut best_idx = match best {
1635        Some(b) if b > 0 => b,
1636        _ => return (String::new(), text.to_vec()),
1637    };
1638    // Snap back to the last space, if that leaves a non-empty head.
1639    if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1640        if pos > 0 {
1641            best_idx = pos;
1642        }
1643    }
1644    (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1645}
1646
1647// ---------------------------------------------------------------------------
1648// semchunk port (the plain-text splitter HybridChunker delegates to)
1649// ---------------------------------------------------------------------------
1650
1651/// Semantically meaningful non-whitespace splitters, most desirable first.
1652const NON_WS_SPLITTERS: &[&str] = &[
1653    ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1654    "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1655];
1656
1657/// Split `text` into chunks of at most `chunk_size` tokens using the most
1658/// semantically meaningful splitter available — the `semchunk` algorithm
1659/// docling's HybridChunker delegates plain-text splitting to.
1660pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1661    let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1662    let mut counter = |s: &str| -> usize {
1663        if let Some(n) = cache.get(s) {
1664            return *n;
1665        }
1666        let n = tok.count_tokens(s);
1667        cache.insert(s.to_string(), n);
1668        n
1669    };
1670    let chunks = semchunk_rec(text, chunk_size, &mut counter);
1671    // top-level: drop empty / all-whitespace chunks
1672    chunks
1673        .into_iter()
1674        .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1675        .collect()
1676}
1677
1678/// One recursion level of semchunk: split, merge windows back up to size, and
1679/// recurse into oversized splits.
1680fn semchunk_rec(
1681    text: &str,
1682    chunk_size: usize,
1683    counter: &mut dyn FnMut(&str) -> usize,
1684) -> Vec<String> {
1685    let (splitter, splitter_is_ws, splits) = split_text(text);
1686
1687    let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1688    let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1689    cum_lens.push(0usize);
1690    for l in &split_lens {
1691        cum_lens.push(cum_lens.last().unwrap() + l);
1692    }
1693    let num_splits_plus_one = splits.len() + 1;
1694
1695    let mut chunks: Vec<String> = Vec::new();
1696    let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1697
1698    for i in 0..splits.len() {
1699        if skips.contains(&i) {
1700            continue;
1701        }
1702        let split = &splits[i];
1703        if counter(split) > chunk_size {
1704            let inner = semchunk_rec(split, chunk_size, counter);
1705            chunks.extend(inner);
1706        } else {
1707            let (end, merged) = merge_splits(
1708                &splits,
1709                &cum_lens,
1710                chunk_size,
1711                &splitter,
1712                counter,
1713                i,
1714                num_splits_plus_one,
1715            );
1716            for j in (i + 1)..end {
1717                skips.insert(j);
1718            }
1719            chunks.push(merged);
1720        }
1721        // Re-attach a non-whitespace splitter to the last chunk (or emit it as
1722        // its own chunk if it doesn't fit).
1723        let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1724        if !splitter_is_ws && !is_last {
1725            let with_splitter = format!(
1726                "{}{}",
1727                chunks.last().map(String::as_str).unwrap_or(""),
1728                splitter
1729            );
1730            if counter(&with_splitter) <= chunk_size {
1731                if let Some(last) = chunks.last_mut() {
1732                    *last = with_splitter;
1733                } else {
1734                    chunks.push(with_splitter);
1735                }
1736            } else {
1737                chunks.push(splitter.clone());
1738            }
1739        }
1740    }
1741    chunks
1742}
1743
1744/// docling/semchunk's `merge_splits`: extend the window with a cum-length-guided
1745/// binary search until the token budget is hit.
1746fn merge_splits(
1747    splits: &[String],
1748    cum_lens: &[usize],
1749    chunk_size: usize,
1750    splitter: &str,
1751    counter: &mut dyn FnMut(&str) -> usize,
1752    start: usize,
1753    high_init: usize,
1754) -> (usize, String) {
1755    let mut average = 0.2f64;
1756    let mut low = start;
1757    let mut high = high_init;
1758    let offset = cum_lens[start];
1759    let mut target = offset as f64 + (chunk_size as f64 * average);
1760
1761    while low < high {
1762        let i = bisect_left(cum_lens, target, low, high);
1763        let midpoint = i.min(high - 1);
1764        let joined = splits[start..midpoint.max(start)].join(splitter);
1765        let tokens = counter(&joined);
1766        let local_cum = cum_lens[midpoint] - offset;
1767        if local_cum > 0 && tokens > 0 {
1768            average = local_cum as f64 / tokens as f64;
1769            target = offset as f64 + (chunk_size as f64 * average);
1770        }
1771        if tokens > chunk_size {
1772            high = midpoint;
1773        } else {
1774            low = midpoint + 1;
1775        }
1776    }
1777    let end = low - 1;
1778    (end, splits[start..end.max(start)].join(splitter))
1779}
1780
1781fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1782    while low < high {
1783        let mid = (low + high) / 2;
1784        if (sorted[mid] as f64) < target {
1785            low = mid + 1;
1786        } else {
1787            high = mid;
1788        }
1789    }
1790    low
1791}
1792
1793/// semchunk's `_split_text`: pick the most desirable splitter present.
1794fn split_text(text: &str) -> (String, bool, Vec<String>) {
1795    // Longest run of newlines/carriage returns.
1796    if text.contains('\n') || text.contains('\r') {
1797        let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1798        return (splitter.clone(), true, split_on(text, &splitter));
1799    }
1800    // Longest run of tabs.
1801    if text.contains('\t') {
1802        let splitter = longest_run(text, |c| c == '\t');
1803        return (splitter.clone(), true, split_on(text, &splitter));
1804    }
1805    // Longest run of whitespace.
1806    if text.chars().any(char::is_whitespace) {
1807        let splitter = longest_run(text, char::is_whitespace);
1808        if splitter.chars().count() == 1 {
1809            // Prefer a whitespace char preceded by a semantic splitter.
1810            for preceder in NON_WS_SPLITTERS {
1811                if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1812                    return (ws, true, parts);
1813                }
1814            }
1815        }
1816        return (splitter.clone(), true, split_on(text, &splitter));
1817    }
1818    // Most desirable semantic splitter present.
1819    for s in NON_WS_SPLITTERS {
1820        if text.contains(s) {
1821            return (s.to_string(), false, split_on(text, s));
1822        }
1823    }
1824    // No splitter at all: split into characters.
1825    (
1826        String::new(),
1827        true,
1828        text.chars().map(|c| c.to_string()).collect(),
1829    )
1830}
1831
1832/// The longest maximal run of chars matching `pred` (first one wins ties).
1833fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1834    let mut best = String::new();
1835    let mut cur = String::new();
1836    for c in text.chars() {
1837        if pred(c) {
1838            cur.push(c);
1839        } else {
1840            if cur.chars().count() > best.chars().count() {
1841                best = cur.clone();
1842            }
1843            cur.clear();
1844        }
1845    }
1846    if cur.chars().count() > best.chars().count() {
1847        best = cur;
1848    }
1849    best
1850}
1851
1852fn split_on(text: &str, splitter: &str) -> Vec<String> {
1853    text.split(splitter).map(str::to_string).collect()
1854}
1855
1856/// Python: `re.search(rf'{p}(\s)', text)` → the first whitespace char preceded
1857/// by `p`; then `re.split(rf'(?<={p}){s}', text)` — split at every occurrence
1858/// of that whitespace char immediately preceded by `p`.
1859fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1860    let chars: Vec<char> = text.chars().collect();
1861    let p: Vec<char> = preceder.chars().collect();
1862    let mut ws: Option<char> = None;
1863    for i in p.len()..chars.len() {
1864        if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1865            ws = Some(chars[i]);
1866            break;
1867        }
1868    }
1869    let ws = ws?;
1870    let mut parts = Vec::new();
1871    let mut cur = String::new();
1872    let mut i = 0usize;
1873    while i < chars.len() {
1874        if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1875            parts.push(std::mem::take(&mut cur));
1876            i += 1;
1877            continue;
1878        }
1879        cur.push(chars[i]);
1880        i += 1;
1881    }
1882    parts.push(cur);
1883    Some((ws.to_string(), parts))
1884}
1885
1886// ---------------------------------------------------------------------------
1887// HuggingFace tokenizer (feature `chunking`)
1888// ---------------------------------------------------------------------------
1889
1890#[cfg(feature = "chunking")]
1891mod hf {
1892    use super::ChunkTokenizer;
1893
1894    /// Where `scripts/install/download_dependencies.sh` puts the hybrid
1895    /// chunker's default tokenizer (all-MiniLM-L6-v2's `tokenizer.json`),
1896    /// relative to the process's working directory — the same convention as
1897    /// the `.models/` ONNX files.
1898    pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1899
1900    /// Resolve the tokenizer path for the hybrid chunker: an explicit path
1901    /// wins; otherwise fall back to [`DEFAULT_TOKENIZER_PATH`] when it exists
1902    /// on disk. Errors with the download instructions when neither is
1903    /// available.
1904    pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1905        if let Some(p) = explicit {
1906            return Ok(p.to_string());
1907        }
1908        let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1909        if std::path::Path::new(&resolved).exists() {
1910            return Ok(resolved);
1911        }
1912        Err(format!(
1913            "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1914             {DEFAULT_TOKENIZER_PATH} does not exist — run \
1915             scripts/install/download_dependencies.sh (or pass an explicit path)"
1916        ))
1917    }
1918
1919    /// [`ChunkTokenizer`] backed by a HuggingFace `tokenizer.json` — the Rust
1920    /// analogue of docling's `HuggingFaceTokenizer` (whose default is
1921    /// `sentence-transformers/all-MiniLM-L6-v2` with `max_tokens` 256).
1922    pub struct HuggingFaceTokenizer {
1923        tok: tokenizers::Tokenizer,
1924        max_tokens: usize,
1925    }
1926
1927    impl HuggingFaceTokenizer {
1928        /// Load the tokenizer from an explicit path, or from
1929        /// [`DEFAULT_TOKENIZER_PATH`] when `path` is `None` (see
1930        /// [`resolve_tokenizer_path`]).
1931        pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1932            Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1933        }
1934
1935        /// Load a `tokenizer.json`. `max_tokens` is the chunk budget (docling
1936        /// resolves it from the model's `sentence_bert_config.json`; for the
1937        /// default MiniLM model that is 256).
1938        pub fn from_file(
1939            path: impl AsRef<std::path::Path>,
1940            max_tokens: usize,
1941        ) -> Result<Self, String> {
1942            let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1943                .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1944            // Counting must see the full text (docling tokenizes without
1945            // truncation, padding, or special tokens — MiniLM's tokenizer.json
1946            // ships with fixed-length padding enabled, which would make every
1947            // short string count as the padded length).
1948            let _ = tok.with_truncation(None);
1949            tok.with_padding(None);
1950            Ok(Self { tok, max_tokens })
1951        }
1952    }
1953
1954    impl ChunkTokenizer for HuggingFaceTokenizer {
1955        fn count_tokens(&self, text: &str) -> usize {
1956            self.tok
1957                .encode(text, false)
1958                .map(|e| e.get_tokens().len())
1959                .unwrap_or(0)
1960        }
1961        fn max_tokens(&self) -> usize {
1962            self.max_tokens
1963        }
1964    }
1965}
1966
1967#[cfg(feature = "chunking")]
1968pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1969
1970// ---------------------------------------------------------------------------
1971// Window chunker (feature `chunking`) — docling-rag's Markdown window chunker
1972// ---------------------------------------------------------------------------
1973
1974#[cfg(feature = "chunking")]
1975mod window {
1976    use super::DocChunk;
1977    use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1978
1979    /// A contiguous run of body text under a heading path.
1980    #[derive(Debug, Clone, Default)]
1981    pub struct Section {
1982        /// The heading stack in effect for this section, outermost first
1983        /// (e.g. `["Guide", "Setup"]`). Empty for pre-heading / body-only text.
1984        pub heading_path: Vec<String>,
1985        /// The plain words of the section body, markup stripped.
1986        pub words: Vec<String>,
1987    }
1988
1989    impl Section {
1990        /// The heading path rendered as a single context line, e.g.
1991        /// `# Guide > Setup`. Empty string when there is no heading.
1992        pub fn heading_context(&self) -> String {
1993            if self.heading_path.is_empty() {
1994                String::new()
1995            } else {
1996                format!("# {}", self.heading_path.join(" > "))
1997            }
1998        }
1999    }
2000
2001    fn level_index(level: HeadingLevel) -> usize {
2002        match level {
2003            HeadingLevel::H1 => 1,
2004            HeadingLevel::H2 => 2,
2005            HeadingLevel::H3 => 3,
2006            HeadingLevel::H4 => 4,
2007            HeadingLevel::H5 => 5,
2008            HeadingLevel::H6 => 6,
2009        }
2010    }
2011
2012    /// Parse Markdown into heading-bounded sections. A new section starts at
2013    /// every heading; the heading path is maintained as a stack keyed by
2014    /// heading level.
2015    pub fn parse_sections(markdown: &str) -> Vec<Section> {
2016        parse_sections_with_stack(markdown, Vec::new()).0
2017    }
2018
2019    /// [`parse_sections`] with an explicit initial heading stack, returning the
2020    /// final stack — lets a streaming caller carry heading context across
2021    /// pieces. `heading_stack[i]` holds the current heading text at level `i+1`
2022    /// (may be empty when a level was skipped).
2023    pub fn parse_sections_with_stack(
2024        markdown: &str,
2025        initial_stack: Vec<String>,
2026    ) -> (Vec<Section>, Vec<String>) {
2027        let mut heading_stack: Vec<String> = initial_stack;
2028        let mut sections: Vec<Section> = Vec::new();
2029        // Text before the first heading of this piece continues the carried-over
2030        // section, so it keeps the heading path in effect at the split point.
2031        let mut current = Section {
2032            heading_path: heading_stack
2033                .iter()
2034                .filter(|h| !h.is_empty())
2035                .cloned()
2036                .collect(),
2037            words: Vec::new(),
2038        };
2039
2040        let mut in_heading = false;
2041        let mut heading_level = 0usize;
2042        let mut heading_buf = String::new();
2043
2044        let push_words = |section: &mut Section, text: &str| {
2045            for w in text.split_whitespace() {
2046                section.words.push(w.to_string());
2047            }
2048        };
2049
2050        let flush = |sections: &mut Vec<Section>, section: &mut Section| {
2051            if !section.words.is_empty() {
2052                sections.push(std::mem::take(section));
2053            } else {
2054                *section = Section::default();
2055            }
2056        };
2057
2058        for event in Parser::new(markdown) {
2059            match event {
2060                Event::Start(Tag::Heading { level, .. }) => {
2061                    in_heading = true;
2062                    heading_level = level_index(level);
2063                    heading_buf.clear();
2064                }
2065                Event::End(TagEnd::Heading(_)) => {
2066                    in_heading = false;
2067                    // Update the heading stack: set this level, drop anything deeper.
2068                    let idx = heading_level.saturating_sub(1);
2069                    if heading_stack.len() <= idx {
2070                        heading_stack.resize(idx + 1, String::new());
2071                    } else {
2072                        heading_stack.truncate(idx + 1);
2073                    }
2074                    heading_stack[idx] = heading_buf.trim().to_string();
2075                    // A heading begins a new section.
2076                    flush(&mut sections, &mut current);
2077                    current.heading_path = heading_stack
2078                        .iter()
2079                        .filter(|h| !h.is_empty())
2080                        .cloned()
2081                        .collect();
2082                }
2083                Event::Text(t) | Event::Code(t) => {
2084                    if in_heading {
2085                        if !heading_buf.is_empty() {
2086                            heading_buf.push(' ');
2087                        }
2088                        heading_buf.push_str(&t);
2089                    } else {
2090                        push_words(&mut current, &t);
2091                    }
2092                }
2093                // Treat hard/soft breaks and rules as whitespace (words already split).
2094                Event::SoftBreak | Event::HardBreak | Event::Rule => {}
2095                _ => {}
2096            }
2097        }
2098        flush(&mut sections, &mut current);
2099        (sections, heading_stack)
2100    }
2101
2102    /// docling-rag's Markdown **window chunker**: the document is split into
2103    /// heading-bounded [`Section`]s of plain words (markup stripped), and a
2104    /// fixed-size window of [`Self::max_words`] words slides over each section
2105    /// with [`Self::overlap`] fractional overlap. A chunk never crosses a
2106    /// heading boundary; [`Self::contextualize`] prefixes the heading path.
2107    #[derive(Debug, Clone)]
2108    pub struct WindowChunker {
2109        /// Window size in words (docling-rag's default 300).
2110        pub max_words: usize,
2111        /// Fractional overlap between consecutive windows (default 0.05 = 5%).
2112        pub overlap: f32,
2113    }
2114
2115    impl Default for WindowChunker {
2116        fn default() -> Self {
2117            WindowChunker {
2118                max_words: 300,
2119                overlap: 0.05,
2120            }
2121        }
2122    }
2123
2124    impl WindowChunker {
2125        pub fn new(max_words: usize, overlap: f32) -> Self {
2126            WindowChunker { max_words, overlap }
2127        }
2128
2129        /// The window size, kept ≥ 1.
2130        fn word_budget(&self) -> usize {
2131            self.max_words.max(1)
2132        }
2133
2134        /// Number of words carried from one window into the next; capped so
2135        /// the window always advances.
2136        fn overlap_words(&self, budget: usize) -> usize {
2137            let o = (budget as f32 * self.overlap).round() as usize;
2138            o.min(budget.saturating_sub(1))
2139        }
2140
2141        /// Chunk a Markdown document.
2142        pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2143            let mut chunks = Vec::new();
2144            self.chunk_with(markdown, &mut |c| {
2145                chunks.push(c);
2146                true
2147            });
2148            chunks
2149        }
2150
2151        /// Stream the chunks: `sink` receives each window as it is cut, and a
2152        /// `false` return cancels. [`Self::chunk`] is this with a collecting
2153        /// sink — the chunks and their order are identical.
2154        pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2155            let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2156            for section in &sections {
2157                if !self.pack_section(section, sink) {
2158                    return;
2159                }
2160            }
2161        }
2162
2163        /// Slide the window over one completed section, feeding each chunk to
2164        /// `sink`. Returns `false` once the sink cancels — exposed so a
2165        /// streaming caller (docling-rag) can pack sections as they complete.
2166        pub fn pack_section(
2167            &self,
2168            section: &Section,
2169            sink: &mut dyn FnMut(DocChunk) -> bool,
2170        ) -> bool {
2171            let words = &section.words;
2172            if words.is_empty() {
2173                return true;
2174            }
2175            let budget = self.word_budget();
2176            let step = budget - self.overlap_words(budget); // ≥ 1 by construction
2177            let mut start = 0;
2178            loop {
2179                let end = (start + budget).min(words.len());
2180                let chunk = DocChunk {
2181                    text: words[start..end].join(" "),
2182                    headings: (!section.heading_path.is_empty())
2183                        .then(|| section.heading_path.clone()),
2184                    doc_items: Vec::new(),
2185                };
2186                if !sink(chunk) {
2187                    return false;
2188                }
2189                if end >= words.len() {
2190                    return true;
2191                }
2192                start += step;
2193            }
2194        }
2195
2196        /// Render a window chunk for embedding — docling-rag's rendering: the
2197        /// heading path as a `# Outer > Inner` context line, a blank line,
2198        /// then the body (just the body above any heading). Note this differs
2199        /// from the docling chunkers' [`contextualize`](super::contextualize),
2200        /// matching docling-rag instead.
2201        pub fn contextualize(chunk: &DocChunk) -> String {
2202            match &chunk.headings {
2203                Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2204                _ => chunk.text.clone(),
2205            }
2206        }
2207    }
2208
2209    #[cfg(test)]
2210    mod tests {
2211        use super::*;
2212
2213        #[test]
2214        fn splits_on_headings_and_tracks_path() {
2215            let md = "\
2216intro words
2217# Chapter 1
2218para one
2219## Section 1.1
2220para two
2221# Chapter 2
2222para three";
2223            let secs = parse_sections(md);
2224            // pre-heading intro, Chapter 1, Section 1.1, Chapter 2.
2225            assert_eq!(secs.len(), 4);
2226            assert!(secs[0].heading_path.is_empty());
2227            assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2228            assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2229            // A deeper heading is dropped when we return to H1.
2230            assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2231        }
2232
2233        #[test]
2234        fn strips_markup_to_plain_words() {
2235            let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2236            let secs = parse_sections(md);
2237            let words = &secs[0].words;
2238            assert!(words.contains(&"bold".to_string()));
2239            assert!(words.contains(&"code".to_string()));
2240            assert!(words.contains(&"link".to_string()));
2241            // No markdown punctuation survives as its own token.
2242            assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2243        }
2244
2245        #[test]
2246        fn windows_overlap_and_never_cross_headings() {
2247            let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2248            let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2249            let chunker = WindowChunker::new(10, 0.2); // step 8, overlap 2
2250            let chunks = chunker.chunk(&md);
2251            // Section A: 25 words → windows [0..10), [8..18), [16..25).
2252            let a: Vec<_> = chunks
2253                .iter()
2254                .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2255                .collect();
2256            assert_eq!(a.len(), 3);
2257            assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2258            assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2259            assert!(a[2].text.ends_with(" w24"));
2260            // Section B stays its own chunk; nothing crosses the heading.
2261            let b: Vec<_> = chunks
2262                .iter()
2263                .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2264                .collect();
2265            assert_eq!(b.len(), 1);
2266            assert_eq!(b[0].text, "short tail");
2267            assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2268        }
2269
2270        #[test]
2271        fn sink_false_cancels_the_window_walk() {
2272            let md = format!(
2273                "# A\n\n{}\n",
2274                (0..50)
2275                    .map(|i| format!("w{i}"))
2276                    .collect::<Vec<_>>()
2277                    .join(" ")
2278            );
2279            let chunker = WindowChunker::new(10, 0.0);
2280            let mut n = 0;
2281            chunker.chunk_with(&md, &mut |_| {
2282                n += 1;
2283                false
2284            });
2285            assert_eq!(n, 1);
2286        }
2287    }
2288}
2289
2290#[cfg(feature = "chunking")]
2291pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2292
2293#[cfg(test)]
2294mod tests {
2295    use super::*;
2296
2297    /// A whitespace "tokenizer" for algorithm tests.
2298    struct WordTok(usize);
2299    impl ChunkTokenizer for WordTok {
2300        fn count_tokens(&self, text: &str) -> usize {
2301            text.split_whitespace().count()
2302        }
2303        fn max_tokens(&self) -> usize {
2304            self.0
2305        }
2306    }
2307
2308    fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2309        let mut d = DoclingDocument::new("t");
2310        for n in nodes {
2311            d.push(n);
2312        }
2313        d
2314    }
2315
2316    #[test]
2317    fn hierarchical_headings_and_items() {
2318        let doc = doc_with(vec![
2319            Node::Heading {
2320                level: 1,
2321                text: "Title".into(),
2322            },
2323            Node::Paragraph {
2324                text: "Intro".into(),
2325            },
2326            Node::Heading {
2327                level: 2,
2328                text: "Sec".into(),
2329            },
2330            Node::Paragraph {
2331                text: "Body".into(),
2332            },
2333        ]);
2334        let chunks = HierarchicalChunker.chunk(&doc);
2335        assert_eq!(chunks.len(), 2);
2336        assert_eq!(chunks[0].text, "Intro");
2337        assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2338        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2339        assert_eq!(
2340            chunks[1].headings.as_deref(),
2341            Some(&["Title".into(), "Sec".into()][..])
2342        );
2343        assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2344    }
2345
2346    /// A page footer is a JSON text item (furniture layer) the chunker never
2347    /// chunks; the refs of the items after it still match the JSON.
2348    #[test]
2349    fn page_furniture_keeps_refs_aligned_with_json() {
2350        let doc = doc_with(vec![
2351            Node::Paragraph {
2352                text: "Before".into(),
2353            },
2354            Node::PageFurniture {
2355                footer: true,
2356                location: [0, 490, 100, 512],
2357                text: "1.10.2".into(),
2358            },
2359            Node::Paragraph {
2360                text: "After".into(),
2361            },
2362        ]);
2363        let chunks = HierarchicalChunker.chunk(&doc);
2364        assert_eq!(chunks.len(), 2);
2365        assert_eq!(chunks[1].text, "After");
2366        let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2367        assert_eq!(json["texts"][2]["text"], "After");
2368        assert_eq!(chunks[1].doc_items[0].self_ref, "#/texts/2");
2369    }
2370
2371    /// A picture's children are never chunked, but they take `#/texts/N`
2372    /// (and list-group) slots in the JSON — the refs after them must still
2373    /// point at the right items.
2374    #[test]
2375    fn picture_children_are_not_chunked_but_keep_refs_aligned() {
2376        let doc = doc_with(vec![
2377            Node::Picture {
2378                caption: None,
2379                caption_href: None,
2380                image: None,
2381                classification: None,
2382                description: None,
2383                caption_parent: crate::CaptionParent::Item,
2384                caption_location: None,
2385            },
2386            Node::PictureChildren(vec![
2387                Node::Paragraph {
2388                    text: "axis label".into(),
2389                },
2390                Node::ListItem {
2391                    ordered: false,
2392                    number: 0,
2393                    first_in_list: true,
2394                    text: "callout".into(),
2395                    level: 0,
2396                    marker: None,
2397                    location: None,
2398                    dclx: None,
2399                    href: None,
2400                    layer: None,
2401                },
2402            ]),
2403            Node::Paragraph {
2404                text: "Body".into(),
2405            },
2406        ]);
2407        let chunks = HierarchicalChunker.chunk(&doc);
2408        assert_eq!(chunks.len(), 1);
2409        assert_eq!(chunks[0].text, "Body");
2410        let json = doc.export_to_json_value();
2411        assert_eq!(json["texts"][2]["text"], "Body");
2412        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/2");
2413    }
2414
2415    #[test]
2416    fn heading_shadowing_prunes_deeper_levels() {
2417        let doc = doc_with(vec![
2418            Node::Heading {
2419                level: 2,
2420                text: "A".into(),
2421            },
2422            Node::Heading {
2423                level: 3,
2424                text: "A.1".into(),
2425            },
2426            Node::Heading {
2427                level: 2,
2428                text: "B".into(),
2429            },
2430            Node::Paragraph { text: "p".into() },
2431        ]);
2432        let chunks = HierarchicalChunker.chunk(&doc);
2433        assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2434    }
2435
2436    #[test]
2437    fn triplet_table() {
2438        let t = Table {
2439            rows: vec![
2440                vec!["".into(), "Col1".into()],
2441                vec!["Row1".into(), "v".into()],
2442            ],
2443            ..Default::default()
2444        };
2445        assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2446        // Single-column: row 0 is the dataframe header, the first data row
2447        // becomes the column name, the rest the values.
2448        let single = Table {
2449            rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2450            ..Default::default()
2451        };
2452        assert_eq!(triplet_table_text(&single), "a = b");
2453    }
2454
2455    /// The peer merge before docling-core#823 (#782): rebuild and re-count
2456    /// the whole window for every chunk. The linear merge must give the
2457    /// same chunks.
2458    fn quadratic_merge<T: ChunkTokenizer>(
2459        h: &HybridChunker<T>,
2460        chunks: Vec<DocChunk>,
2461    ) -> Vec<DocChunk> {
2462        let mut out = Vec::new();
2463        let mut window: Vec<DocChunk> = Vec::new();
2464        let flush = |window: &mut Vec<DocChunk>, out: &mut Vec<DocChunk>| match window.len() {
2465            0 => {}
2466            1 => out.push(window.pop().unwrap()),
2467            _ => {
2468                out.push(merged_chunk(window.iter()));
2469                window.clear();
2470            }
2471        };
2472        for chunk in chunks {
2473            if window.is_empty() {
2474                window.push(chunk);
2475                continue;
2476            }
2477            let candidate = merged_chunk(window.iter().chain([&chunk]));
2478            if chunk.headings == window[0].headings
2479                && h.count_chunk_tokens(&candidate) <= h.max_tokens()
2480            {
2481                window.push(chunk);
2482            } else {
2483                flush(&mut window, &mut out);
2484                window.push(chunk);
2485            }
2486        }
2487        flush(&mut window, &mut out);
2488        out
2489    }
2490
2491    fn linear_merge<T: ChunkTokenizer>(
2492        h: &HybridChunker<T>,
2493        chunks: Vec<DocChunk>,
2494    ) -> Vec<DocChunk> {
2495        let mut out = Vec::new();
2496        let mut sink = |c| {
2497            out.push(c);
2498            true
2499        };
2500        let mut m = PeerMerger::default();
2501        for c in chunks {
2502            assert!(h.merge_push(&mut m, c, &mut sink));
2503        }
2504        assert!(h.merge_flush(&mut m, &mut sink));
2505        out
2506    }
2507
2508    /// A non-additive "tokenizer": a token per started 4 characters, so the
2509    /// joined text counts differently from the sum of its parts and the
2510    /// delimiter's measured cost is negative — the running estimate is
2511    /// re-anchored and the final window shrunk back.
2512    struct QuadCharTok(usize);
2513    impl ChunkTokenizer for QuadCharTok {
2514        fn count_tokens(&self, text: &str) -> usize {
2515            text.chars().count().div_ceil(4)
2516        }
2517        fn max_tokens(&self) -> usize {
2518            self.0
2519        }
2520    }
2521
2522    /// Pseudo-random chunks: short texts of 1–12 words under one of three
2523    /// heading paths (runs of equal headings, as the walk yields them).
2524    fn random_chunks(seed: u64, n: usize) -> Vec<DocChunk> {
2525        let mut state = seed;
2526        let mut next = move |m: u64| {
2527            state = state
2528                .wrapping_mul(6364136223846793005)
2529                .wrapping_add(1442695040888963407);
2530            (state >> 33) % m
2531        };
2532        let headings = [
2533            None,
2534            Some(vec!["A".to_string()]),
2535            Some(vec!["A".into(), "B".into()]),
2536        ];
2537        let mut current = 0;
2538        (0..n)
2539            .map(|i| {
2540                if next(4) == 0 {
2541                    current = next(3) as usize;
2542                }
2543                let words = 1 + next(12) as usize;
2544                let text = (0..words)
2545                    .map(|w| ["x", "lorem", "ipsum", "dolor", "sit"][(w + i) % 5])
2546                    .collect::<Vec<_>>()
2547                    .join(" ");
2548                DocChunk {
2549                    text,
2550                    headings: headings[current].clone(),
2551                    doc_items: vec![ChunkItem {
2552                        self_ref: format!("#/texts/{i}"),
2553                        kind: ChunkItemKind::Text,
2554                        text: String::new(),
2555                    }],
2556                }
2557            })
2558            .collect()
2559    }
2560
2561    /// #782 (docling-core#823): the linear peer merge gives exactly the
2562    /// chunks of the quadratic one, with an additive and a non-additive
2563    /// tokenizer, over many budgets.
2564    #[test]
2565    fn linear_peer_merge_matches_the_quadratic_one() {
2566        for seed in 0..40 {
2567            let chunks = random_chunks(seed, 60);
2568            for max in [3, 8, 16, 40, 200] {
2569                let h = HybridChunker::new(WordTok(max));
2570                assert_eq!(
2571                    linear_merge(&h, chunks.clone()),
2572                    quadratic_merge(&h, chunks.clone()),
2573                    "words, seed {seed}, max {max}"
2574                );
2575                let h = HybridChunker::new(QuadCharTok(max));
2576                assert_eq!(
2577                    linear_merge(&h, chunks.clone()),
2578                    quadratic_merge(&h, chunks.clone()),
2579                    "chars/4, seed {seed}, max {max}"
2580                );
2581            }
2582        }
2583    }
2584
2585    /// 10k tiny paragraphs at an 8192-token budget — docling-core#823's
2586    /// benchmark. `cargo test -p docling-core --release -- --ignored
2587    /// peer_merge_bench --nocapture` prints both timings.
2588    #[test]
2589    #[ignore]
2590    fn peer_merge_bench() {
2591        let chunks: Vec<DocChunk> = (0..10_000)
2592            .map(|i| DocChunk {
2593                text: format!("Paragraph number {i} is short."),
2594                headings: Some(vec!["Section".into()]),
2595                doc_items: Vec::new(),
2596            })
2597            .collect();
2598        let h = HybridChunker::new(WordTok(8192));
2599        let t = std::time::Instant::now();
2600        let fast = linear_merge(&h, chunks.clone());
2601        let linear = t.elapsed();
2602        let t = std::time::Instant::now();
2603        let slow = quadratic_merge(&h, chunks);
2604        let quadratic = t.elapsed();
2605        assert_eq!(fast, slow);
2606        println!(
2607            "linear {linear:?}, quadratic {quadratic:?}, {} chunks",
2608            fast.len()
2609        );
2610    }
2611
2612    /// A table from first-class cells `(text, row, col, row_span,
2613    /// column_header)`, its grid repeating a spanning cell's text.
2614    fn cell_table(
2615        num_rows: usize,
2616        num_cols: usize,
2617        cells: &[(&str, usize, usize, usize, bool)],
2618    ) -> Table {
2619        let mut rows = vec![vec![String::new(); num_cols]; num_rows];
2620        for &(text, r, c, span, _) in cells {
2621            for row in rows.iter_mut().skip(r).take(span) {
2622                row[c] = text.into();
2623            }
2624        }
2625        Table {
2626            rows,
2627            cells: Some(
2628                cells
2629                    .iter()
2630                    .map(|&(text, r, c, span, ched)| crate::TableCell {
2631                        text: text.into(),
2632                        bbox: None,
2633                        start_row: r,
2634                        start_col: c,
2635                        row_span: span,
2636                        col_span: 1,
2637                        column_header: ched,
2638                        row_header: false,
2639                        row_section: false,
2640                    })
2641                    .collect(),
2642            ),
2643            ..Default::default()
2644        }
2645    }
2646
2647    /// #778 (docling-core#767, 2.102): the Markdown table and the triplet
2648    /// chunk agree on the header rows — upstream's parametrized
2649    /// `test_table_header_rows_in_markdown_and_dataframe`, plus the issue's
2650    /// two tables. Expected texts are docling-core 2.102.0's (compact
2651    /// Markdown, `HierarchicalChunker` chunk text).
2652    #[test]
2653    fn markdown_and_triplets_share_the_header_rows() {
2654        type Case<'a> = (
2655            &'a str,
2656            usize,
2657            usize,
2658            Vec<(&'a str, usize, usize, usize, bool)>,
2659            &'a str,
2660            &'a str,
2661        );
2662        let cases: Vec<Case> = vec![
2663            (
2664                "rows under a row-spanning header stay in the body",
2665                2,
2666                2,
2667                vec![("Price", 0, 0, 1, true), ("Category", 0, 1, 2, true), ("10.00", 1, 0, 1, false)],
2668                "| Price | Category |\n| - | - |\n| 10.00 | Category |",
2669                "10.00, Category = Category",
2670            ),
2671            (
2672                "a row header flagged as column header stays in the body",
2673                3,
2674                3,
2675                vec![
2676                    ("Year", 0, 0, 1, true),
2677                    ("Month", 0, 1, 1, true),
2678                    ("Revenue", 0, 2, 1, true),
2679                    ("2025", 1, 0, 2, true),
2680                    ("January", 1, 1, 1, false),
2681                    ("$134", 1, 2, 1, false),
2682                    ("February", 2, 1, 1, false),
2683                    ("$150", 2, 2, 1, false),
2684                ],
2685                "| Year | Month | Revenue |\n| - | - | - |\n| 2025 | January | $134 |\n| 2025 | February | $150 |",
2686                "2025, Month = January. 2025, Revenue = $134. 2025, Month = February. 2025, Revenue = $150",
2687            ),
2688            (
2689                "stacked header with an unmarked row label",
2690                3,
2691                3,
2692                vec![
2693                    ("", 0, 0, 1, true),
2694                    ("% of Total", 0, 1, 1, true),
2695                    ("% of Total", 0, 2, 1, true),
2696                    ("class label", 1, 0, 1, false),
2697                    ("Train", 1, 1, 1, true),
2698                    ("Test", 1, 2, 1, true),
2699                    ("Caption", 2, 0, 1, false),
2700                    ("2.5", 2, 1, 1, false),
2701                    ("2.4", 2, 2, 1, false),
2702                ],
2703                "| class label | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.5 | 2.4 |",
2704                "Caption, % of Total.Train = 2.5. Caption, % of Total.Test = 2.4",
2705            ),
2706            (
2707                "a partially flagged first row stays the header",
2708                2,
2709                2,
2710                vec![("Name", 0, 0, 1, true), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2711                "| Name | Value |\n| - | - |\n| A | 1 |",
2712                "A, Value = 1",
2713            ),
2714            (
2715                "no flags: Markdown promotes row 0, triplets do not",
2716                2,
2717                2,
2718                vec![("Name", 0, 0, 1, false), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2719                "| Name | Value |\n| - | - |\n| A | 1 |",
2720                "Name, 1 = Value. A, 1 = 1",
2721            ),
2722            (
2723                "row headers flagged as column headers stay in the body",
2724                2,
2725                2,
2726                vec![("2025", 0, 0, 1, true), ("$134", 0, 1, 1, false), ("2026", 1, 0, 1, true), ("$150", 1, 1, 1, false)],
2727                "|  |  |\n| - | - |\n| 2025 | $134 |\n| 2026 | $150 |",
2728                "2025, 1 = $134. 2026, 1 = $150",
2729            ),
2730            (
2731                "a form with flagged first-column labels is all data",
2732                3,
2733                2,
2734                vec![
2735                    ("Name", 0, 0, 1, true),
2736                    ("Bob", 0, 1, 1, false),
2737                    ("Age", 1, 0, 1, true),
2738                    ("42", 1, 1, 1, false),
2739                    ("City", 2, 0, 1, true),
2740                    ("Paris", 2, 1, 1, false),
2741                ],
2742                "|  |  |\n| - | - |\n| Name | Bob |\n| Age | 42 |\n| City | Paris |",
2743                "Name, 1 = Bob. Age, 1 = 42. City, 1 = Paris",
2744            ),
2745            (
2746                "a partly flagged row 0 above a stacked header row",
2747                3,
2748                3,
2749                vec![
2750                    ("Region", 0, 0, 1, true),
2751                    ("2025", 0, 1, 1, false),
2752                    ("", 0, 2, 1, false),
2753                    ("", 1, 0, 1, false),
2754                    ("Q1", 1, 1, 1, true),
2755                    ("Q2", 1, 2, 1, true),
2756                    ("North", 2, 0, 1, false),
2757                    ("1", 2, 1, 1, false),
2758                    ("2", 2, 2, 1, false),
2759                ],
2760                "| Region | 2025 - Q1 | Q2 |\n| - | - | - |\n| North | 1 | 2 |",
2761                "North, 2025.Q1 = 1. North, Q2 = 2",
2762            ),
2763        ];
2764        for (name, rows, cols, cells, md, triplets) in cases {
2765            let t = cell_table(rows, cols, &cells);
2766            assert_eq!(
2767                crate::markdown::render_table(&t, true),
2768                md,
2769                "{name}: markdown"
2770            );
2771            assert_eq!(triplet_table_text(&t), triplets, "{name}: triplets");
2772        }
2773    }
2774
2775    /// #777 (docling-core#857, 2.102): a rich header cell spanning two
2776    /// columns repeats in both — upstream's `col_span_rich_table_doc`. Its
2777    /// blocks sit on the anchor only and the covered position's flat text is
2778    /// blank, so LaTeX and the triplets must resolve the anchor themselves.
2779    #[test]
2780    fn a_column_spanning_rich_cell_repeats_in_every_column() {
2781        let mut t = cell_table(
2782            2,
2783            3,
2784            &[
2785                ("", 0, 0, 1, true),
2786                ("Partner", 0, 1, 1, true),
2787                ("Status", 1, 0, 1, false),
2788                ("Done", 1, 1, 1, false),
2789                ("Open", 1, 2, 1, false),
2790            ],
2791        );
2792        if let Some(cells) = t.cells.as_mut() {
2793            cells[1].col_span = 2;
2794        }
2795        let mut blocks = vec![vec![Vec::new(); 3]; 2];
2796        blocks[0][1] = vec![Node::Paragraph {
2797            text: "Partner".into(),
2798        }];
2799        t.cell_blocks = Some(blocks);
2800        assert_eq!(
2801            t.rows[0][2], "",
2802            "the covered position carries no text of its own"
2803        );
2804        assert_eq!(
2805            triplet_table_text(&t),
2806            "Status, Partner = Done. Status, Partner = Open"
2807        );
2808        let latex = crate::latex::render_table(&t);
2809        assert!(
2810            latex.contains(" & Partner & Partner \\\\ \\hline\nStatus & Done & Open \\\\ \\hline"),
2811            "{latex}"
2812        );
2813        // Markdown reads the flat text, which the backends now fill.
2814        t.rows[0][2] = "Partner".into();
2815        assert_eq!(
2816            crate::markdown::render_table(&t, true),
2817            "|  | Partner | Partner |\n| - | - | - |\n| Status | Done | Open |"
2818        );
2819    }
2820
2821    #[test]
2822    fn hybrid_merges_small_peers_and_splits_large() {
2823        let doc = doc_with(vec![
2824            Node::Heading {
2825                level: 2,
2826                text: "S".into(),
2827            },
2828            Node::Paragraph { text: "a b".into() },
2829            Node::Paragraph { text: "c d".into() },
2830        ]);
2831        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2832        assert_eq!(chunks.len(), 1, "peers under one heading merge");
2833        assert_eq!(chunks[0].text, "a b\nc d");
2834
2835        let long = "w ".repeat(40).trim().to_string();
2836        let doc = doc_with(vec![Node::Paragraph { text: long }]);
2837        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2838        assert!(chunks.len() > 1, "oversized paragraph splits");
2839        for c in &chunks {
2840            assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2841        }
2842    }
2843
2844    #[test]
2845    fn semchunk_prefers_newlines_then_sentences() {
2846        let tok = WordTok(4);
2847        let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2848        assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2849    }
2850}