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        let mut prev: Option<(bool, u64)> = None;
273        for k in 0..run.len() {
274            let Node::ListItem {
275                ordered,
276                number,
277                first_in_list,
278                level,
279                ..
280            } = &run[k]
281            else {
282                continue;
283            };
284            if *level != base {
285                continue; // nested item — handled inside `list`
286            }
287            if k > seg {
288                if let Some((po, pn)) = prev {
289                    if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
290                        self.list(&run[seg..k]);
291                        seg = k;
292                    }
293                }
294            }
295            prev = Some((*ordered, *number));
296        }
297        self.list(&run[seg..]);
298    }
299
300    /// One `ListGroup`: allocate refs in `json.rs::add_list` order (group,
301    /// then per top item its text ref followed by any nested groups) and emit
302    /// a single chunk whose text is the indented markdown list.
303    fn list(&mut self, items: &[Node]) {
304        self.alloc.group();
305        let mut chunk_items = Vec::new();
306        self.list_refs(items, &mut chunk_items);
307        let text = render_list(items);
308        self.emit(text, chunk_items);
309    }
310
311    /// Allocate refs for one list's items (and nested sibling lists), mirroring
312    /// `json.rs::add_list` / `add_sibling_lists` recursion, collecting the
313    /// non-furniture items in allocation (= document) order.
314    fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
315        let base = level_of(&items[0]);
316        let mut i = 0;
317        while i < items.len() {
318            let Node::ListItem {
319                ordered,
320                number,
321                text,
322                level,
323                layer,
324                ..
325            } = &items[i]
326            else {
327                i += 1;
328                continue;
329            };
330            if *level > base {
331                i += 1;
332                continue;
333            }
334            let item_ref = self.alloc.text();
335            let mut j = i + 1;
336            while j < items.len() && level_of(&items[j]) > base {
337                j += 1;
338            }
339            let has_nested = j > i + 1;
340            if layer.is_none() {
341                let marker = if *ordered {
342                    format!("{number}.")
343                } else {
344                    "-".to_string()
345                };
346                // An item that carries both inline spans and a nested list is an
347                // empty list item wrapping an inline group in docling's model:
348                // its marker and each inline span are separate doc items.
349                // docling represents a list item whose content is not pure
350                // inline text (it carries a nested list or child pictures) as
351                // an empty item wrapping an inline group: its marker and each
352                // inline span become separate doc items.
353                // docling represents a list item whose content is not pure
354                // inline text (it carries a nested list or its own images) as
355                // an empty item wrapping an inline group: its marker and each
356                // inline span become separate doc items. The chunker's
357                // image placeholder is empty, so the markers are stripped from
358                // the text before segmentation.
359                let has_pics = text.contains("<!-- image -->");
360                let text = strip_image_markers(text);
361                let text = text.as_str();
362                let segments = inline_segments(text);
363                if (has_nested || has_pics) && segments.len() > 1 && text.contains("](") {
364                    out.push(ChunkItem {
365                        self_ref: item_ref.clone(),
366                        kind: ChunkItemKind::Text,
367                        text: format!("{marker} "),
368                    });
369                    for seg in segments {
370                        out.push(ChunkItem {
371                            self_ref: item_ref.clone(),
372                            kind: ChunkItemKind::Text,
373                            text: seg,
374                        });
375                    }
376                } else {
377                    out.push(ChunkItem {
378                        self_ref: item_ref.clone(),
379                        kind: ChunkItemKind::Text,
380                        text: format!("{marker} {}", unescape_text(text)),
381                    });
382                }
383            }
384            // nested items group under this one; each nested sibling list is a
385            // fresh group ref
386            if j > i + 1 {
387                self.nested_sibling_lists(&items[i + 1..j], out);
388            }
389            i = j;
390        }
391    }
392
393    fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
394        let base = level_of(&run[0]);
395        let mut seg = 0;
396        let mut prev: Option<(bool, u64)> = None;
397        for k in 0..run.len() {
398            let Node::ListItem {
399                ordered,
400                number,
401                first_in_list,
402                level,
403                ..
404            } = &run[k]
405            else {
406                continue;
407            };
408            if *level != base {
409                continue;
410            }
411            if k > seg {
412                if let Some((po, pn)) = prev {
413                    if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
414                        self.alloc.group();
415                        self.list_refs(&run[seg..k], out);
416                        seg = k;
417                    }
418                }
419            }
420            prev = Some((*ordered, *number));
421        }
422        self.alloc.group();
423        self.list_refs(&run[seg..], out);
424    }
425
426    fn one(&mut self, node: &Node) {
427        match node {
428            Node::Heading { level, text } => {
429                let doc_level = if *level == 1 {
430                    0
431                } else {
432                    level.saturating_sub(1)
433                };
434                let self_ref = self.alloc.text();
435                // docling stores heading text unformatted: a heading that is one
436                // uniformly formatted span keeps its plain text; a *partially*
437                // formatted heading becomes an empty heading whose content is an
438                // inline group — which the chunker then yields as a chunk of its
439                // own (under the freshly-set empty heading).
440                let runs = crate::inline_runs_from_markdown(text);
441                if runs.len() <= 1 {
442                    let plain = runs
443                        .first()
444                        .map(|r| r.text.clone())
445                        .unwrap_or_else(|| text.clone());
446                    self.set_heading(doc_level, unescape_text(&plain));
447                } else {
448                    self.set_heading(doc_level, String::new());
449                    let body = unescape_text(text);
450                    self.emit(
451                        body.clone(),
452                        vec![ChunkItem {
453                            self_ref,
454                            kind: ChunkItemKind::Text,
455                            text: body,
456                        }],
457                    );
458                }
459            }
460            Node::Paragraph { text } => {
461                let t = text.trim();
462                let self_ref = self.alloc.text();
463                // A whole-paragraph display equation is a formula item; docling's
464                // chunk serializer re-wraps the raw latex in `$$…$$`.
465                if let Some(inner) = t
466                    .strip_prefix("$$")
467                    .and_then(|s| s.strip_suffix("$$"))
468                    .filter(|s| !s.is_empty())
469                {
470                    let body = format!("$${inner}$$");
471                    self.emit(
472                        body.clone(),
473                        vec![ChunkItem {
474                            self_ref,
475                            kind: ChunkItemKind::Text,
476                            text: body,
477                        }],
478                    );
479                    return;
480                }
481                self.emit_inline(text, self_ref);
482            }
483            Node::CheckboxItem { checked, text } => {
484                let self_ref = self.alloc.text();
485                let mark = if *checked { "- [x] " } else { "- [ ] " };
486                let body = format!("{mark}{}", unescape_text(text));
487                self.emit(
488                    body.clone(),
489                    vec![ChunkItem {
490                        self_ref,
491                        kind: ChunkItemKind::Text,
492                        text: body,
493                    }],
494                );
495            }
496            // An enriched display formula chunks like docling's formula item:
497            // the LaTeX is the chunk text.
498            Node::Formula { latex, .. } => {
499                let self_ref = self.alloc.text();
500                let body = format!("$${}$$", latex);
501                self.emit(
502                    body.clone(),
503                    vec![ChunkItem {
504                        self_ref,
505                        kind: ChunkItemKind::Text,
506                        text: body,
507                    }],
508                );
509            }
510            Node::Code { text, .. } => {
511                let self_ref = self.alloc.text();
512                let body = format!("```\n{}\n```", unescape_text(text));
513                self.emit(
514                    body.clone(),
515                    vec![ChunkItem {
516                        self_ref,
517                        kind: ChunkItemKind::Text,
518                        text: body,
519                    }],
520                );
521            }
522            Node::Table(t) => {
523                let self_ref = self.alloc.table();
524                let body = triplet_table_text(t);
525                self.emit(
526                    body.clone(),
527                    vec![ChunkItem {
528                        self_ref,
529                        kind: ChunkItemKind::Table,
530                        text: body,
531                    }],
532                );
533            }
534            Node::Picture { caption, .. } => {
535                let cap = caption.as_deref().filter(|c| !c.is_empty());
536                let cap_item = cap.map(|c| ChunkItem {
537                    self_ref: self.alloc.text(),
538                    kind: ChunkItemKind::Text,
539                    text: unescape_text(c),
540                });
541                self.alloc.picture();
542                // The picture itself serializes to the (empty) chunking image
543                // placeholder, and its caption is already consumed by the
544                // caption chunk — so only the caption text is emitted.
545                if let Some(cap_item) = cap_item {
546                    let body = cap_item.text.clone();
547                    self.emit(body, vec![cap_item]);
548                }
549            }
550            Node::Chart {
551                kind,
552                table,
553                caption,
554                ..
555            } => {
556                let cap = caption.as_deref().filter(|c| !c.is_empty());
557                let cap_item = cap.map(|c| ChunkItem {
558                    self_ref: self.alloc.text(),
559                    kind: ChunkItemKind::Text,
560                    text: unescape_text(c),
561                });
562                let pic_ref = self.alloc.picture();
563                // caption, humanized classification, then the chart's data grid
564                // as a (padded) markdown table — docling's picture serializer
565                // parts, joined with blank lines.
566                let mut parts: Vec<String> = Vec::new();
567                if let Some(ci) = &cap_item {
568                    parts.push(ci.text.clone());
569                }
570                parts.push(humanize_label(kind));
571                let grid = crate::markdown::render_table(table, false);
572                if !grid.is_empty() {
573                    parts.push(unescape_text(&grid));
574                }
575                let body = parts.join("\n\n");
576                // Re-serialized standalone (the hybrid window join), the picture
577                // carries its caption itself, while the caption *item* renders
578                // empty — docling's markdown serializer emits caption-label text
579                // only through the picture.
580                let pic_item = ChunkItem {
581                    self_ref: pic_ref,
582                    kind: ChunkItemKind::Picture,
583                    text: body.clone(),
584                };
585                let items = match cap_item {
586                    Some(mut ci) => {
587                        ci.text = String::new();
588                        vec![ci, pic_item]
589                    }
590                    None => vec![pic_item],
591                };
592                self.emit(body, items);
593            }
594            Node::Group { children, .. } => {
595                // A generic group is a structural container: docling recurses
596                // into it rather than chunking it whole.
597                self.alloc.group();
598                self.walk(children);
599            }
600            Node::FieldRegion { items } => {
601                // Each field part (marker / key / value) is its own text item,
602                // and docling chunks each one individually.
603                self.alloc.field_region();
604                for item in items {
605                    self.alloc.field_item();
606                    for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
607                        let self_ref = self.alloc.text();
608                        let body = unescape_text(part);
609                        self.emit(
610                            body.clone(),
611                            vec![ChunkItem {
612                                self_ref,
613                                kind: ChunkItemKind::Text,
614                                text: body,
615                            }],
616                        );
617                    }
618                }
619            }
620            Node::InlineGroup { md_text, runs, .. } => {
621                let self_ref = self.alloc.text();
622                self.emit_inline_with_runs(md_text, self_ref, runs);
623            }
624            Node::TextDump(text) => {
625                let self_ref = self.alloc.text();
626                let body = unescape_text(text);
627                self.emit(
628                    body.clone(),
629                    vec![ChunkItem {
630                        self_ref,
631                        kind: ChunkItemKind::Text,
632                        text: body,
633                    }],
634                );
635            }
636            // Layout provenance is transparent.
637            Node::Located { inner, .. } => self.one(inner),
638            // Non-body layers and doclang-only nodes don't reach the chunker
639            // (nor the JSON body).
640            Node::Furniture { .. }
641            | Node::PageFurniture { .. }
642            | Node::PageBreak
643            | Node::PageInfo { .. }
644            | Node::DoclangOnly(_) => {}
645            // Runs are grouped by `walk`; a stray single item (hand-built
646            // document, `Located` wrapper) still chunks instead of panicking.
647            Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
648        }
649    }
650}
651
652fn level_of(node: &Node) -> u8 {
653    match node {
654        Node::ListItem { level, .. } => *level,
655        _ => 0,
656    }
657}
658
659/// Render one sibling list as its markdown chunk text (indented items, same
660/// rules as the full markdown serializer's list rendering).
661fn render_list(items: &[Node]) -> String {
662    let mut lines: Vec<String> = Vec::new();
663    for item in items {
664        let Node::ListItem {
665            ordered,
666            number,
667            text,
668            level,
669            layer,
670            ..
671        } = item
672        else {
673            continue;
674        };
675        if layer.is_some() {
676            continue;
677        }
678        let indent = "    ".repeat(*level as usize);
679        let marker = if *ordered {
680            format!("{number}.")
681        } else {
682            "-".to_string()
683        };
684        lines.push(format!(
685            "{indent}{marker} {}",
686            unescape_text(&strip_image_markers(text))
687        ));
688    }
689    lines.join("\n")
690}
691
692/// Strip the Markdown image placeholders a list item's own images fold into
693/// its text (the chunking serializer's `image_placeholder` is empty, so
694/// docling's chunk text carries no marker), collapsing the newlines that
695/// carried them.
696fn strip_image_markers(text: &str) -> String {
697    if !text.contains("<!-- image -->") {
698        return text.to_string();
699    }
700    let cleaned: Vec<&str> = text
701        .split('\n')
702        .map(str::trim_end)
703        .filter(|l| *l != "<!-- image -->")
704        .collect();
705    cleaned.join("\n").trim_end().to_string()
706}
707
708/// docling-core's `_humanize_text`: underscores to spaces, first letter
709/// capitalized (`line_chart` → `Line chart`).
710fn humanize_label(label: &str) -> String {
711    let text = label.replace('_', " ");
712    let mut chars = text.chars();
713    match chars.next() {
714        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
715        None => text,
716    }
717}
718
719/// docling's `TripletTableSerializer` over `export_to_dataframe` semantics: the
720/// leading rows carrying column-header cells become the dataframe's column
721/// names (multiple header rows join per column with `.`; no header rows at all
722/// yield pandas' integer column names), the rest are data rows; the dataframe
723/// is then rendered as `row, column = value` sentences (with the header-only /
724/// single-column special cases and the plain-text flatten fallback).
725fn triplet_table_text(t: &Table) -> String {
726    let rows: Vec<Vec<String>> = t
727        .rows
728        .iter()
729        .enumerate()
730        .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
731        .collect();
732    let num_rows = rows.len();
733    let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
734    if num_rows == 0 || num_cols == 0 {
735        return String::new();
736    }
737    let cell = |r: usize, c: usize| -> &str {
738        rows.get(r)
739            .and_then(|row| row.get(c))
740            .map(String::as_str)
741            .unwrap_or("")
742    };
743
744    // Whether a cell is a column-header cell, resolving span continuations to
745    // their origin (docling's grid replicates the spanning cell, so a header
746    // spilling into the next row makes that row a header row too).
747    let cell_is_header = |r: usize, c: usize| -> bool {
748        let (mut r, mut c) = (r, c);
749        loop {
750            match &t.structure {
751                Some(s) if !s.col_header.is_empty() => {
752                    return s
753                        .col_header
754                        .get(r)
755                        .and_then(|row| row.get(c))
756                        .copied()
757                        .unwrap_or(false)
758                }
759                Some(s) => {
760                    let cont = |g: &Vec<Vec<bool>>| {
761                        g.get(r)
762                            .and_then(|row| row.get(c))
763                            .copied()
764                            .unwrap_or(false)
765                    };
766                    if r > 0 && cont(&s.row_continuation) {
767                        r -= 1;
768                        continue;
769                    }
770                    if c > 0 && cont(&s.col_continuation) {
771                        c -= 1;
772                        continue;
773                    }
774                    return if s.header_row.is_empty() {
775                        r == 0
776                    } else {
777                        s.header_row.get(r).copied().unwrap_or(false)
778                    };
779                }
780                None => return r == 0,
781            }
782        }
783    };
784    let row_is_header = |r: usize| (0..num_cols).any(|c| cell_is_header(r, c));
785    let num_headers = (0..num_rows).take_while(|r| row_is_header(*r)).count();
786
787    // Column names: header-row texts joined per column with '.', or the integer
788    // positions when there are no header rows.
789    let columns: Vec<String> = if num_headers > 0 {
790        (0..num_cols)
791            .map(|c| {
792                let mut name = String::new();
793                for r in 0..num_headers {
794                    if !name.is_empty() {
795                        name.push('.');
796                    }
797                    name.push_str(cell(r, c));
798                }
799                name
800            })
801            .collect()
802    } else {
803        (0..num_cols).map(|c| c.to_string()).collect()
804    };
805    let data_rows = num_headers..num_rows;
806    let n_data = data_rows.len();
807
808    // Header-only table: emit the header texts directly.
809    if n_data == 0 {
810        return columns
811            .iter()
812            .map(|s| s.trim())
813            .filter(|s| !s.is_empty())
814            .collect::<Vec<_>>()
815            .join(". ");
816    }
817
818    let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
819    let text = if num_cols == 1 {
820        // Single-column: the first data row is the column name, the rest are
821        // values (a single data row emits its cell text alone).
822        let col_name = data(0, 0).trim().to_string();
823        if n_data == 1 {
824            col_name
825        } else {
826            (1..n_data)
827                .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
828                .collect::<Vec<_>>()
829                .join(". ")
830        }
831    } else {
832        // Triplets over the dataframe with the column names copied as row 0.
833        let mut parts = Vec::new();
834        for r in 0..n_data {
835            for (c, col_name) in columns.iter().enumerate().skip(1) {
836                parts.push(format!(
837                    "{}, {} = {}",
838                    data(r, 0).trim(),
839                    col_name.trim(),
840                    data(r, c).trim()
841                ));
842            }
843        }
844        parts.join(". ")
845    };
846    if !text.is_empty() {
847        return text;
848    }
849
850    // Last-resort flatten: the data rows' non-blank cells joined with '. '
851    // (the header rows are the dataframe's columns, so they are not included).
852    (0..n_data)
853        .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
854        .map(|(r, c)| data(r, c).trim())
855        .filter(|s| !s.is_empty())
856        .collect::<Vec<_>>()
857        .join(". ")
858}
859
860/// Split a markdown-flavoured text into docling's inline-item granularity: a
861/// hyperlink / formatted span / inline formula is its own document item in
862/// docling's model, with plain text runs between them. Returns the standalone
863/// serialization of each item (`[text](url)`, `**bold**`, a formula re-wrapped
864/// as `$$latex$$`, plain text), or a single-element vector when the text is one
865/// uniform item. The single space docling's serializer inserts between inline
866/// items is stripped from the adjacent plain runs.
867fn inline_segments(md: &str) -> Vec<String> {
868    inline_segments_tagged(md)
869        .into_iter()
870        .map(|(t, _)| t)
871        .collect()
872}
873
874/// Like [`inline_segments`], with each segment tagged `true` when it came from
875/// plain (unmarked) text — those may still need splitting at run boundaries
876/// invisible in markdown (underline, soft breaks).
877fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
878    let chars: Vec<char> = md.chars().collect();
879    let n = chars.len();
880    let find = |from: usize, pat: &str| -> Option<usize> {
881        let hay: String = chars[from..].iter().collect();
882        hay.find(pat).map(|p| from + hay[..p].chars().count())
883    };
884    let mut out: Vec<(String, bool)> = Vec::new();
885    let mut plain = String::new();
886    let mut after_span = false;
887
888    fn flush(
889        out: &mut Vec<(String, bool)>,
890        plain: &mut String,
891        before_span: bool,
892        after_span: bool,
893    ) {
894        let mut p = std::mem::take(plain);
895        if after_span {
896            if let Some(rest) = p.strip_prefix(' ') {
897                p = rest.to_string();
898            }
899        }
900        if before_span {
901            if let Some(rest) = p.strip_suffix(' ') {
902                p = rest.to_string();
903            }
904        }
905        if !p.is_empty() {
906            out.push((unescape_text(&p), true));
907        }
908    }
909
910    let mut i = 0;
911    while i < n {
912        let rest: String = chars[i..].iter().collect();
913        // A hyperlink span (not an image): the whole `[text](url)` is one item.
914        // The URL may itself contain balanced parentheses (`/Duck_(film)`),
915        // so the closing `)` is found by paren depth, not first match.
916        if chars[i] == '[' && !rest.starts_with("[](") {
917            // The label may itself contain balanced brackets (`[[ 1 ]](#ref)`
918            // has label `[ 1 ]`), but an *unbalanced* `[` means this bracket is
919            // plain text preceding a real link (`[ [*note*](url) ]`).
920            let balanced = |c: usize| {
921                let mut d = 0i32;
922                for &ch in &chars[i + 1..c] {
923                    match ch {
924                        '[' => d += 1,
925                        ']' => d -= 1,
926                        _ => {}
927                    }
928                }
929                d == 0
930            };
931            if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
932                let mut depth = 0usize;
933                let mut url_end = None;
934                for (k, &c) in chars.iter().enumerate().skip(close + 2) {
935                    match c {
936                        '(' => depth += 1,
937                        ')' => {
938                            if depth == 0 {
939                                url_end = Some(k);
940                                break;
941                            }
942                            depth -= 1;
943                        }
944                        _ => {}
945                    }
946                }
947                if let Some(endp) = url_end {
948                    flush(&mut out, &mut plain, true, after_span);
949                    out.push((
950                        unescape_text(&chars[i..=endp].iter().collect::<String>()),
951                        false,
952                    ));
953                    i = endp + 1;
954                    after_span = true;
955                    continue;
956                }
957            }
958        }
959        // A formatted span; longest markers first. An inline code span is a
960        // code item in docling's model, whose standalone form is a fenced block.
961        let mut matched = false;
962        for marker in ["***", "**", "*", "~~", "`"] {
963            if rest.starts_with(marker) {
964                let mlen = marker.chars().count();
965                if let Some(end) = find(i + mlen, marker) {
966                    // A whitespace-only span (`* *` from a literal asterisk in
967                    // running text next to a real italic) is not a docling run:
968                    // treat the marker as plain text.
969                    let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
970                    if end > i + mlen && !inner_blank {
971                        flush(&mut out, &mut plain, true, after_span);
972                        if marker == "`" {
973                            let inner: String = chars[i + 1..end].iter().collect();
974                            out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
975                        } else {
976                            out.push((
977                                unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
978                                false,
979                            ));
980                        }
981                        i = end + mlen;
982                        after_span = true;
983                        matched = true;
984                    }
985                }
986                break;
987            }
988        }
989        if matched {
990            continue;
991        }
992        // A literal `$$` inside running text is not an inline formula: copy it
993        // through as plain characters.
994        if rest.starts_with("$$") {
995            plain.push_str("$$");
996            i += 2;
997            continue;
998        }
999        // An inline formula: standalone it re-serializes in display form.
1000        if chars[i] == '$' {
1001            if let Some(end) = find(i + 1, "$") {
1002                if end > i + 1 {
1003                    flush(&mut out, &mut plain, true, after_span);
1004                    let latex: String = chars[i + 1..end].iter().collect();
1005                    out.push((format!("$${latex}$$"), false));
1006                    i = end + 1;
1007                    after_span = true;
1008                    continue;
1009                }
1010            }
1011        }
1012        plain.push(chars[i]);
1013        i += 1;
1014    }
1015    flush(&mut out, &mut plain, false, after_span);
1016    if out.is_empty() {
1017        out.push((unescape_text(md), true));
1018    }
1019    out
1020}
1021
1022/// Split a plain markdown segment at run boundaries the markdown cannot show
1023/// (an underlined run, a `<sub>`/`<sup>` run): when a consecutive window of
1024/// two or more unmarked runs exactly covers the segment, each run is its own
1025/// document item.
1026fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1027    let target = segment.trim();
1028    if target.is_empty() {
1029        return None;
1030    }
1031    let plainish =
1032        |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1033    let fully_plain =
1034        |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1035    let unmarked: Vec<(&str, bool)> = runs
1036        .iter()
1037        .filter(|r| plainish(r))
1038        .map(|r| (r.text.as_str(), fully_plain(r)))
1039        .collect();
1040    for start in 0..unmarked.len() {
1041        let mut rest = target;
1042        let mut taken: Vec<(String, bool)> = Vec::new();
1043        for (t, fully) in &unmarked[start..] {
1044            let t = t.trim();
1045            if t.is_empty() {
1046                continue;
1047            }
1048            match rest.strip_prefix(t) {
1049                Some(r) => {
1050                    taken.push((unescape_text(t), *fully));
1051                    rest = r.trim_start();
1052                    if rest.is_empty() {
1053                        break;
1054                    }
1055                }
1056                None => break,
1057            }
1058        }
1059        if rest.is_empty() && taken.len() >= 2 {
1060            // Consecutive fully-plain pieces (no underline / sub / sup) are one
1061            // annotation in docling's model — `simplify_text_elements` merges
1062            // them joined with a space, so they never split between themselves.
1063            // Only an underline / sub / sup run is a genuine boundary.
1064            let mut merged: Vec<(String, bool)> = Vec::new();
1065            for (t, fully) in taken {
1066                match merged.last_mut() {
1067                    Some((last, true)) if fully => {
1068                        last.push(' ');
1069                        last.push_str(&t);
1070                    }
1071                    _ => merged.push((t, fully)),
1072                }
1073            }
1074            if merged.len() >= 2 {
1075                return Some(merged.into_iter().map(|(t, _)| t).collect());
1076            }
1077            return None;
1078        }
1079    }
1080    None
1081}
1082
1083/// A table cell's text for the triplet serializer. A *rich* cell (one carrying
1084/// block content) is re-serialized the way docling's chunking serializer sees
1085/// it: paragraphs joined with blank lines, a nested table as its own triplet
1086/// sentences, pictures as an empty placeholder. Plain cells use the flat text
1087/// with the markdown image placeholder stripped (the chunking serializer's
1088/// `image_placeholder` is empty).
1089fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
1090    if let Some(blocks) = t
1091        .cell_blocks
1092        .as_ref()
1093        .and_then(|b| b.get(r))
1094        .and_then(|row| row.get(c))
1095        .filter(|b| !b.is_empty())
1096    {
1097        let mut parts: Vec<String> = Vec::new();
1098        for node in blocks.iter() {
1099            let part = block_chunk_text(node);
1100            if !part.is_empty() {
1101                parts.push(part);
1102            }
1103        }
1104        return parts.join("\n\n");
1105    }
1106    let flat = t
1107        .rows
1108        .get(r)
1109        .and_then(|row| row.get(c))
1110        .map(String::as_str)
1111        .unwrap_or("");
1112    unescape_text(flat)
1113        .replace("<!-- image -->", "")
1114        .trim()
1115        .to_string()
1116}
1117
1118/// One block of a rich cell, serialized for chunking.
1119fn block_chunk_text(node: &Node) -> String {
1120    match node {
1121        Node::Paragraph { text } => unescape_text(text),
1122        Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1123        Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1124        Node::Table(inner) => triplet_table_text(inner),
1125        Node::Picture { caption, .. } => caption
1126            .as_deref()
1127            .filter(|c| !c.is_empty())
1128            .map(unescape_text)
1129            .unwrap_or_default(),
1130        Node::ListItem {
1131            ordered,
1132            number,
1133            text,
1134            ..
1135        } => {
1136            let marker = if *ordered {
1137                format!("{number}.")
1138            } else {
1139                "-".to_string()
1140            };
1141            format!("{marker} {}", unescape_text(text))
1142        }
1143        Node::CheckboxItem { checked, text } => {
1144            let mark = if *checked { "- [x] " } else { "- [ ] " };
1145            format!("{mark}{}", unescape_text(text))
1146        }
1147        Node::Heading { text, .. } => unescape_text(text),
1148        Node::Located { inner, .. } => block_chunk_text(inner),
1149        Node::Group { children, .. } => children
1150            .iter()
1151            .map(block_chunk_text)
1152            .filter(|s| !s.is_empty())
1153            .collect::<Vec<_>>()
1154            .join("\n"),
1155        _ => String::new(),
1156    }
1157}
1158
1159/// Reverse the model's baked markdown text escaping — same mapping as the JSON
1160/// exporter (docling chunks carry raw text).
1161fn unescape_text(s: &str) -> String {
1162    s.replace("&lt;", "<")
1163        .replace("&gt;", ">")
1164        .replace("&amp;", "&")
1165        .replace("\\_", "_")
1166}
1167
1168// ---------------------------------------------------------------------------
1169// Hybrid chunker
1170// ---------------------------------------------------------------------------
1171
1172/// Token counting for [`HybridChunker`] — docling's `BaseTokenizer`.
1173pub trait ChunkTokenizer {
1174    /// Number of tokens in `text` (no special tokens).
1175    fn count_tokens(&self, text: &str) -> usize;
1176    /// The chunk budget (docling's `max_tokens`, e.g. 256 for MiniLM).
1177    fn max_tokens(&self) -> usize;
1178}
1179
1180/// Tokenization-aware chunker on top of [`HierarchicalChunker`] — docling's
1181/// `HybridChunker` with default parameters (`merge_peers`,
1182/// `repeat_table_header` on; `omit_header_on_overflow` off).
1183pub struct HybridChunker<T: ChunkTokenizer> {
1184    tokenizer: T,
1185    merge_peers: bool,
1186}
1187
1188impl<T: ChunkTokenizer> HybridChunker<T> {
1189    pub fn new(tokenizer: T) -> Self {
1190        Self {
1191            tokenizer,
1192            merge_peers: true,
1193        }
1194    }
1195
1196    /// Disable merging of undersized same-heading neighbours.
1197    pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1198        self.merge_peers = merge_peers;
1199        self
1200    }
1201
1202    pub fn max_tokens(&self) -> usize {
1203        self.tokenizer.max_tokens()
1204    }
1205
1206    /// Chunk the document.
1207    pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1208        let mut chunks = Vec::new();
1209        self.chunk_with(doc, &mut |c| {
1210            chunks.push(c);
1211            true
1212        });
1213        chunks
1214    }
1215
1216    /// Stream the chunks: each hierarchical chunk is split against the token
1217    /// budget as the document walk produces it, and the peer merge flushes a
1218    /// merged chunk to `sink` as soon as its window closes (a chunk with
1219    /// different headings arrives, or the budget fills). A `false` return from
1220    /// `sink` cancels the chunking. [`Self::chunk`] is this with a collecting
1221    /// sink — the chunks and their order are identical.
1222    pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1223        let mut merger = PeerMerger::default();
1224        let mut alive = true;
1225        HierarchicalChunker.chunk_with(doc, &mut |c| {
1226            for split in self.split_by_doc_items(c) {
1227                for chunk in self.split_using_plain_text(split) {
1228                    if !alive {
1229                        return false;
1230                    }
1231                    alive = if self.merge_peers {
1232                        self.merge_push(&mut merger, chunk, sink)
1233                    } else {
1234                        sink(chunk)
1235                    };
1236                }
1237            }
1238            alive
1239        });
1240        if alive {
1241            self.merge_flush(&mut merger, sink);
1242        }
1243    }
1244
1245    fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1246        self.tokenizer.count_tokens(&contextualize(chunk))
1247    }
1248
1249    /// docling's `_make_chunk_from_doc_items`: single-item chunks keep their
1250    /// text; multi-item windows re-join the items' standalone serializations.
1251    fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1252        let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1253        let text = if chunk.doc_items.len() == 1 {
1254            chunk.text.clone()
1255        } else {
1256            doc_items
1257                .iter()
1258                .filter(|it| !it.text.is_empty())
1259                .map(|it| it.text.as_str())
1260                .collect::<Vec<_>>()
1261                .join("\n")
1262        };
1263        DocChunk {
1264            text,
1265            headings: chunk.headings.clone(),
1266            doc_items,
1267        }
1268    }
1269
1270    fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1271        if chunk.doc_items.is_empty() {
1272            return vec![chunk];
1273        }
1274        let max = self.max_tokens();
1275        let num_items = chunk.doc_items.len();
1276        let mut chunks = Vec::new();
1277        let mut window_start = 0usize;
1278        let mut window_end = 0usize; // inclusive
1279        while window_end < num_items {
1280            let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1281            if self.count_chunk_tokens(&new_chunk) <= max {
1282                if window_end < num_items - 1 {
1283                    window_end += 1;
1284                    continue;
1285                } else {
1286                    window_end = num_items; // last loop
1287                }
1288            } else if window_start == window_end {
1289                // One item that doesn't fit: keep it; the plain-text splitter
1290                // takes over.
1291                window_end += 1;
1292                window_start = window_end;
1293            } else {
1294                // The window without its last item fit; flush that and start a
1295                // new window at the current item.
1296                new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1297                window_start = window_end;
1298            }
1299            chunks.push(new_chunk);
1300        }
1301        chunks
1302    }
1303
1304    fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1305        let total = self.count_chunk_tokens(&chunk);
1306        let max = self.max_tokens();
1307        if total <= max {
1308            return vec![chunk];
1309        }
1310        let text_len = self.tokenizer.count_tokens(&chunk.text);
1311        let other_len = total - text_len;
1312        if other_len >= max {
1313            // Headings alone exceed the budget: drop them and retry.
1314            let stripped = DocChunk {
1315                headings: None,
1316                ..chunk
1317            };
1318            return self.split_using_plain_text(stripped);
1319        }
1320        let available = max - other_len;
1321
1322        let segments =
1323            if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1324                // Table: split line-based, repeating headers. The triplet
1325                // serializer has no header lines, so this is a line-preserving
1326                // split of the table text. (docling constructs the line chunker
1327                // with the *tokenizer's* max_tokens — the `max_tokens=available`
1328                // argument is silently dropped by pydantic — so the line budget is
1329                // the full window, not `available`.)
1330                let lines: Vec<String> = chunk
1331                    .text
1332                    .split('\n')
1333                    .filter(|l| !l.trim().is_empty())
1334                    .map(|l| l.to_string())
1335                    .collect();
1336                line_chunk_text(&lines, &self.tokenizer, max)
1337            } else {
1338                semchunk(&chunk.text, available, &self.tokenizer)
1339            };
1340        segments
1341            .into_iter()
1342            .map(|s| DocChunk {
1343                text: s,
1344                headings: chunk.headings.clone(),
1345                doc_items: chunk.doc_items.clone(),
1346            })
1347            .collect()
1348    }
1349
1350    /// One step of docling's `_merge_chunks_with_matching_metadata`, streamed:
1351    /// extend the window with `chunk` when its headings match the window's and
1352    /// the merged candidate stays within budget, otherwise flush the window to
1353    /// `sink` and start a new one at `chunk`. Returns `false` once the sink
1354    /// cancels.
1355    fn merge_push(
1356        &self,
1357        m: &mut PeerMerger,
1358        chunk: DocChunk,
1359        sink: &mut dyn FnMut(DocChunk) -> bool,
1360    ) -> bool {
1361        if m.window.is_empty() {
1362            m.window.push(chunk);
1363            return true;
1364        }
1365        let candidate = DocChunk {
1366            text: m
1367                .window
1368                .iter()
1369                .map(|c| c.text.as_str())
1370                .chain([chunk.text.as_str()])
1371                .collect::<Vec<_>>()
1372                .join("\n"),
1373            headings: m.window[0].headings.clone(),
1374            doc_items: m
1375                .window
1376                .iter()
1377                .flat_map(|c| c.doc_items.iter().cloned())
1378                .chain(chunk.doc_items.iter().cloned())
1379                .collect(),
1380        };
1381        if chunk.headings == m.window[0].headings
1382            && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1383        {
1384            m.window.push(chunk);
1385            m.merged = Some(candidate);
1386            true
1387        } else {
1388            let alive = self.merge_flush(m, sink);
1389            m.window.push(chunk);
1390            alive
1391        }
1392    }
1393
1394    /// Flush the merge window: a single chunk passes through unchanged, a
1395    /// multi-chunk window emits its precomputed merge. Returns `false` once
1396    /// the sink cancels.
1397    fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1398        let alive = if m.window.len() == 1 {
1399            sink(m.window.pop().expect("single-chunk window"))
1400        } else if !m.window.is_empty() {
1401            m.window.clear();
1402            sink(m.merged.take().expect("multi-chunk window has a merge"))
1403        } else {
1404            true
1405        };
1406        m.merged = None;
1407        alive
1408    }
1409}
1410
1411/// The in-flight peer-merge window of [`HybridChunker::chunk_with`].
1412#[derive(Default)]
1413struct PeerMerger {
1414    window: Vec<DocChunk>,
1415    merged: Option<DocChunk>,
1416}
1417
1418// ---------------------------------------------------------------------------
1419// Line-based token chunking (docling's LineBasedTokenChunker, empty prefix)
1420// ---------------------------------------------------------------------------
1421
1422/// Pack lines into chunks of at most `max_tokens`, splitting a line only when
1423/// it exceeds the budget on its own — docling's `LineBasedTokenChunker
1424/// .chunk_text` with an empty prefix (which is what the triplet table
1425/// serializer yields). Reproduces its exact output, including the `\n` it
1426/// prepends to a carried-over segment of an oversized line.
1427fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1428    let mut chunks: Vec<String> = Vec::new();
1429    let mut current = String::new();
1430    let mut current_len = 0usize;
1431
1432    for line in lines {
1433        let mut remaining: Vec<char> = line.chars().collect();
1434        loop {
1435            let rem_str: String = remaining.iter().collect();
1436            let line_tokens = tok.count_tokens(&rem_str);
1437            let available = max_tokens.saturating_sub(current_len);
1438
1439            if line_tokens <= available {
1440                current.push_str(&rem_str);
1441                current_len += line_tokens;
1442                break;
1443            }
1444            if line_tokens <= max_tokens {
1445                chunks.push(std::mem::take(&mut current));
1446                current_len = 0;
1447                continue;
1448            }
1449            // Too large even for an empty chunk: split off what fits.
1450            let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1451            let mut rest = rest;
1452            if take.is_empty() {
1453                if rest.is_empty() {
1454                    break;
1455                }
1456                take = rest[..1].iter().collect();
1457                rest = rest[1..].to_vec();
1458            }
1459            current.push('\n');
1460            current.push_str(&take);
1461            chunks.push(std::mem::take(&mut current));
1462            current_len = 0;
1463            remaining = rest;
1464        }
1465    }
1466    if !current.is_empty() {
1467        chunks.push(current);
1468    }
1469    chunks
1470}
1471
1472/// Binary-search the longest char-prefix of `text` within `token_limit`
1473/// tokens, preferring to break at the last ASCII space — docling's
1474/// `split_by_token_limit`.
1475fn split_by_token_limit<T: ChunkTokenizer>(
1476    text: &[char],
1477    token_limit: usize,
1478    tok: &T,
1479) -> (String, Vec<char>) {
1480    if token_limit == 0 || text.is_empty() {
1481        return (String::new(), text.to_vec());
1482    }
1483    let full: String = text.iter().collect();
1484    if tok.count_tokens(&full) <= token_limit {
1485        return (full, Vec::new());
1486    }
1487    let (mut lo, mut hi) = (0usize, text.len());
1488    let mut best: Option<usize> = None;
1489    while lo <= hi {
1490        let mid = (lo + hi) / 2;
1491        let head: String = text[..mid].iter().collect();
1492        if tok.count_tokens(&head) <= token_limit {
1493            best = Some(mid);
1494            lo = mid + 1;
1495        } else {
1496            if mid == 0 {
1497                break;
1498            }
1499            hi = mid - 1;
1500        }
1501    }
1502    let mut best_idx = match best {
1503        Some(b) if b > 0 => b,
1504        _ => return (String::new(), text.to_vec()),
1505    };
1506    // Snap back to the last space, if that leaves a non-empty head.
1507    if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1508        if pos > 0 {
1509            best_idx = pos;
1510        }
1511    }
1512    (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1513}
1514
1515// ---------------------------------------------------------------------------
1516// semchunk port (the plain-text splitter HybridChunker delegates to)
1517// ---------------------------------------------------------------------------
1518
1519/// Semantically meaningful non-whitespace splitters, most desirable first.
1520const NON_WS_SPLITTERS: &[&str] = &[
1521    ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1522    "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1523];
1524
1525/// Split `text` into chunks of at most `chunk_size` tokens using the most
1526/// semantically meaningful splitter available — the `semchunk` algorithm
1527/// docling's HybridChunker delegates plain-text splitting to.
1528pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1529    let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1530    let mut counter = |s: &str| -> usize {
1531        if let Some(n) = cache.get(s) {
1532            return *n;
1533        }
1534        let n = tok.count_tokens(s);
1535        cache.insert(s.to_string(), n);
1536        n
1537    };
1538    let chunks = semchunk_rec(text, chunk_size, &mut counter);
1539    // top-level: drop empty / all-whitespace chunks
1540    chunks
1541        .into_iter()
1542        .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1543        .collect()
1544}
1545
1546/// One recursion level of semchunk: split, merge windows back up to size, and
1547/// recurse into oversized splits.
1548fn semchunk_rec(
1549    text: &str,
1550    chunk_size: usize,
1551    counter: &mut dyn FnMut(&str) -> usize,
1552) -> Vec<String> {
1553    let (splitter, splitter_is_ws, splits) = split_text(text);
1554
1555    let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1556    let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1557    cum_lens.push(0usize);
1558    for l in &split_lens {
1559        cum_lens.push(cum_lens.last().unwrap() + l);
1560    }
1561    let num_splits_plus_one = splits.len() + 1;
1562
1563    let mut chunks: Vec<String> = Vec::new();
1564    let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1565
1566    for i in 0..splits.len() {
1567        if skips.contains(&i) {
1568            continue;
1569        }
1570        let split = &splits[i];
1571        if counter(split) > chunk_size {
1572            let inner = semchunk_rec(split, chunk_size, counter);
1573            chunks.extend(inner);
1574        } else {
1575            let (end, merged) = merge_splits(
1576                &splits,
1577                &cum_lens,
1578                chunk_size,
1579                &splitter,
1580                counter,
1581                i,
1582                num_splits_plus_one,
1583            );
1584            for j in (i + 1)..end {
1585                skips.insert(j);
1586            }
1587            chunks.push(merged);
1588        }
1589        // Re-attach a non-whitespace splitter to the last chunk (or emit it as
1590        // its own chunk if it doesn't fit).
1591        let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1592        if !splitter_is_ws && !is_last {
1593            let with_splitter = format!(
1594                "{}{}",
1595                chunks.last().map(String::as_str).unwrap_or(""),
1596                splitter
1597            );
1598            if counter(&with_splitter) <= chunk_size {
1599                if let Some(last) = chunks.last_mut() {
1600                    *last = with_splitter;
1601                } else {
1602                    chunks.push(with_splitter);
1603                }
1604            } else {
1605                chunks.push(splitter.clone());
1606            }
1607        }
1608    }
1609    chunks
1610}
1611
1612/// docling/semchunk's `merge_splits`: extend the window with a cum-length-guided
1613/// binary search until the token budget is hit.
1614fn merge_splits(
1615    splits: &[String],
1616    cum_lens: &[usize],
1617    chunk_size: usize,
1618    splitter: &str,
1619    counter: &mut dyn FnMut(&str) -> usize,
1620    start: usize,
1621    high_init: usize,
1622) -> (usize, String) {
1623    let mut average = 0.2f64;
1624    let mut low = start;
1625    let mut high = high_init;
1626    let offset = cum_lens[start];
1627    let mut target = offset as f64 + (chunk_size as f64 * average);
1628
1629    while low < high {
1630        let i = bisect_left(cum_lens, target, low, high);
1631        let midpoint = i.min(high - 1);
1632        let joined = splits[start..midpoint.max(start)].join(splitter);
1633        let tokens = counter(&joined);
1634        let local_cum = cum_lens[midpoint] - offset;
1635        if local_cum > 0 && tokens > 0 {
1636            average = local_cum as f64 / tokens as f64;
1637            target = offset as f64 + (chunk_size as f64 * average);
1638        }
1639        if tokens > chunk_size {
1640            high = midpoint;
1641        } else {
1642            low = midpoint + 1;
1643        }
1644    }
1645    let end = low - 1;
1646    (end, splits[start..end.max(start)].join(splitter))
1647}
1648
1649fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1650    while low < high {
1651        let mid = (low + high) / 2;
1652        if (sorted[mid] as f64) < target {
1653            low = mid + 1;
1654        } else {
1655            high = mid;
1656        }
1657    }
1658    low
1659}
1660
1661/// semchunk's `_split_text`: pick the most desirable splitter present.
1662fn split_text(text: &str) -> (String, bool, Vec<String>) {
1663    // Longest run of newlines/carriage returns.
1664    if text.contains('\n') || text.contains('\r') {
1665        let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1666        return (splitter.clone(), true, split_on(text, &splitter));
1667    }
1668    // Longest run of tabs.
1669    if text.contains('\t') {
1670        let splitter = longest_run(text, |c| c == '\t');
1671        return (splitter.clone(), true, split_on(text, &splitter));
1672    }
1673    // Longest run of whitespace.
1674    if text.chars().any(char::is_whitespace) {
1675        let splitter = longest_run(text, char::is_whitespace);
1676        if splitter.chars().count() == 1 {
1677            // Prefer a whitespace char preceded by a semantic splitter.
1678            for preceder in NON_WS_SPLITTERS {
1679                if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1680                    return (ws, true, parts);
1681                }
1682            }
1683        }
1684        return (splitter.clone(), true, split_on(text, &splitter));
1685    }
1686    // Most desirable semantic splitter present.
1687    for s in NON_WS_SPLITTERS {
1688        if text.contains(s) {
1689            return (s.to_string(), false, split_on(text, s));
1690        }
1691    }
1692    // No splitter at all: split into characters.
1693    (
1694        String::new(),
1695        true,
1696        text.chars().map(|c| c.to_string()).collect(),
1697    )
1698}
1699
1700/// The longest maximal run of chars matching `pred` (first one wins ties).
1701fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1702    let mut best = String::new();
1703    let mut cur = String::new();
1704    for c in text.chars() {
1705        if pred(c) {
1706            cur.push(c);
1707        } else {
1708            if cur.chars().count() > best.chars().count() {
1709                best = cur.clone();
1710            }
1711            cur.clear();
1712        }
1713    }
1714    if cur.chars().count() > best.chars().count() {
1715        best = cur;
1716    }
1717    best
1718}
1719
1720fn split_on(text: &str, splitter: &str) -> Vec<String> {
1721    text.split(splitter).map(str::to_string).collect()
1722}
1723
1724/// Python: `re.search(rf'{p}(\s)', text)` → the first whitespace char preceded
1725/// by `p`; then `re.split(rf'(?<={p}){s}', text)` — split at every occurrence
1726/// of that whitespace char immediately preceded by `p`.
1727fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1728    let chars: Vec<char> = text.chars().collect();
1729    let p: Vec<char> = preceder.chars().collect();
1730    let mut ws: Option<char> = None;
1731    for i in p.len()..chars.len() {
1732        if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1733            ws = Some(chars[i]);
1734            break;
1735        }
1736    }
1737    let ws = ws?;
1738    let mut parts = Vec::new();
1739    let mut cur = String::new();
1740    let mut i = 0usize;
1741    while i < chars.len() {
1742        if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1743            parts.push(std::mem::take(&mut cur));
1744            i += 1;
1745            continue;
1746        }
1747        cur.push(chars[i]);
1748        i += 1;
1749    }
1750    parts.push(cur);
1751    Some((ws.to_string(), parts))
1752}
1753
1754// ---------------------------------------------------------------------------
1755// HuggingFace tokenizer (feature `chunking`)
1756// ---------------------------------------------------------------------------
1757
1758#[cfg(feature = "chunking")]
1759mod hf {
1760    use super::ChunkTokenizer;
1761
1762    /// Where `scripts/install/download_dependencies.sh` puts the hybrid
1763    /// chunker's default tokenizer (all-MiniLM-L6-v2's `tokenizer.json`),
1764    /// relative to the process's working directory — the same convention as
1765    /// the `.models/` ONNX files.
1766    pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1767
1768    /// Resolve the tokenizer path for the hybrid chunker: an explicit path
1769    /// wins; otherwise fall back to [`DEFAULT_TOKENIZER_PATH`] when it exists
1770    /// on disk. Errors with the download instructions when neither is
1771    /// available.
1772    pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1773        if let Some(p) = explicit {
1774            return Ok(p.to_string());
1775        }
1776        let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1777        if std::path::Path::new(&resolved).exists() {
1778            return Ok(resolved);
1779        }
1780        Err(format!(
1781            "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1782             {DEFAULT_TOKENIZER_PATH} does not exist — run \
1783             scripts/install/download_dependencies.sh (or pass an explicit path)"
1784        ))
1785    }
1786
1787    /// [`ChunkTokenizer`] backed by a HuggingFace `tokenizer.json` — the Rust
1788    /// analogue of docling's `HuggingFaceTokenizer` (whose default is
1789    /// `sentence-transformers/all-MiniLM-L6-v2` with `max_tokens` 256).
1790    pub struct HuggingFaceTokenizer {
1791        tok: tokenizers::Tokenizer,
1792        max_tokens: usize,
1793    }
1794
1795    impl HuggingFaceTokenizer {
1796        /// Load the tokenizer from an explicit path, or from
1797        /// [`DEFAULT_TOKENIZER_PATH`] when `path` is `None` (see
1798        /// [`resolve_tokenizer_path`]).
1799        pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1800            Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1801        }
1802
1803        /// Load a `tokenizer.json`. `max_tokens` is the chunk budget (docling
1804        /// resolves it from the model's `sentence_bert_config.json`; for the
1805        /// default MiniLM model that is 256).
1806        pub fn from_file(
1807            path: impl AsRef<std::path::Path>,
1808            max_tokens: usize,
1809        ) -> Result<Self, String> {
1810            let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1811                .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1812            // Counting must see the full text (docling tokenizes without
1813            // truncation, padding, or special tokens — MiniLM's tokenizer.json
1814            // ships with fixed-length padding enabled, which would make every
1815            // short string count as the padded length).
1816            let _ = tok.with_truncation(None);
1817            tok.with_padding(None);
1818            Ok(Self { tok, max_tokens })
1819        }
1820    }
1821
1822    impl ChunkTokenizer for HuggingFaceTokenizer {
1823        fn count_tokens(&self, text: &str) -> usize {
1824            self.tok
1825                .encode(text, false)
1826                .map(|e| e.get_tokens().len())
1827                .unwrap_or(0)
1828        }
1829        fn max_tokens(&self) -> usize {
1830            self.max_tokens
1831        }
1832    }
1833}
1834
1835#[cfg(feature = "chunking")]
1836pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1837
1838// ---------------------------------------------------------------------------
1839// Window chunker (feature `chunking`) — docling-rag's Markdown window chunker
1840// ---------------------------------------------------------------------------
1841
1842#[cfg(feature = "chunking")]
1843mod window {
1844    use super::DocChunk;
1845    use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1846
1847    /// A contiguous run of body text under a heading path.
1848    #[derive(Debug, Clone, Default)]
1849    pub struct Section {
1850        /// The heading stack in effect for this section, outermost first
1851        /// (e.g. `["Guide", "Setup"]`). Empty for pre-heading / body-only text.
1852        pub heading_path: Vec<String>,
1853        /// The plain words of the section body, markup stripped.
1854        pub words: Vec<String>,
1855    }
1856
1857    impl Section {
1858        /// The heading path rendered as a single context line, e.g.
1859        /// `# Guide > Setup`. Empty string when there is no heading.
1860        pub fn heading_context(&self) -> String {
1861            if self.heading_path.is_empty() {
1862                String::new()
1863            } else {
1864                format!("# {}", self.heading_path.join(" > "))
1865            }
1866        }
1867    }
1868
1869    fn level_index(level: HeadingLevel) -> usize {
1870        match level {
1871            HeadingLevel::H1 => 1,
1872            HeadingLevel::H2 => 2,
1873            HeadingLevel::H3 => 3,
1874            HeadingLevel::H4 => 4,
1875            HeadingLevel::H5 => 5,
1876            HeadingLevel::H6 => 6,
1877        }
1878    }
1879
1880    /// Parse Markdown into heading-bounded sections. A new section starts at
1881    /// every heading; the heading path is maintained as a stack keyed by
1882    /// heading level.
1883    pub fn parse_sections(markdown: &str) -> Vec<Section> {
1884        parse_sections_with_stack(markdown, Vec::new()).0
1885    }
1886
1887    /// [`parse_sections`] with an explicit initial heading stack, returning the
1888    /// final stack — lets a streaming caller carry heading context across
1889    /// pieces. `heading_stack[i]` holds the current heading text at level `i+1`
1890    /// (may be empty when a level was skipped).
1891    pub fn parse_sections_with_stack(
1892        markdown: &str,
1893        initial_stack: Vec<String>,
1894    ) -> (Vec<Section>, Vec<String>) {
1895        let mut heading_stack: Vec<String> = initial_stack;
1896        let mut sections: Vec<Section> = Vec::new();
1897        // Text before the first heading of this piece continues the carried-over
1898        // section, so it keeps the heading path in effect at the split point.
1899        let mut current = Section {
1900            heading_path: heading_stack
1901                .iter()
1902                .filter(|h| !h.is_empty())
1903                .cloned()
1904                .collect(),
1905            words: Vec::new(),
1906        };
1907
1908        let mut in_heading = false;
1909        let mut heading_level = 0usize;
1910        let mut heading_buf = String::new();
1911
1912        let push_words = |section: &mut Section, text: &str| {
1913            for w in text.split_whitespace() {
1914                section.words.push(w.to_string());
1915            }
1916        };
1917
1918        let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1919            if !section.words.is_empty() {
1920                sections.push(std::mem::take(section));
1921            } else {
1922                *section = Section::default();
1923            }
1924        };
1925
1926        for event in Parser::new(markdown) {
1927            match event {
1928                Event::Start(Tag::Heading { level, .. }) => {
1929                    in_heading = true;
1930                    heading_level = level_index(level);
1931                    heading_buf.clear();
1932                }
1933                Event::End(TagEnd::Heading(_)) => {
1934                    in_heading = false;
1935                    // Update the heading stack: set this level, drop anything deeper.
1936                    let idx = heading_level.saturating_sub(1);
1937                    if heading_stack.len() <= idx {
1938                        heading_stack.resize(idx + 1, String::new());
1939                    } else {
1940                        heading_stack.truncate(idx + 1);
1941                    }
1942                    heading_stack[idx] = heading_buf.trim().to_string();
1943                    // A heading begins a new section.
1944                    flush(&mut sections, &mut current);
1945                    current.heading_path = heading_stack
1946                        .iter()
1947                        .filter(|h| !h.is_empty())
1948                        .cloned()
1949                        .collect();
1950                }
1951                Event::Text(t) | Event::Code(t) => {
1952                    if in_heading {
1953                        if !heading_buf.is_empty() {
1954                            heading_buf.push(' ');
1955                        }
1956                        heading_buf.push_str(&t);
1957                    } else {
1958                        push_words(&mut current, &t);
1959                    }
1960                }
1961                // Treat hard/soft breaks and rules as whitespace (words already split).
1962                Event::SoftBreak | Event::HardBreak | Event::Rule => {}
1963                _ => {}
1964            }
1965        }
1966        flush(&mut sections, &mut current);
1967        (sections, heading_stack)
1968    }
1969
1970    /// docling-rag's Markdown **window chunker**: the document is split into
1971    /// heading-bounded [`Section`]s of plain words (markup stripped), and a
1972    /// fixed-size window of [`Self::max_words`] words slides over each section
1973    /// with [`Self::overlap`] fractional overlap. A chunk never crosses a
1974    /// heading boundary; [`Self::contextualize`] prefixes the heading path.
1975    #[derive(Debug, Clone)]
1976    pub struct WindowChunker {
1977        /// Window size in words (docling-rag's default 300).
1978        pub max_words: usize,
1979        /// Fractional overlap between consecutive windows (default 0.05 = 5%).
1980        pub overlap: f32,
1981    }
1982
1983    impl Default for WindowChunker {
1984        fn default() -> Self {
1985            WindowChunker {
1986                max_words: 300,
1987                overlap: 0.05,
1988            }
1989        }
1990    }
1991
1992    impl WindowChunker {
1993        pub fn new(max_words: usize, overlap: f32) -> Self {
1994            WindowChunker { max_words, overlap }
1995        }
1996
1997        /// The window size, kept ≥ 1.
1998        fn word_budget(&self) -> usize {
1999            self.max_words.max(1)
2000        }
2001
2002        /// Number of words carried from one window into the next; capped so
2003        /// the window always advances.
2004        fn overlap_words(&self, budget: usize) -> usize {
2005            let o = (budget as f32 * self.overlap).round() as usize;
2006            o.min(budget.saturating_sub(1))
2007        }
2008
2009        /// Chunk a Markdown document.
2010        pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2011            let mut chunks = Vec::new();
2012            self.chunk_with(markdown, &mut |c| {
2013                chunks.push(c);
2014                true
2015            });
2016            chunks
2017        }
2018
2019        /// Stream the chunks: `sink` receives each window as it is cut, and a
2020        /// `false` return cancels. [`Self::chunk`] is this with a collecting
2021        /// sink — the chunks and their order are identical.
2022        pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2023            let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2024            for section in &sections {
2025                if !self.pack_section(section, sink) {
2026                    return;
2027                }
2028            }
2029        }
2030
2031        /// Slide the window over one completed section, feeding each chunk to
2032        /// `sink`. Returns `false` once the sink cancels — exposed so a
2033        /// streaming caller (docling-rag) can pack sections as they complete.
2034        pub fn pack_section(
2035            &self,
2036            section: &Section,
2037            sink: &mut dyn FnMut(DocChunk) -> bool,
2038        ) -> bool {
2039            let words = &section.words;
2040            if words.is_empty() {
2041                return true;
2042            }
2043            let budget = self.word_budget();
2044            let step = budget - self.overlap_words(budget); // ≥ 1 by construction
2045            let mut start = 0;
2046            loop {
2047                let end = (start + budget).min(words.len());
2048                let chunk = DocChunk {
2049                    text: words[start..end].join(" "),
2050                    headings: (!section.heading_path.is_empty())
2051                        .then(|| section.heading_path.clone()),
2052                    doc_items: Vec::new(),
2053                };
2054                if !sink(chunk) {
2055                    return false;
2056                }
2057                if end >= words.len() {
2058                    return true;
2059                }
2060                start += step;
2061            }
2062        }
2063
2064        /// Render a window chunk for embedding — docling-rag's rendering: the
2065        /// heading path as a `# Outer > Inner` context line, a blank line,
2066        /// then the body (just the body above any heading). Note this differs
2067        /// from the docling chunkers' [`contextualize`](super::contextualize),
2068        /// matching docling-rag instead.
2069        pub fn contextualize(chunk: &DocChunk) -> String {
2070            match &chunk.headings {
2071                Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2072                _ => chunk.text.clone(),
2073            }
2074        }
2075    }
2076
2077    #[cfg(test)]
2078    mod tests {
2079        use super::*;
2080
2081        #[test]
2082        fn splits_on_headings_and_tracks_path() {
2083            let md = "\
2084intro words
2085# Chapter 1
2086para one
2087## Section 1.1
2088para two
2089# Chapter 2
2090para three";
2091            let secs = parse_sections(md);
2092            // pre-heading intro, Chapter 1, Section 1.1, Chapter 2.
2093            assert_eq!(secs.len(), 4);
2094            assert!(secs[0].heading_path.is_empty());
2095            assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2096            assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2097            // A deeper heading is dropped when we return to H1.
2098            assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2099        }
2100
2101        #[test]
2102        fn strips_markup_to_plain_words() {
2103            let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2104            let secs = parse_sections(md);
2105            let words = &secs[0].words;
2106            assert!(words.contains(&"bold".to_string()));
2107            assert!(words.contains(&"code".to_string()));
2108            assert!(words.contains(&"link".to_string()));
2109            // No markdown punctuation survives as its own token.
2110            assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2111        }
2112
2113        #[test]
2114        fn windows_overlap_and_never_cross_headings() {
2115            let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2116            let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2117            let chunker = WindowChunker::new(10, 0.2); // step 8, overlap 2
2118            let chunks = chunker.chunk(&md);
2119            // Section A: 25 words → windows [0..10), [8..18), [16..25).
2120            let a: Vec<_> = chunks
2121                .iter()
2122                .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2123                .collect();
2124            assert_eq!(a.len(), 3);
2125            assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2126            assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2127            assert!(a[2].text.ends_with(" w24"));
2128            // Section B stays its own chunk; nothing crosses the heading.
2129            let b: Vec<_> = chunks
2130                .iter()
2131                .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2132                .collect();
2133            assert_eq!(b.len(), 1);
2134            assert_eq!(b[0].text, "short tail");
2135            assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2136        }
2137
2138        #[test]
2139        fn sink_false_cancels_the_window_walk() {
2140            let md = format!(
2141                "# A\n\n{}\n",
2142                (0..50)
2143                    .map(|i| format!("w{i}"))
2144                    .collect::<Vec<_>>()
2145                    .join(" ")
2146            );
2147            let chunker = WindowChunker::new(10, 0.0);
2148            let mut n = 0;
2149            chunker.chunk_with(&md, &mut |_| {
2150                n += 1;
2151                false
2152            });
2153            assert_eq!(n, 1);
2154        }
2155    }
2156}
2157
2158#[cfg(feature = "chunking")]
2159pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2160
2161#[cfg(test)]
2162mod tests {
2163    use super::*;
2164
2165    /// A whitespace "tokenizer" for algorithm tests.
2166    struct WordTok(usize);
2167    impl ChunkTokenizer for WordTok {
2168        fn count_tokens(&self, text: &str) -> usize {
2169            text.split_whitespace().count()
2170        }
2171        fn max_tokens(&self) -> usize {
2172            self.0
2173        }
2174    }
2175
2176    fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2177        let mut d = DoclingDocument::new("t");
2178        for n in nodes {
2179            d.push(n);
2180        }
2181        d
2182    }
2183
2184    #[test]
2185    fn hierarchical_headings_and_items() {
2186        let doc = doc_with(vec![
2187            Node::Heading {
2188                level: 1,
2189                text: "Title".into(),
2190            },
2191            Node::Paragraph {
2192                text: "Intro".into(),
2193            },
2194            Node::Heading {
2195                level: 2,
2196                text: "Sec".into(),
2197            },
2198            Node::Paragraph {
2199                text: "Body".into(),
2200            },
2201        ]);
2202        let chunks = HierarchicalChunker.chunk(&doc);
2203        assert_eq!(chunks.len(), 2);
2204        assert_eq!(chunks[0].text, "Intro");
2205        assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2206        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2207        assert_eq!(
2208            chunks[1].headings.as_deref(),
2209            Some(&["Title".into(), "Sec".into()][..])
2210        );
2211        assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2212    }
2213
2214    #[test]
2215    fn heading_shadowing_prunes_deeper_levels() {
2216        let doc = doc_with(vec![
2217            Node::Heading {
2218                level: 2,
2219                text: "A".into(),
2220            },
2221            Node::Heading {
2222                level: 3,
2223                text: "A.1".into(),
2224            },
2225            Node::Heading {
2226                level: 2,
2227                text: "B".into(),
2228            },
2229            Node::Paragraph { text: "p".into() },
2230        ]);
2231        let chunks = HierarchicalChunker.chunk(&doc);
2232        assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2233    }
2234
2235    #[test]
2236    fn triplet_table() {
2237        let t = Table {
2238            rows: vec![
2239                vec!["".into(), "Col1".into()],
2240                vec!["Row1".into(), "v".into()],
2241            ],
2242            ..Default::default()
2243        };
2244        assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2245        // Single-column: row 0 is the dataframe header, the first data row
2246        // becomes the column name, the rest the values.
2247        let single = Table {
2248            rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2249            ..Default::default()
2250        };
2251        assert_eq!(triplet_table_text(&single), "a = b");
2252    }
2253
2254    #[test]
2255    fn hybrid_merges_small_peers_and_splits_large() {
2256        let doc = doc_with(vec![
2257            Node::Heading {
2258                level: 2,
2259                text: "S".into(),
2260            },
2261            Node::Paragraph { text: "a b".into() },
2262            Node::Paragraph { text: "c d".into() },
2263        ]);
2264        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2265        assert_eq!(chunks.len(), 1, "peers under one heading merge");
2266        assert_eq!(chunks[0].text, "a b\nc d");
2267
2268        let long = "w ".repeat(40).trim().to_string();
2269        let doc = doc_with(vec![Node::Paragraph { text: long }]);
2270        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2271        assert!(chunks.len() > 1, "oversized paragraph splits");
2272        for c in &chunks {
2273            assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2274        }
2275    }
2276
2277    #[test]
2278    fn semchunk_prefers_newlines_then_sentences() {
2279        let tok = WordTok(4);
2280        let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2281        assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2282    }
2283}