1use std::collections::BTreeMap;
24
25use crate::document::{DoclingDocument, Node, Table};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ChunkItemKind {
31 Text,
33 Table,
36 Picture,
38}
39
40#[derive(Debug, Clone, PartialEq)]
43pub struct ChunkItem {
44 pub self_ref: String,
47 pub kind: ChunkItemKind,
48 pub text: String,
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct DocChunk {
57 pub text: String,
60 pub headings: Option<Vec<String>>,
63 pub doc_items: Vec<ChunkItem>,
65}
66
67pub 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#[derive(Debug, Clone, Default)]
86pub struct HierarchicalChunker;
87
88impl HierarchicalChunker {
89 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
91 let mut chunks = Vec::new();
92 self.chunk_with(doc, &mut |c| {
93 chunks.push(c);
94 true
95 });
96 chunks
97 }
98
99 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
105 let mut w = Walker {
106 alloc: Alloc::default(),
107 headings: BTreeMap::new(),
108 stopped: false,
109 sink,
110 };
111 w.walk(&doc.nodes);
112 }
113}
114
115#[derive(Debug, Default)]
118struct Alloc {
119 texts: usize,
120 groups: usize,
121 tables: usize,
122 pictures: usize,
123 field_regions: usize,
124 field_items: usize,
125}
126
127impl Alloc {
128 fn text(&mut self) -> String {
129 let r = format!("#/texts/{}", self.texts);
130 self.texts += 1;
131 r
132 }
133 fn group(&mut self) -> String {
134 let r = format!("#/groups/{}", self.groups);
135 self.groups += 1;
136 r
137 }
138 fn table(&mut self) -> String {
139 let r = format!("#/tables/{}", self.tables);
140 self.tables += 1;
141 r
142 }
143 fn picture(&mut self) -> String {
144 let r = format!("#/pictures/{}", self.pictures);
145 self.pictures += 1;
146 r
147 }
148 fn field_region(&mut self) -> String {
149 let r = format!("#/field_regions/{}", self.field_regions);
150 self.field_regions += 1;
151 r
152 }
153 fn field_item(&mut self) -> String {
154 let r = format!("#/field_items/{}", self.field_items);
155 self.field_items += 1;
156 r
157 }
158}
159
160struct Walker<'s> {
161 alloc: Alloc,
162 headings: BTreeMap<u8, String>,
165 stopped: bool,
167 sink: &'s mut dyn FnMut(DocChunk) -> bool,
168}
169
170impl Walker<'_> {
171 fn emit(&mut self, text: String, doc_items: Vec<ChunkItem>) {
172 if self.stopped || text.is_empty() {
173 return;
174 }
175 let headings: Vec<String> = self.headings.values().cloned().collect();
176 self.stopped = !(self.sink)(DocChunk {
177 text,
178 headings: (!headings.is_empty()).then_some(headings),
179 doc_items,
180 });
181 }
182
183 fn emit_inline(&mut self, md_text: &str, self_ref: String) {
187 self.emit_inline_with_runs(md_text, self_ref, &[]);
188 }
189
190 fn emit_inline_with_runs(
191 &mut self,
192 md_text: &str,
193 self_ref: String,
194 runs: &[crate::InlineRun],
195 ) {
196 let body = unescape_text(md_text);
197 if body.is_empty() {
198 return;
199 }
200 let segments: Vec<String> = inline_segments_tagged(md_text)
201 .into_iter()
202 .flat_map(|(text, is_plain)| {
203 if is_plain {
204 if let Some(split) = split_plain_by_runs(&text, runs) {
205 return split;
206 }
207 }
208 vec![text]
209 })
210 .collect();
211 let items: Vec<ChunkItem> = if segments.len() <= 1 {
212 vec![ChunkItem {
213 self_ref,
214 kind: ChunkItemKind::Text,
215 text: body.clone(),
216 }]
217 } else {
218 segments
219 .into_iter()
220 .map(|text| ChunkItem {
221 self_ref: self_ref.clone(),
222 kind: ChunkItemKind::Text,
223 text,
224 })
225 .collect()
226 };
227 self.emit(body, items);
228 }
229
230 fn set_heading(&mut self, doc_level: u8, text: String) {
231 self.headings.retain(|k, _| *k < doc_level);
232 self.headings.insert(doc_level, text);
233 }
234
235 fn walk(&mut self, nodes: &[Node]) {
236 let mut i = 0;
237 while i < nodes.len() {
238 if self.stopped {
239 return;
240 }
241 if matches!(nodes[i], Node::ListItem { .. }) {
242 let start = i;
243 i += 1;
244 loop {
245 match nodes.get(i) {
246 Some(Node::ListItem { .. }) => i += 1,
247 Some(Node::Paragraph { text })
250 if text.is_empty()
251 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
252 {
253 i += 1
254 }
255 _ => break,
256 }
257 }
258 self.sibling_lists(&nodes[start..i]);
259 } else {
260 self.one(&nodes[i]);
261 i += 1;
262 }
263 }
264 }
265
266 fn sibling_lists(&mut self, run: &[Node]) {
270 let base = level_of(&run[0]);
271 let mut seg = 0;
272 for k in 0..run.len() {
273 let Node::ListItem {
274 first_in_list,
275 level,
276 ..
277 } = &run[k]
278 else {
279 continue;
280 };
281 if *level != base {
282 continue; }
284 if k > seg && *first_in_list {
286 self.list(&run[seg..k]);
287 seg = k;
288 }
289 }
290 self.list(&run[seg..]);
291 }
292
293 fn list(&mut self, items: &[Node]) {
297 self.alloc.group();
298 let mut chunk_items = Vec::new();
299 self.list_refs(items, &mut chunk_items);
300 let text = render_list(items);
301 self.emit(text, chunk_items);
302 }
303
304 fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
308 let base = level_of(&items[0]);
309 let mut i = 0;
310 while i < items.len() {
311 let Node::ListItem {
312 ordered,
313 number,
314 text,
315 level,
316 layer,
317 ..
318 } = &items[i]
319 else {
320 i += 1;
321 continue;
322 };
323 if *level > base {
324 i += 1;
325 continue;
326 }
327 let item_ref = self.alloc.text();
328 let mut j = i + 1;
329 while j < items.len() && level_of(&items[j]) > base {
330 j += 1;
331 }
332 let has_nested = j > i + 1;
333 if layer.is_none() {
334 let marker = if *ordered {
335 format!("{number}.")
336 } else {
337 "-".to_string()
338 };
339 let has_pics = text.contains("<!-- image -->");
353 let text = strip_image_markers(text);
354 let text = text.as_str();
355 let segments = inline_segments(text);
356 if (has_nested || has_pics) && segments.len() > 1 && text.contains("](") {
357 out.push(ChunkItem {
358 self_ref: item_ref.clone(),
359 kind: ChunkItemKind::Text,
360 text: format!("{marker} "),
361 });
362 for seg in segments {
363 out.push(ChunkItem {
364 self_ref: item_ref.clone(),
365 kind: ChunkItemKind::Text,
366 text: seg,
367 });
368 }
369 } else {
370 out.push(ChunkItem {
371 self_ref: item_ref.clone(),
372 kind: ChunkItemKind::Text,
373 text: format!("{marker} {}", unescape_text(text)),
374 });
375 }
376 }
377 if j > i + 1 {
380 self.nested_sibling_lists(&items[i + 1..j], out);
381 }
382 i = j;
383 }
384 }
385
386 fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
387 let base = level_of(&run[0]);
388 let mut seg = 0;
389 for k in 0..run.len() {
390 let Node::ListItem {
391 first_in_list,
392 level,
393 ..
394 } = &run[k]
395 else {
396 continue;
397 };
398 if *level != base {
399 continue;
400 }
401 if k > seg && *first_in_list {
402 self.alloc.group();
403 self.list_refs(&run[seg..k], out);
404 seg = k;
405 }
406 }
407 self.alloc.group();
408 self.list_refs(&run[seg..], out);
409 }
410
411 fn one(&mut self, node: &Node) {
412 match node {
413 Node::Heading { level, text } => {
414 let doc_level = if *level == 1 {
415 0
416 } else {
417 level.saturating_sub(1)
418 };
419 let self_ref = self.alloc.text();
420 let runs = crate::inline_runs_from_markdown(text);
426 if runs.len() <= 1 {
427 let plain = runs
428 .first()
429 .map(|r| r.text.clone())
430 .unwrap_or_else(|| text.clone());
431 self.set_heading(doc_level, unescape_text(&plain));
432 } else {
433 self.set_heading(doc_level, String::new());
434 let body = unescape_text(text);
435 self.emit(
436 body.clone(),
437 vec![ChunkItem {
438 self_ref,
439 kind: ChunkItemKind::Text,
440 text: body,
441 }],
442 );
443 }
444 }
445 Node::Paragraph { text } => {
446 let t = text.trim();
447 let self_ref = self.alloc.text();
448 if let Some(inner) = t
451 .strip_prefix("$$")
452 .and_then(|s| s.strip_suffix("$$"))
453 .filter(|s| !s.is_empty())
454 {
455 let body = format!("$${inner}$$");
456 self.emit(
457 body.clone(),
458 vec![ChunkItem {
459 self_ref,
460 kind: ChunkItemKind::Text,
461 text: body,
462 }],
463 );
464 return;
465 }
466 self.emit_inline(text, self_ref);
467 }
468 Node::Caption { text, .. } => {
471 let self_ref = self.alloc.text();
472 self.emit_inline(text, self_ref);
473 }
474 Node::LabeledText { .. } => self.one(&node.labeled_as_paragraph()),
475 Node::CheckboxItem { checked, text } => {
476 let self_ref = self.alloc.text();
477 let mark = if *checked { "- [x] " } else { "- [ ] " };
478 let body = format!("{mark}{}", unescape_text(text));
479 self.emit(
480 body.clone(),
481 vec![ChunkItem {
482 self_ref,
483 kind: ChunkItemKind::Text,
484 text: body,
485 }],
486 );
487 }
488 Node::Formula { latex, .. } => {
491 let self_ref = self.alloc.text();
492 let body = format!("$${}$$", latex);
493 self.emit(
494 body.clone(),
495 vec![ChunkItem {
496 self_ref,
497 kind: ChunkItemKind::Text,
498 text: body,
499 }],
500 );
501 }
502 Node::Code { text, .. } => {
503 let self_ref = self.alloc.text();
504 let body = format!("```\n{}\n```", unescape_text(text));
505 self.emit(
506 body.clone(),
507 vec![ChunkItem {
508 self_ref,
509 kind: ChunkItemKind::Text,
510 text: body,
511 }],
512 );
513 }
514 Node::Table(t) => {
515 let self_ref = self.alloc.table();
516 let body = triplet_table_text(t);
517 self.emit(
518 body.clone(),
519 vec![ChunkItem {
520 self_ref,
521 kind: ChunkItemKind::Table,
522 text: body,
523 }],
524 );
525 }
526 Node::Picture {
527 caption,
528 description,
529 ..
530 } => {
531 let cap = caption.as_deref().filter(|c| !c.is_empty());
532 let cap_item = cap.map(|c| ChunkItem {
533 self_ref: self.alloc.text(),
534 kind: ChunkItemKind::Text,
535 text: unescape_text(c),
536 });
537 let pic_ref = self.alloc.picture();
538 let desc = description
546 .as_ref()
547 .map(|d| d.text.as_str())
548 .filter(|t| !t.is_empty());
549 match (cap_item, desc) {
550 (Some(cap_item), None) => {
551 let body = cap_item.text.clone();
552 self.emit(body, vec![cap_item]);
553 }
554 (Some(cap_item), Some(desc)) => {
555 let body = format!("{}\n\n{desc}", cap_item.text);
556 let pic_item = ChunkItem {
557 self_ref: pic_ref,
558 kind: ChunkItemKind::Picture,
559 text: desc.to_string(),
560 };
561 self.emit(body, vec![cap_item, pic_item]);
562 }
563 (None, Some(desc)) => {
564 let pic_item = ChunkItem {
565 self_ref: pic_ref,
566 kind: ChunkItemKind::Picture,
567 text: desc.to_string(),
568 };
569 self.emit(desc.to_string(), vec![pic_item]);
570 }
571 (None, None) => {}
572 }
573 }
574 Node::Chart {
575 kind,
576 table,
577 caption,
578 ..
579 } => {
580 let cap = caption.as_deref().filter(|c| !c.is_empty());
581 let cap_item = cap.map(|c| ChunkItem {
582 self_ref: self.alloc.text(),
583 kind: ChunkItemKind::Text,
584 text: unescape_text(c),
585 });
586 let pic_ref = self.alloc.picture();
587 let mut parts: Vec<String> = Vec::new();
591 if let Some(ci) = &cap_item {
592 parts.push(ci.text.clone());
593 }
594 parts.push(humanize_label(kind));
595 let grid = crate::markdown::render_table(table, false);
596 if !grid.is_empty() {
597 parts.push(unescape_text(&grid));
598 }
599 let body = parts.join("\n\n");
600 let pic_item = ChunkItem {
605 self_ref: pic_ref,
606 kind: ChunkItemKind::Picture,
607 text: body.clone(),
608 };
609 let items = match cap_item {
610 Some(mut ci) => {
611 ci.text = String::new();
612 vec![ci, pic_item]
613 }
614 None => vec![pic_item],
615 };
616 self.emit(body, items);
617 }
618 Node::Group { layer: Some(_), .. } => {}
621 Node::Group { children, .. } => {
622 self.alloc.group();
625 self.walk(children);
626 }
627 Node::KeyValueGraph { .. } => {}
629 Node::FieldRegion { items } => {
630 self.alloc.field_region();
633 for item in items {
634 self.alloc.field_item();
635 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
636 let self_ref = self.alloc.text();
637 let body = unescape_text(part);
638 self.emit(
639 body.clone(),
640 vec![ChunkItem {
641 self_ref,
642 kind: ChunkItemKind::Text,
643 text: body,
644 }],
645 );
646 }
647 }
648 }
649 Node::InlineGroup { md_text, runs, .. } => {
650 let self_ref = self.alloc.text();
651 self.emit_inline_with_runs(md_text, self_ref, runs);
652 }
653 Node::TextDump(text) => {
654 let self_ref = self.alloc.text();
655 let body = unescape_text(text);
656 self.emit(
657 body.clone(),
658 vec![ChunkItem {
659 self_ref,
660 kind: ChunkItemKind::Text,
661 text: body,
662 }],
663 );
664 }
665 Node::Located { inner, .. }
667 | Node::Prov { inner, .. }
668 | Node::Track { inner, .. }
669 | Node::Commented { inner, .. } => self.one(inner),
670 Node::PageFurniture { .. } | Node::FurnitureText { .. } => {
674 self.alloc.text();
675 }
676 Node::PictureChildren(children) => {
682 for child in children {
683 match child {
684 Node::PageFurniture { .. } => self.one(child),
687 Node::ListItem { .. } => {
688 self.alloc.group();
689 self.alloc.text();
690 }
691 _ => {
692 self.alloc.text();
693 }
694 }
695 }
696 }
697 Node::CommentSection { .. }
700 | Node::Furniture { .. }
701 | Node::PageBreak
702 | Node::PageInfo { .. }
703 | Node::DoclangOnly(_) => {}
704 Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
707 }
708 }
709}
710
711fn level_of(node: &Node) -> u8 {
712 match node {
713 Node::ListItem { level, .. } => *level,
714 _ => 0,
715 }
716}
717
718fn render_list(items: &[Node]) -> String {
721 let mut lines: Vec<String> = Vec::new();
722 for item in items {
723 let Node::ListItem {
724 ordered,
725 number,
726 text,
727 level,
728 layer,
729 ..
730 } = item
731 else {
732 continue;
733 };
734 if layer.is_some() {
735 continue;
736 }
737 let indent = " ".repeat(*level as usize);
738 let marker = if *ordered {
739 format!("{number}.")
740 } else {
741 "-".to_string()
742 };
743 lines.push(format!(
744 "{indent}{marker} {}",
745 unescape_text(&strip_image_markers(text))
746 ));
747 }
748 lines.join("\n")
749}
750
751fn strip_image_markers(text: &str) -> String {
756 if !text.contains("<!-- image -->") {
757 return text.to_string();
758 }
759 let cleaned: Vec<&str> = text
760 .split('\n')
761 .map(str::trim_end)
762 .filter(|l| *l != "<!-- image -->")
763 .collect();
764 cleaned.join("\n").trim_end().to_string()
765}
766
767fn humanize_label(label: &str) -> String {
770 let text = label.replace('_', " ");
771 let mut chars = text.chars();
772 match chars.next() {
773 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
774 None => text,
775 }
776}
777
778fn triplet_table_text(t: &Table) -> String {
785 let rows: Vec<Vec<String>> = t
786 .rows
787 .iter()
788 .enumerate()
789 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
790 .collect();
791 let num_rows = rows.len();
792 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
793 if num_rows == 0 || num_cols == 0 {
794 return String::new();
795 }
796 let cell = |r: usize, c: usize| -> &str {
797 rows.get(r)
798 .and_then(|row| row.get(c))
799 .map(String::as_str)
800 .unwrap_or("")
801 };
802
803 let num_headers = {
809 let derived;
810 let cells: &[crate::TableCell] = match &t.cells {
811 Some(c) if !c.is_empty() => c,
812 _ => {
813 derived = t.derive_cells();
814 &derived
815 }
816 };
817 (0..num_rows)
818 .take_while(|&r| cells.iter().any(|c| c.column_header && c.start_row == r))
819 .count()
820 };
821
822 let columns: Vec<String> = if num_headers > 0 {
825 (0..num_cols)
826 .map(|c| {
827 let mut name = String::new();
828 for r in 0..num_headers {
829 if !name.is_empty() {
830 name.push('.');
831 }
832 name.push_str(cell(r, c));
833 }
834 name
835 })
836 .collect()
837 } else {
838 (0..num_cols).map(|c| c.to_string()).collect()
839 };
840 let data_rows = num_headers..num_rows;
841 let n_data = data_rows.len();
842
843 if n_data == 0 {
845 return columns
846 .iter()
847 .map(|s| s.trim())
848 .filter(|s| !s.is_empty())
849 .collect::<Vec<_>>()
850 .join(". ");
851 }
852
853 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
854 let text = if num_cols == 1 {
855 let col_name = data(0, 0).trim().to_string();
858 if n_data == 1 {
859 col_name
860 } else {
861 (1..n_data)
862 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
863 .collect::<Vec<_>>()
864 .join(". ")
865 }
866 } else {
867 let mut parts = Vec::new();
869 for r in 0..n_data {
870 for (c, col_name) in columns.iter().enumerate().skip(1) {
871 parts.push(format!(
872 "{}, {} = {}",
873 data(r, 0).trim(),
874 col_name.trim(),
875 data(r, c).trim()
876 ));
877 }
878 }
879 parts.join(". ")
880 };
881 if !text.is_empty() {
882 return text;
883 }
884
885 (0..n_data)
888 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
889 .map(|(r, c)| data(r, c).trim())
890 .filter(|s| !s.is_empty())
891 .collect::<Vec<_>>()
892 .join(". ")
893}
894
895fn inline_segments(md: &str) -> Vec<String> {
903 inline_segments_tagged(md)
904 .into_iter()
905 .map(|(t, _)| t)
906 .collect()
907}
908
909fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
913 let chars: Vec<char> = md.chars().collect();
914 let n = chars.len();
915 let find = |from: usize, pat: &str| -> Option<usize> {
916 let hay: String = chars[from..].iter().collect();
917 hay.find(pat).map(|p| from + hay[..p].chars().count())
918 };
919 let mut out: Vec<(String, bool)> = Vec::new();
920 let mut plain = String::new();
921 let mut after_span = false;
922
923 fn flush(
924 out: &mut Vec<(String, bool)>,
925 plain: &mut String,
926 before_span: bool,
927 after_span: bool,
928 ) {
929 let mut p = std::mem::take(plain);
930 if after_span {
931 if let Some(rest) = p.strip_prefix(' ') {
932 p = rest.to_string();
933 }
934 }
935 if before_span {
936 if let Some(rest) = p.strip_suffix(' ') {
937 p = rest.to_string();
938 }
939 }
940 if !p.is_empty() {
941 out.push((unescape_text(&p), true));
942 }
943 }
944
945 let mut i = 0;
946 while i < n {
947 let rest: String = chars[i..].iter().collect();
948 if chars[i] == '[' && !rest.starts_with("[](") {
952 let balanced = |c: usize| {
956 let mut d = 0i32;
957 for &ch in &chars[i + 1..c] {
958 match ch {
959 '[' => d += 1,
960 ']' => d -= 1,
961 _ => {}
962 }
963 }
964 d == 0
965 };
966 if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
967 let mut depth = 0usize;
968 let mut url_end = None;
969 for (k, &c) in chars.iter().enumerate().skip(close + 2) {
970 match c {
971 '(' => depth += 1,
972 ')' => {
973 if depth == 0 {
974 url_end = Some(k);
975 break;
976 }
977 depth -= 1;
978 }
979 _ => {}
980 }
981 }
982 if let Some(endp) = url_end {
983 flush(&mut out, &mut plain, true, after_span);
984 out.push((
985 unescape_text(&chars[i..=endp].iter().collect::<String>()),
986 false,
987 ));
988 i = endp + 1;
989 after_span = true;
990 continue;
991 }
992 }
993 }
994 let mut matched = false;
997 for marker in ["***", "**", "*", "~~", "`"] {
998 if rest.starts_with(marker) {
999 let mlen = marker.chars().count();
1000 if let Some(end) = find(i + mlen, marker) {
1001 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
1005 if end > i + mlen && !inner_blank {
1006 flush(&mut out, &mut plain, true, after_span);
1007 if marker == "`" {
1008 let inner: String = chars[i + 1..end].iter().collect();
1009 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
1010 } else {
1011 out.push((
1012 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
1013 false,
1014 ));
1015 }
1016 i = end + mlen;
1017 after_span = true;
1018 matched = true;
1019 }
1020 }
1021 break;
1022 }
1023 }
1024 if matched {
1025 continue;
1026 }
1027 if rest.starts_with("$$") {
1030 plain.push_str("$$");
1031 i += 2;
1032 continue;
1033 }
1034 if chars[i] == '$' {
1036 if let Some(end) = find(i + 1, "$") {
1037 if end > i + 1 {
1038 flush(&mut out, &mut plain, true, after_span);
1039 let latex: String = chars[i + 1..end].iter().collect();
1040 out.push((format!("$${latex}$$"), false));
1041 i = end + 1;
1042 after_span = true;
1043 continue;
1044 }
1045 }
1046 }
1047 plain.push(chars[i]);
1048 i += 1;
1049 }
1050 flush(&mut out, &mut plain, false, after_span);
1051 if out.is_empty() {
1052 out.push((unescape_text(md), true));
1053 }
1054 out
1055}
1056
1057fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1062 let target = segment.trim();
1063 if target.is_empty() {
1064 return None;
1065 }
1066 let plainish =
1067 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1068 let fully_plain =
1069 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1070 let unmarked: Vec<(&str, bool)> = runs
1071 .iter()
1072 .filter(|r| plainish(r))
1073 .map(|r| (r.text.as_str(), fully_plain(r)))
1074 .collect();
1075 for start in 0..unmarked.len() {
1076 let mut rest = target;
1077 let mut taken: Vec<(String, bool)> = Vec::new();
1078 for (t, fully) in &unmarked[start..] {
1079 let t = t.trim();
1080 if t.is_empty() {
1081 continue;
1082 }
1083 match rest.strip_prefix(t) {
1084 Some(r) => {
1085 taken.push((unescape_text(t), *fully));
1086 rest = r.trim_start();
1087 if rest.is_empty() {
1088 break;
1089 }
1090 }
1091 None => break,
1092 }
1093 }
1094 if rest.is_empty() && taken.len() >= 2 {
1095 let mut merged: Vec<(String, bool)> = Vec::new();
1100 for (t, fully) in taken {
1101 match merged.last_mut() {
1102 Some((last, true)) if fully => {
1103 last.push(' ');
1104 last.push_str(&t);
1105 }
1106 _ => merged.push((t, fully)),
1107 }
1108 }
1109 if merged.len() >= 2 {
1110 return Some(merged.into_iter().map(|(t, _)| t).collect());
1111 }
1112 return None;
1113 }
1114 }
1115 None
1116}
1117
1118fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
1125 if let Some(blocks) = t
1126 .cell_blocks
1127 .as_ref()
1128 .and_then(|b| b.get(r))
1129 .and_then(|row| row.get(c))
1130 .filter(|b| !b.is_empty())
1131 {
1132 let mut parts: Vec<String> = Vec::new();
1133 for node in blocks.iter() {
1134 let part = block_chunk_text(node);
1135 if !part.is_empty() {
1136 parts.push(part);
1137 }
1138 }
1139 return parts.join("\n\n");
1140 }
1141 let flat = t
1142 .rows
1143 .get(r)
1144 .and_then(|row| row.get(c))
1145 .map(String::as_str)
1146 .unwrap_or("");
1147 unescape_text(flat)
1148 .replace("<!-- image -->", "")
1149 .trim()
1150 .to_string()
1151}
1152
1153fn block_chunk_text(node: &Node) -> String {
1155 match node {
1156 Node::Paragraph { text } => unescape_text(text),
1157 Node::LabeledText { .. } => block_chunk_text(&node.labeled_as_paragraph()),
1158 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1159 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1160 Node::Table(inner) => triplet_table_text(inner),
1161 Node::Picture { caption, .. } => caption
1162 .as_deref()
1163 .filter(|c| !c.is_empty())
1164 .map(unescape_text)
1165 .unwrap_or_default(),
1166 Node::ListItem {
1167 ordered,
1168 number,
1169 text,
1170 ..
1171 } => {
1172 let marker = if *ordered {
1173 format!("{number}.")
1174 } else {
1175 "-".to_string()
1176 };
1177 format!("{marker} {}", unescape_text(text))
1178 }
1179 Node::CheckboxItem { checked, text } => {
1180 let mark = if *checked { "- [x] " } else { "- [ ] " };
1181 format!("{mark}{}", unescape_text(text))
1182 }
1183 Node::Heading { text, .. } => unescape_text(text),
1184 Node::Located { inner, .. }
1185 | Node::Prov { inner, .. }
1186 | Node::Track { inner, .. }
1187 | Node::Commented { inner, .. } => block_chunk_text(inner),
1188 Node::Group { layer: Some(_), .. } => String::new(),
1189 Node::Group { children, .. } => children
1190 .iter()
1191 .map(block_chunk_text)
1192 .filter(|s| !s.is_empty())
1193 .collect::<Vec<_>>()
1194 .join("\n"),
1195 _ => String::new(),
1196 }
1197}
1198
1199fn unescape_text(s: &str) -> String {
1202 s.replace("<", "<")
1203 .replace(">", ">")
1204 .replace("&", "&")
1205 .replace("\\_", "_")
1206}
1207
1208pub trait ChunkTokenizer {
1214 fn count_tokens(&self, text: &str) -> usize;
1216 fn max_tokens(&self) -> usize;
1218}
1219
1220pub struct HybridChunker<T: ChunkTokenizer> {
1224 tokenizer: T,
1225 merge_peers: bool,
1226}
1227
1228impl<T: ChunkTokenizer> HybridChunker<T> {
1229 pub fn new(tokenizer: T) -> Self {
1230 Self {
1231 tokenizer,
1232 merge_peers: true,
1233 }
1234 }
1235
1236 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1238 self.merge_peers = merge_peers;
1239 self
1240 }
1241
1242 pub fn max_tokens(&self) -> usize {
1243 self.tokenizer.max_tokens()
1244 }
1245
1246 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1248 let mut chunks = Vec::new();
1249 self.chunk_with(doc, &mut |c| {
1250 chunks.push(c);
1251 true
1252 });
1253 chunks
1254 }
1255
1256 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1263 let mut merger = PeerMerger::default();
1264 let mut alive = true;
1265 HierarchicalChunker.chunk_with(doc, &mut |c| {
1266 for split in self.split_by_doc_items(c) {
1267 for chunk in self.split_using_plain_text(split) {
1268 if !alive {
1269 return false;
1270 }
1271 alive = if self.merge_peers {
1272 self.merge_push(&mut merger, chunk, sink)
1273 } else {
1274 sink(chunk)
1275 };
1276 }
1277 }
1278 alive
1279 });
1280 if alive {
1281 self.merge_flush(&mut merger, sink);
1282 }
1283 }
1284
1285 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1286 self.tokenizer.count_tokens(&contextualize(chunk))
1287 }
1288
1289 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1292 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1293 let text = if chunk.doc_items.len() == 1 {
1294 chunk.text.clone()
1295 } else {
1296 doc_items
1297 .iter()
1298 .filter(|it| !it.text.is_empty())
1299 .map(|it| it.text.as_str())
1300 .collect::<Vec<_>>()
1301 .join("\n")
1302 };
1303 DocChunk {
1304 text,
1305 headings: chunk.headings.clone(),
1306 doc_items,
1307 }
1308 }
1309
1310 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1311 if chunk.doc_items.is_empty() {
1312 return vec![chunk];
1313 }
1314 let max = self.max_tokens();
1315 let num_items = chunk.doc_items.len();
1316 let mut chunks = Vec::new();
1317 let mut window_start = 0usize;
1318 let mut window_end = 0usize; while window_end < num_items {
1320 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1321 if self.count_chunk_tokens(&new_chunk) <= max {
1322 if window_end < num_items - 1 {
1323 window_end += 1;
1324 continue;
1325 } else {
1326 window_end = num_items; }
1328 } else if window_start == window_end {
1329 window_end += 1;
1332 window_start = window_end;
1333 } else {
1334 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1337 window_start = window_end;
1338 }
1339 chunks.push(new_chunk);
1340 }
1341 chunks
1342 }
1343
1344 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1345 let total = self.count_chunk_tokens(&chunk);
1346 let max = self.max_tokens();
1347 if total <= max {
1348 return vec![chunk];
1349 }
1350 let text_len = self.tokenizer.count_tokens(&chunk.text);
1351 let other_len = total - text_len;
1352 if other_len >= max {
1353 let stripped = DocChunk {
1355 headings: None,
1356 ..chunk
1357 };
1358 return self.split_using_plain_text(stripped);
1359 }
1360 let available = max - other_len;
1361
1362 let segments =
1363 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1364 let lines: Vec<String> = chunk
1371 .text
1372 .split('\n')
1373 .filter(|l| !l.trim().is_empty())
1374 .map(|l| l.to_string())
1375 .collect();
1376 line_chunk_text(&lines, &self.tokenizer, max)
1377 } else {
1378 semchunk(&chunk.text, available, &self.tokenizer)
1379 };
1380 segments
1381 .into_iter()
1382 .map(|s| DocChunk {
1383 text: s,
1384 headings: chunk.headings.clone(),
1385 doc_items: chunk.doc_items.clone(),
1386 })
1387 .collect()
1388 }
1389
1390 fn merge_push(
1396 &self,
1397 m: &mut PeerMerger,
1398 chunk: DocChunk,
1399 sink: &mut dyn FnMut(DocChunk) -> bool,
1400 ) -> bool {
1401 if m.window.is_empty() {
1402 m.window.push(chunk);
1403 return true;
1404 }
1405 let candidate = DocChunk {
1406 text: m
1407 .window
1408 .iter()
1409 .map(|c| c.text.as_str())
1410 .chain([chunk.text.as_str()])
1411 .collect::<Vec<_>>()
1412 .join("\n"),
1413 headings: m.window[0].headings.clone(),
1414 doc_items: m
1415 .window
1416 .iter()
1417 .flat_map(|c| c.doc_items.iter().cloned())
1418 .chain(chunk.doc_items.iter().cloned())
1419 .collect(),
1420 };
1421 if chunk.headings == m.window[0].headings
1422 && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1423 {
1424 m.window.push(chunk);
1425 m.merged = Some(candidate);
1426 true
1427 } else {
1428 let alive = self.merge_flush(m, sink);
1429 m.window.push(chunk);
1430 alive
1431 }
1432 }
1433
1434 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1438 let alive = if m.window.len() == 1 {
1439 sink(m.window.pop().expect("single-chunk window"))
1440 } else if !m.window.is_empty() {
1441 m.window.clear();
1442 sink(m.merged.take().expect("multi-chunk window has a merge"))
1443 } else {
1444 true
1445 };
1446 m.merged = None;
1447 alive
1448 }
1449}
1450
1451#[derive(Default)]
1453struct PeerMerger {
1454 window: Vec<DocChunk>,
1455 merged: Option<DocChunk>,
1456}
1457
1458fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1468 let mut chunks: Vec<String> = Vec::new();
1469 let mut current = String::new();
1470 let mut current_len = 0usize;
1471
1472 for line in lines {
1473 let mut remaining: Vec<char> = line.chars().collect();
1474 loop {
1475 let rem_str: String = remaining.iter().collect();
1476 let line_tokens = tok.count_tokens(&rem_str);
1477 let available = max_tokens.saturating_sub(current_len);
1478
1479 if line_tokens <= available {
1480 current.push_str(&rem_str);
1481 current_len += line_tokens;
1482 break;
1483 }
1484 if line_tokens <= max_tokens {
1485 chunks.push(std::mem::take(&mut current));
1486 current_len = 0;
1487 continue;
1488 }
1489 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1491 let mut rest = rest;
1492 if take.is_empty() {
1493 if rest.is_empty() {
1494 break;
1495 }
1496 take = rest[..1].iter().collect();
1497 rest = rest[1..].to_vec();
1498 }
1499 current.push('\n');
1500 current.push_str(&take);
1501 chunks.push(std::mem::take(&mut current));
1502 current_len = 0;
1503 remaining = rest;
1504 }
1505 }
1506 if !current.is_empty() {
1507 chunks.push(current);
1508 }
1509 chunks
1510}
1511
1512fn split_by_token_limit<T: ChunkTokenizer>(
1516 text: &[char],
1517 token_limit: usize,
1518 tok: &T,
1519) -> (String, Vec<char>) {
1520 if token_limit == 0 || text.is_empty() {
1521 return (String::new(), text.to_vec());
1522 }
1523 let full: String = text.iter().collect();
1524 if tok.count_tokens(&full) <= token_limit {
1525 return (full, Vec::new());
1526 }
1527 let (mut lo, mut hi) = (0usize, text.len());
1528 let mut best: Option<usize> = None;
1529 while lo <= hi {
1530 let mid = (lo + hi) / 2;
1531 let head: String = text[..mid].iter().collect();
1532 if tok.count_tokens(&head) <= token_limit {
1533 best = Some(mid);
1534 lo = mid + 1;
1535 } else {
1536 if mid == 0 {
1537 break;
1538 }
1539 hi = mid - 1;
1540 }
1541 }
1542 let mut best_idx = match best {
1543 Some(b) if b > 0 => b,
1544 _ => return (String::new(), text.to_vec()),
1545 };
1546 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1548 if pos > 0 {
1549 best_idx = pos;
1550 }
1551 }
1552 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1553}
1554
1555const NON_WS_SPLITTERS: &[&str] = &[
1561 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1562 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1563];
1564
1565pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1569 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1570 let mut counter = |s: &str| -> usize {
1571 if let Some(n) = cache.get(s) {
1572 return *n;
1573 }
1574 let n = tok.count_tokens(s);
1575 cache.insert(s.to_string(), n);
1576 n
1577 };
1578 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1579 chunks
1581 .into_iter()
1582 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1583 .collect()
1584}
1585
1586fn semchunk_rec(
1589 text: &str,
1590 chunk_size: usize,
1591 counter: &mut dyn FnMut(&str) -> usize,
1592) -> Vec<String> {
1593 let (splitter, splitter_is_ws, splits) = split_text(text);
1594
1595 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1596 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1597 cum_lens.push(0usize);
1598 for l in &split_lens {
1599 cum_lens.push(cum_lens.last().unwrap() + l);
1600 }
1601 let num_splits_plus_one = splits.len() + 1;
1602
1603 let mut chunks: Vec<String> = Vec::new();
1604 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1605
1606 for i in 0..splits.len() {
1607 if skips.contains(&i) {
1608 continue;
1609 }
1610 let split = &splits[i];
1611 if counter(split) > chunk_size {
1612 let inner = semchunk_rec(split, chunk_size, counter);
1613 chunks.extend(inner);
1614 } else {
1615 let (end, merged) = merge_splits(
1616 &splits,
1617 &cum_lens,
1618 chunk_size,
1619 &splitter,
1620 counter,
1621 i,
1622 num_splits_plus_one,
1623 );
1624 for j in (i + 1)..end {
1625 skips.insert(j);
1626 }
1627 chunks.push(merged);
1628 }
1629 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1632 if !splitter_is_ws && !is_last {
1633 let with_splitter = format!(
1634 "{}{}",
1635 chunks.last().map(String::as_str).unwrap_or(""),
1636 splitter
1637 );
1638 if counter(&with_splitter) <= chunk_size {
1639 if let Some(last) = chunks.last_mut() {
1640 *last = with_splitter;
1641 } else {
1642 chunks.push(with_splitter);
1643 }
1644 } else {
1645 chunks.push(splitter.clone());
1646 }
1647 }
1648 }
1649 chunks
1650}
1651
1652fn merge_splits(
1655 splits: &[String],
1656 cum_lens: &[usize],
1657 chunk_size: usize,
1658 splitter: &str,
1659 counter: &mut dyn FnMut(&str) -> usize,
1660 start: usize,
1661 high_init: usize,
1662) -> (usize, String) {
1663 let mut average = 0.2f64;
1664 let mut low = start;
1665 let mut high = high_init;
1666 let offset = cum_lens[start];
1667 let mut target = offset as f64 + (chunk_size as f64 * average);
1668
1669 while low < high {
1670 let i = bisect_left(cum_lens, target, low, high);
1671 let midpoint = i.min(high - 1);
1672 let joined = splits[start..midpoint.max(start)].join(splitter);
1673 let tokens = counter(&joined);
1674 let local_cum = cum_lens[midpoint] - offset;
1675 if local_cum > 0 && tokens > 0 {
1676 average = local_cum as f64 / tokens as f64;
1677 target = offset as f64 + (chunk_size as f64 * average);
1678 }
1679 if tokens > chunk_size {
1680 high = midpoint;
1681 } else {
1682 low = midpoint + 1;
1683 }
1684 }
1685 let end = low - 1;
1686 (end, splits[start..end.max(start)].join(splitter))
1687}
1688
1689fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1690 while low < high {
1691 let mid = (low + high) / 2;
1692 if (sorted[mid] as f64) < target {
1693 low = mid + 1;
1694 } else {
1695 high = mid;
1696 }
1697 }
1698 low
1699}
1700
1701fn split_text(text: &str) -> (String, bool, Vec<String>) {
1703 if text.contains('\n') || text.contains('\r') {
1705 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1706 return (splitter.clone(), true, split_on(text, &splitter));
1707 }
1708 if text.contains('\t') {
1710 let splitter = longest_run(text, |c| c == '\t');
1711 return (splitter.clone(), true, split_on(text, &splitter));
1712 }
1713 if text.chars().any(char::is_whitespace) {
1715 let splitter = longest_run(text, char::is_whitespace);
1716 if splitter.chars().count() == 1 {
1717 for preceder in NON_WS_SPLITTERS {
1719 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1720 return (ws, true, parts);
1721 }
1722 }
1723 }
1724 return (splitter.clone(), true, split_on(text, &splitter));
1725 }
1726 for s in NON_WS_SPLITTERS {
1728 if text.contains(s) {
1729 return (s.to_string(), false, split_on(text, s));
1730 }
1731 }
1732 (
1734 String::new(),
1735 true,
1736 text.chars().map(|c| c.to_string()).collect(),
1737 )
1738}
1739
1740fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1742 let mut best = String::new();
1743 let mut cur = String::new();
1744 for c in text.chars() {
1745 if pred(c) {
1746 cur.push(c);
1747 } else {
1748 if cur.chars().count() > best.chars().count() {
1749 best = cur.clone();
1750 }
1751 cur.clear();
1752 }
1753 }
1754 if cur.chars().count() > best.chars().count() {
1755 best = cur;
1756 }
1757 best
1758}
1759
1760fn split_on(text: &str, splitter: &str) -> Vec<String> {
1761 text.split(splitter).map(str::to_string).collect()
1762}
1763
1764fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1768 let chars: Vec<char> = text.chars().collect();
1769 let p: Vec<char> = preceder.chars().collect();
1770 let mut ws: Option<char> = None;
1771 for i in p.len()..chars.len() {
1772 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1773 ws = Some(chars[i]);
1774 break;
1775 }
1776 }
1777 let ws = ws?;
1778 let mut parts = Vec::new();
1779 let mut cur = String::new();
1780 let mut i = 0usize;
1781 while i < chars.len() {
1782 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1783 parts.push(std::mem::take(&mut cur));
1784 i += 1;
1785 continue;
1786 }
1787 cur.push(chars[i]);
1788 i += 1;
1789 }
1790 parts.push(cur);
1791 Some((ws.to_string(), parts))
1792}
1793
1794#[cfg(feature = "chunking")]
1799mod hf {
1800 use super::ChunkTokenizer;
1801
1802 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1807
1808 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1813 if let Some(p) = explicit {
1814 return Ok(p.to_string());
1815 }
1816 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1817 if std::path::Path::new(&resolved).exists() {
1818 return Ok(resolved);
1819 }
1820 Err(format!(
1821 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1822 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1823 scripts/install/download_dependencies.sh (or pass an explicit path)"
1824 ))
1825 }
1826
1827 pub struct HuggingFaceTokenizer {
1831 tok: tokenizers::Tokenizer,
1832 max_tokens: usize,
1833 }
1834
1835 impl HuggingFaceTokenizer {
1836 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1840 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1841 }
1842
1843 pub fn from_file(
1847 path: impl AsRef<std::path::Path>,
1848 max_tokens: usize,
1849 ) -> Result<Self, String> {
1850 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1851 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1852 let _ = tok.with_truncation(None);
1857 tok.with_padding(None);
1858 Ok(Self { tok, max_tokens })
1859 }
1860 }
1861
1862 impl ChunkTokenizer for HuggingFaceTokenizer {
1863 fn count_tokens(&self, text: &str) -> usize {
1864 self.tok
1865 .encode(text, false)
1866 .map(|e| e.get_tokens().len())
1867 .unwrap_or(0)
1868 }
1869 fn max_tokens(&self) -> usize {
1870 self.max_tokens
1871 }
1872 }
1873}
1874
1875#[cfg(feature = "chunking")]
1876pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1877
1878#[cfg(feature = "chunking")]
1883mod window {
1884 use super::DocChunk;
1885 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1886
1887 #[derive(Debug, Clone, Default)]
1889 pub struct Section {
1890 pub heading_path: Vec<String>,
1893 pub words: Vec<String>,
1895 }
1896
1897 impl Section {
1898 pub fn heading_context(&self) -> String {
1901 if self.heading_path.is_empty() {
1902 String::new()
1903 } else {
1904 format!("# {}", self.heading_path.join(" > "))
1905 }
1906 }
1907 }
1908
1909 fn level_index(level: HeadingLevel) -> usize {
1910 match level {
1911 HeadingLevel::H1 => 1,
1912 HeadingLevel::H2 => 2,
1913 HeadingLevel::H3 => 3,
1914 HeadingLevel::H4 => 4,
1915 HeadingLevel::H5 => 5,
1916 HeadingLevel::H6 => 6,
1917 }
1918 }
1919
1920 pub fn parse_sections(markdown: &str) -> Vec<Section> {
1924 parse_sections_with_stack(markdown, Vec::new()).0
1925 }
1926
1927 pub fn parse_sections_with_stack(
1932 markdown: &str,
1933 initial_stack: Vec<String>,
1934 ) -> (Vec<Section>, Vec<String>) {
1935 let mut heading_stack: Vec<String> = initial_stack;
1936 let mut sections: Vec<Section> = Vec::new();
1937 let mut current = Section {
1940 heading_path: heading_stack
1941 .iter()
1942 .filter(|h| !h.is_empty())
1943 .cloned()
1944 .collect(),
1945 words: Vec::new(),
1946 };
1947
1948 let mut in_heading = false;
1949 let mut heading_level = 0usize;
1950 let mut heading_buf = String::new();
1951
1952 let push_words = |section: &mut Section, text: &str| {
1953 for w in text.split_whitespace() {
1954 section.words.push(w.to_string());
1955 }
1956 };
1957
1958 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1959 if !section.words.is_empty() {
1960 sections.push(std::mem::take(section));
1961 } else {
1962 *section = Section::default();
1963 }
1964 };
1965
1966 for event in Parser::new(markdown) {
1967 match event {
1968 Event::Start(Tag::Heading { level, .. }) => {
1969 in_heading = true;
1970 heading_level = level_index(level);
1971 heading_buf.clear();
1972 }
1973 Event::End(TagEnd::Heading(_)) => {
1974 in_heading = false;
1975 let idx = heading_level.saturating_sub(1);
1977 if heading_stack.len() <= idx {
1978 heading_stack.resize(idx + 1, String::new());
1979 } else {
1980 heading_stack.truncate(idx + 1);
1981 }
1982 heading_stack[idx] = heading_buf.trim().to_string();
1983 flush(&mut sections, &mut current);
1985 current.heading_path = heading_stack
1986 .iter()
1987 .filter(|h| !h.is_empty())
1988 .cloned()
1989 .collect();
1990 }
1991 Event::Text(t) | Event::Code(t) => {
1992 if in_heading {
1993 if !heading_buf.is_empty() {
1994 heading_buf.push(' ');
1995 }
1996 heading_buf.push_str(&t);
1997 } else {
1998 push_words(&mut current, &t);
1999 }
2000 }
2001 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
2003 _ => {}
2004 }
2005 }
2006 flush(&mut sections, &mut current);
2007 (sections, heading_stack)
2008 }
2009
2010 #[derive(Debug, Clone)]
2016 pub struct WindowChunker {
2017 pub max_words: usize,
2019 pub overlap: f32,
2021 }
2022
2023 impl Default for WindowChunker {
2024 fn default() -> Self {
2025 WindowChunker {
2026 max_words: 300,
2027 overlap: 0.05,
2028 }
2029 }
2030 }
2031
2032 impl WindowChunker {
2033 pub fn new(max_words: usize, overlap: f32) -> Self {
2034 WindowChunker { max_words, overlap }
2035 }
2036
2037 fn word_budget(&self) -> usize {
2039 self.max_words.max(1)
2040 }
2041
2042 fn overlap_words(&self, budget: usize) -> usize {
2045 let o = (budget as f32 * self.overlap).round() as usize;
2046 o.min(budget.saturating_sub(1))
2047 }
2048
2049 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2051 let mut chunks = Vec::new();
2052 self.chunk_with(markdown, &mut |c| {
2053 chunks.push(c);
2054 true
2055 });
2056 chunks
2057 }
2058
2059 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2063 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2064 for section in §ions {
2065 if !self.pack_section(section, sink) {
2066 return;
2067 }
2068 }
2069 }
2070
2071 pub fn pack_section(
2075 &self,
2076 section: &Section,
2077 sink: &mut dyn FnMut(DocChunk) -> bool,
2078 ) -> bool {
2079 let words = §ion.words;
2080 if words.is_empty() {
2081 return true;
2082 }
2083 let budget = self.word_budget();
2084 let step = budget - self.overlap_words(budget); let mut start = 0;
2086 loop {
2087 let end = (start + budget).min(words.len());
2088 let chunk = DocChunk {
2089 text: words[start..end].join(" "),
2090 headings: (!section.heading_path.is_empty())
2091 .then(|| section.heading_path.clone()),
2092 doc_items: Vec::new(),
2093 };
2094 if !sink(chunk) {
2095 return false;
2096 }
2097 if end >= words.len() {
2098 return true;
2099 }
2100 start += step;
2101 }
2102 }
2103
2104 pub fn contextualize(chunk: &DocChunk) -> String {
2110 match &chunk.headings {
2111 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2112 _ => chunk.text.clone(),
2113 }
2114 }
2115 }
2116
2117 #[cfg(test)]
2118 mod tests {
2119 use super::*;
2120
2121 #[test]
2122 fn splits_on_headings_and_tracks_path() {
2123 let md = "\
2124intro words
2125# Chapter 1
2126para one
2127## Section 1.1
2128para two
2129# Chapter 2
2130para three";
2131 let secs = parse_sections(md);
2132 assert_eq!(secs.len(), 4);
2134 assert!(secs[0].heading_path.is_empty());
2135 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2136 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2137 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2139 }
2140
2141 #[test]
2142 fn strips_markup_to_plain_words() {
2143 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2144 let secs = parse_sections(md);
2145 let words = &secs[0].words;
2146 assert!(words.contains(&"bold".to_string()));
2147 assert!(words.contains(&"code".to_string()));
2148 assert!(words.contains(&"link".to_string()));
2149 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2151 }
2152
2153 #[test]
2154 fn windows_overlap_and_never_cross_headings() {
2155 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2156 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2157 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2159 let a: Vec<_> = chunks
2161 .iter()
2162 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2163 .collect();
2164 assert_eq!(a.len(), 3);
2165 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2166 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2167 assert!(a[2].text.ends_with(" w24"));
2168 let b: Vec<_> = chunks
2170 .iter()
2171 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2172 .collect();
2173 assert_eq!(b.len(), 1);
2174 assert_eq!(b[0].text, "short tail");
2175 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2176 }
2177
2178 #[test]
2179 fn sink_false_cancels_the_window_walk() {
2180 let md = format!(
2181 "# A\n\n{}\n",
2182 (0..50)
2183 .map(|i| format!("w{i}"))
2184 .collect::<Vec<_>>()
2185 .join(" ")
2186 );
2187 let chunker = WindowChunker::new(10, 0.0);
2188 let mut n = 0;
2189 chunker.chunk_with(&md, &mut |_| {
2190 n += 1;
2191 false
2192 });
2193 assert_eq!(n, 1);
2194 }
2195 }
2196}
2197
2198#[cfg(feature = "chunking")]
2199pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2200
2201#[cfg(test)]
2202mod tests {
2203 use super::*;
2204
2205 struct WordTok(usize);
2207 impl ChunkTokenizer for WordTok {
2208 fn count_tokens(&self, text: &str) -> usize {
2209 text.split_whitespace().count()
2210 }
2211 fn max_tokens(&self) -> usize {
2212 self.0
2213 }
2214 }
2215
2216 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2217 let mut d = DoclingDocument::new("t");
2218 for n in nodes {
2219 d.push(n);
2220 }
2221 d
2222 }
2223
2224 #[test]
2225 fn hierarchical_headings_and_items() {
2226 let doc = doc_with(vec![
2227 Node::Heading {
2228 level: 1,
2229 text: "Title".into(),
2230 },
2231 Node::Paragraph {
2232 text: "Intro".into(),
2233 },
2234 Node::Heading {
2235 level: 2,
2236 text: "Sec".into(),
2237 },
2238 Node::Paragraph {
2239 text: "Body".into(),
2240 },
2241 ]);
2242 let chunks = HierarchicalChunker.chunk(&doc);
2243 assert_eq!(chunks.len(), 2);
2244 assert_eq!(chunks[0].text, "Intro");
2245 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2246 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2247 assert_eq!(
2248 chunks[1].headings.as_deref(),
2249 Some(&["Title".into(), "Sec".into()][..])
2250 );
2251 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2252 }
2253
2254 #[test]
2257 fn page_furniture_keeps_refs_aligned_with_json() {
2258 let doc = doc_with(vec![
2259 Node::Paragraph {
2260 text: "Before".into(),
2261 },
2262 Node::PageFurniture {
2263 footer: true,
2264 location: [0, 490, 100, 512],
2265 text: "1.10.2".into(),
2266 },
2267 Node::Paragraph {
2268 text: "After".into(),
2269 },
2270 ]);
2271 let chunks = HierarchicalChunker.chunk(&doc);
2272 assert_eq!(chunks.len(), 2);
2273 assert_eq!(chunks[1].text, "After");
2274 let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2275 assert_eq!(json["texts"][2]["text"], "After");
2276 assert_eq!(chunks[1].doc_items[0].self_ref, "#/texts/2");
2277 }
2278
2279 #[test]
2283 fn picture_children_are_not_chunked_but_keep_refs_aligned() {
2284 let doc = doc_with(vec![
2285 Node::Picture {
2286 caption: None,
2287 caption_href: None,
2288 image: None,
2289 classification: None,
2290 description: None,
2291 caption_parent: crate::CaptionParent::Item,
2292 caption_location: None,
2293 },
2294 Node::PictureChildren(vec![
2295 Node::Paragraph {
2296 text: "axis label".into(),
2297 },
2298 Node::ListItem {
2299 ordered: false,
2300 number: 0,
2301 first_in_list: true,
2302 text: "callout".into(),
2303 level: 0,
2304 marker: None,
2305 location: None,
2306 dclx: None,
2307 href: None,
2308 layer: None,
2309 },
2310 ]),
2311 Node::Paragraph {
2312 text: "Body".into(),
2313 },
2314 ]);
2315 let chunks = HierarchicalChunker.chunk(&doc);
2316 assert_eq!(chunks.len(), 1);
2317 assert_eq!(chunks[0].text, "Body");
2318 let json = doc.export_to_json_value();
2319 assert_eq!(json["texts"][2]["text"], "Body");
2320 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/2");
2321 }
2322
2323 #[test]
2324 fn heading_shadowing_prunes_deeper_levels() {
2325 let doc = doc_with(vec![
2326 Node::Heading {
2327 level: 2,
2328 text: "A".into(),
2329 },
2330 Node::Heading {
2331 level: 3,
2332 text: "A.1".into(),
2333 },
2334 Node::Heading {
2335 level: 2,
2336 text: "B".into(),
2337 },
2338 Node::Paragraph { text: "p".into() },
2339 ]);
2340 let chunks = HierarchicalChunker.chunk(&doc);
2341 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2342 }
2343
2344 #[test]
2345 fn triplet_table() {
2346 let t = Table {
2347 rows: vec![
2348 vec!["".into(), "Col1".into()],
2349 vec!["Row1".into(), "v".into()],
2350 ],
2351 ..Default::default()
2352 };
2353 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2354 let single = Table {
2357 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2358 ..Default::default()
2359 };
2360 assert_eq!(triplet_table_text(&single), "a = b");
2361 }
2362
2363 #[test]
2364 fn hybrid_merges_small_peers_and_splits_large() {
2365 let doc = doc_with(vec![
2366 Node::Heading {
2367 level: 2,
2368 text: "S".into(),
2369 },
2370 Node::Paragraph { text: "a b".into() },
2371 Node::Paragraph { text: "c d".into() },
2372 ]);
2373 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2374 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2375 assert_eq!(chunks[0].text, "a b\nc d");
2376
2377 let long = "w ".repeat(40).trim().to_string();
2378 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2379 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2380 assert!(chunks.len() > 1, "oversized paragraph splits");
2381 for c in &chunks {
2382 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2383 }
2384 }
2385
2386 #[test]
2387 fn semchunk_prefers_newlines_then_sentences() {
2388 let tok = WordTok(4);
2389 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2390 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2391 }
2392}