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::CheckboxItem { checked, text } => {
475 let self_ref = self.alloc.text();
476 let mark = if *checked { "- [x] " } else { "- [ ] " };
477 let body = format!("{mark}{}", unescape_text(text));
478 self.emit(
479 body.clone(),
480 vec![ChunkItem {
481 self_ref,
482 kind: ChunkItemKind::Text,
483 text: body,
484 }],
485 );
486 }
487 Node::Formula { latex, .. } => {
490 let self_ref = self.alloc.text();
491 let body = format!("$${}$$", latex);
492 self.emit(
493 body.clone(),
494 vec![ChunkItem {
495 self_ref,
496 kind: ChunkItemKind::Text,
497 text: body,
498 }],
499 );
500 }
501 Node::Code { text, .. } => {
502 let self_ref = self.alloc.text();
503 let body = format!("```\n{}\n```", unescape_text(text));
504 self.emit(
505 body.clone(),
506 vec![ChunkItem {
507 self_ref,
508 kind: ChunkItemKind::Text,
509 text: body,
510 }],
511 );
512 }
513 Node::Table(t) => {
514 let self_ref = self.alloc.table();
515 let body = triplet_table_text(t);
516 self.emit(
517 body.clone(),
518 vec![ChunkItem {
519 self_ref,
520 kind: ChunkItemKind::Table,
521 text: body,
522 }],
523 );
524 }
525 Node::Picture { caption, .. } => {
526 let cap = caption.as_deref().filter(|c| !c.is_empty());
527 let cap_item = cap.map(|c| ChunkItem {
528 self_ref: self.alloc.text(),
529 kind: ChunkItemKind::Text,
530 text: unescape_text(c),
531 });
532 self.alloc.picture();
533 if let Some(cap_item) = cap_item {
537 let body = cap_item.text.clone();
538 self.emit(body, vec![cap_item]);
539 }
540 }
541 Node::Chart {
542 kind,
543 table,
544 caption,
545 ..
546 } => {
547 let cap = caption.as_deref().filter(|c| !c.is_empty());
548 let cap_item = cap.map(|c| ChunkItem {
549 self_ref: self.alloc.text(),
550 kind: ChunkItemKind::Text,
551 text: unescape_text(c),
552 });
553 let pic_ref = self.alloc.picture();
554 let mut parts: Vec<String> = Vec::new();
558 if let Some(ci) = &cap_item {
559 parts.push(ci.text.clone());
560 }
561 parts.push(humanize_label(kind));
562 let grid = crate::markdown::render_table(table, false);
563 if !grid.is_empty() {
564 parts.push(unescape_text(&grid));
565 }
566 let body = parts.join("\n\n");
567 let pic_item = ChunkItem {
572 self_ref: pic_ref,
573 kind: ChunkItemKind::Picture,
574 text: body.clone(),
575 };
576 let items = match cap_item {
577 Some(mut ci) => {
578 ci.text = String::new();
579 vec![ci, pic_item]
580 }
581 None => vec![pic_item],
582 };
583 self.emit(body, items);
584 }
585 Node::Group { layer: Some(_), .. } => {}
588 Node::Group { children, .. } => {
589 self.alloc.group();
592 self.walk(children);
593 }
594 Node::KeyValueGraph { .. } => {}
596 Node::FieldRegion { items } => {
597 self.alloc.field_region();
600 for item in items {
601 self.alloc.field_item();
602 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
603 let self_ref = self.alloc.text();
604 let body = unescape_text(part);
605 self.emit(
606 body.clone(),
607 vec![ChunkItem {
608 self_ref,
609 kind: ChunkItemKind::Text,
610 text: body,
611 }],
612 );
613 }
614 }
615 }
616 Node::InlineGroup { md_text, runs, .. } => {
617 let self_ref = self.alloc.text();
618 self.emit_inline_with_runs(md_text, self_ref, runs);
619 }
620 Node::TextDump(text) => {
621 let self_ref = self.alloc.text();
622 let body = unescape_text(text);
623 self.emit(
624 body.clone(),
625 vec![ChunkItem {
626 self_ref,
627 kind: ChunkItemKind::Text,
628 text: body,
629 }],
630 );
631 }
632 Node::Located { inner, .. }
634 | Node::Prov { inner, .. }
635 | Node::Commented { inner, .. } => self.one(inner),
636 Node::CommentSection { .. }
639 | Node::Furniture { .. }
640 | Node::PageFurniture { .. }
641 | Node::PageBreak
642 | Node::PageInfo { .. }
643 | Node::DoclangOnly(_) => {}
644 Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
647 }
648 }
649}
650
651fn level_of(node: &Node) -> u8 {
652 match node {
653 Node::ListItem { level, .. } => *level,
654 _ => 0,
655 }
656}
657
658fn render_list(items: &[Node]) -> String {
661 let mut lines: Vec<String> = Vec::new();
662 for item in items {
663 let Node::ListItem {
664 ordered,
665 number,
666 text,
667 level,
668 layer,
669 ..
670 } = item
671 else {
672 continue;
673 };
674 if layer.is_some() {
675 continue;
676 }
677 let indent = " ".repeat(*level as usize);
678 let marker = if *ordered {
679 format!("{number}.")
680 } else {
681 "-".to_string()
682 };
683 lines.push(format!(
684 "{indent}{marker} {}",
685 unescape_text(&strip_image_markers(text))
686 ));
687 }
688 lines.join("\n")
689}
690
691fn strip_image_markers(text: &str) -> String {
696 if !text.contains("<!-- image -->") {
697 return text.to_string();
698 }
699 let cleaned: Vec<&str> = text
700 .split('\n')
701 .map(str::trim_end)
702 .filter(|l| *l != "<!-- image -->")
703 .collect();
704 cleaned.join("\n").trim_end().to_string()
705}
706
707fn humanize_label(label: &str) -> String {
710 let text = label.replace('_', " ");
711 let mut chars = text.chars();
712 match chars.next() {
713 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
714 None => text,
715 }
716}
717
718fn triplet_table_text(t: &Table) -> String {
725 let rows: Vec<Vec<String>> = t
726 .rows
727 .iter()
728 .enumerate()
729 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
730 .collect();
731 let num_rows = rows.len();
732 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
733 if num_rows == 0 || num_cols == 0 {
734 return String::new();
735 }
736 let cell = |r: usize, c: usize| -> &str {
737 rows.get(r)
738 .and_then(|row| row.get(c))
739 .map(String::as_str)
740 .unwrap_or("")
741 };
742
743 let num_headers = {
749 let derived;
750 let cells: &[crate::TableCell] = match &t.cells {
751 Some(c) if !c.is_empty() => c,
752 _ => {
753 derived = t.derive_cells();
754 &derived
755 }
756 };
757 (0..num_rows)
758 .take_while(|&r| cells.iter().any(|c| c.column_header && c.start_row == r))
759 .count()
760 };
761
762 let columns: Vec<String> = if num_headers > 0 {
765 (0..num_cols)
766 .map(|c| {
767 let mut name = String::new();
768 for r in 0..num_headers {
769 if !name.is_empty() {
770 name.push('.');
771 }
772 name.push_str(cell(r, c));
773 }
774 name
775 })
776 .collect()
777 } else {
778 (0..num_cols).map(|c| c.to_string()).collect()
779 };
780 let data_rows = num_headers..num_rows;
781 let n_data = data_rows.len();
782
783 if n_data == 0 {
785 return columns
786 .iter()
787 .map(|s| s.trim())
788 .filter(|s| !s.is_empty())
789 .collect::<Vec<_>>()
790 .join(". ");
791 }
792
793 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
794 let text = if num_cols == 1 {
795 let col_name = data(0, 0).trim().to_string();
798 if n_data == 1 {
799 col_name
800 } else {
801 (1..n_data)
802 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
803 .collect::<Vec<_>>()
804 .join(". ")
805 }
806 } else {
807 let mut parts = Vec::new();
809 for r in 0..n_data {
810 for (c, col_name) in columns.iter().enumerate().skip(1) {
811 parts.push(format!(
812 "{}, {} = {}",
813 data(r, 0).trim(),
814 col_name.trim(),
815 data(r, c).trim()
816 ));
817 }
818 }
819 parts.join(". ")
820 };
821 if !text.is_empty() {
822 return text;
823 }
824
825 (0..n_data)
828 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
829 .map(|(r, c)| data(r, c).trim())
830 .filter(|s| !s.is_empty())
831 .collect::<Vec<_>>()
832 .join(". ")
833}
834
835fn inline_segments(md: &str) -> Vec<String> {
843 inline_segments_tagged(md)
844 .into_iter()
845 .map(|(t, _)| t)
846 .collect()
847}
848
849fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
853 let chars: Vec<char> = md.chars().collect();
854 let n = chars.len();
855 let find = |from: usize, pat: &str| -> Option<usize> {
856 let hay: String = chars[from..].iter().collect();
857 hay.find(pat).map(|p| from + hay[..p].chars().count())
858 };
859 let mut out: Vec<(String, bool)> = Vec::new();
860 let mut plain = String::new();
861 let mut after_span = false;
862
863 fn flush(
864 out: &mut Vec<(String, bool)>,
865 plain: &mut String,
866 before_span: bool,
867 after_span: bool,
868 ) {
869 let mut p = std::mem::take(plain);
870 if after_span {
871 if let Some(rest) = p.strip_prefix(' ') {
872 p = rest.to_string();
873 }
874 }
875 if before_span {
876 if let Some(rest) = p.strip_suffix(' ') {
877 p = rest.to_string();
878 }
879 }
880 if !p.is_empty() {
881 out.push((unescape_text(&p), true));
882 }
883 }
884
885 let mut i = 0;
886 while i < n {
887 let rest: String = chars[i..].iter().collect();
888 if chars[i] == '[' && !rest.starts_with("[](") {
892 let balanced = |c: usize| {
896 let mut d = 0i32;
897 for &ch in &chars[i + 1..c] {
898 match ch {
899 '[' => d += 1,
900 ']' => d -= 1,
901 _ => {}
902 }
903 }
904 d == 0
905 };
906 if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
907 let mut depth = 0usize;
908 let mut url_end = None;
909 for (k, &c) in chars.iter().enumerate().skip(close + 2) {
910 match c {
911 '(' => depth += 1,
912 ')' => {
913 if depth == 0 {
914 url_end = Some(k);
915 break;
916 }
917 depth -= 1;
918 }
919 _ => {}
920 }
921 }
922 if let Some(endp) = url_end {
923 flush(&mut out, &mut plain, true, after_span);
924 out.push((
925 unescape_text(&chars[i..=endp].iter().collect::<String>()),
926 false,
927 ));
928 i = endp + 1;
929 after_span = true;
930 continue;
931 }
932 }
933 }
934 let mut matched = false;
937 for marker in ["***", "**", "*", "~~", "`"] {
938 if rest.starts_with(marker) {
939 let mlen = marker.chars().count();
940 if let Some(end) = find(i + mlen, marker) {
941 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
945 if end > i + mlen && !inner_blank {
946 flush(&mut out, &mut plain, true, after_span);
947 if marker == "`" {
948 let inner: String = chars[i + 1..end].iter().collect();
949 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
950 } else {
951 out.push((
952 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
953 false,
954 ));
955 }
956 i = end + mlen;
957 after_span = true;
958 matched = true;
959 }
960 }
961 break;
962 }
963 }
964 if matched {
965 continue;
966 }
967 if rest.starts_with("$$") {
970 plain.push_str("$$");
971 i += 2;
972 continue;
973 }
974 if chars[i] == '$' {
976 if let Some(end) = find(i + 1, "$") {
977 if end > i + 1 {
978 flush(&mut out, &mut plain, true, after_span);
979 let latex: String = chars[i + 1..end].iter().collect();
980 out.push((format!("$${latex}$$"), false));
981 i = end + 1;
982 after_span = true;
983 continue;
984 }
985 }
986 }
987 plain.push(chars[i]);
988 i += 1;
989 }
990 flush(&mut out, &mut plain, false, after_span);
991 if out.is_empty() {
992 out.push((unescape_text(md), true));
993 }
994 out
995}
996
997fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1002 let target = segment.trim();
1003 if target.is_empty() {
1004 return None;
1005 }
1006 let plainish =
1007 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1008 let fully_plain =
1009 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1010 let unmarked: Vec<(&str, bool)> = runs
1011 .iter()
1012 .filter(|r| plainish(r))
1013 .map(|r| (r.text.as_str(), fully_plain(r)))
1014 .collect();
1015 for start in 0..unmarked.len() {
1016 let mut rest = target;
1017 let mut taken: Vec<(String, bool)> = Vec::new();
1018 for (t, fully) in &unmarked[start..] {
1019 let t = t.trim();
1020 if t.is_empty() {
1021 continue;
1022 }
1023 match rest.strip_prefix(t) {
1024 Some(r) => {
1025 taken.push((unescape_text(t), *fully));
1026 rest = r.trim_start();
1027 if rest.is_empty() {
1028 break;
1029 }
1030 }
1031 None => break,
1032 }
1033 }
1034 if rest.is_empty() && taken.len() >= 2 {
1035 let mut merged: Vec<(String, bool)> = Vec::new();
1040 for (t, fully) in taken {
1041 match merged.last_mut() {
1042 Some((last, true)) if fully => {
1043 last.push(' ');
1044 last.push_str(&t);
1045 }
1046 _ => merged.push((t, fully)),
1047 }
1048 }
1049 if merged.len() >= 2 {
1050 return Some(merged.into_iter().map(|(t, _)| t).collect());
1051 }
1052 return None;
1053 }
1054 }
1055 None
1056}
1057
1058fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
1065 if let Some(blocks) = t
1066 .cell_blocks
1067 .as_ref()
1068 .and_then(|b| b.get(r))
1069 .and_then(|row| row.get(c))
1070 .filter(|b| !b.is_empty())
1071 {
1072 let mut parts: Vec<String> = Vec::new();
1073 for node in blocks.iter() {
1074 let part = block_chunk_text(node);
1075 if !part.is_empty() {
1076 parts.push(part);
1077 }
1078 }
1079 return parts.join("\n\n");
1080 }
1081 let flat = t
1082 .rows
1083 .get(r)
1084 .and_then(|row| row.get(c))
1085 .map(String::as_str)
1086 .unwrap_or("");
1087 unescape_text(flat)
1088 .replace("<!-- image -->", "")
1089 .trim()
1090 .to_string()
1091}
1092
1093fn block_chunk_text(node: &Node) -> String {
1095 match node {
1096 Node::Paragraph { text } => unescape_text(text),
1097 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1098 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1099 Node::Table(inner) => triplet_table_text(inner),
1100 Node::Picture { caption, .. } => caption
1101 .as_deref()
1102 .filter(|c| !c.is_empty())
1103 .map(unescape_text)
1104 .unwrap_or_default(),
1105 Node::ListItem {
1106 ordered,
1107 number,
1108 text,
1109 ..
1110 } => {
1111 let marker = if *ordered {
1112 format!("{number}.")
1113 } else {
1114 "-".to_string()
1115 };
1116 format!("{marker} {}", unescape_text(text))
1117 }
1118 Node::CheckboxItem { checked, text } => {
1119 let mark = if *checked { "- [x] " } else { "- [ ] " };
1120 format!("{mark}{}", unescape_text(text))
1121 }
1122 Node::Heading { text, .. } => unescape_text(text),
1123 Node::Located { inner, .. } | Node::Prov { inner, .. } | Node::Commented { inner, .. } => {
1124 block_chunk_text(inner)
1125 }
1126 Node::Group { layer: Some(_), .. } => String::new(),
1127 Node::Group { children, .. } => children
1128 .iter()
1129 .map(block_chunk_text)
1130 .filter(|s| !s.is_empty())
1131 .collect::<Vec<_>>()
1132 .join("\n"),
1133 _ => String::new(),
1134 }
1135}
1136
1137fn unescape_text(s: &str) -> String {
1140 s.replace("<", "<")
1141 .replace(">", ">")
1142 .replace("&", "&")
1143 .replace("\\_", "_")
1144}
1145
1146pub trait ChunkTokenizer {
1152 fn count_tokens(&self, text: &str) -> usize;
1154 fn max_tokens(&self) -> usize;
1156}
1157
1158pub struct HybridChunker<T: ChunkTokenizer> {
1162 tokenizer: T,
1163 merge_peers: bool,
1164}
1165
1166impl<T: ChunkTokenizer> HybridChunker<T> {
1167 pub fn new(tokenizer: T) -> Self {
1168 Self {
1169 tokenizer,
1170 merge_peers: true,
1171 }
1172 }
1173
1174 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1176 self.merge_peers = merge_peers;
1177 self
1178 }
1179
1180 pub fn max_tokens(&self) -> usize {
1181 self.tokenizer.max_tokens()
1182 }
1183
1184 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1186 let mut chunks = Vec::new();
1187 self.chunk_with(doc, &mut |c| {
1188 chunks.push(c);
1189 true
1190 });
1191 chunks
1192 }
1193
1194 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1201 let mut merger = PeerMerger::default();
1202 let mut alive = true;
1203 HierarchicalChunker.chunk_with(doc, &mut |c| {
1204 for split in self.split_by_doc_items(c) {
1205 for chunk in self.split_using_plain_text(split) {
1206 if !alive {
1207 return false;
1208 }
1209 alive = if self.merge_peers {
1210 self.merge_push(&mut merger, chunk, sink)
1211 } else {
1212 sink(chunk)
1213 };
1214 }
1215 }
1216 alive
1217 });
1218 if alive {
1219 self.merge_flush(&mut merger, sink);
1220 }
1221 }
1222
1223 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1224 self.tokenizer.count_tokens(&contextualize(chunk))
1225 }
1226
1227 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1230 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1231 let text = if chunk.doc_items.len() == 1 {
1232 chunk.text.clone()
1233 } else {
1234 doc_items
1235 .iter()
1236 .filter(|it| !it.text.is_empty())
1237 .map(|it| it.text.as_str())
1238 .collect::<Vec<_>>()
1239 .join("\n")
1240 };
1241 DocChunk {
1242 text,
1243 headings: chunk.headings.clone(),
1244 doc_items,
1245 }
1246 }
1247
1248 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1249 if chunk.doc_items.is_empty() {
1250 return vec![chunk];
1251 }
1252 let max = self.max_tokens();
1253 let num_items = chunk.doc_items.len();
1254 let mut chunks = Vec::new();
1255 let mut window_start = 0usize;
1256 let mut window_end = 0usize; while window_end < num_items {
1258 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1259 if self.count_chunk_tokens(&new_chunk) <= max {
1260 if window_end < num_items - 1 {
1261 window_end += 1;
1262 continue;
1263 } else {
1264 window_end = num_items; }
1266 } else if window_start == window_end {
1267 window_end += 1;
1270 window_start = window_end;
1271 } else {
1272 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1275 window_start = window_end;
1276 }
1277 chunks.push(new_chunk);
1278 }
1279 chunks
1280 }
1281
1282 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1283 let total = self.count_chunk_tokens(&chunk);
1284 let max = self.max_tokens();
1285 if total <= max {
1286 return vec![chunk];
1287 }
1288 let text_len = self.tokenizer.count_tokens(&chunk.text);
1289 let other_len = total - text_len;
1290 if other_len >= max {
1291 let stripped = DocChunk {
1293 headings: None,
1294 ..chunk
1295 };
1296 return self.split_using_plain_text(stripped);
1297 }
1298 let available = max - other_len;
1299
1300 let segments =
1301 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1302 let lines: Vec<String> = chunk
1309 .text
1310 .split('\n')
1311 .filter(|l| !l.trim().is_empty())
1312 .map(|l| l.to_string())
1313 .collect();
1314 line_chunk_text(&lines, &self.tokenizer, max)
1315 } else {
1316 semchunk(&chunk.text, available, &self.tokenizer)
1317 };
1318 segments
1319 .into_iter()
1320 .map(|s| DocChunk {
1321 text: s,
1322 headings: chunk.headings.clone(),
1323 doc_items: chunk.doc_items.clone(),
1324 })
1325 .collect()
1326 }
1327
1328 fn merge_push(
1334 &self,
1335 m: &mut PeerMerger,
1336 chunk: DocChunk,
1337 sink: &mut dyn FnMut(DocChunk) -> bool,
1338 ) -> bool {
1339 if m.window.is_empty() {
1340 m.window.push(chunk);
1341 return true;
1342 }
1343 let candidate = DocChunk {
1344 text: m
1345 .window
1346 .iter()
1347 .map(|c| c.text.as_str())
1348 .chain([chunk.text.as_str()])
1349 .collect::<Vec<_>>()
1350 .join("\n"),
1351 headings: m.window[0].headings.clone(),
1352 doc_items: m
1353 .window
1354 .iter()
1355 .flat_map(|c| c.doc_items.iter().cloned())
1356 .chain(chunk.doc_items.iter().cloned())
1357 .collect(),
1358 };
1359 if chunk.headings == m.window[0].headings
1360 && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1361 {
1362 m.window.push(chunk);
1363 m.merged = Some(candidate);
1364 true
1365 } else {
1366 let alive = self.merge_flush(m, sink);
1367 m.window.push(chunk);
1368 alive
1369 }
1370 }
1371
1372 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1376 let alive = if m.window.len() == 1 {
1377 sink(m.window.pop().expect("single-chunk window"))
1378 } else if !m.window.is_empty() {
1379 m.window.clear();
1380 sink(m.merged.take().expect("multi-chunk window has a merge"))
1381 } else {
1382 true
1383 };
1384 m.merged = None;
1385 alive
1386 }
1387}
1388
1389#[derive(Default)]
1391struct PeerMerger {
1392 window: Vec<DocChunk>,
1393 merged: Option<DocChunk>,
1394}
1395
1396fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1406 let mut chunks: Vec<String> = Vec::new();
1407 let mut current = String::new();
1408 let mut current_len = 0usize;
1409
1410 for line in lines {
1411 let mut remaining: Vec<char> = line.chars().collect();
1412 loop {
1413 let rem_str: String = remaining.iter().collect();
1414 let line_tokens = tok.count_tokens(&rem_str);
1415 let available = max_tokens.saturating_sub(current_len);
1416
1417 if line_tokens <= available {
1418 current.push_str(&rem_str);
1419 current_len += line_tokens;
1420 break;
1421 }
1422 if line_tokens <= max_tokens {
1423 chunks.push(std::mem::take(&mut current));
1424 current_len = 0;
1425 continue;
1426 }
1427 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1429 let mut rest = rest;
1430 if take.is_empty() {
1431 if rest.is_empty() {
1432 break;
1433 }
1434 take = rest[..1].iter().collect();
1435 rest = rest[1..].to_vec();
1436 }
1437 current.push('\n');
1438 current.push_str(&take);
1439 chunks.push(std::mem::take(&mut current));
1440 current_len = 0;
1441 remaining = rest;
1442 }
1443 }
1444 if !current.is_empty() {
1445 chunks.push(current);
1446 }
1447 chunks
1448}
1449
1450fn split_by_token_limit<T: ChunkTokenizer>(
1454 text: &[char],
1455 token_limit: usize,
1456 tok: &T,
1457) -> (String, Vec<char>) {
1458 if token_limit == 0 || text.is_empty() {
1459 return (String::new(), text.to_vec());
1460 }
1461 let full: String = text.iter().collect();
1462 if tok.count_tokens(&full) <= token_limit {
1463 return (full, Vec::new());
1464 }
1465 let (mut lo, mut hi) = (0usize, text.len());
1466 let mut best: Option<usize> = None;
1467 while lo <= hi {
1468 let mid = (lo + hi) / 2;
1469 let head: String = text[..mid].iter().collect();
1470 if tok.count_tokens(&head) <= token_limit {
1471 best = Some(mid);
1472 lo = mid + 1;
1473 } else {
1474 if mid == 0 {
1475 break;
1476 }
1477 hi = mid - 1;
1478 }
1479 }
1480 let mut best_idx = match best {
1481 Some(b) if b > 0 => b,
1482 _ => return (String::new(), text.to_vec()),
1483 };
1484 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1486 if pos > 0 {
1487 best_idx = pos;
1488 }
1489 }
1490 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1491}
1492
1493const NON_WS_SPLITTERS: &[&str] = &[
1499 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1500 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1501];
1502
1503pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1507 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1508 let mut counter = |s: &str| -> usize {
1509 if let Some(n) = cache.get(s) {
1510 return *n;
1511 }
1512 let n = tok.count_tokens(s);
1513 cache.insert(s.to_string(), n);
1514 n
1515 };
1516 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1517 chunks
1519 .into_iter()
1520 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1521 .collect()
1522}
1523
1524fn semchunk_rec(
1527 text: &str,
1528 chunk_size: usize,
1529 counter: &mut dyn FnMut(&str) -> usize,
1530) -> Vec<String> {
1531 let (splitter, splitter_is_ws, splits) = split_text(text);
1532
1533 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1534 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1535 cum_lens.push(0usize);
1536 for l in &split_lens {
1537 cum_lens.push(cum_lens.last().unwrap() + l);
1538 }
1539 let num_splits_plus_one = splits.len() + 1;
1540
1541 let mut chunks: Vec<String> = Vec::new();
1542 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1543
1544 for i in 0..splits.len() {
1545 if skips.contains(&i) {
1546 continue;
1547 }
1548 let split = &splits[i];
1549 if counter(split) > chunk_size {
1550 let inner = semchunk_rec(split, chunk_size, counter);
1551 chunks.extend(inner);
1552 } else {
1553 let (end, merged) = merge_splits(
1554 &splits,
1555 &cum_lens,
1556 chunk_size,
1557 &splitter,
1558 counter,
1559 i,
1560 num_splits_plus_one,
1561 );
1562 for j in (i + 1)..end {
1563 skips.insert(j);
1564 }
1565 chunks.push(merged);
1566 }
1567 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1570 if !splitter_is_ws && !is_last {
1571 let with_splitter = format!(
1572 "{}{}",
1573 chunks.last().map(String::as_str).unwrap_or(""),
1574 splitter
1575 );
1576 if counter(&with_splitter) <= chunk_size {
1577 if let Some(last) = chunks.last_mut() {
1578 *last = with_splitter;
1579 } else {
1580 chunks.push(with_splitter);
1581 }
1582 } else {
1583 chunks.push(splitter.clone());
1584 }
1585 }
1586 }
1587 chunks
1588}
1589
1590fn merge_splits(
1593 splits: &[String],
1594 cum_lens: &[usize],
1595 chunk_size: usize,
1596 splitter: &str,
1597 counter: &mut dyn FnMut(&str) -> usize,
1598 start: usize,
1599 high_init: usize,
1600) -> (usize, String) {
1601 let mut average = 0.2f64;
1602 let mut low = start;
1603 let mut high = high_init;
1604 let offset = cum_lens[start];
1605 let mut target = offset as f64 + (chunk_size as f64 * average);
1606
1607 while low < high {
1608 let i = bisect_left(cum_lens, target, low, high);
1609 let midpoint = i.min(high - 1);
1610 let joined = splits[start..midpoint.max(start)].join(splitter);
1611 let tokens = counter(&joined);
1612 let local_cum = cum_lens[midpoint] - offset;
1613 if local_cum > 0 && tokens > 0 {
1614 average = local_cum as f64 / tokens as f64;
1615 target = offset as f64 + (chunk_size as f64 * average);
1616 }
1617 if tokens > chunk_size {
1618 high = midpoint;
1619 } else {
1620 low = midpoint + 1;
1621 }
1622 }
1623 let end = low - 1;
1624 (end, splits[start..end.max(start)].join(splitter))
1625}
1626
1627fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1628 while low < high {
1629 let mid = (low + high) / 2;
1630 if (sorted[mid] as f64) < target {
1631 low = mid + 1;
1632 } else {
1633 high = mid;
1634 }
1635 }
1636 low
1637}
1638
1639fn split_text(text: &str) -> (String, bool, Vec<String>) {
1641 if text.contains('\n') || text.contains('\r') {
1643 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1644 return (splitter.clone(), true, split_on(text, &splitter));
1645 }
1646 if text.contains('\t') {
1648 let splitter = longest_run(text, |c| c == '\t');
1649 return (splitter.clone(), true, split_on(text, &splitter));
1650 }
1651 if text.chars().any(char::is_whitespace) {
1653 let splitter = longest_run(text, char::is_whitespace);
1654 if splitter.chars().count() == 1 {
1655 for preceder in NON_WS_SPLITTERS {
1657 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1658 return (ws, true, parts);
1659 }
1660 }
1661 }
1662 return (splitter.clone(), true, split_on(text, &splitter));
1663 }
1664 for s in NON_WS_SPLITTERS {
1666 if text.contains(s) {
1667 return (s.to_string(), false, split_on(text, s));
1668 }
1669 }
1670 (
1672 String::new(),
1673 true,
1674 text.chars().map(|c| c.to_string()).collect(),
1675 )
1676}
1677
1678fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1680 let mut best = String::new();
1681 let mut cur = String::new();
1682 for c in text.chars() {
1683 if pred(c) {
1684 cur.push(c);
1685 } else {
1686 if cur.chars().count() > best.chars().count() {
1687 best = cur.clone();
1688 }
1689 cur.clear();
1690 }
1691 }
1692 if cur.chars().count() > best.chars().count() {
1693 best = cur;
1694 }
1695 best
1696}
1697
1698fn split_on(text: &str, splitter: &str) -> Vec<String> {
1699 text.split(splitter).map(str::to_string).collect()
1700}
1701
1702fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1706 let chars: Vec<char> = text.chars().collect();
1707 let p: Vec<char> = preceder.chars().collect();
1708 let mut ws: Option<char> = None;
1709 for i in p.len()..chars.len() {
1710 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1711 ws = Some(chars[i]);
1712 break;
1713 }
1714 }
1715 let ws = ws?;
1716 let mut parts = Vec::new();
1717 let mut cur = String::new();
1718 let mut i = 0usize;
1719 while i < chars.len() {
1720 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1721 parts.push(std::mem::take(&mut cur));
1722 i += 1;
1723 continue;
1724 }
1725 cur.push(chars[i]);
1726 i += 1;
1727 }
1728 parts.push(cur);
1729 Some((ws.to_string(), parts))
1730}
1731
1732#[cfg(feature = "chunking")]
1737mod hf {
1738 use super::ChunkTokenizer;
1739
1740 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1745
1746 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1751 if let Some(p) = explicit {
1752 return Ok(p.to_string());
1753 }
1754 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1755 if std::path::Path::new(&resolved).exists() {
1756 return Ok(resolved);
1757 }
1758 Err(format!(
1759 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1760 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1761 scripts/install/download_dependencies.sh (or pass an explicit path)"
1762 ))
1763 }
1764
1765 pub struct HuggingFaceTokenizer {
1769 tok: tokenizers::Tokenizer,
1770 max_tokens: usize,
1771 }
1772
1773 impl HuggingFaceTokenizer {
1774 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1778 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1779 }
1780
1781 pub fn from_file(
1785 path: impl AsRef<std::path::Path>,
1786 max_tokens: usize,
1787 ) -> Result<Self, String> {
1788 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1789 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1790 let _ = tok.with_truncation(None);
1795 tok.with_padding(None);
1796 Ok(Self { tok, max_tokens })
1797 }
1798 }
1799
1800 impl ChunkTokenizer for HuggingFaceTokenizer {
1801 fn count_tokens(&self, text: &str) -> usize {
1802 self.tok
1803 .encode(text, false)
1804 .map(|e| e.get_tokens().len())
1805 .unwrap_or(0)
1806 }
1807 fn max_tokens(&self) -> usize {
1808 self.max_tokens
1809 }
1810 }
1811}
1812
1813#[cfg(feature = "chunking")]
1814pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1815
1816#[cfg(feature = "chunking")]
1821mod window {
1822 use super::DocChunk;
1823 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1824
1825 #[derive(Debug, Clone, Default)]
1827 pub struct Section {
1828 pub heading_path: Vec<String>,
1831 pub words: Vec<String>,
1833 }
1834
1835 impl Section {
1836 pub fn heading_context(&self) -> String {
1839 if self.heading_path.is_empty() {
1840 String::new()
1841 } else {
1842 format!("# {}", self.heading_path.join(" > "))
1843 }
1844 }
1845 }
1846
1847 fn level_index(level: HeadingLevel) -> usize {
1848 match level {
1849 HeadingLevel::H1 => 1,
1850 HeadingLevel::H2 => 2,
1851 HeadingLevel::H3 => 3,
1852 HeadingLevel::H4 => 4,
1853 HeadingLevel::H5 => 5,
1854 HeadingLevel::H6 => 6,
1855 }
1856 }
1857
1858 pub fn parse_sections(markdown: &str) -> Vec<Section> {
1862 parse_sections_with_stack(markdown, Vec::new()).0
1863 }
1864
1865 pub fn parse_sections_with_stack(
1870 markdown: &str,
1871 initial_stack: Vec<String>,
1872 ) -> (Vec<Section>, Vec<String>) {
1873 let mut heading_stack: Vec<String> = initial_stack;
1874 let mut sections: Vec<Section> = Vec::new();
1875 let mut current = Section {
1878 heading_path: heading_stack
1879 .iter()
1880 .filter(|h| !h.is_empty())
1881 .cloned()
1882 .collect(),
1883 words: Vec::new(),
1884 };
1885
1886 let mut in_heading = false;
1887 let mut heading_level = 0usize;
1888 let mut heading_buf = String::new();
1889
1890 let push_words = |section: &mut Section, text: &str| {
1891 for w in text.split_whitespace() {
1892 section.words.push(w.to_string());
1893 }
1894 };
1895
1896 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1897 if !section.words.is_empty() {
1898 sections.push(std::mem::take(section));
1899 } else {
1900 *section = Section::default();
1901 }
1902 };
1903
1904 for event in Parser::new(markdown) {
1905 match event {
1906 Event::Start(Tag::Heading { level, .. }) => {
1907 in_heading = true;
1908 heading_level = level_index(level);
1909 heading_buf.clear();
1910 }
1911 Event::End(TagEnd::Heading(_)) => {
1912 in_heading = false;
1913 let idx = heading_level.saturating_sub(1);
1915 if heading_stack.len() <= idx {
1916 heading_stack.resize(idx + 1, String::new());
1917 } else {
1918 heading_stack.truncate(idx + 1);
1919 }
1920 heading_stack[idx] = heading_buf.trim().to_string();
1921 flush(&mut sections, &mut current);
1923 current.heading_path = heading_stack
1924 .iter()
1925 .filter(|h| !h.is_empty())
1926 .cloned()
1927 .collect();
1928 }
1929 Event::Text(t) | Event::Code(t) => {
1930 if in_heading {
1931 if !heading_buf.is_empty() {
1932 heading_buf.push(' ');
1933 }
1934 heading_buf.push_str(&t);
1935 } else {
1936 push_words(&mut current, &t);
1937 }
1938 }
1939 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
1941 _ => {}
1942 }
1943 }
1944 flush(&mut sections, &mut current);
1945 (sections, heading_stack)
1946 }
1947
1948 #[derive(Debug, Clone)]
1954 pub struct WindowChunker {
1955 pub max_words: usize,
1957 pub overlap: f32,
1959 }
1960
1961 impl Default for WindowChunker {
1962 fn default() -> Self {
1963 WindowChunker {
1964 max_words: 300,
1965 overlap: 0.05,
1966 }
1967 }
1968 }
1969
1970 impl WindowChunker {
1971 pub fn new(max_words: usize, overlap: f32) -> Self {
1972 WindowChunker { max_words, overlap }
1973 }
1974
1975 fn word_budget(&self) -> usize {
1977 self.max_words.max(1)
1978 }
1979
1980 fn overlap_words(&self, budget: usize) -> usize {
1983 let o = (budget as f32 * self.overlap).round() as usize;
1984 o.min(budget.saturating_sub(1))
1985 }
1986
1987 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
1989 let mut chunks = Vec::new();
1990 self.chunk_with(markdown, &mut |c| {
1991 chunks.push(c);
1992 true
1993 });
1994 chunks
1995 }
1996
1997 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2001 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2002 for section in §ions {
2003 if !self.pack_section(section, sink) {
2004 return;
2005 }
2006 }
2007 }
2008
2009 pub fn pack_section(
2013 &self,
2014 section: &Section,
2015 sink: &mut dyn FnMut(DocChunk) -> bool,
2016 ) -> bool {
2017 let words = §ion.words;
2018 if words.is_empty() {
2019 return true;
2020 }
2021 let budget = self.word_budget();
2022 let step = budget - self.overlap_words(budget); let mut start = 0;
2024 loop {
2025 let end = (start + budget).min(words.len());
2026 let chunk = DocChunk {
2027 text: words[start..end].join(" "),
2028 headings: (!section.heading_path.is_empty())
2029 .then(|| section.heading_path.clone()),
2030 doc_items: Vec::new(),
2031 };
2032 if !sink(chunk) {
2033 return false;
2034 }
2035 if end >= words.len() {
2036 return true;
2037 }
2038 start += step;
2039 }
2040 }
2041
2042 pub fn contextualize(chunk: &DocChunk) -> String {
2048 match &chunk.headings {
2049 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2050 _ => chunk.text.clone(),
2051 }
2052 }
2053 }
2054
2055 #[cfg(test)]
2056 mod tests {
2057 use super::*;
2058
2059 #[test]
2060 fn splits_on_headings_and_tracks_path() {
2061 let md = "\
2062intro words
2063# Chapter 1
2064para one
2065## Section 1.1
2066para two
2067# Chapter 2
2068para three";
2069 let secs = parse_sections(md);
2070 assert_eq!(secs.len(), 4);
2072 assert!(secs[0].heading_path.is_empty());
2073 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2074 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2075 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2077 }
2078
2079 #[test]
2080 fn strips_markup_to_plain_words() {
2081 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2082 let secs = parse_sections(md);
2083 let words = &secs[0].words;
2084 assert!(words.contains(&"bold".to_string()));
2085 assert!(words.contains(&"code".to_string()));
2086 assert!(words.contains(&"link".to_string()));
2087 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2089 }
2090
2091 #[test]
2092 fn windows_overlap_and_never_cross_headings() {
2093 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2094 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2095 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2097 let a: Vec<_> = chunks
2099 .iter()
2100 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2101 .collect();
2102 assert_eq!(a.len(), 3);
2103 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2104 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2105 assert!(a[2].text.ends_with(" w24"));
2106 let b: Vec<_> = chunks
2108 .iter()
2109 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2110 .collect();
2111 assert_eq!(b.len(), 1);
2112 assert_eq!(b[0].text, "short tail");
2113 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2114 }
2115
2116 #[test]
2117 fn sink_false_cancels_the_window_walk() {
2118 let md = format!(
2119 "# A\n\n{}\n",
2120 (0..50)
2121 .map(|i| format!("w{i}"))
2122 .collect::<Vec<_>>()
2123 .join(" ")
2124 );
2125 let chunker = WindowChunker::new(10, 0.0);
2126 let mut n = 0;
2127 chunker.chunk_with(&md, &mut |_| {
2128 n += 1;
2129 false
2130 });
2131 assert_eq!(n, 1);
2132 }
2133 }
2134}
2135
2136#[cfg(feature = "chunking")]
2137pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2138
2139#[cfg(test)]
2140mod tests {
2141 use super::*;
2142
2143 struct WordTok(usize);
2145 impl ChunkTokenizer for WordTok {
2146 fn count_tokens(&self, text: &str) -> usize {
2147 text.split_whitespace().count()
2148 }
2149 fn max_tokens(&self) -> usize {
2150 self.0
2151 }
2152 }
2153
2154 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2155 let mut d = DoclingDocument::new("t");
2156 for n in nodes {
2157 d.push(n);
2158 }
2159 d
2160 }
2161
2162 #[test]
2163 fn hierarchical_headings_and_items() {
2164 let doc = doc_with(vec![
2165 Node::Heading {
2166 level: 1,
2167 text: "Title".into(),
2168 },
2169 Node::Paragraph {
2170 text: "Intro".into(),
2171 },
2172 Node::Heading {
2173 level: 2,
2174 text: "Sec".into(),
2175 },
2176 Node::Paragraph {
2177 text: "Body".into(),
2178 },
2179 ]);
2180 let chunks = HierarchicalChunker.chunk(&doc);
2181 assert_eq!(chunks.len(), 2);
2182 assert_eq!(chunks[0].text, "Intro");
2183 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2184 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2185 assert_eq!(
2186 chunks[1].headings.as_deref(),
2187 Some(&["Title".into(), "Sec".into()][..])
2188 );
2189 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2190 }
2191
2192 #[test]
2193 fn heading_shadowing_prunes_deeper_levels() {
2194 let doc = doc_with(vec![
2195 Node::Heading {
2196 level: 2,
2197 text: "A".into(),
2198 },
2199 Node::Heading {
2200 level: 3,
2201 text: "A.1".into(),
2202 },
2203 Node::Heading {
2204 level: 2,
2205 text: "B".into(),
2206 },
2207 Node::Paragraph { text: "p".into() },
2208 ]);
2209 let chunks = HierarchicalChunker.chunk(&doc);
2210 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2211 }
2212
2213 #[test]
2214 fn triplet_table() {
2215 let t = Table {
2216 rows: vec![
2217 vec!["".into(), "Col1".into()],
2218 vec!["Row1".into(), "v".into()],
2219 ],
2220 ..Default::default()
2221 };
2222 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2223 let single = Table {
2226 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2227 ..Default::default()
2228 };
2229 assert_eq!(triplet_table_text(&single), "a = b");
2230 }
2231
2232 #[test]
2233 fn hybrid_merges_small_peers_and_splits_large() {
2234 let doc = doc_with(vec![
2235 Node::Heading {
2236 level: 2,
2237 text: "S".into(),
2238 },
2239 Node::Paragraph { text: "a b".into() },
2240 Node::Paragraph { text: "c d".into() },
2241 ]);
2242 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2243 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2244 assert_eq!(chunks[0].text, "a b\nc d");
2245
2246 let long = "w ".repeat(40).trim().to_string();
2247 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2248 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2249 assert!(chunks.len() > 1, "oversized paragraph splits");
2250 for c in &chunks {
2251 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2252 }
2253 }
2254
2255 #[test]
2256 fn semchunk_prefers_newlines_then_sentences() {
2257 let tok = WordTok(4);
2258 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2259 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2260 }
2261}