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;
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 } => {
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 rows: Vec<Vec<String>> = t
786        .rows
787        .iter()
788        .enumerate()
789        .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
790        .collect();
791    let num_rows = rows.len();
792    let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
793    if num_rows == 0 || num_cols == 0 {
794        return String::new();
795    }
796    let cell = |r: usize, c: usize| -> &str {
797        rows.get(r)
798            .and_then(|row| row.get(c))
799            .map(String::as_str)
800            .unwrap_or("")
801    };
802
803    // The header block is the leading run of rows on which a column-header
804    // cell *starts* (docling-core#756): the grid replicates a spanning header
805    // into every row it covers, and the rows beneath it are data, not more
806    // header. Unlike the Markdown serializer there is no "no flags -> row 0"
807    // fallback here: pandas then gets integer column names.
808    let num_headers = {
809        let derived;
810        let cells: &[crate::TableCell] = match &t.cells {
811            Some(c) if !c.is_empty() => c,
812            _ => {
813                derived = t.derive_cells();
814                &derived
815            }
816        };
817        (0..num_rows)
818            .take_while(|&r| cells.iter().any(|c| c.column_header && c.start_row == r))
819            .count()
820    };
821
822    // Column names: header-row texts joined per column with '.', or the integer
823    // positions when there are no header rows.
824    let columns: Vec<String> = if num_headers > 0 {
825        (0..num_cols)
826            .map(|c| {
827                let mut name = String::new();
828                for r in 0..num_headers {
829                    if !name.is_empty() {
830                        name.push('.');
831                    }
832                    name.push_str(cell(r, c));
833                }
834                name
835            })
836            .collect()
837    } else {
838        (0..num_cols).map(|c| c.to_string()).collect()
839    };
840    let data_rows = num_headers..num_rows;
841    let n_data = data_rows.len();
842
843    // Header-only table: emit the header texts directly.
844    if n_data == 0 {
845        return columns
846            .iter()
847            .map(|s| s.trim())
848            .filter(|s| !s.is_empty())
849            .collect::<Vec<_>>()
850            .join(". ");
851    }
852
853    let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
854    let text = if num_cols == 1 {
855        // Single-column: the first data row is the column name, the rest are
856        // values (a single data row emits its cell text alone).
857        let col_name = data(0, 0).trim().to_string();
858        if n_data == 1 {
859            col_name
860        } else {
861            (1..n_data)
862                .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
863                .collect::<Vec<_>>()
864                .join(". ")
865        }
866    } else {
867        // Triplets over the dataframe with the column names copied as row 0.
868        let mut parts = Vec::new();
869        for r in 0..n_data {
870            for (c, col_name) in columns.iter().enumerate().skip(1) {
871                parts.push(format!(
872                    "{}, {} = {}",
873                    data(r, 0).trim(),
874                    col_name.trim(),
875                    data(r, c).trim()
876                ));
877            }
878        }
879        parts.join(". ")
880    };
881    if !text.is_empty() {
882        return text;
883    }
884
885    // Last-resort flatten: the data rows' non-blank cells joined with '. '
886    // (the header rows are the dataframe's columns, so they are not included).
887    (0..n_data)
888        .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
889        .map(|(r, c)| data(r, c).trim())
890        .filter(|s| !s.is_empty())
891        .collect::<Vec<_>>()
892        .join(". ")
893}
894
895/// Split a markdown-flavoured text into docling's inline-item granularity: a
896/// hyperlink / formatted span / inline formula is its own document item in
897/// docling's model, with plain text runs between them. Returns the standalone
898/// serialization of each item (`[text](url)`, `**bold**`, a formula re-wrapped
899/// as `$$latex$$`, plain text), or a single-element vector when the text is one
900/// uniform item. The single space docling's serializer inserts between inline
901/// items is stripped from the adjacent plain runs.
902fn inline_segments(md: &str) -> Vec<String> {
903    inline_segments_tagged(md)
904        .into_iter()
905        .map(|(t, _)| t)
906        .collect()
907}
908
909/// Like [`inline_segments`], with each segment tagged `true` when it came from
910/// plain (unmarked) text — those may still need splitting at run boundaries
911/// invisible in markdown (underline, soft breaks).
912fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
913    let chars: Vec<char> = md.chars().collect();
914    let n = chars.len();
915    let find = |from: usize, pat: &str| -> Option<usize> {
916        let hay: String = chars[from..].iter().collect();
917        hay.find(pat).map(|p| from + hay[..p].chars().count())
918    };
919    let mut out: Vec<(String, bool)> = Vec::new();
920    let mut plain = String::new();
921    let mut after_span = false;
922
923    fn flush(
924        out: &mut Vec<(String, bool)>,
925        plain: &mut String,
926        before_span: bool,
927        after_span: bool,
928    ) {
929        let mut p = std::mem::take(plain);
930        if after_span {
931            if let Some(rest) = p.strip_prefix(' ') {
932                p = rest.to_string();
933            }
934        }
935        if before_span {
936            if let Some(rest) = p.strip_suffix(' ') {
937                p = rest.to_string();
938            }
939        }
940        if !p.is_empty() {
941            out.push((unescape_text(&p), true));
942        }
943    }
944
945    let mut i = 0;
946    while i < n {
947        let rest: String = chars[i..].iter().collect();
948        // A hyperlink span (not an image): the whole `[text](url)` is one item.
949        // The URL may itself contain balanced parentheses (`/Duck_(film)`),
950        // so the closing `)` is found by paren depth, not first match.
951        if chars[i] == '[' && !rest.starts_with("[](") {
952            // The label may itself contain balanced brackets (`[[ 1 ]](#ref)`
953            // has label `[ 1 ]`), but an *unbalanced* `[` means this bracket is
954            // plain text preceding a real link (`[ [*note*](url) ]`).
955            let balanced = |c: usize| {
956                let mut d = 0i32;
957                for &ch in &chars[i + 1..c] {
958                    match ch {
959                        '[' => d += 1,
960                        ']' => d -= 1,
961                        _ => {}
962                    }
963                }
964                d == 0
965            };
966            if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
967                let mut depth = 0usize;
968                let mut url_end = None;
969                for (k, &c) in chars.iter().enumerate().skip(close + 2) {
970                    match c {
971                        '(' => depth += 1,
972                        ')' => {
973                            if depth == 0 {
974                                url_end = Some(k);
975                                break;
976                            }
977                            depth -= 1;
978                        }
979                        _ => {}
980                    }
981                }
982                if let Some(endp) = url_end {
983                    flush(&mut out, &mut plain, true, after_span);
984                    out.push((
985                        unescape_text(&chars[i..=endp].iter().collect::<String>()),
986                        false,
987                    ));
988                    i = endp + 1;
989                    after_span = true;
990                    continue;
991                }
992            }
993        }
994        // A formatted span; longest markers first. An inline code span is a
995        // code item in docling's model, whose standalone form is a fenced block.
996        let mut matched = false;
997        for marker in ["***", "**", "*", "~~", "`"] {
998            if rest.starts_with(marker) {
999                let mlen = marker.chars().count();
1000                if let Some(end) = find(i + mlen, marker) {
1001                    // A whitespace-only span (`* *` from a literal asterisk in
1002                    // running text next to a real italic) is not a docling run:
1003                    // treat the marker as plain text.
1004                    let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
1005                    if end > i + mlen && !inner_blank {
1006                        flush(&mut out, &mut plain, true, after_span);
1007                        if marker == "`" {
1008                            let inner: String = chars[i + 1..end].iter().collect();
1009                            out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
1010                        } else {
1011                            out.push((
1012                                unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
1013                                false,
1014                            ));
1015                        }
1016                        i = end + mlen;
1017                        after_span = true;
1018                        matched = true;
1019                    }
1020                }
1021                break;
1022            }
1023        }
1024        if matched {
1025            continue;
1026        }
1027        // A literal `$$` inside running text is not an inline formula: copy it
1028        // through as plain characters.
1029        if rest.starts_with("$$") {
1030            plain.push_str("$$");
1031            i += 2;
1032            continue;
1033        }
1034        // An inline formula: standalone it re-serializes in display form.
1035        if chars[i] == '$' {
1036            if let Some(end) = find(i + 1, "$") {
1037                if end > i + 1 {
1038                    flush(&mut out, &mut plain, true, after_span);
1039                    let latex: String = chars[i + 1..end].iter().collect();
1040                    out.push((format!("$${latex}$$"), false));
1041                    i = end + 1;
1042                    after_span = true;
1043                    continue;
1044                }
1045            }
1046        }
1047        plain.push(chars[i]);
1048        i += 1;
1049    }
1050    flush(&mut out, &mut plain, false, after_span);
1051    if out.is_empty() {
1052        out.push((unescape_text(md), true));
1053    }
1054    out
1055}
1056
1057/// Split a plain markdown segment at run boundaries the markdown cannot show
1058/// (an underlined run, a `<sub>`/`<sup>` run): when a consecutive window of
1059/// two or more unmarked runs exactly covers the segment, each run is its own
1060/// document item.
1061fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1062    let target = segment.trim();
1063    if target.is_empty() {
1064        return None;
1065    }
1066    let plainish =
1067        |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1068    let fully_plain =
1069        |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1070    let unmarked: Vec<(&str, bool)> = runs
1071        .iter()
1072        .filter(|r| plainish(r))
1073        .map(|r| (r.text.as_str(), fully_plain(r)))
1074        .collect();
1075    for start in 0..unmarked.len() {
1076        let mut rest = target;
1077        let mut taken: Vec<(String, bool)> = Vec::new();
1078        for (t, fully) in &unmarked[start..] {
1079            let t = t.trim();
1080            if t.is_empty() {
1081                continue;
1082            }
1083            match rest.strip_prefix(t) {
1084                Some(r) => {
1085                    taken.push((unescape_text(t), *fully));
1086                    rest = r.trim_start();
1087                    if rest.is_empty() {
1088                        break;
1089                    }
1090                }
1091                None => break,
1092            }
1093        }
1094        if rest.is_empty() && taken.len() >= 2 {
1095            // Consecutive fully-plain pieces (no underline / sub / sup) are one
1096            // annotation in docling's model — `simplify_text_elements` merges
1097            // them joined with a space, so they never split between themselves.
1098            // Only an underline / sub / sup run is a genuine boundary.
1099            let mut merged: Vec<(String, bool)> = Vec::new();
1100            for (t, fully) in taken {
1101                match merged.last_mut() {
1102                    Some((last, true)) if fully => {
1103                        last.push(' ');
1104                        last.push_str(&t);
1105                    }
1106                    _ => merged.push((t, fully)),
1107                }
1108            }
1109            if merged.len() >= 2 {
1110                return Some(merged.into_iter().map(|(t, _)| t).collect());
1111            }
1112            return None;
1113        }
1114    }
1115    None
1116}
1117
1118/// A table cell's text for the triplet serializer. A *rich* cell (one carrying
1119/// block content) is re-serialized the way docling's chunking serializer sees
1120/// it: paragraphs joined with blank lines, a nested table as its own triplet
1121/// sentences, pictures as an empty placeholder. Plain cells use the flat text
1122/// with the markdown image placeholder stripped (the chunking serializer's
1123/// `image_placeholder` is empty).
1124fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
1125    if let Some(blocks) = t
1126        .cell_blocks
1127        .as_ref()
1128        .and_then(|b| b.get(r))
1129        .and_then(|row| row.get(c))
1130        .filter(|b| !b.is_empty())
1131    {
1132        let mut parts: Vec<String> = Vec::new();
1133        for node in blocks.iter() {
1134            let part = block_chunk_text(node);
1135            if !part.is_empty() {
1136                parts.push(part);
1137            }
1138        }
1139        return parts.join("\n\n");
1140    }
1141    let flat = t
1142        .rows
1143        .get(r)
1144        .and_then(|row| row.get(c))
1145        .map(String::as_str)
1146        .unwrap_or("");
1147    unescape_text(flat)
1148        .replace("<!-- image -->", "")
1149        .trim()
1150        .to_string()
1151}
1152
1153/// One block of a rich cell, serialized for chunking.
1154fn block_chunk_text(node: &Node) -> String {
1155    match node {
1156        Node::Paragraph { text } => unescape_text(text),
1157        Node::LabeledText { .. } => block_chunk_text(&node.labeled_as_paragraph()),
1158        Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1159        Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1160        Node::Table(inner) => triplet_table_text(inner),
1161        Node::Picture { caption, .. } => caption
1162            .as_deref()
1163            .filter(|c| !c.is_empty())
1164            .map(unescape_text)
1165            .unwrap_or_default(),
1166        Node::ListItem {
1167            ordered,
1168            number,
1169            text,
1170            ..
1171        } => {
1172            let marker = if *ordered {
1173                format!("{number}.")
1174            } else {
1175                "-".to_string()
1176            };
1177            format!("{marker} {}", unescape_text(text))
1178        }
1179        Node::CheckboxItem { checked, text } => {
1180            let mark = if *checked { "- [x] " } else { "- [ ] " };
1181            format!("{mark}{}", unescape_text(text))
1182        }
1183        Node::Heading { text, .. } => unescape_text(text),
1184        Node::Located { inner, .. }
1185        | Node::Prov { inner, .. }
1186        | Node::Track { inner, .. }
1187        | Node::Commented { inner, .. } => block_chunk_text(inner),
1188        Node::Group { layer: Some(_), .. } => String::new(),
1189        Node::Group { children, .. } => children
1190            .iter()
1191            .map(block_chunk_text)
1192            .filter(|s| !s.is_empty())
1193            .collect::<Vec<_>>()
1194            .join("\n"),
1195        _ => String::new(),
1196    }
1197}
1198
1199/// Reverse the model's baked markdown text escaping — same mapping as the JSON
1200/// exporter (docling chunks carry raw text).
1201fn unescape_text(s: &str) -> String {
1202    s.replace("&lt;", "<")
1203        .replace("&gt;", ">")
1204        .replace("&amp;", "&")
1205        .replace("\\_", "_")
1206}
1207
1208// ---------------------------------------------------------------------------
1209// Hybrid chunker
1210// ---------------------------------------------------------------------------
1211
1212/// Token counting for [`HybridChunker`] — docling's `BaseTokenizer`.
1213pub trait ChunkTokenizer {
1214    /// Number of tokens in `text` (no special tokens).
1215    fn count_tokens(&self, text: &str) -> usize;
1216    /// The chunk budget (docling's `max_tokens`, e.g. 256 for MiniLM).
1217    fn max_tokens(&self) -> usize;
1218}
1219
1220/// Tokenization-aware chunker on top of [`HierarchicalChunker`] — docling's
1221/// `HybridChunker` with default parameters (`merge_peers`,
1222/// `repeat_table_header` on; `omit_header_on_overflow` off).
1223pub struct HybridChunker<T: ChunkTokenizer> {
1224    tokenizer: T,
1225    merge_peers: bool,
1226}
1227
1228impl<T: ChunkTokenizer> HybridChunker<T> {
1229    pub fn new(tokenizer: T) -> Self {
1230        Self {
1231            tokenizer,
1232            merge_peers: true,
1233        }
1234    }
1235
1236    /// Disable merging of undersized same-heading neighbours.
1237    pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1238        self.merge_peers = merge_peers;
1239        self
1240    }
1241
1242    pub fn max_tokens(&self) -> usize {
1243        self.tokenizer.max_tokens()
1244    }
1245
1246    /// Chunk the document.
1247    pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1248        let mut chunks = Vec::new();
1249        self.chunk_with(doc, &mut |c| {
1250            chunks.push(c);
1251            true
1252        });
1253        chunks
1254    }
1255
1256    /// Stream the chunks: each hierarchical chunk is split against the token
1257    /// budget as the document walk produces it, and the peer merge flushes a
1258    /// merged chunk to `sink` as soon as its window closes (a chunk with
1259    /// different headings arrives, or the budget fills). A `false` return from
1260    /// `sink` cancels the chunking. [`Self::chunk`] is this with a collecting
1261    /// sink — the chunks and their order are identical.
1262    pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1263        let mut merger = PeerMerger::default();
1264        let mut alive = true;
1265        HierarchicalChunker.chunk_with(doc, &mut |c| {
1266            for split in self.split_by_doc_items(c) {
1267                for chunk in self.split_using_plain_text(split) {
1268                    if !alive {
1269                        return false;
1270                    }
1271                    alive = if self.merge_peers {
1272                        self.merge_push(&mut merger, chunk, sink)
1273                    } else {
1274                        sink(chunk)
1275                    };
1276                }
1277            }
1278            alive
1279        });
1280        if alive {
1281            self.merge_flush(&mut merger, sink);
1282        }
1283    }
1284
1285    fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1286        self.tokenizer.count_tokens(&contextualize(chunk))
1287    }
1288
1289    /// docling's `_make_chunk_from_doc_items`: single-item chunks keep their
1290    /// text; multi-item windows re-join the items' standalone serializations.
1291    fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1292        let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1293        let text = if chunk.doc_items.len() == 1 {
1294            chunk.text.clone()
1295        } else {
1296            doc_items
1297                .iter()
1298                .filter(|it| !it.text.is_empty())
1299                .map(|it| it.text.as_str())
1300                .collect::<Vec<_>>()
1301                .join("\n")
1302        };
1303        DocChunk {
1304            text,
1305            headings: chunk.headings.clone(),
1306            doc_items,
1307        }
1308    }
1309
1310    fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1311        if chunk.doc_items.is_empty() {
1312            return vec![chunk];
1313        }
1314        let max = self.max_tokens();
1315        let num_items = chunk.doc_items.len();
1316        let mut chunks = Vec::new();
1317        let mut window_start = 0usize;
1318        let mut window_end = 0usize; // inclusive
1319        while window_end < num_items {
1320            let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1321            if self.count_chunk_tokens(&new_chunk) <= max {
1322                if window_end < num_items - 1 {
1323                    window_end += 1;
1324                    continue;
1325                } else {
1326                    window_end = num_items; // last loop
1327                }
1328            } else if window_start == window_end {
1329                // One item that doesn't fit: keep it; the plain-text splitter
1330                // takes over.
1331                window_end += 1;
1332                window_start = window_end;
1333            } else {
1334                // The window without its last item fit; flush that and start a
1335                // new window at the current item.
1336                new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1337                window_start = window_end;
1338            }
1339            chunks.push(new_chunk);
1340        }
1341        chunks
1342    }
1343
1344    fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1345        let total = self.count_chunk_tokens(&chunk);
1346        let max = self.max_tokens();
1347        if total <= max {
1348            return vec![chunk];
1349        }
1350        let text_len = self.tokenizer.count_tokens(&chunk.text);
1351        let other_len = total - text_len;
1352        if other_len >= max {
1353            // Headings alone exceed the budget: drop them and retry.
1354            let stripped = DocChunk {
1355                headings: None,
1356                ..chunk
1357            };
1358            return self.split_using_plain_text(stripped);
1359        }
1360        let available = max - other_len;
1361
1362        let segments =
1363            if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1364                // Table: split line-based, repeating headers. The triplet
1365                // serializer has no header lines, so this is a line-preserving
1366                // split of the table text. (docling constructs the line chunker
1367                // with the *tokenizer's* max_tokens — the `max_tokens=available`
1368                // argument is silently dropped by pydantic — so the line budget is
1369                // the full window, not `available`.)
1370                let lines: Vec<String> = chunk
1371                    .text
1372                    .split('\n')
1373                    .filter(|l| !l.trim().is_empty())
1374                    .map(|l| l.to_string())
1375                    .collect();
1376                line_chunk_text(&lines, &self.tokenizer, max)
1377            } else {
1378                semchunk(&chunk.text, available, &self.tokenizer)
1379            };
1380        segments
1381            .into_iter()
1382            .map(|s| DocChunk {
1383                text: s,
1384                headings: chunk.headings.clone(),
1385                doc_items: chunk.doc_items.clone(),
1386            })
1387            .collect()
1388    }
1389
1390    /// One step of docling's `_merge_chunks_with_matching_metadata`, streamed:
1391    /// extend the window with `chunk` when its headings match the window's and
1392    /// the merged candidate stays within budget, otherwise flush the window to
1393    /// `sink` and start a new one at `chunk`. Returns `false` once the sink
1394    /// cancels.
1395    fn merge_push(
1396        &self,
1397        m: &mut PeerMerger,
1398        chunk: DocChunk,
1399        sink: &mut dyn FnMut(DocChunk) -> bool,
1400    ) -> bool {
1401        if m.window.is_empty() {
1402            m.window.push(chunk);
1403            return true;
1404        }
1405        let candidate = DocChunk {
1406            text: m
1407                .window
1408                .iter()
1409                .map(|c| c.text.as_str())
1410                .chain([chunk.text.as_str()])
1411                .collect::<Vec<_>>()
1412                .join("\n"),
1413            headings: m.window[0].headings.clone(),
1414            doc_items: m
1415                .window
1416                .iter()
1417                .flat_map(|c| c.doc_items.iter().cloned())
1418                .chain(chunk.doc_items.iter().cloned())
1419                .collect(),
1420        };
1421        if chunk.headings == m.window[0].headings
1422            && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1423        {
1424            m.window.push(chunk);
1425            m.merged = Some(candidate);
1426            true
1427        } else {
1428            let alive = self.merge_flush(m, sink);
1429            m.window.push(chunk);
1430            alive
1431        }
1432    }
1433
1434    /// Flush the merge window: a single chunk passes through unchanged, a
1435    /// multi-chunk window emits its precomputed merge. Returns `false` once
1436    /// the sink cancels.
1437    fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1438        let alive = if m.window.len() == 1 {
1439            sink(m.window.pop().expect("single-chunk window"))
1440        } else if !m.window.is_empty() {
1441            m.window.clear();
1442            sink(m.merged.take().expect("multi-chunk window has a merge"))
1443        } else {
1444            true
1445        };
1446        m.merged = None;
1447        alive
1448    }
1449}
1450
1451/// The in-flight peer-merge window of [`HybridChunker::chunk_with`].
1452#[derive(Default)]
1453struct PeerMerger {
1454    window: Vec<DocChunk>,
1455    merged: Option<DocChunk>,
1456}
1457
1458// ---------------------------------------------------------------------------
1459// Line-based token chunking (docling's LineBasedTokenChunker, empty prefix)
1460// ---------------------------------------------------------------------------
1461
1462/// Pack lines into chunks of at most `max_tokens`, splitting a line only when
1463/// it exceeds the budget on its own — docling's `LineBasedTokenChunker
1464/// .chunk_text` with an empty prefix (which is what the triplet table
1465/// serializer yields). Reproduces its exact output, including the `\n` it
1466/// prepends to a carried-over segment of an oversized line.
1467fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1468    let mut chunks: Vec<String> = Vec::new();
1469    let mut current = String::new();
1470    let mut current_len = 0usize;
1471
1472    for line in lines {
1473        let mut remaining: Vec<char> = line.chars().collect();
1474        loop {
1475            let rem_str: String = remaining.iter().collect();
1476            let line_tokens = tok.count_tokens(&rem_str);
1477            let available = max_tokens.saturating_sub(current_len);
1478
1479            if line_tokens <= available {
1480                current.push_str(&rem_str);
1481                current_len += line_tokens;
1482                break;
1483            }
1484            if line_tokens <= max_tokens {
1485                chunks.push(std::mem::take(&mut current));
1486                current_len = 0;
1487                continue;
1488            }
1489            // Too large even for an empty chunk: split off what fits.
1490            let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1491            let mut rest = rest;
1492            if take.is_empty() {
1493                if rest.is_empty() {
1494                    break;
1495                }
1496                take = rest[..1].iter().collect();
1497                rest = rest[1..].to_vec();
1498            }
1499            current.push('\n');
1500            current.push_str(&take);
1501            chunks.push(std::mem::take(&mut current));
1502            current_len = 0;
1503            remaining = rest;
1504        }
1505    }
1506    if !current.is_empty() {
1507        chunks.push(current);
1508    }
1509    chunks
1510}
1511
1512/// Binary-search the longest char-prefix of `text` within `token_limit`
1513/// tokens, preferring to break at the last ASCII space — docling's
1514/// `split_by_token_limit`.
1515fn split_by_token_limit<T: ChunkTokenizer>(
1516    text: &[char],
1517    token_limit: usize,
1518    tok: &T,
1519) -> (String, Vec<char>) {
1520    if token_limit == 0 || text.is_empty() {
1521        return (String::new(), text.to_vec());
1522    }
1523    let full: String = text.iter().collect();
1524    if tok.count_tokens(&full) <= token_limit {
1525        return (full, Vec::new());
1526    }
1527    let (mut lo, mut hi) = (0usize, text.len());
1528    let mut best: Option<usize> = None;
1529    while lo <= hi {
1530        let mid = (lo + hi) / 2;
1531        let head: String = text[..mid].iter().collect();
1532        if tok.count_tokens(&head) <= token_limit {
1533            best = Some(mid);
1534            lo = mid + 1;
1535        } else {
1536            if mid == 0 {
1537                break;
1538            }
1539            hi = mid - 1;
1540        }
1541    }
1542    let mut best_idx = match best {
1543        Some(b) if b > 0 => b,
1544        _ => return (String::new(), text.to_vec()),
1545    };
1546    // Snap back to the last space, if that leaves a non-empty head.
1547    if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1548        if pos > 0 {
1549            best_idx = pos;
1550        }
1551    }
1552    (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1553}
1554
1555// ---------------------------------------------------------------------------
1556// semchunk port (the plain-text splitter HybridChunker delegates to)
1557// ---------------------------------------------------------------------------
1558
1559/// Semantically meaningful non-whitespace splitters, most desirable first.
1560const NON_WS_SPLITTERS: &[&str] = &[
1561    ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1562    "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1563];
1564
1565/// Split `text` into chunks of at most `chunk_size` tokens using the most
1566/// semantically meaningful splitter available — the `semchunk` algorithm
1567/// docling's HybridChunker delegates plain-text splitting to.
1568pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1569    let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1570    let mut counter = |s: &str| -> usize {
1571        if let Some(n) = cache.get(s) {
1572            return *n;
1573        }
1574        let n = tok.count_tokens(s);
1575        cache.insert(s.to_string(), n);
1576        n
1577    };
1578    let chunks = semchunk_rec(text, chunk_size, &mut counter);
1579    // top-level: drop empty / all-whitespace chunks
1580    chunks
1581        .into_iter()
1582        .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1583        .collect()
1584}
1585
1586/// One recursion level of semchunk: split, merge windows back up to size, and
1587/// recurse into oversized splits.
1588fn semchunk_rec(
1589    text: &str,
1590    chunk_size: usize,
1591    counter: &mut dyn FnMut(&str) -> usize,
1592) -> Vec<String> {
1593    let (splitter, splitter_is_ws, splits) = split_text(text);
1594
1595    let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1596    let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1597    cum_lens.push(0usize);
1598    for l in &split_lens {
1599        cum_lens.push(cum_lens.last().unwrap() + l);
1600    }
1601    let num_splits_plus_one = splits.len() + 1;
1602
1603    let mut chunks: Vec<String> = Vec::new();
1604    let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1605
1606    for i in 0..splits.len() {
1607        if skips.contains(&i) {
1608            continue;
1609        }
1610        let split = &splits[i];
1611        if counter(split) > chunk_size {
1612            let inner = semchunk_rec(split, chunk_size, counter);
1613            chunks.extend(inner);
1614        } else {
1615            let (end, merged) = merge_splits(
1616                &splits,
1617                &cum_lens,
1618                chunk_size,
1619                &splitter,
1620                counter,
1621                i,
1622                num_splits_plus_one,
1623            );
1624            for j in (i + 1)..end {
1625                skips.insert(j);
1626            }
1627            chunks.push(merged);
1628        }
1629        // Re-attach a non-whitespace splitter to the last chunk (or emit it as
1630        // its own chunk if it doesn't fit).
1631        let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1632        if !splitter_is_ws && !is_last {
1633            let with_splitter = format!(
1634                "{}{}",
1635                chunks.last().map(String::as_str).unwrap_or(""),
1636                splitter
1637            );
1638            if counter(&with_splitter) <= chunk_size {
1639                if let Some(last) = chunks.last_mut() {
1640                    *last = with_splitter;
1641                } else {
1642                    chunks.push(with_splitter);
1643                }
1644            } else {
1645                chunks.push(splitter.clone());
1646            }
1647        }
1648    }
1649    chunks
1650}
1651
1652/// docling/semchunk's `merge_splits`: extend the window with a cum-length-guided
1653/// binary search until the token budget is hit.
1654fn merge_splits(
1655    splits: &[String],
1656    cum_lens: &[usize],
1657    chunk_size: usize,
1658    splitter: &str,
1659    counter: &mut dyn FnMut(&str) -> usize,
1660    start: usize,
1661    high_init: usize,
1662) -> (usize, String) {
1663    let mut average = 0.2f64;
1664    let mut low = start;
1665    let mut high = high_init;
1666    let offset = cum_lens[start];
1667    let mut target = offset as f64 + (chunk_size as f64 * average);
1668
1669    while low < high {
1670        let i = bisect_left(cum_lens, target, low, high);
1671        let midpoint = i.min(high - 1);
1672        let joined = splits[start..midpoint.max(start)].join(splitter);
1673        let tokens = counter(&joined);
1674        let local_cum = cum_lens[midpoint] - offset;
1675        if local_cum > 0 && tokens > 0 {
1676            average = local_cum as f64 / tokens as f64;
1677            target = offset as f64 + (chunk_size as f64 * average);
1678        }
1679        if tokens > chunk_size {
1680            high = midpoint;
1681        } else {
1682            low = midpoint + 1;
1683        }
1684    }
1685    let end = low - 1;
1686    (end, splits[start..end.max(start)].join(splitter))
1687}
1688
1689fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1690    while low < high {
1691        let mid = (low + high) / 2;
1692        if (sorted[mid] as f64) < target {
1693            low = mid + 1;
1694        } else {
1695            high = mid;
1696        }
1697    }
1698    low
1699}
1700
1701/// semchunk's `_split_text`: pick the most desirable splitter present.
1702fn split_text(text: &str) -> (String, bool, Vec<String>) {
1703    // Longest run of newlines/carriage returns.
1704    if text.contains('\n') || text.contains('\r') {
1705        let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1706        return (splitter.clone(), true, split_on(text, &splitter));
1707    }
1708    // Longest run of tabs.
1709    if text.contains('\t') {
1710        let splitter = longest_run(text, |c| c == '\t');
1711        return (splitter.clone(), true, split_on(text, &splitter));
1712    }
1713    // Longest run of whitespace.
1714    if text.chars().any(char::is_whitespace) {
1715        let splitter = longest_run(text, char::is_whitespace);
1716        if splitter.chars().count() == 1 {
1717            // Prefer a whitespace char preceded by a semantic splitter.
1718            for preceder in NON_WS_SPLITTERS {
1719                if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1720                    return (ws, true, parts);
1721                }
1722            }
1723        }
1724        return (splitter.clone(), true, split_on(text, &splitter));
1725    }
1726    // Most desirable semantic splitter present.
1727    for s in NON_WS_SPLITTERS {
1728        if text.contains(s) {
1729            return (s.to_string(), false, split_on(text, s));
1730        }
1731    }
1732    // No splitter at all: split into characters.
1733    (
1734        String::new(),
1735        true,
1736        text.chars().map(|c| c.to_string()).collect(),
1737    )
1738}
1739
1740/// The longest maximal run of chars matching `pred` (first one wins ties).
1741fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1742    let mut best = String::new();
1743    let mut cur = String::new();
1744    for c in text.chars() {
1745        if pred(c) {
1746            cur.push(c);
1747        } else {
1748            if cur.chars().count() > best.chars().count() {
1749                best = cur.clone();
1750            }
1751            cur.clear();
1752        }
1753    }
1754    if cur.chars().count() > best.chars().count() {
1755        best = cur;
1756    }
1757    best
1758}
1759
1760fn split_on(text: &str, splitter: &str) -> Vec<String> {
1761    text.split(splitter).map(str::to_string).collect()
1762}
1763
1764/// Python: `re.search(rf'{p}(\s)', text)` → the first whitespace char preceded
1765/// by `p`; then `re.split(rf'(?<={p}){s}', text)` — split at every occurrence
1766/// of that whitespace char immediately preceded by `p`.
1767fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1768    let chars: Vec<char> = text.chars().collect();
1769    let p: Vec<char> = preceder.chars().collect();
1770    let mut ws: Option<char> = None;
1771    for i in p.len()..chars.len() {
1772        if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1773            ws = Some(chars[i]);
1774            break;
1775        }
1776    }
1777    let ws = ws?;
1778    let mut parts = Vec::new();
1779    let mut cur = String::new();
1780    let mut i = 0usize;
1781    while i < chars.len() {
1782        if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1783            parts.push(std::mem::take(&mut cur));
1784            i += 1;
1785            continue;
1786        }
1787        cur.push(chars[i]);
1788        i += 1;
1789    }
1790    parts.push(cur);
1791    Some((ws.to_string(), parts))
1792}
1793
1794// ---------------------------------------------------------------------------
1795// HuggingFace tokenizer (feature `chunking`)
1796// ---------------------------------------------------------------------------
1797
1798#[cfg(feature = "chunking")]
1799mod hf {
1800    use super::ChunkTokenizer;
1801
1802    /// Where `scripts/install/download_dependencies.sh` puts the hybrid
1803    /// chunker's default tokenizer (all-MiniLM-L6-v2's `tokenizer.json`),
1804    /// relative to the process's working directory — the same convention as
1805    /// the `.models/` ONNX files.
1806    pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1807
1808    /// Resolve the tokenizer path for the hybrid chunker: an explicit path
1809    /// wins; otherwise fall back to [`DEFAULT_TOKENIZER_PATH`] when it exists
1810    /// on disk. Errors with the download instructions when neither is
1811    /// available.
1812    pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1813        if let Some(p) = explicit {
1814            return Ok(p.to_string());
1815        }
1816        let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1817        if std::path::Path::new(&resolved).exists() {
1818            return Ok(resolved);
1819        }
1820        Err(format!(
1821            "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1822             {DEFAULT_TOKENIZER_PATH} does not exist — run \
1823             scripts/install/download_dependencies.sh (or pass an explicit path)"
1824        ))
1825    }
1826
1827    /// [`ChunkTokenizer`] backed by a HuggingFace `tokenizer.json` — the Rust
1828    /// analogue of docling's `HuggingFaceTokenizer` (whose default is
1829    /// `sentence-transformers/all-MiniLM-L6-v2` with `max_tokens` 256).
1830    pub struct HuggingFaceTokenizer {
1831        tok: tokenizers::Tokenizer,
1832        max_tokens: usize,
1833    }
1834
1835    impl HuggingFaceTokenizer {
1836        /// Load the tokenizer from an explicit path, or from
1837        /// [`DEFAULT_TOKENIZER_PATH`] when `path` is `None` (see
1838        /// [`resolve_tokenizer_path`]).
1839        pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1840            Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1841        }
1842
1843        /// Load a `tokenizer.json`. `max_tokens` is the chunk budget (docling
1844        /// resolves it from the model's `sentence_bert_config.json`; for the
1845        /// default MiniLM model that is 256).
1846        pub fn from_file(
1847            path: impl AsRef<std::path::Path>,
1848            max_tokens: usize,
1849        ) -> Result<Self, String> {
1850            let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1851                .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1852            // Counting must see the full text (docling tokenizes without
1853            // truncation, padding, or special tokens — MiniLM's tokenizer.json
1854            // ships with fixed-length padding enabled, which would make every
1855            // short string count as the padded length).
1856            let _ = tok.with_truncation(None);
1857            tok.with_padding(None);
1858            Ok(Self { tok, max_tokens })
1859        }
1860    }
1861
1862    impl ChunkTokenizer for HuggingFaceTokenizer {
1863        fn count_tokens(&self, text: &str) -> usize {
1864            self.tok
1865                .encode(text, false)
1866                .map(|e| e.get_tokens().len())
1867                .unwrap_or(0)
1868        }
1869        fn max_tokens(&self) -> usize {
1870            self.max_tokens
1871        }
1872    }
1873}
1874
1875#[cfg(feature = "chunking")]
1876pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1877
1878// ---------------------------------------------------------------------------
1879// Window chunker (feature `chunking`) — docling-rag's Markdown window chunker
1880// ---------------------------------------------------------------------------
1881
1882#[cfg(feature = "chunking")]
1883mod window {
1884    use super::DocChunk;
1885    use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1886
1887    /// A contiguous run of body text under a heading path.
1888    #[derive(Debug, Clone, Default)]
1889    pub struct Section {
1890        /// The heading stack in effect for this section, outermost first
1891        /// (e.g. `["Guide", "Setup"]`). Empty for pre-heading / body-only text.
1892        pub heading_path: Vec<String>,
1893        /// The plain words of the section body, markup stripped.
1894        pub words: Vec<String>,
1895    }
1896
1897    impl Section {
1898        /// The heading path rendered as a single context line, e.g.
1899        /// `# Guide > Setup`. Empty string when there is no heading.
1900        pub fn heading_context(&self) -> String {
1901            if self.heading_path.is_empty() {
1902                String::new()
1903            } else {
1904                format!("# {}", self.heading_path.join(" > "))
1905            }
1906        }
1907    }
1908
1909    fn level_index(level: HeadingLevel) -> usize {
1910        match level {
1911            HeadingLevel::H1 => 1,
1912            HeadingLevel::H2 => 2,
1913            HeadingLevel::H3 => 3,
1914            HeadingLevel::H4 => 4,
1915            HeadingLevel::H5 => 5,
1916            HeadingLevel::H6 => 6,
1917        }
1918    }
1919
1920    /// Parse Markdown into heading-bounded sections. A new section starts at
1921    /// every heading; the heading path is maintained as a stack keyed by
1922    /// heading level.
1923    pub fn parse_sections(markdown: &str) -> Vec<Section> {
1924        parse_sections_with_stack(markdown, Vec::new()).0
1925    }
1926
1927    /// [`parse_sections`] with an explicit initial heading stack, returning the
1928    /// final stack — lets a streaming caller carry heading context across
1929    /// pieces. `heading_stack[i]` holds the current heading text at level `i+1`
1930    /// (may be empty when a level was skipped).
1931    pub fn parse_sections_with_stack(
1932        markdown: &str,
1933        initial_stack: Vec<String>,
1934    ) -> (Vec<Section>, Vec<String>) {
1935        let mut heading_stack: Vec<String> = initial_stack;
1936        let mut sections: Vec<Section> = Vec::new();
1937        // Text before the first heading of this piece continues the carried-over
1938        // section, so it keeps the heading path in effect at the split point.
1939        let mut current = Section {
1940            heading_path: heading_stack
1941                .iter()
1942                .filter(|h| !h.is_empty())
1943                .cloned()
1944                .collect(),
1945            words: Vec::new(),
1946        };
1947
1948        let mut in_heading = false;
1949        let mut heading_level = 0usize;
1950        let mut heading_buf = String::new();
1951
1952        let push_words = |section: &mut Section, text: &str| {
1953            for w in text.split_whitespace() {
1954                section.words.push(w.to_string());
1955            }
1956        };
1957
1958        let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1959            if !section.words.is_empty() {
1960                sections.push(std::mem::take(section));
1961            } else {
1962                *section = Section::default();
1963            }
1964        };
1965
1966        for event in Parser::new(markdown) {
1967            match event {
1968                Event::Start(Tag::Heading { level, .. }) => {
1969                    in_heading = true;
1970                    heading_level = level_index(level);
1971                    heading_buf.clear();
1972                }
1973                Event::End(TagEnd::Heading(_)) => {
1974                    in_heading = false;
1975                    // Update the heading stack: set this level, drop anything deeper.
1976                    let idx = heading_level.saturating_sub(1);
1977                    if heading_stack.len() <= idx {
1978                        heading_stack.resize(idx + 1, String::new());
1979                    } else {
1980                        heading_stack.truncate(idx + 1);
1981                    }
1982                    heading_stack[idx] = heading_buf.trim().to_string();
1983                    // A heading begins a new section.
1984                    flush(&mut sections, &mut current);
1985                    current.heading_path = heading_stack
1986                        .iter()
1987                        .filter(|h| !h.is_empty())
1988                        .cloned()
1989                        .collect();
1990                }
1991                Event::Text(t) | Event::Code(t) => {
1992                    if in_heading {
1993                        if !heading_buf.is_empty() {
1994                            heading_buf.push(' ');
1995                        }
1996                        heading_buf.push_str(&t);
1997                    } else {
1998                        push_words(&mut current, &t);
1999                    }
2000                }
2001                // Treat hard/soft breaks and rules as whitespace (words already split).
2002                Event::SoftBreak | Event::HardBreak | Event::Rule => {}
2003                _ => {}
2004            }
2005        }
2006        flush(&mut sections, &mut current);
2007        (sections, heading_stack)
2008    }
2009
2010    /// docling-rag's Markdown **window chunker**: the document is split into
2011    /// heading-bounded [`Section`]s of plain words (markup stripped), and a
2012    /// fixed-size window of [`Self::max_words`] words slides over each section
2013    /// with [`Self::overlap`] fractional overlap. A chunk never crosses a
2014    /// heading boundary; [`Self::contextualize`] prefixes the heading path.
2015    #[derive(Debug, Clone)]
2016    pub struct WindowChunker {
2017        /// Window size in words (docling-rag's default 300).
2018        pub max_words: usize,
2019        /// Fractional overlap between consecutive windows (default 0.05 = 5%).
2020        pub overlap: f32,
2021    }
2022
2023    impl Default for WindowChunker {
2024        fn default() -> Self {
2025            WindowChunker {
2026                max_words: 300,
2027                overlap: 0.05,
2028            }
2029        }
2030    }
2031
2032    impl WindowChunker {
2033        pub fn new(max_words: usize, overlap: f32) -> Self {
2034            WindowChunker { max_words, overlap }
2035        }
2036
2037        /// The window size, kept ≥ 1.
2038        fn word_budget(&self) -> usize {
2039            self.max_words.max(1)
2040        }
2041
2042        /// Number of words carried from one window into the next; capped so
2043        /// the window always advances.
2044        fn overlap_words(&self, budget: usize) -> usize {
2045            let o = (budget as f32 * self.overlap).round() as usize;
2046            o.min(budget.saturating_sub(1))
2047        }
2048
2049        /// Chunk a Markdown document.
2050        pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2051            let mut chunks = Vec::new();
2052            self.chunk_with(markdown, &mut |c| {
2053                chunks.push(c);
2054                true
2055            });
2056            chunks
2057        }
2058
2059        /// Stream the chunks: `sink` receives each window as it is cut, and a
2060        /// `false` return cancels. [`Self::chunk`] is this with a collecting
2061        /// sink — the chunks and their order are identical.
2062        pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2063            let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2064            for section in &sections {
2065                if !self.pack_section(section, sink) {
2066                    return;
2067                }
2068            }
2069        }
2070
2071        /// Slide the window over one completed section, feeding each chunk to
2072        /// `sink`. Returns `false` once the sink cancels — exposed so a
2073        /// streaming caller (docling-rag) can pack sections as they complete.
2074        pub fn pack_section(
2075            &self,
2076            section: &Section,
2077            sink: &mut dyn FnMut(DocChunk) -> bool,
2078        ) -> bool {
2079            let words = &section.words;
2080            if words.is_empty() {
2081                return true;
2082            }
2083            let budget = self.word_budget();
2084            let step = budget - self.overlap_words(budget); // ≥ 1 by construction
2085            let mut start = 0;
2086            loop {
2087                let end = (start + budget).min(words.len());
2088                let chunk = DocChunk {
2089                    text: words[start..end].join(" "),
2090                    headings: (!section.heading_path.is_empty())
2091                        .then(|| section.heading_path.clone()),
2092                    doc_items: Vec::new(),
2093                };
2094                if !sink(chunk) {
2095                    return false;
2096                }
2097                if end >= words.len() {
2098                    return true;
2099                }
2100                start += step;
2101            }
2102        }
2103
2104        /// Render a window chunk for embedding — docling-rag's rendering: the
2105        /// heading path as a `# Outer > Inner` context line, a blank line,
2106        /// then the body (just the body above any heading). Note this differs
2107        /// from the docling chunkers' [`contextualize`](super::contextualize),
2108        /// matching docling-rag instead.
2109        pub fn contextualize(chunk: &DocChunk) -> String {
2110            match &chunk.headings {
2111                Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2112                _ => chunk.text.clone(),
2113            }
2114        }
2115    }
2116
2117    #[cfg(test)]
2118    mod tests {
2119        use super::*;
2120
2121        #[test]
2122        fn splits_on_headings_and_tracks_path() {
2123            let md = "\
2124intro words
2125# Chapter 1
2126para one
2127## Section 1.1
2128para two
2129# Chapter 2
2130para three";
2131            let secs = parse_sections(md);
2132            // pre-heading intro, Chapter 1, Section 1.1, Chapter 2.
2133            assert_eq!(secs.len(), 4);
2134            assert!(secs[0].heading_path.is_empty());
2135            assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2136            assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2137            // A deeper heading is dropped when we return to H1.
2138            assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2139        }
2140
2141        #[test]
2142        fn strips_markup_to_plain_words() {
2143            let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2144            let secs = parse_sections(md);
2145            let words = &secs[0].words;
2146            assert!(words.contains(&"bold".to_string()));
2147            assert!(words.contains(&"code".to_string()));
2148            assert!(words.contains(&"link".to_string()));
2149            // No markdown punctuation survives as its own token.
2150            assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2151        }
2152
2153        #[test]
2154        fn windows_overlap_and_never_cross_headings() {
2155            let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2156            let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2157            let chunker = WindowChunker::new(10, 0.2); // step 8, overlap 2
2158            let chunks = chunker.chunk(&md);
2159            // Section A: 25 words → windows [0..10), [8..18), [16..25).
2160            let a: Vec<_> = chunks
2161                .iter()
2162                .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2163                .collect();
2164            assert_eq!(a.len(), 3);
2165            assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2166            assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2167            assert!(a[2].text.ends_with(" w24"));
2168            // Section B stays its own chunk; nothing crosses the heading.
2169            let b: Vec<_> = chunks
2170                .iter()
2171                .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2172                .collect();
2173            assert_eq!(b.len(), 1);
2174            assert_eq!(b[0].text, "short tail");
2175            assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2176        }
2177
2178        #[test]
2179        fn sink_false_cancels_the_window_walk() {
2180            let md = format!(
2181                "# A\n\n{}\n",
2182                (0..50)
2183                    .map(|i| format!("w{i}"))
2184                    .collect::<Vec<_>>()
2185                    .join(" ")
2186            );
2187            let chunker = WindowChunker::new(10, 0.0);
2188            let mut n = 0;
2189            chunker.chunk_with(&md, &mut |_| {
2190                n += 1;
2191                false
2192            });
2193            assert_eq!(n, 1);
2194        }
2195    }
2196}
2197
2198#[cfg(feature = "chunking")]
2199pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2200
2201#[cfg(test)]
2202mod tests {
2203    use super::*;
2204
2205    /// A whitespace "tokenizer" for algorithm tests.
2206    struct WordTok(usize);
2207    impl ChunkTokenizer for WordTok {
2208        fn count_tokens(&self, text: &str) -> usize {
2209            text.split_whitespace().count()
2210        }
2211        fn max_tokens(&self) -> usize {
2212            self.0
2213        }
2214    }
2215
2216    fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2217        let mut d = DoclingDocument::new("t");
2218        for n in nodes {
2219            d.push(n);
2220        }
2221        d
2222    }
2223
2224    #[test]
2225    fn hierarchical_headings_and_items() {
2226        let doc = doc_with(vec![
2227            Node::Heading {
2228                level: 1,
2229                text: "Title".into(),
2230            },
2231            Node::Paragraph {
2232                text: "Intro".into(),
2233            },
2234            Node::Heading {
2235                level: 2,
2236                text: "Sec".into(),
2237            },
2238            Node::Paragraph {
2239                text: "Body".into(),
2240            },
2241        ]);
2242        let chunks = HierarchicalChunker.chunk(&doc);
2243        assert_eq!(chunks.len(), 2);
2244        assert_eq!(chunks[0].text, "Intro");
2245        assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2246        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2247        assert_eq!(
2248            chunks[1].headings.as_deref(),
2249            Some(&["Title".into(), "Sec".into()][..])
2250        );
2251        assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2252    }
2253
2254    /// A page footer is a JSON text item (furniture layer) the chunker never
2255    /// chunks; the refs of the items after it still match the JSON.
2256    #[test]
2257    fn page_furniture_keeps_refs_aligned_with_json() {
2258        let doc = doc_with(vec![
2259            Node::Paragraph {
2260                text: "Before".into(),
2261            },
2262            Node::PageFurniture {
2263                footer: true,
2264                location: [0, 490, 100, 512],
2265                text: "1.10.2".into(),
2266            },
2267            Node::Paragraph {
2268                text: "After".into(),
2269            },
2270        ]);
2271        let chunks = HierarchicalChunker.chunk(&doc);
2272        assert_eq!(chunks.len(), 2);
2273        assert_eq!(chunks[1].text, "After");
2274        let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2275        assert_eq!(json["texts"][2]["text"], "After");
2276        assert_eq!(chunks[1].doc_items[0].self_ref, "#/texts/2");
2277    }
2278
2279    /// A picture's children are never chunked, but they take `#/texts/N`
2280    /// (and list-group) slots in the JSON — the refs after them must still
2281    /// point at the right items.
2282    #[test]
2283    fn picture_children_are_not_chunked_but_keep_refs_aligned() {
2284        let doc = doc_with(vec![
2285            Node::Picture {
2286                caption: None,
2287                caption_href: None,
2288                image: None,
2289                classification: None,
2290                description: None,
2291                caption_parent: crate::CaptionParent::Item,
2292                caption_location: None,
2293            },
2294            Node::PictureChildren(vec![
2295                Node::Paragraph {
2296                    text: "axis label".into(),
2297                },
2298                Node::ListItem {
2299                    ordered: false,
2300                    number: 0,
2301                    first_in_list: true,
2302                    text: "callout".into(),
2303                    level: 0,
2304                    marker: None,
2305                    location: None,
2306                    dclx: None,
2307                    href: None,
2308                    layer: None,
2309                },
2310            ]),
2311            Node::Paragraph {
2312                text: "Body".into(),
2313            },
2314        ]);
2315        let chunks = HierarchicalChunker.chunk(&doc);
2316        assert_eq!(chunks.len(), 1);
2317        assert_eq!(chunks[0].text, "Body");
2318        let json = doc.export_to_json_value();
2319        assert_eq!(json["texts"][2]["text"], "Body");
2320        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/2");
2321    }
2322
2323    #[test]
2324    fn heading_shadowing_prunes_deeper_levels() {
2325        let doc = doc_with(vec![
2326            Node::Heading {
2327                level: 2,
2328                text: "A".into(),
2329            },
2330            Node::Heading {
2331                level: 3,
2332                text: "A.1".into(),
2333            },
2334            Node::Heading {
2335                level: 2,
2336                text: "B".into(),
2337            },
2338            Node::Paragraph { text: "p".into() },
2339        ]);
2340        let chunks = HierarchicalChunker.chunk(&doc);
2341        assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2342    }
2343
2344    #[test]
2345    fn triplet_table() {
2346        let t = Table {
2347            rows: vec![
2348                vec!["".into(), "Col1".into()],
2349                vec!["Row1".into(), "v".into()],
2350            ],
2351            ..Default::default()
2352        };
2353        assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2354        // Single-column: row 0 is the dataframe header, the first data row
2355        // becomes the column name, the rest the values.
2356        let single = Table {
2357            rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2358            ..Default::default()
2359        };
2360        assert_eq!(triplet_table_text(&single), "a = b");
2361    }
2362
2363    #[test]
2364    fn hybrid_merges_small_peers_and_splits_large() {
2365        let doc = doc_with(vec![
2366            Node::Heading {
2367                level: 2,
2368                text: "S".into(),
2369            },
2370            Node::Paragraph { text: "a b".into() },
2371            Node::Paragraph { text: "c d".into() },
2372        ]);
2373        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2374        assert_eq!(chunks.len(), 1, "peers under one heading merge");
2375        assert_eq!(chunks[0].text, "a b\nc d");
2376
2377        let long = "w ".repeat(40).trim().to_string();
2378        let doc = doc_with(vec![Node::Paragraph { text: long }]);
2379        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2380        assert!(chunks.len() > 1, "oversized paragraph splits");
2381        for c in &chunks {
2382            assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2383        }
2384    }
2385
2386    #[test]
2387    fn semchunk_prefers_newlines_then_sentences() {
2388        let tok = WordTok(4);
2389        let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2390        assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2391    }
2392}