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