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 w = Walker::default();
92        w.walk(&doc.nodes);
93        w.chunks
94    }
95}
96
97/// Ref allocator mirroring the numbering `json.rs` gives every item, so
98/// `ChunkItem::self_ref` matches the document's JSON export.
99#[derive(Debug, Default)]
100struct Alloc {
101    texts: usize,
102    groups: usize,
103    tables: usize,
104    pictures: usize,
105    field_regions: usize,
106    field_items: usize,
107}
108
109impl Alloc {
110    fn text(&mut self) -> String {
111        let r = format!("#/texts/{}", self.texts);
112        self.texts += 1;
113        r
114    }
115    fn group(&mut self) -> String {
116        let r = format!("#/groups/{}", self.groups);
117        self.groups += 1;
118        r
119    }
120    fn table(&mut self) -> String {
121        let r = format!("#/tables/{}", self.tables);
122        self.tables += 1;
123        r
124    }
125    fn picture(&mut self) -> String {
126        let r = format!("#/pictures/{}", self.pictures);
127        self.pictures += 1;
128        r
129    }
130    fn field_region(&mut self) -> String {
131        let r = format!("#/field_regions/{}", self.field_regions);
132        self.field_regions += 1;
133        r
134    }
135    fn field_item(&mut self) -> String {
136        let r = format!("#/field_items/{}", self.field_items);
137        self.field_items += 1;
138        r
139    }
140}
141
142#[derive(Debug, Default)]
143struct Walker {
144    alloc: Alloc,
145    /// Active heading per docling level (title = 0, `section_header` = its
146    /// `level`), pruned like docling's `heading_by_level`.
147    headings: BTreeMap<u8, String>,
148    chunks: Vec<DocChunk>,
149}
150
151impl Walker {
152    fn emit(&mut self, text: String, doc_items: Vec<ChunkItem>) {
153        if text.is_empty() {
154            return;
155        }
156        let headings: Vec<String> = self.headings.values().cloned().collect();
157        self.chunks.push(DocChunk {
158            text,
159            headings: (!headings.is_empty()).then_some(headings),
160            doc_items,
161        });
162    }
163
164    /// Emit a text chunk whose doc items follow docling's inline granularity:
165    /// mixed inline content (a paragraph docling represents as an inline group)
166    /// contributes one item per span, plain text one item.
167    fn emit_inline(&mut self, md_text: &str, self_ref: String) {
168        self.emit_inline_with_runs(md_text, self_ref, &[]);
169    }
170
171    fn emit_inline_with_runs(
172        &mut self,
173        md_text: &str,
174        self_ref: String,
175        runs: &[crate::InlineRun],
176    ) {
177        let body = unescape_text(md_text);
178        if body.is_empty() {
179            return;
180        }
181        let segments: Vec<String> = inline_segments_tagged(md_text)
182            .into_iter()
183            .flat_map(|(text, is_plain)| {
184                if is_plain {
185                    if let Some(split) = split_plain_by_runs(&text, runs) {
186                        return split;
187                    }
188                }
189                vec![text]
190            })
191            .collect();
192        let items: Vec<ChunkItem> = if segments.len() <= 1 {
193            vec![ChunkItem {
194                self_ref,
195                kind: ChunkItemKind::Text,
196                text: body.clone(),
197            }]
198        } else {
199            segments
200                .into_iter()
201                .map(|text| ChunkItem {
202                    self_ref: self_ref.clone(),
203                    kind: ChunkItemKind::Text,
204                    text,
205                })
206                .collect()
207        };
208        self.emit(body, items);
209    }
210
211    fn set_heading(&mut self, doc_level: u8, text: String) {
212        self.headings.retain(|k, _| *k < doc_level);
213        self.headings.insert(doc_level, text);
214    }
215
216    fn walk(&mut self, nodes: &[Node]) {
217        let mut i = 0;
218        while i < nodes.len() {
219            if matches!(nodes[i], Node::ListItem { .. }) {
220                let start = i;
221                i += 1;
222                loop {
223                    match nodes.get(i) {
224                        Some(Node::ListItem { .. }) => i += 1,
225                        // An empty paragraph between two list items is absorbed
226                        // into the run (mirrors json.rs / markdown.rs).
227                        Some(Node::Paragraph { text })
228                            if text.is_empty()
229                                && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
230                        {
231                            i += 1
232                        }
233                        _ => break,
234                    }
235                }
236                self.sibling_lists(&nodes[start..i]);
237            } else {
238                self.one(&nodes[i]);
239                i += 1;
240            }
241        }
242    }
243
244    /// Split a run of list items into sibling lists exactly like
245    /// `json.rs::add_sibling_lists`, chunking each list separately (docling
246    /// yields one chunk per `ListGroup`).
247    fn sibling_lists(&mut self, run: &[Node]) {
248        let base = level_of(&run[0]);
249        let mut seg = 0;
250        let mut prev: Option<(bool, u64)> = None;
251        for k in 0..run.len() {
252            let Node::ListItem {
253                ordered,
254                number,
255                first_in_list,
256                level,
257                ..
258            } = &run[k]
259            else {
260                continue;
261            };
262            if *level != base {
263                continue; // nested item — handled inside `list`
264            }
265            if k > seg {
266                if let Some((po, pn)) = prev {
267                    if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
268                        self.list(&run[seg..k]);
269                        seg = k;
270                    }
271                }
272            }
273            prev = Some((*ordered, *number));
274        }
275        self.list(&run[seg..]);
276    }
277
278    /// One `ListGroup`: allocate refs in `json.rs::add_list` order (group,
279    /// then per top item its text ref followed by any nested groups) and emit
280    /// a single chunk whose text is the indented markdown list.
281    fn list(&mut self, items: &[Node]) {
282        self.alloc.group();
283        let mut chunk_items = Vec::new();
284        self.list_refs(items, &mut chunk_items);
285        let text = render_list(items);
286        self.emit(text, chunk_items);
287    }
288
289    /// Allocate refs for one list's items (and nested sibling lists), mirroring
290    /// `json.rs::add_list` / `add_sibling_lists` recursion, collecting the
291    /// non-furniture items in allocation (= document) order.
292    fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
293        let base = level_of(&items[0]);
294        let mut i = 0;
295        while i < items.len() {
296            let Node::ListItem {
297                ordered,
298                number,
299                text,
300                level,
301                layer,
302                ..
303            } = &items[i]
304            else {
305                i += 1;
306                continue;
307            };
308            if *level > base {
309                i += 1;
310                continue;
311            }
312            let item_ref = self.alloc.text();
313            let mut j = i + 1;
314            while j < items.len() && level_of(&items[j]) > base {
315                j += 1;
316            }
317            let has_nested = j > i + 1;
318            if layer.is_none() {
319                let marker = if *ordered {
320                    format!("{number}.")
321                } else {
322                    "-".to_string()
323                };
324                // An item that carries both inline spans and a nested list is an
325                // empty list item wrapping an inline group in docling's model:
326                // its marker and each inline span are separate doc items.
327                let segments = inline_segments(text);
328                if has_nested && segments.len() > 1 && text.contains("](") {
329                    out.push(ChunkItem {
330                        self_ref: item_ref.clone(),
331                        kind: ChunkItemKind::Text,
332                        text: format!("{marker} "),
333                    });
334                    for seg in segments {
335                        out.push(ChunkItem {
336                            self_ref: item_ref.clone(),
337                            kind: ChunkItemKind::Text,
338                            text: seg,
339                        });
340                    }
341                } else {
342                    out.push(ChunkItem {
343                        self_ref: item_ref.clone(),
344                        kind: ChunkItemKind::Text,
345                        text: format!("{marker} {}", unescape_text(text)),
346                    });
347                }
348            }
349            // nested items group under this one; each nested sibling list is a
350            // fresh group ref
351            if j > i + 1 {
352                self.nested_sibling_lists(&items[i + 1..j], out);
353            }
354            i = j;
355        }
356    }
357
358    fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
359        let base = level_of(&run[0]);
360        let mut seg = 0;
361        let mut prev: Option<(bool, u64)> = None;
362        for k in 0..run.len() {
363            let Node::ListItem {
364                ordered,
365                number,
366                first_in_list,
367                level,
368                ..
369            } = &run[k]
370            else {
371                continue;
372            };
373            if *level != base {
374                continue;
375            }
376            if k > seg {
377                if let Some((po, pn)) = prev {
378                    if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
379                        self.alloc.group();
380                        self.list_refs(&run[seg..k], out);
381                        seg = k;
382                    }
383                }
384            }
385            prev = Some((*ordered, *number));
386        }
387        self.alloc.group();
388        self.list_refs(&run[seg..], out);
389    }
390
391    fn one(&mut self, node: &Node) {
392        match node {
393            Node::Heading { level, text } => {
394                let doc_level = if *level == 1 {
395                    0
396                } else {
397                    level.saturating_sub(1)
398                };
399                let self_ref = self.alloc.text();
400                // docling stores heading text unformatted: a heading that is one
401                // uniformly formatted span keeps its plain text; a *partially*
402                // formatted heading becomes an empty heading whose content is an
403                // inline group — which the chunker then yields as a chunk of its
404                // own (under the freshly-set empty heading).
405                let runs = crate::inline_runs_from_markdown(text);
406                if runs.len() <= 1 {
407                    let plain = runs
408                        .first()
409                        .map(|r| r.text.clone())
410                        .unwrap_or_else(|| text.clone());
411                    self.set_heading(doc_level, unescape_text(&plain));
412                } else {
413                    self.set_heading(doc_level, String::new());
414                    let body = unescape_text(text);
415                    self.emit(
416                        body.clone(),
417                        vec![ChunkItem {
418                            self_ref,
419                            kind: ChunkItemKind::Text,
420                            text: body,
421                        }],
422                    );
423                }
424            }
425            Node::Paragraph { text } => {
426                let t = text.trim();
427                let self_ref = self.alloc.text();
428                // A whole-paragraph display equation is a formula item; docling's
429                // chunk serializer re-wraps the raw latex in `$$…$$`.
430                if let Some(inner) = t
431                    .strip_prefix("$$")
432                    .and_then(|s| s.strip_suffix("$$"))
433                    .filter(|s| !s.is_empty())
434                {
435                    let body = format!("$${inner}$$");
436                    self.emit(
437                        body.clone(),
438                        vec![ChunkItem {
439                            self_ref,
440                            kind: ChunkItemKind::Text,
441                            text: body,
442                        }],
443                    );
444                    return;
445                }
446                self.emit_inline(text, self_ref);
447            }
448            Node::CheckboxItem { checked, text } => {
449                let self_ref = self.alloc.text();
450                let mark = if *checked { "- [x] " } else { "- [ ] " };
451                let body = format!("{mark}{}", unescape_text(text));
452                self.emit(
453                    body.clone(),
454                    vec![ChunkItem {
455                        self_ref,
456                        kind: ChunkItemKind::Text,
457                        text: body,
458                    }],
459                );
460            }
461            // An enriched display formula chunks like docling's formula item:
462            // the LaTeX is the chunk text.
463            Node::Formula { latex, .. } => {
464                let self_ref = self.alloc.text();
465                let body = format!("$${}$$", latex);
466                self.emit(
467                    body.clone(),
468                    vec![ChunkItem {
469                        self_ref,
470                        kind: ChunkItemKind::Text,
471                        text: body,
472                    }],
473                );
474            }
475            Node::Code { text, .. } => {
476                let self_ref = self.alloc.text();
477                let body = format!("```\n{}\n```", unescape_text(text));
478                self.emit(
479                    body.clone(),
480                    vec![ChunkItem {
481                        self_ref,
482                        kind: ChunkItemKind::Text,
483                        text: body,
484                    }],
485                );
486            }
487            Node::Table(t) => {
488                let self_ref = self.alloc.table();
489                let body = triplet_table_text(t);
490                self.emit(
491                    body.clone(),
492                    vec![ChunkItem {
493                        self_ref,
494                        kind: ChunkItemKind::Table,
495                        text: body,
496                    }],
497                );
498            }
499            Node::Picture { caption, .. } => {
500                let cap = caption.as_deref().filter(|c| !c.is_empty());
501                let cap_item = cap.map(|c| ChunkItem {
502                    self_ref: self.alloc.text(),
503                    kind: ChunkItemKind::Text,
504                    text: unescape_text(c),
505                });
506                self.alloc.picture();
507                // The picture itself serializes to the (empty) chunking image
508                // placeholder, and its caption is already consumed by the
509                // caption chunk — so only the caption text is emitted.
510                if let Some(cap_item) = cap_item {
511                    let body = cap_item.text.clone();
512                    self.emit(body, vec![cap_item]);
513                }
514            }
515            Node::Chart {
516                kind,
517                table,
518                caption,
519                ..
520            } => {
521                let cap = caption.as_deref().filter(|c| !c.is_empty());
522                let cap_item = cap.map(|c| ChunkItem {
523                    self_ref: self.alloc.text(),
524                    kind: ChunkItemKind::Text,
525                    text: unescape_text(c),
526                });
527                let pic_ref = self.alloc.picture();
528                // caption, humanized classification, then the chart's data grid
529                // as a (padded) markdown table — docling's picture serializer
530                // parts, joined with blank lines.
531                let mut parts: Vec<String> = Vec::new();
532                if let Some(ci) = &cap_item {
533                    parts.push(ci.text.clone());
534                }
535                parts.push(humanize_label(kind));
536                let grid = crate::markdown::render_table(table, false);
537                if !grid.is_empty() {
538                    parts.push(unescape_text(&grid));
539                }
540                let body = parts.join("\n\n");
541                // Re-serialized standalone (the hybrid window join), the picture
542                // carries its caption itself, while the caption *item* renders
543                // empty — docling's markdown serializer emits caption-label text
544                // only through the picture.
545                let pic_item = ChunkItem {
546                    self_ref: pic_ref,
547                    kind: ChunkItemKind::Picture,
548                    text: body.clone(),
549                };
550                let items = match cap_item {
551                    Some(mut ci) => {
552                        ci.text = String::new();
553                        vec![ci, pic_item]
554                    }
555                    None => vec![pic_item],
556                };
557                self.emit(body, items);
558            }
559            Node::Group { children, .. } => {
560                // A generic group is a structural container: docling recurses
561                // into it rather than chunking it whole.
562                self.alloc.group();
563                self.walk(children);
564            }
565            Node::FieldRegion { items } => {
566                // Each field part (marker / key / value) is its own text item,
567                // and docling chunks each one individually.
568                self.alloc.field_region();
569                for item in items {
570                    self.alloc.field_item();
571                    for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
572                        let self_ref = self.alloc.text();
573                        let body = unescape_text(part);
574                        self.emit(
575                            body.clone(),
576                            vec![ChunkItem {
577                                self_ref,
578                                kind: ChunkItemKind::Text,
579                                text: body,
580                            }],
581                        );
582                    }
583                }
584            }
585            Node::InlineGroup { md_text, runs, .. } => {
586                let self_ref = self.alloc.text();
587                self.emit_inline_with_runs(md_text, self_ref, runs);
588            }
589            Node::TextDump(text) => {
590                let self_ref = self.alloc.text();
591                let body = unescape_text(text);
592                self.emit(
593                    body.clone(),
594                    vec![ChunkItem {
595                        self_ref,
596                        kind: ChunkItemKind::Text,
597                        text: body,
598                    }],
599                );
600            }
601            // Layout provenance is transparent.
602            Node::Located { inner, .. } => self.one(inner),
603            // Non-body layers and doclang-only nodes don't reach the chunker
604            // (nor the JSON body).
605            Node::Furniture { .. }
606            | Node::PageFurniture { .. }
607            | Node::PageBreak
608            | Node::DoclangOnly(_) => {}
609            Node::ListItem { .. } => unreachable!("list items are chunked in runs"),
610        }
611    }
612}
613
614fn level_of(node: &Node) -> u8 {
615    match node {
616        Node::ListItem { level, .. } => *level,
617        _ => 0,
618    }
619}
620
621/// Render one sibling list as its markdown chunk text (indented items, same
622/// rules as the full markdown serializer's list rendering).
623fn render_list(items: &[Node]) -> String {
624    let mut lines: Vec<String> = Vec::new();
625    for item in items {
626        let Node::ListItem {
627            ordered,
628            number,
629            text,
630            level,
631            layer,
632            ..
633        } = item
634        else {
635            continue;
636        };
637        if layer.is_some() {
638            continue;
639        }
640        let indent = "    ".repeat(*level as usize);
641        let marker = if *ordered {
642            format!("{number}.")
643        } else {
644            "-".to_string()
645        };
646        lines.push(format!("{indent}{marker} {}", unescape_text(text)));
647    }
648    lines.join("\n")
649}
650
651/// docling-core's `_humanize_text`: underscores to spaces, first letter
652/// capitalized (`line_chart` → `Line chart`).
653fn humanize_label(label: &str) -> String {
654    let text = label.replace('_', " ");
655    let mut chars = text.chars();
656    match chars.next() {
657        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
658        None => text,
659    }
660}
661
662/// docling's `TripletTableSerializer` over `export_to_dataframe` semantics: the
663/// leading rows carrying column-header cells become the dataframe's column
664/// names (multiple header rows join per column with `.`; no header rows at all
665/// yield pandas' integer column names), the rest are data rows; the dataframe
666/// is then rendered as `row, column = value` sentences (with the header-only /
667/// single-column special cases and the plain-text flatten fallback).
668fn triplet_table_text(t: &Table) -> String {
669    let rows: Vec<Vec<String>> = t
670        .rows
671        .iter()
672        .enumerate()
673        .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
674        .collect();
675    let num_rows = rows.len();
676    let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
677    if num_rows == 0 || num_cols == 0 {
678        return String::new();
679    }
680    let cell = |r: usize, c: usize| -> &str {
681        rows.get(r)
682            .and_then(|row| row.get(c))
683            .map(String::as_str)
684            .unwrap_or("")
685    };
686
687    // Whether a cell is a column-header cell, resolving span continuations to
688    // their origin (docling's grid replicates the spanning cell, so a header
689    // spilling into the next row makes that row a header row too).
690    let cell_is_header = |r: usize, c: usize| -> bool {
691        let (mut r, mut c) = (r, c);
692        loop {
693            match &t.structure {
694                Some(s) if !s.col_header.is_empty() => {
695                    return s
696                        .col_header
697                        .get(r)
698                        .and_then(|row| row.get(c))
699                        .copied()
700                        .unwrap_or(false)
701                }
702                Some(s) => {
703                    let cont = |g: &Vec<Vec<bool>>| {
704                        g.get(r)
705                            .and_then(|row| row.get(c))
706                            .copied()
707                            .unwrap_or(false)
708                    };
709                    if r > 0 && cont(&s.row_continuation) {
710                        r -= 1;
711                        continue;
712                    }
713                    if c > 0 && cont(&s.col_continuation) {
714                        c -= 1;
715                        continue;
716                    }
717                    return if s.header_row.is_empty() {
718                        r == 0
719                    } else {
720                        s.header_row.get(r).copied().unwrap_or(false)
721                    };
722                }
723                None => return r == 0,
724            }
725        }
726    };
727    let row_is_header = |r: usize| (0..num_cols).any(|c| cell_is_header(r, c));
728    let num_headers = (0..num_rows).take_while(|r| row_is_header(*r)).count();
729
730    // Column names: header-row texts joined per column with '.', or the integer
731    // positions when there are no header rows.
732    let columns: Vec<String> = if num_headers > 0 {
733        (0..num_cols)
734            .map(|c| {
735                let mut name = String::new();
736                for r in 0..num_headers {
737                    if !name.is_empty() {
738                        name.push('.');
739                    }
740                    name.push_str(cell(r, c));
741                }
742                name
743            })
744            .collect()
745    } else {
746        (0..num_cols).map(|c| c.to_string()).collect()
747    };
748    let data_rows = num_headers..num_rows;
749    let n_data = data_rows.len();
750
751    // Header-only table: emit the header texts directly.
752    if n_data == 0 {
753        return columns
754            .iter()
755            .map(|s| s.trim())
756            .filter(|s| !s.is_empty())
757            .collect::<Vec<_>>()
758            .join(". ");
759    }
760
761    let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
762    let text = if num_cols == 1 {
763        // Single-column: the first data row is the column name, the rest are
764        // values (a single data row emits its cell text alone).
765        let col_name = data(0, 0).trim().to_string();
766        if n_data == 1 {
767            col_name
768        } else {
769            (1..n_data)
770                .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
771                .collect::<Vec<_>>()
772                .join(". ")
773        }
774    } else {
775        // Triplets over the dataframe with the column names copied as row 0.
776        let mut parts = Vec::new();
777        for r in 0..n_data {
778            for (c, col_name) in columns.iter().enumerate().skip(1) {
779                parts.push(format!(
780                    "{}, {} = {}",
781                    data(r, 0).trim(),
782                    col_name.trim(),
783                    data(r, c).trim()
784                ));
785            }
786        }
787        parts.join(". ")
788    };
789    if !text.is_empty() {
790        return text;
791    }
792
793    // Last-resort flatten: the data rows' non-blank cells joined with '. '
794    // (the header rows are the dataframe's columns, so they are not included).
795    (0..n_data)
796        .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
797        .map(|(r, c)| data(r, c).trim())
798        .filter(|s| !s.is_empty())
799        .collect::<Vec<_>>()
800        .join(". ")
801}
802
803/// Split a markdown-flavoured text into docling's inline-item granularity: a
804/// hyperlink / formatted span / inline formula is its own document item in
805/// docling's model, with plain text runs between them. Returns the standalone
806/// serialization of each item (`[text](url)`, `**bold**`, a formula re-wrapped
807/// as `$$latex$$`, plain text), or a single-element vector when the text is one
808/// uniform item. The single space docling's serializer inserts between inline
809/// items is stripped from the adjacent plain runs.
810fn inline_segments(md: &str) -> Vec<String> {
811    inline_segments_tagged(md)
812        .into_iter()
813        .map(|(t, _)| t)
814        .collect()
815}
816
817/// Like [`inline_segments`], with each segment tagged `true` when it came from
818/// plain (unmarked) text — those may still need splitting at run boundaries
819/// invisible in markdown (underline, soft breaks).
820fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
821    let chars: Vec<char> = md.chars().collect();
822    let n = chars.len();
823    let find = |from: usize, pat: &str| -> Option<usize> {
824        let hay: String = chars[from..].iter().collect();
825        hay.find(pat).map(|p| from + hay[..p].chars().count())
826    };
827    let mut out: Vec<(String, bool)> = Vec::new();
828    let mut plain = String::new();
829    let mut after_span = false;
830
831    fn flush(
832        out: &mut Vec<(String, bool)>,
833        plain: &mut String,
834        before_span: bool,
835        after_span: bool,
836    ) {
837        let mut p = std::mem::take(plain);
838        if after_span {
839            if let Some(rest) = p.strip_prefix(' ') {
840                p = rest.to_string();
841            }
842        }
843        if before_span {
844            if let Some(rest) = p.strip_suffix(' ') {
845                p = rest.to_string();
846            }
847        }
848        if !p.is_empty() {
849            out.push((unescape_text(&p), true));
850        }
851    }
852
853    let mut i = 0;
854    while i < n {
855        let rest: String = chars[i..].iter().collect();
856        // A hyperlink span (not an image): the whole `[text](url)` is one item.
857        if chars[i] == '[' && !rest.starts_with("[](") {
858            if let Some(close) = find(i + 1, "](") {
859                if let Some(endp) = find(close + 2, ")") {
860                    flush(&mut out, &mut plain, true, after_span);
861                    out.push((
862                        unescape_text(&chars[i..=endp].iter().collect::<String>()),
863                        false,
864                    ));
865                    i = endp + 1;
866                    after_span = true;
867                    continue;
868                }
869            }
870        }
871        // A formatted span; longest markers first. An inline code span is a
872        // code item in docling's model, whose standalone form is a fenced block.
873        let mut matched = false;
874        for marker in ["***", "**", "*", "~~", "`"] {
875            if rest.starts_with(marker) {
876                let mlen = marker.chars().count();
877                if let Some(end) = find(i + mlen, marker) {
878                    if end > i + mlen {
879                        flush(&mut out, &mut plain, true, after_span);
880                        if marker == "`" {
881                            let inner: String = chars[i + 1..end].iter().collect();
882                            out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
883                        } else {
884                            out.push((
885                                unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
886                                false,
887                            ));
888                        }
889                        i = end + mlen;
890                        after_span = true;
891                        matched = true;
892                    }
893                }
894                break;
895            }
896        }
897        if matched {
898            continue;
899        }
900        // A literal `$$` inside running text is not an inline formula: copy it
901        // through as plain characters.
902        if rest.starts_with("$$") {
903            plain.push_str("$$");
904            i += 2;
905            continue;
906        }
907        // An inline formula: standalone it re-serializes in display form.
908        if chars[i] == '$' {
909            if let Some(end) = find(i + 1, "$") {
910                if end > i + 1 {
911                    flush(&mut out, &mut plain, true, after_span);
912                    let latex: String = chars[i + 1..end].iter().collect();
913                    out.push((format!("$${latex}$$"), false));
914                    i = end + 1;
915                    after_span = true;
916                    continue;
917                }
918            }
919        }
920        plain.push(chars[i]);
921        i += 1;
922    }
923    flush(&mut out, &mut plain, false, after_span);
924    if out.is_empty() {
925        out.push((unescape_text(md), true));
926    }
927    out
928}
929
930/// Split a plain markdown segment at run boundaries the markdown cannot show
931/// (an underlined run, a `<sub>`/`<sup>` run): when a consecutive window of
932/// two or more unmarked runs exactly covers the segment, each run is its own
933/// document item.
934fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
935    let target = segment.trim();
936    if target.is_empty() {
937        return None;
938    }
939    let unmarked: Vec<&str> = runs
940        .iter()
941        .filter(|r| !r.bold && !r.italic && !r.strike && !r.code && !r.formula)
942        .map(|r| r.text.as_str())
943        .collect();
944    for start in 0..unmarked.len() {
945        let mut rest = target;
946        let mut taken: Vec<String> = Vec::new();
947        for t in &unmarked[start..] {
948            let t = t.trim();
949            if t.is_empty() {
950                continue;
951            }
952            match rest.strip_prefix(t) {
953                Some(r) => {
954                    taken.push(unescape_text(t));
955                    rest = r.trim_start();
956                    if rest.is_empty() {
957                        break;
958                    }
959                }
960                None => break,
961            }
962        }
963        if rest.is_empty() && taken.len() >= 2 {
964            return Some(taken);
965        }
966    }
967    None
968}
969
970/// A table cell's text for the triplet serializer. A *rich* cell (one carrying
971/// block content) is re-serialized the way docling's chunking serializer sees
972/// it: paragraphs joined with blank lines, a nested table as its own triplet
973/// sentences, pictures as an empty placeholder. Plain cells use the flat text
974/// with the markdown image placeholder stripped (the chunking serializer's
975/// `image_placeholder` is empty).
976fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
977    if let Some(blocks) = t
978        .cell_blocks
979        .as_ref()
980        .and_then(|b| b.get(r))
981        .and_then(|row| row.get(c))
982        .filter(|b| !b.is_empty())
983    {
984        let mut parts: Vec<String> = Vec::new();
985        for node in blocks.iter() {
986            let part = block_chunk_text(node);
987            if !part.is_empty() {
988                parts.push(part);
989            }
990        }
991        return parts.join("\n\n");
992    }
993    let flat = t
994        .rows
995        .get(r)
996        .and_then(|row| row.get(c))
997        .map(String::as_str)
998        .unwrap_or("");
999    unescape_text(flat)
1000        .replace("<!-- image -->", "")
1001        .trim()
1002        .to_string()
1003}
1004
1005/// One block of a rich cell, serialized for chunking.
1006fn block_chunk_text(node: &Node) -> String {
1007    match node {
1008        Node::Paragraph { text } => unescape_text(text),
1009        Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1010        Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1011        Node::Table(inner) => triplet_table_text(inner),
1012        Node::Picture { caption, .. } => caption
1013            .as_deref()
1014            .filter(|c| !c.is_empty())
1015            .map(unescape_text)
1016            .unwrap_or_default(),
1017        Node::ListItem {
1018            ordered,
1019            number,
1020            text,
1021            ..
1022        } => {
1023            let marker = if *ordered {
1024                format!("{number}.")
1025            } else {
1026                "-".to_string()
1027            };
1028            format!("{marker} {}", unescape_text(text))
1029        }
1030        Node::CheckboxItem { checked, text } => {
1031            let mark = if *checked { "- [x] " } else { "- [ ] " };
1032            format!("{mark}{}", unescape_text(text))
1033        }
1034        Node::Heading { text, .. } => unescape_text(text),
1035        Node::Located { inner, .. } => block_chunk_text(inner),
1036        Node::Group { children, .. } => children
1037            .iter()
1038            .map(block_chunk_text)
1039            .filter(|s| !s.is_empty())
1040            .collect::<Vec<_>>()
1041            .join("\n"),
1042        _ => String::new(),
1043    }
1044}
1045
1046/// Reverse the model's baked markdown text escaping — same mapping as the JSON
1047/// exporter (docling chunks carry raw text).
1048fn unescape_text(s: &str) -> String {
1049    s.replace("&lt;", "<")
1050        .replace("&gt;", ">")
1051        .replace("&amp;", "&")
1052        .replace("\\_", "_")
1053}
1054
1055// ---------------------------------------------------------------------------
1056// Hybrid chunker
1057// ---------------------------------------------------------------------------
1058
1059/// Token counting for [`HybridChunker`] — docling's `BaseTokenizer`.
1060pub trait ChunkTokenizer {
1061    /// Number of tokens in `text` (no special tokens).
1062    fn count_tokens(&self, text: &str) -> usize;
1063    /// The chunk budget (docling's `max_tokens`, e.g. 256 for MiniLM).
1064    fn max_tokens(&self) -> usize;
1065}
1066
1067/// Tokenization-aware chunker on top of [`HierarchicalChunker`] — docling's
1068/// `HybridChunker` with default parameters (`merge_peers`,
1069/// `repeat_table_header` on; `omit_header_on_overflow` off).
1070pub struct HybridChunker<T: ChunkTokenizer> {
1071    tokenizer: T,
1072    merge_peers: bool,
1073}
1074
1075impl<T: ChunkTokenizer> HybridChunker<T> {
1076    pub fn new(tokenizer: T) -> Self {
1077        Self {
1078            tokenizer,
1079            merge_peers: true,
1080        }
1081    }
1082
1083    /// Disable merging of undersized same-heading neighbours.
1084    pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1085        self.merge_peers = merge_peers;
1086        self
1087    }
1088
1089    pub fn max_tokens(&self) -> usize {
1090        self.tokenizer.max_tokens()
1091    }
1092
1093    /// Chunk the document.
1094    pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1095        let chunks = HierarchicalChunker.chunk(doc);
1096        let chunks: Vec<DocChunk> = chunks
1097            .into_iter()
1098            .flat_map(|c| self.split_by_doc_items(c))
1099            .collect();
1100        let chunks: Vec<DocChunk> = chunks
1101            .into_iter()
1102            .flat_map(|c| self.split_using_plain_text(c))
1103            .collect();
1104        if self.merge_peers {
1105            self.merge_matching(chunks)
1106        } else {
1107            chunks
1108        }
1109    }
1110
1111    fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1112        self.tokenizer.count_tokens(&contextualize(chunk))
1113    }
1114
1115    /// docling's `_make_chunk_from_doc_items`: single-item chunks keep their
1116    /// text; multi-item windows re-join the items' standalone serializations.
1117    fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1118        let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1119        let text = if chunk.doc_items.len() == 1 {
1120            chunk.text.clone()
1121        } else {
1122            doc_items
1123                .iter()
1124                .filter(|it| !it.text.is_empty())
1125                .map(|it| it.text.as_str())
1126                .collect::<Vec<_>>()
1127                .join("\n")
1128        };
1129        DocChunk {
1130            text,
1131            headings: chunk.headings.clone(),
1132            doc_items,
1133        }
1134    }
1135
1136    fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1137        if chunk.doc_items.is_empty() {
1138            return vec![chunk];
1139        }
1140        let max = self.max_tokens();
1141        let num_items = chunk.doc_items.len();
1142        let mut chunks = Vec::new();
1143        let mut window_start = 0usize;
1144        let mut window_end = 0usize; // inclusive
1145        while window_end < num_items {
1146            let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1147            if self.count_chunk_tokens(&new_chunk) <= max {
1148                if window_end < num_items - 1 {
1149                    window_end += 1;
1150                    continue;
1151                } else {
1152                    window_end = num_items; // last loop
1153                }
1154            } else if window_start == window_end {
1155                // One item that doesn't fit: keep it; the plain-text splitter
1156                // takes over.
1157                window_end += 1;
1158                window_start = window_end;
1159            } else {
1160                // The window without its last item fit; flush that and start a
1161                // new window at the current item.
1162                new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1163                window_start = window_end;
1164            }
1165            chunks.push(new_chunk);
1166        }
1167        chunks
1168    }
1169
1170    fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1171        let total = self.count_chunk_tokens(&chunk);
1172        let max = self.max_tokens();
1173        if total <= max {
1174            return vec![chunk];
1175        }
1176        let text_len = self.tokenizer.count_tokens(&chunk.text);
1177        let other_len = total - text_len;
1178        if other_len >= max {
1179            // Headings alone exceed the budget: drop them and retry.
1180            let stripped = DocChunk {
1181                headings: None,
1182                ..chunk
1183            };
1184            return self.split_using_plain_text(stripped);
1185        }
1186        let available = max - other_len;
1187
1188        let segments =
1189            if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1190                // Table: split line-based, repeating headers. The triplet
1191                // serializer has no header lines, so this is a line-preserving
1192                // split of the table text. (docling constructs the line chunker
1193                // with the *tokenizer's* max_tokens — the `max_tokens=available`
1194                // argument is silently dropped by pydantic — so the line budget is
1195                // the full window, not `available`.)
1196                let lines: Vec<String> = chunk
1197                    .text
1198                    .split('\n')
1199                    .filter(|l| !l.trim().is_empty())
1200                    .map(|l| l.to_string())
1201                    .collect();
1202                line_chunk_text(&lines, &self.tokenizer, max)
1203            } else {
1204                semchunk(&chunk.text, available, &self.tokenizer)
1205            };
1206        segments
1207            .into_iter()
1208            .map(|s| DocChunk {
1209                text: s,
1210                headings: chunk.headings.clone(),
1211                doc_items: chunk.doc_items.clone(),
1212            })
1213            .collect()
1214    }
1215
1216    /// docling's `_merge_chunks_with_matching_metadata`.
1217    fn merge_matching(&self, chunks: Vec<DocChunk>) -> Vec<DocChunk> {
1218        let max = self.max_tokens();
1219        let num = chunks.len();
1220        let mut out = Vec::new();
1221        let mut window_start = 0usize;
1222        let mut window_end = 0usize;
1223        let mut current_headings: Option<Vec<String>> = None;
1224        let mut merged: Option<DocChunk> = None;
1225        while window_end < num {
1226            let chunk = &chunks[window_end];
1227            let mut ready_to_append = false;
1228            if window_start == window_end {
1229                current_headings = chunk.headings.clone();
1230                window_end += 1;
1231            } else {
1232                let window = &chunks[window_start..=window_end];
1233                let candidate = DocChunk {
1234                    text: window
1235                        .iter()
1236                        .map(|c| c.text.as_str())
1237                        .collect::<Vec<_>>()
1238                        .join("\n"),
1239                    headings: current_headings.clone(),
1240                    doc_items: window
1241                        .iter()
1242                        .flat_map(|c| c.doc_items.iter().cloned())
1243                        .collect(),
1244                };
1245                if chunk.headings == current_headings && self.count_chunk_tokens(&candidate) <= max
1246                {
1247                    window_end += 1;
1248                    merged = Some(candidate);
1249                } else {
1250                    ready_to_append = true;
1251                }
1252            }
1253            if ready_to_append || window_end == num {
1254                if window_start + 1 == window_end {
1255                    out.push(chunks[window_start].clone());
1256                } else {
1257                    out.push(merged.take().expect("multi-chunk window has a merge"));
1258                }
1259                window_start = window_end;
1260            }
1261        }
1262        out
1263    }
1264}
1265
1266// ---------------------------------------------------------------------------
1267// Line-based token chunking (docling's LineBasedTokenChunker, empty prefix)
1268// ---------------------------------------------------------------------------
1269
1270/// Pack lines into chunks of at most `max_tokens`, splitting a line only when
1271/// it exceeds the budget on its own — docling's `LineBasedTokenChunker
1272/// .chunk_text` with an empty prefix (which is what the triplet table
1273/// serializer yields). Reproduces its exact output, including the `\n` it
1274/// prepends to a carried-over segment of an oversized line.
1275fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1276    let mut chunks: Vec<String> = Vec::new();
1277    let mut current = String::new();
1278    let mut current_len = 0usize;
1279
1280    for line in lines {
1281        let mut remaining: Vec<char> = line.chars().collect();
1282        loop {
1283            let rem_str: String = remaining.iter().collect();
1284            let line_tokens = tok.count_tokens(&rem_str);
1285            let available = max_tokens.saturating_sub(current_len);
1286
1287            if line_tokens <= available {
1288                current.push_str(&rem_str);
1289                current_len += line_tokens;
1290                break;
1291            }
1292            if line_tokens <= max_tokens {
1293                chunks.push(std::mem::take(&mut current));
1294                current_len = 0;
1295                continue;
1296            }
1297            // Too large even for an empty chunk: split off what fits.
1298            let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1299            let mut rest = rest;
1300            if take.is_empty() {
1301                if rest.is_empty() {
1302                    break;
1303                }
1304                take = rest[..1].iter().collect();
1305                rest = rest[1..].to_vec();
1306            }
1307            current.push('\n');
1308            current.push_str(&take);
1309            chunks.push(std::mem::take(&mut current));
1310            current_len = 0;
1311            remaining = rest;
1312        }
1313    }
1314    if !current.is_empty() {
1315        chunks.push(current);
1316    }
1317    chunks
1318}
1319
1320/// Binary-search the longest char-prefix of `text` within `token_limit`
1321/// tokens, preferring to break at the last ASCII space — docling's
1322/// `split_by_token_limit`.
1323fn split_by_token_limit<T: ChunkTokenizer>(
1324    text: &[char],
1325    token_limit: usize,
1326    tok: &T,
1327) -> (String, Vec<char>) {
1328    if token_limit == 0 || text.is_empty() {
1329        return (String::new(), text.to_vec());
1330    }
1331    let full: String = text.iter().collect();
1332    if tok.count_tokens(&full) <= token_limit {
1333        return (full, Vec::new());
1334    }
1335    let (mut lo, mut hi) = (0usize, text.len());
1336    let mut best: Option<usize> = None;
1337    while lo <= hi {
1338        let mid = (lo + hi) / 2;
1339        let head: String = text[..mid].iter().collect();
1340        if tok.count_tokens(&head) <= token_limit {
1341            best = Some(mid);
1342            lo = mid + 1;
1343        } else {
1344            if mid == 0 {
1345                break;
1346            }
1347            hi = mid - 1;
1348        }
1349    }
1350    let mut best_idx = match best {
1351        Some(b) if b > 0 => b,
1352        _ => return (String::new(), text.to_vec()),
1353    };
1354    // Snap back to the last space, if that leaves a non-empty head.
1355    if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1356        if pos > 0 {
1357            best_idx = pos;
1358        }
1359    }
1360    (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1361}
1362
1363// ---------------------------------------------------------------------------
1364// semchunk port (the plain-text splitter HybridChunker delegates to)
1365// ---------------------------------------------------------------------------
1366
1367/// Semantically meaningful non-whitespace splitters, most desirable first.
1368const NON_WS_SPLITTERS: &[&str] = &[
1369    ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1370    "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1371];
1372
1373/// Split `text` into chunks of at most `chunk_size` tokens using the most
1374/// semantically meaningful splitter available — the `semchunk` algorithm
1375/// docling's HybridChunker delegates plain-text splitting to.
1376pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1377    let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1378    let mut counter = |s: &str| -> usize {
1379        if let Some(n) = cache.get(s) {
1380            return *n;
1381        }
1382        let n = tok.count_tokens(s);
1383        cache.insert(s.to_string(), n);
1384        n
1385    };
1386    let chunks = semchunk_rec(text, chunk_size, &mut counter);
1387    // top-level: drop empty / all-whitespace chunks
1388    chunks
1389        .into_iter()
1390        .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1391        .collect()
1392}
1393
1394/// One recursion level of semchunk: split, merge windows back up to size, and
1395/// recurse into oversized splits.
1396fn semchunk_rec(
1397    text: &str,
1398    chunk_size: usize,
1399    counter: &mut dyn FnMut(&str) -> usize,
1400) -> Vec<String> {
1401    let (splitter, splitter_is_ws, splits) = split_text(text);
1402
1403    let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1404    let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1405    cum_lens.push(0usize);
1406    for l in &split_lens {
1407        cum_lens.push(cum_lens.last().unwrap() + l);
1408    }
1409    let num_splits_plus_one = splits.len() + 1;
1410
1411    let mut chunks: Vec<String> = Vec::new();
1412    let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1413
1414    for i in 0..splits.len() {
1415        if skips.contains(&i) {
1416            continue;
1417        }
1418        let split = &splits[i];
1419        if counter(split) > chunk_size {
1420            let inner = semchunk_rec(split, chunk_size, counter);
1421            chunks.extend(inner);
1422        } else {
1423            let (end, merged) = merge_splits(
1424                &splits,
1425                &cum_lens,
1426                chunk_size,
1427                &splitter,
1428                counter,
1429                i,
1430                num_splits_plus_one,
1431            );
1432            for j in (i + 1)..end {
1433                skips.insert(j);
1434            }
1435            chunks.push(merged);
1436        }
1437        // Re-attach a non-whitespace splitter to the last chunk (or emit it as
1438        // its own chunk if it doesn't fit).
1439        let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1440        if !splitter_is_ws && !is_last {
1441            let with_splitter = format!(
1442                "{}{}",
1443                chunks.last().map(String::as_str).unwrap_or(""),
1444                splitter
1445            );
1446            if counter(&with_splitter) <= chunk_size {
1447                if let Some(last) = chunks.last_mut() {
1448                    *last = with_splitter;
1449                } else {
1450                    chunks.push(with_splitter);
1451                }
1452            } else {
1453                chunks.push(splitter.clone());
1454            }
1455        }
1456    }
1457    chunks
1458}
1459
1460/// docling/semchunk's `merge_splits`: extend the window with a cum-length-guided
1461/// binary search until the token budget is hit.
1462fn merge_splits(
1463    splits: &[String],
1464    cum_lens: &[usize],
1465    chunk_size: usize,
1466    splitter: &str,
1467    counter: &mut dyn FnMut(&str) -> usize,
1468    start: usize,
1469    high_init: usize,
1470) -> (usize, String) {
1471    let mut average = 0.2f64;
1472    let mut low = start;
1473    let mut high = high_init;
1474    let offset = cum_lens[start];
1475    let mut target = offset as f64 + (chunk_size as f64 * average);
1476
1477    while low < high {
1478        let i = bisect_left(cum_lens, target, low, high);
1479        let midpoint = i.min(high - 1);
1480        let joined = splits[start..midpoint.max(start)].join(splitter);
1481        let tokens = counter(&joined);
1482        let local_cum = cum_lens[midpoint] - offset;
1483        if local_cum > 0 && tokens > 0 {
1484            average = local_cum as f64 / tokens as f64;
1485            target = offset as f64 + (chunk_size as f64 * average);
1486        }
1487        if tokens > chunk_size {
1488            high = midpoint;
1489        } else {
1490            low = midpoint + 1;
1491        }
1492    }
1493    let end = low - 1;
1494    (end, splits[start..end.max(start)].join(splitter))
1495}
1496
1497fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1498    while low < high {
1499        let mid = (low + high) / 2;
1500        if (sorted[mid] as f64) < target {
1501            low = mid + 1;
1502        } else {
1503            high = mid;
1504        }
1505    }
1506    low
1507}
1508
1509/// semchunk's `_split_text`: pick the most desirable splitter present.
1510fn split_text(text: &str) -> (String, bool, Vec<String>) {
1511    // Longest run of newlines/carriage returns.
1512    if text.contains('\n') || text.contains('\r') {
1513        let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1514        return (splitter.clone(), true, split_on(text, &splitter));
1515    }
1516    // Longest run of tabs.
1517    if text.contains('\t') {
1518        let splitter = longest_run(text, |c| c == '\t');
1519        return (splitter.clone(), true, split_on(text, &splitter));
1520    }
1521    // Longest run of whitespace.
1522    if text.chars().any(char::is_whitespace) {
1523        let splitter = longest_run(text, char::is_whitespace);
1524        if splitter.chars().count() == 1 {
1525            // Prefer a whitespace char preceded by a semantic splitter.
1526            for preceder in NON_WS_SPLITTERS {
1527                if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1528                    return (ws, true, parts);
1529                }
1530            }
1531        }
1532        return (splitter.clone(), true, split_on(text, &splitter));
1533    }
1534    // Most desirable semantic splitter present.
1535    for s in NON_WS_SPLITTERS {
1536        if text.contains(s) {
1537            return (s.to_string(), false, split_on(text, s));
1538        }
1539    }
1540    // No splitter at all: split into characters.
1541    (
1542        String::new(),
1543        true,
1544        text.chars().map(|c| c.to_string()).collect(),
1545    )
1546}
1547
1548/// The longest maximal run of chars matching `pred` (first one wins ties).
1549fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1550    let mut best = String::new();
1551    let mut cur = String::new();
1552    for c in text.chars() {
1553        if pred(c) {
1554            cur.push(c);
1555        } else {
1556            if cur.chars().count() > best.chars().count() {
1557                best = cur.clone();
1558            }
1559            cur.clear();
1560        }
1561    }
1562    if cur.chars().count() > best.chars().count() {
1563        best = cur;
1564    }
1565    best
1566}
1567
1568fn split_on(text: &str, splitter: &str) -> Vec<String> {
1569    text.split(splitter).map(str::to_string).collect()
1570}
1571
1572/// Python: `re.search(rf'{p}(\s)', text)` → the first whitespace char preceded
1573/// by `p`; then `re.split(rf'(?<={p}){s}', text)` — split at every occurrence
1574/// of that whitespace char immediately preceded by `p`.
1575fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1576    let chars: Vec<char> = text.chars().collect();
1577    let p: Vec<char> = preceder.chars().collect();
1578    let mut ws: Option<char> = None;
1579    for i in p.len()..chars.len() {
1580        if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1581            ws = Some(chars[i]);
1582            break;
1583        }
1584    }
1585    let ws = ws?;
1586    let mut parts = Vec::new();
1587    let mut cur = String::new();
1588    let mut i = 0usize;
1589    while i < chars.len() {
1590        if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1591            parts.push(std::mem::take(&mut cur));
1592            i += 1;
1593            continue;
1594        }
1595        cur.push(chars[i]);
1596        i += 1;
1597    }
1598    parts.push(cur);
1599    Some((ws.to_string(), parts))
1600}
1601
1602// ---------------------------------------------------------------------------
1603// HuggingFace tokenizer (feature `chunking`)
1604// ---------------------------------------------------------------------------
1605
1606#[cfg(feature = "chunking")]
1607mod hf {
1608    use super::ChunkTokenizer;
1609
1610    /// Where `scripts/install/download_dependencies.sh` puts the hybrid
1611    /// chunker's default tokenizer (all-MiniLM-L6-v2's `tokenizer.json`),
1612    /// relative to the process's working directory — the same convention as
1613    /// the `models/` ONNX files.
1614    pub const DEFAULT_TOKENIZER_PATH: &str = "models/chunk/tokenizer.json";
1615
1616    /// Resolve the tokenizer path for the hybrid chunker: an explicit path
1617    /// wins; otherwise fall back to [`DEFAULT_TOKENIZER_PATH`] when it exists
1618    /// on disk. Errors with the download instructions when neither is
1619    /// available.
1620    pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1621        if let Some(p) = explicit {
1622            return Ok(p.to_string());
1623        }
1624        if std::path::Path::new(DEFAULT_TOKENIZER_PATH).exists() {
1625            return Ok(DEFAULT_TOKENIZER_PATH.to_string());
1626        }
1627        Err(format!(
1628            "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1629             {DEFAULT_TOKENIZER_PATH} does not exist — run \
1630             scripts/install/download_dependencies.sh (or pass an explicit path)"
1631        ))
1632    }
1633
1634    /// [`ChunkTokenizer`] backed by a HuggingFace `tokenizer.json` — the Rust
1635    /// analogue of docling's `HuggingFaceTokenizer` (whose default is
1636    /// `sentence-transformers/all-MiniLM-L6-v2` with `max_tokens` 256).
1637    pub struct HuggingFaceTokenizer {
1638        tok: tokenizers::Tokenizer,
1639        max_tokens: usize,
1640    }
1641
1642    impl HuggingFaceTokenizer {
1643        /// Load the tokenizer from an explicit path, or from
1644        /// [`DEFAULT_TOKENIZER_PATH`] when `path` is `None` (see
1645        /// [`resolve_tokenizer_path`]).
1646        pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1647            Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1648        }
1649
1650        /// Load a `tokenizer.json`. `max_tokens` is the chunk budget (docling
1651        /// resolves it from the model's `sentence_bert_config.json`; for the
1652        /// default MiniLM model that is 256).
1653        pub fn from_file(
1654            path: impl AsRef<std::path::Path>,
1655            max_tokens: usize,
1656        ) -> Result<Self, String> {
1657            let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1658                .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1659            // Counting must see the full text (docling tokenizes without
1660            // truncation, padding, or special tokens — MiniLM's tokenizer.json
1661            // ships with fixed-length padding enabled, which would make every
1662            // short string count as the padded length).
1663            let _ = tok.with_truncation(None);
1664            tok.with_padding(None);
1665            Ok(Self { tok, max_tokens })
1666        }
1667    }
1668
1669    impl ChunkTokenizer for HuggingFaceTokenizer {
1670        fn count_tokens(&self, text: &str) -> usize {
1671            self.tok
1672                .encode(text, false)
1673                .map(|e| e.get_tokens().len())
1674                .unwrap_or(0)
1675        }
1676        fn max_tokens(&self) -> usize {
1677            self.max_tokens
1678        }
1679    }
1680}
1681
1682#[cfg(feature = "chunking")]
1683pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1684
1685#[cfg(test)]
1686mod tests {
1687    use super::*;
1688
1689    /// A whitespace "tokenizer" for algorithm tests.
1690    struct WordTok(usize);
1691    impl ChunkTokenizer for WordTok {
1692        fn count_tokens(&self, text: &str) -> usize {
1693            text.split_whitespace().count()
1694        }
1695        fn max_tokens(&self) -> usize {
1696            self.0
1697        }
1698    }
1699
1700    fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
1701        let mut d = DoclingDocument::new("t");
1702        for n in nodes {
1703            d.push(n);
1704        }
1705        d
1706    }
1707
1708    #[test]
1709    fn hierarchical_headings_and_items() {
1710        let doc = doc_with(vec![
1711            Node::Heading {
1712                level: 1,
1713                text: "Title".into(),
1714            },
1715            Node::Paragraph {
1716                text: "Intro".into(),
1717            },
1718            Node::Heading {
1719                level: 2,
1720                text: "Sec".into(),
1721            },
1722            Node::Paragraph {
1723                text: "Body".into(),
1724            },
1725        ]);
1726        let chunks = HierarchicalChunker.chunk(&doc);
1727        assert_eq!(chunks.len(), 2);
1728        assert_eq!(chunks[0].text, "Intro");
1729        assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
1730        assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
1731        assert_eq!(
1732            chunks[1].headings.as_deref(),
1733            Some(&["Title".into(), "Sec".into()][..])
1734        );
1735        assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
1736    }
1737
1738    #[test]
1739    fn heading_shadowing_prunes_deeper_levels() {
1740        let doc = doc_with(vec![
1741            Node::Heading {
1742                level: 2,
1743                text: "A".into(),
1744            },
1745            Node::Heading {
1746                level: 3,
1747                text: "A.1".into(),
1748            },
1749            Node::Heading {
1750                level: 2,
1751                text: "B".into(),
1752            },
1753            Node::Paragraph { text: "p".into() },
1754        ]);
1755        let chunks = HierarchicalChunker.chunk(&doc);
1756        assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
1757    }
1758
1759    #[test]
1760    fn triplet_table() {
1761        let t = Table {
1762            rows: vec![
1763                vec!["".into(), "Col1".into()],
1764                vec!["Row1".into(), "v".into()],
1765            ],
1766            ..Default::default()
1767        };
1768        assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
1769        // Single-column: row 0 is the dataframe header, the first data row
1770        // becomes the column name, the rest the values.
1771        let single = Table {
1772            rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
1773            ..Default::default()
1774        };
1775        assert_eq!(triplet_table_text(&single), "a = b");
1776    }
1777
1778    #[test]
1779    fn hybrid_merges_small_peers_and_splits_large() {
1780        let doc = doc_with(vec![
1781            Node::Heading {
1782                level: 2,
1783                text: "S".into(),
1784            },
1785            Node::Paragraph { text: "a b".into() },
1786            Node::Paragraph { text: "c d".into() },
1787        ]);
1788        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
1789        assert_eq!(chunks.len(), 1, "peers under one heading merge");
1790        assert_eq!(chunks[0].text, "a b\nc d");
1791
1792        let long = "w ".repeat(40).trim().to_string();
1793        let doc = doc_with(vec![Node::Paragraph { text: long }]);
1794        let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
1795        assert!(chunks.len() > 1, "oversized paragraph splits");
1796        for c in &chunks {
1797            assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
1798        }
1799    }
1800
1801    #[test]
1802    fn semchunk_prefers_newlines_then_sentences() {
1803        let tok = WordTok(4);
1804        let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
1805        assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
1806    }
1807}