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