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