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 let mut prev: Option<(bool, u64)> = None;
273 for k in 0..run.len() {
274 let Node::ListItem {
275 ordered,
276 number,
277 first_in_list,
278 level,
279 ..
280 } = &run[k]
281 else {
282 continue;
283 };
284 if *level != base {
285 continue; }
287 if k > seg {
288 if let Some((po, pn)) = prev {
289 if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
290 self.list(&run[seg..k]);
291 seg = k;
292 }
293 }
294 }
295 prev = Some((*ordered, *number));
296 }
297 self.list(&run[seg..]);
298 }
299
300 fn list(&mut self, items: &[Node]) {
304 self.alloc.group();
305 let mut chunk_items = Vec::new();
306 self.list_refs(items, &mut chunk_items);
307 let text = render_list(items);
308 self.emit(text, chunk_items);
309 }
310
311 fn list_refs(&mut self, items: &[Node], out: &mut Vec<ChunkItem>) {
315 let base = level_of(&items[0]);
316 let mut i = 0;
317 while i < items.len() {
318 let Node::ListItem {
319 ordered,
320 number,
321 text,
322 level,
323 layer,
324 ..
325 } = &items[i]
326 else {
327 i += 1;
328 continue;
329 };
330 if *level > base {
331 i += 1;
332 continue;
333 }
334 let item_ref = self.alloc.text();
335 let mut j = i + 1;
336 while j < items.len() && level_of(&items[j]) > base {
337 j += 1;
338 }
339 let has_nested = j > i + 1;
340 if layer.is_none() {
341 let marker = if *ordered {
342 format!("{number}.")
343 } else {
344 "-".to_string()
345 };
346 let has_pics = text.contains("<!-- image -->");
360 let text = strip_image_markers(text);
361 let text = text.as_str();
362 let segments = inline_segments(text);
363 if (has_nested || has_pics) && segments.len() > 1 && text.contains("](") {
364 out.push(ChunkItem {
365 self_ref: item_ref.clone(),
366 kind: ChunkItemKind::Text,
367 text: format!("{marker} "),
368 });
369 for seg in segments {
370 out.push(ChunkItem {
371 self_ref: item_ref.clone(),
372 kind: ChunkItemKind::Text,
373 text: seg,
374 });
375 }
376 } else {
377 out.push(ChunkItem {
378 self_ref: item_ref.clone(),
379 kind: ChunkItemKind::Text,
380 text: format!("{marker} {}", unescape_text(text)),
381 });
382 }
383 }
384 if j > i + 1 {
387 self.nested_sibling_lists(&items[i + 1..j], out);
388 }
389 i = j;
390 }
391 }
392
393 fn nested_sibling_lists(&mut self, run: &[Node], out: &mut Vec<ChunkItem>) {
394 let base = level_of(&run[0]);
395 let mut seg = 0;
396 let mut prev: Option<(bool, u64)> = None;
397 for k in 0..run.len() {
398 let Node::ListItem {
399 ordered,
400 number,
401 first_in_list,
402 level,
403 ..
404 } = &run[k]
405 else {
406 continue;
407 };
408 if *level != base {
409 continue;
410 }
411 if k > seg {
412 if let Some((po, pn)) = prev {
413 if *first_in_list || po != *ordered || (*ordered && *number != pn + 1) {
414 self.alloc.group();
415 self.list_refs(&run[seg..k], out);
416 seg = k;
417 }
418 }
419 }
420 prev = Some((*ordered, *number));
421 }
422 self.alloc.group();
423 self.list_refs(&run[seg..], out);
424 }
425
426 fn one(&mut self, node: &Node) {
427 match node {
428 Node::Heading { level, text } => {
429 let doc_level = if *level == 1 {
430 0
431 } else {
432 level.saturating_sub(1)
433 };
434 let self_ref = self.alloc.text();
435 let runs = crate::inline_runs_from_markdown(text);
441 if runs.len() <= 1 {
442 let plain = runs
443 .first()
444 .map(|r| r.text.clone())
445 .unwrap_or_else(|| text.clone());
446 self.set_heading(doc_level, unescape_text(&plain));
447 } else {
448 self.set_heading(doc_level, String::new());
449 let body = unescape_text(text);
450 self.emit(
451 body.clone(),
452 vec![ChunkItem {
453 self_ref,
454 kind: ChunkItemKind::Text,
455 text: body,
456 }],
457 );
458 }
459 }
460 Node::Paragraph { text } => {
461 let t = text.trim();
462 let self_ref = self.alloc.text();
463 if let Some(inner) = t
466 .strip_prefix("$$")
467 .and_then(|s| s.strip_suffix("$$"))
468 .filter(|s| !s.is_empty())
469 {
470 let body = format!("$${inner}$$");
471 self.emit(
472 body.clone(),
473 vec![ChunkItem {
474 self_ref,
475 kind: ChunkItemKind::Text,
476 text: body,
477 }],
478 );
479 return;
480 }
481 self.emit_inline(text, self_ref);
482 }
483 Node::CheckboxItem { checked, text } => {
484 let self_ref = self.alloc.text();
485 let mark = if *checked { "- [x] " } else { "- [ ] " };
486 let body = format!("{mark}{}", unescape_text(text));
487 self.emit(
488 body.clone(),
489 vec![ChunkItem {
490 self_ref,
491 kind: ChunkItemKind::Text,
492 text: body,
493 }],
494 );
495 }
496 Node::Formula { latex, .. } => {
499 let self_ref = self.alloc.text();
500 let body = format!("$${}$$", latex);
501 self.emit(
502 body.clone(),
503 vec![ChunkItem {
504 self_ref,
505 kind: ChunkItemKind::Text,
506 text: body,
507 }],
508 );
509 }
510 Node::Code { text, .. } => {
511 let self_ref = self.alloc.text();
512 let body = format!("```\n{}\n```", unescape_text(text));
513 self.emit(
514 body.clone(),
515 vec![ChunkItem {
516 self_ref,
517 kind: ChunkItemKind::Text,
518 text: body,
519 }],
520 );
521 }
522 Node::Table(t) => {
523 let self_ref = self.alloc.table();
524 let body = triplet_table_text(t);
525 self.emit(
526 body.clone(),
527 vec![ChunkItem {
528 self_ref,
529 kind: ChunkItemKind::Table,
530 text: body,
531 }],
532 );
533 }
534 Node::Picture { caption, .. } => {
535 let cap = caption.as_deref().filter(|c| !c.is_empty());
536 let cap_item = cap.map(|c| ChunkItem {
537 self_ref: self.alloc.text(),
538 kind: ChunkItemKind::Text,
539 text: unescape_text(c),
540 });
541 self.alloc.picture();
542 if let Some(cap_item) = cap_item {
546 let body = cap_item.text.clone();
547 self.emit(body, vec![cap_item]);
548 }
549 }
550 Node::Chart {
551 kind,
552 table,
553 caption,
554 ..
555 } => {
556 let cap = caption.as_deref().filter(|c| !c.is_empty());
557 let cap_item = cap.map(|c| ChunkItem {
558 self_ref: self.alloc.text(),
559 kind: ChunkItemKind::Text,
560 text: unescape_text(c),
561 });
562 let pic_ref = self.alloc.picture();
563 let mut parts: Vec<String> = Vec::new();
567 if let Some(ci) = &cap_item {
568 parts.push(ci.text.clone());
569 }
570 parts.push(humanize_label(kind));
571 let grid = crate::markdown::render_table(table, false);
572 if !grid.is_empty() {
573 parts.push(unescape_text(&grid));
574 }
575 let body = parts.join("\n\n");
576 let pic_item = ChunkItem {
581 self_ref: pic_ref,
582 kind: ChunkItemKind::Picture,
583 text: body.clone(),
584 };
585 let items = match cap_item {
586 Some(mut ci) => {
587 ci.text = String::new();
588 vec![ci, pic_item]
589 }
590 None => vec![pic_item],
591 };
592 self.emit(body, items);
593 }
594 Node::Group { layer: Some(_), .. } => {}
597 Node::Group { children, .. } => {
598 self.alloc.group();
601 self.walk(children);
602 }
603 Node::FieldRegion { items } => {
604 self.alloc.field_region();
607 for item in items {
608 self.alloc.field_item();
609 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
610 let self_ref = self.alloc.text();
611 let body = unescape_text(part);
612 self.emit(
613 body.clone(),
614 vec![ChunkItem {
615 self_ref,
616 kind: ChunkItemKind::Text,
617 text: body,
618 }],
619 );
620 }
621 }
622 }
623 Node::InlineGroup { md_text, runs, .. } => {
624 let self_ref = self.alloc.text();
625 self.emit_inline_with_runs(md_text, self_ref, runs);
626 }
627 Node::TextDump(text) => {
628 let self_ref = self.alloc.text();
629 let body = unescape_text(text);
630 self.emit(
631 body.clone(),
632 vec![ChunkItem {
633 self_ref,
634 kind: ChunkItemKind::Text,
635 text: body,
636 }],
637 );
638 }
639 Node::Located { inner, .. } | Node::Commented { inner, .. } => self.one(inner),
641 Node::CommentSection { .. }
644 | Node::Furniture { .. }
645 | Node::PageFurniture { .. }
646 | Node::PageBreak
647 | Node::PageInfo { .. }
648 | Node::DoclangOnly(_) => {}
649 Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
652 }
653 }
654}
655
656fn level_of(node: &Node) -> u8 {
657 match node {
658 Node::ListItem { level, .. } => *level,
659 _ => 0,
660 }
661}
662
663fn render_list(items: &[Node]) -> String {
666 let mut lines: Vec<String> = Vec::new();
667 for item in items {
668 let Node::ListItem {
669 ordered,
670 number,
671 text,
672 level,
673 layer,
674 ..
675 } = item
676 else {
677 continue;
678 };
679 if layer.is_some() {
680 continue;
681 }
682 let indent = " ".repeat(*level as usize);
683 let marker = if *ordered {
684 format!("{number}.")
685 } else {
686 "-".to_string()
687 };
688 lines.push(format!(
689 "{indent}{marker} {}",
690 unescape_text(&strip_image_markers(text))
691 ));
692 }
693 lines.join("\n")
694}
695
696fn strip_image_markers(text: &str) -> String {
701 if !text.contains("<!-- image -->") {
702 return text.to_string();
703 }
704 let cleaned: Vec<&str> = text
705 .split('\n')
706 .map(str::trim_end)
707 .filter(|l| *l != "<!-- image -->")
708 .collect();
709 cleaned.join("\n").trim_end().to_string()
710}
711
712fn humanize_label(label: &str) -> String {
715 let text = label.replace('_', " ");
716 let mut chars = text.chars();
717 match chars.next() {
718 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
719 None => text,
720 }
721}
722
723fn triplet_table_text(t: &Table) -> String {
730 let rows: Vec<Vec<String>> = t
731 .rows
732 .iter()
733 .enumerate()
734 .map(|(ri, r)| (0..r.len()).map(|ci| cell_chunk_text(t, ri, ci)).collect())
735 .collect();
736 let num_rows = rows.len();
737 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
738 if num_rows == 0 || num_cols == 0 {
739 return String::new();
740 }
741 let cell = |r: usize, c: usize| -> &str {
742 rows.get(r)
743 .and_then(|row| row.get(c))
744 .map(String::as_str)
745 .unwrap_or("")
746 };
747
748 let cell_is_header = |r: usize, c: usize| -> bool {
752 let (mut r, mut c) = (r, c);
753 loop {
754 match &t.structure {
755 Some(s) if !s.col_header.is_empty() => {
756 return s
757 .col_header
758 .get(r)
759 .and_then(|row| row.get(c))
760 .copied()
761 .unwrap_or(false)
762 }
763 Some(s) => {
764 let cont = |g: &Vec<Vec<bool>>| {
765 g.get(r)
766 .and_then(|row| row.get(c))
767 .copied()
768 .unwrap_or(false)
769 };
770 if r > 0 && cont(&s.row_continuation) {
771 r -= 1;
772 continue;
773 }
774 if c > 0 && cont(&s.col_continuation) {
775 c -= 1;
776 continue;
777 }
778 return if s.header_row.is_empty() {
779 r == 0
780 } else {
781 s.header_row.get(r).copied().unwrap_or(false)
782 };
783 }
784 None => return r == 0,
785 }
786 }
787 };
788 let row_is_header = |r: usize| (0..num_cols).any(|c| cell_is_header(r, c));
789 let num_headers = (0..num_rows).take_while(|r| row_is_header(*r)).count();
790
791 let columns: Vec<String> = if num_headers > 0 {
794 (0..num_cols)
795 .map(|c| {
796 let mut name = String::new();
797 for r in 0..num_headers {
798 if !name.is_empty() {
799 name.push('.');
800 }
801 name.push_str(cell(r, c));
802 }
803 name
804 })
805 .collect()
806 } else {
807 (0..num_cols).map(|c| c.to_string()).collect()
808 };
809 let data_rows = num_headers..num_rows;
810 let n_data = data_rows.len();
811
812 if n_data == 0 {
814 return columns
815 .iter()
816 .map(|s| s.trim())
817 .filter(|s| !s.is_empty())
818 .collect::<Vec<_>>()
819 .join(". ");
820 }
821
822 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
823 let text = if num_cols == 1 {
824 let col_name = data(0, 0).trim().to_string();
827 if n_data == 1 {
828 col_name
829 } else {
830 (1..n_data)
831 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
832 .collect::<Vec<_>>()
833 .join(". ")
834 }
835 } else {
836 let mut parts = Vec::new();
838 for r in 0..n_data {
839 for (c, col_name) in columns.iter().enumerate().skip(1) {
840 parts.push(format!(
841 "{}, {} = {}",
842 data(r, 0).trim(),
843 col_name.trim(),
844 data(r, c).trim()
845 ));
846 }
847 }
848 parts.join(". ")
849 };
850 if !text.is_empty() {
851 return text;
852 }
853
854 (0..n_data)
857 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
858 .map(|(r, c)| data(r, c).trim())
859 .filter(|s| !s.is_empty())
860 .collect::<Vec<_>>()
861 .join(". ")
862}
863
864fn inline_segments(md: &str) -> Vec<String> {
872 inline_segments_tagged(md)
873 .into_iter()
874 .map(|(t, _)| t)
875 .collect()
876}
877
878fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
882 let chars: Vec<char> = md.chars().collect();
883 let n = chars.len();
884 let find = |from: usize, pat: &str| -> Option<usize> {
885 let hay: String = chars[from..].iter().collect();
886 hay.find(pat).map(|p| from + hay[..p].chars().count())
887 };
888 let mut out: Vec<(String, bool)> = Vec::new();
889 let mut plain = String::new();
890 let mut after_span = false;
891
892 fn flush(
893 out: &mut Vec<(String, bool)>,
894 plain: &mut String,
895 before_span: bool,
896 after_span: bool,
897 ) {
898 let mut p = std::mem::take(plain);
899 if after_span {
900 if let Some(rest) = p.strip_prefix(' ') {
901 p = rest.to_string();
902 }
903 }
904 if before_span {
905 if let Some(rest) = p.strip_suffix(' ') {
906 p = rest.to_string();
907 }
908 }
909 if !p.is_empty() {
910 out.push((unescape_text(&p), true));
911 }
912 }
913
914 let mut i = 0;
915 while i < n {
916 let rest: String = chars[i..].iter().collect();
917 if chars[i] == '[' && !rest.starts_with("[](") {
921 let balanced = |c: usize| {
925 let mut d = 0i32;
926 for &ch in &chars[i + 1..c] {
927 match ch {
928 '[' => d += 1,
929 ']' => d -= 1,
930 _ => {}
931 }
932 }
933 d == 0
934 };
935 if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
936 let mut depth = 0usize;
937 let mut url_end = None;
938 for (k, &c) in chars.iter().enumerate().skip(close + 2) {
939 match c {
940 '(' => depth += 1,
941 ')' => {
942 if depth == 0 {
943 url_end = Some(k);
944 break;
945 }
946 depth -= 1;
947 }
948 _ => {}
949 }
950 }
951 if let Some(endp) = url_end {
952 flush(&mut out, &mut plain, true, after_span);
953 out.push((
954 unescape_text(&chars[i..=endp].iter().collect::<String>()),
955 false,
956 ));
957 i = endp + 1;
958 after_span = true;
959 continue;
960 }
961 }
962 }
963 let mut matched = false;
966 for marker in ["***", "**", "*", "~~", "`"] {
967 if rest.starts_with(marker) {
968 let mlen = marker.chars().count();
969 if let Some(end) = find(i + mlen, marker) {
970 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
974 if end > i + mlen && !inner_blank {
975 flush(&mut out, &mut plain, true, after_span);
976 if marker == "`" {
977 let inner: String = chars[i + 1..end].iter().collect();
978 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
979 } else {
980 out.push((
981 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
982 false,
983 ));
984 }
985 i = end + mlen;
986 after_span = true;
987 matched = true;
988 }
989 }
990 break;
991 }
992 }
993 if matched {
994 continue;
995 }
996 if rest.starts_with("$$") {
999 plain.push_str("$$");
1000 i += 2;
1001 continue;
1002 }
1003 if chars[i] == '$' {
1005 if let Some(end) = find(i + 1, "$") {
1006 if end > i + 1 {
1007 flush(&mut out, &mut plain, true, after_span);
1008 let latex: String = chars[i + 1..end].iter().collect();
1009 out.push((format!("$${latex}$$"), false));
1010 i = end + 1;
1011 after_span = true;
1012 continue;
1013 }
1014 }
1015 }
1016 plain.push(chars[i]);
1017 i += 1;
1018 }
1019 flush(&mut out, &mut plain, false, after_span);
1020 if out.is_empty() {
1021 out.push((unescape_text(md), true));
1022 }
1023 out
1024}
1025
1026fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1031 let target = segment.trim();
1032 if target.is_empty() {
1033 return None;
1034 }
1035 let plainish =
1036 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1037 let fully_plain =
1038 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1039 let unmarked: Vec<(&str, bool)> = runs
1040 .iter()
1041 .filter(|r| plainish(r))
1042 .map(|r| (r.text.as_str(), fully_plain(r)))
1043 .collect();
1044 for start in 0..unmarked.len() {
1045 let mut rest = target;
1046 let mut taken: Vec<(String, bool)> = Vec::new();
1047 for (t, fully) in &unmarked[start..] {
1048 let t = t.trim();
1049 if t.is_empty() {
1050 continue;
1051 }
1052 match rest.strip_prefix(t) {
1053 Some(r) => {
1054 taken.push((unescape_text(t), *fully));
1055 rest = r.trim_start();
1056 if rest.is_empty() {
1057 break;
1058 }
1059 }
1060 None => break,
1061 }
1062 }
1063 if rest.is_empty() && taken.len() >= 2 {
1064 let mut merged: Vec<(String, bool)> = Vec::new();
1069 for (t, fully) in taken {
1070 match merged.last_mut() {
1071 Some((last, true)) if fully => {
1072 last.push(' ');
1073 last.push_str(&t);
1074 }
1075 _ => merged.push((t, fully)),
1076 }
1077 }
1078 if merged.len() >= 2 {
1079 return Some(merged.into_iter().map(|(t, _)| t).collect());
1080 }
1081 return None;
1082 }
1083 }
1084 None
1085}
1086
1087fn cell_chunk_text(t: &Table, r: usize, c: usize) -> String {
1094 if let Some(blocks) = t
1095 .cell_blocks
1096 .as_ref()
1097 .and_then(|b| b.get(r))
1098 .and_then(|row| row.get(c))
1099 .filter(|b| !b.is_empty())
1100 {
1101 let mut parts: Vec<String> = Vec::new();
1102 for node in blocks.iter() {
1103 let part = block_chunk_text(node);
1104 if !part.is_empty() {
1105 parts.push(part);
1106 }
1107 }
1108 return parts.join("\n\n");
1109 }
1110 let flat = t
1111 .rows
1112 .get(r)
1113 .and_then(|row| row.get(c))
1114 .map(String::as_str)
1115 .unwrap_or("");
1116 unescape_text(flat)
1117 .replace("<!-- image -->", "")
1118 .trim()
1119 .to_string()
1120}
1121
1122fn block_chunk_text(node: &Node) -> String {
1124 match node {
1125 Node::Paragraph { text } => unescape_text(text),
1126 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1127 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1128 Node::Table(inner) => triplet_table_text(inner),
1129 Node::Picture { caption, .. } => caption
1130 .as_deref()
1131 .filter(|c| !c.is_empty())
1132 .map(unescape_text)
1133 .unwrap_or_default(),
1134 Node::ListItem {
1135 ordered,
1136 number,
1137 text,
1138 ..
1139 } => {
1140 let marker = if *ordered {
1141 format!("{number}.")
1142 } else {
1143 "-".to_string()
1144 };
1145 format!("{marker} {}", unescape_text(text))
1146 }
1147 Node::CheckboxItem { checked, text } => {
1148 let mark = if *checked { "- [x] " } else { "- [ ] " };
1149 format!("{mark}{}", unescape_text(text))
1150 }
1151 Node::Heading { text, .. } => unescape_text(text),
1152 Node::Located { inner, .. } | Node::Commented { inner, .. } => block_chunk_text(inner),
1153 Node::Group { layer: Some(_), .. } => String::new(),
1154 Node::Group { children, .. } => children
1155 .iter()
1156 .map(block_chunk_text)
1157 .filter(|s| !s.is_empty())
1158 .collect::<Vec<_>>()
1159 .join("\n"),
1160 _ => String::new(),
1161 }
1162}
1163
1164fn unescape_text(s: &str) -> String {
1167 s.replace("<", "<")
1168 .replace(">", ">")
1169 .replace("&", "&")
1170 .replace("\\_", "_")
1171}
1172
1173pub trait ChunkTokenizer {
1179 fn count_tokens(&self, text: &str) -> usize;
1181 fn max_tokens(&self) -> usize;
1183}
1184
1185pub struct HybridChunker<T: ChunkTokenizer> {
1189 tokenizer: T,
1190 merge_peers: bool,
1191}
1192
1193impl<T: ChunkTokenizer> HybridChunker<T> {
1194 pub fn new(tokenizer: T) -> Self {
1195 Self {
1196 tokenizer,
1197 merge_peers: true,
1198 }
1199 }
1200
1201 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1203 self.merge_peers = merge_peers;
1204 self
1205 }
1206
1207 pub fn max_tokens(&self) -> usize {
1208 self.tokenizer.max_tokens()
1209 }
1210
1211 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1213 let mut chunks = Vec::new();
1214 self.chunk_with(doc, &mut |c| {
1215 chunks.push(c);
1216 true
1217 });
1218 chunks
1219 }
1220
1221 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1228 let mut merger = PeerMerger::default();
1229 let mut alive = true;
1230 HierarchicalChunker.chunk_with(doc, &mut |c| {
1231 for split in self.split_by_doc_items(c) {
1232 for chunk in self.split_using_plain_text(split) {
1233 if !alive {
1234 return false;
1235 }
1236 alive = if self.merge_peers {
1237 self.merge_push(&mut merger, chunk, sink)
1238 } else {
1239 sink(chunk)
1240 };
1241 }
1242 }
1243 alive
1244 });
1245 if alive {
1246 self.merge_flush(&mut merger, sink);
1247 }
1248 }
1249
1250 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1251 self.tokenizer.count_tokens(&contextualize(chunk))
1252 }
1253
1254 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1257 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1258 let text = if chunk.doc_items.len() == 1 {
1259 chunk.text.clone()
1260 } else {
1261 doc_items
1262 .iter()
1263 .filter(|it| !it.text.is_empty())
1264 .map(|it| it.text.as_str())
1265 .collect::<Vec<_>>()
1266 .join("\n")
1267 };
1268 DocChunk {
1269 text,
1270 headings: chunk.headings.clone(),
1271 doc_items,
1272 }
1273 }
1274
1275 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1276 if chunk.doc_items.is_empty() {
1277 return vec![chunk];
1278 }
1279 let max = self.max_tokens();
1280 let num_items = chunk.doc_items.len();
1281 let mut chunks = Vec::new();
1282 let mut window_start = 0usize;
1283 let mut window_end = 0usize; while window_end < num_items {
1285 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1286 if self.count_chunk_tokens(&new_chunk) <= max {
1287 if window_end < num_items - 1 {
1288 window_end += 1;
1289 continue;
1290 } else {
1291 window_end = num_items; }
1293 } else if window_start == window_end {
1294 window_end += 1;
1297 window_start = window_end;
1298 } else {
1299 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1302 window_start = window_end;
1303 }
1304 chunks.push(new_chunk);
1305 }
1306 chunks
1307 }
1308
1309 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1310 let total = self.count_chunk_tokens(&chunk);
1311 let max = self.max_tokens();
1312 if total <= max {
1313 return vec![chunk];
1314 }
1315 let text_len = self.tokenizer.count_tokens(&chunk.text);
1316 let other_len = total - text_len;
1317 if other_len >= max {
1318 let stripped = DocChunk {
1320 headings: None,
1321 ..chunk
1322 };
1323 return self.split_using_plain_text(stripped);
1324 }
1325 let available = max - other_len;
1326
1327 let segments =
1328 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1329 let lines: Vec<String> = chunk
1336 .text
1337 .split('\n')
1338 .filter(|l| !l.trim().is_empty())
1339 .map(|l| l.to_string())
1340 .collect();
1341 line_chunk_text(&lines, &self.tokenizer, max)
1342 } else {
1343 semchunk(&chunk.text, available, &self.tokenizer)
1344 };
1345 segments
1346 .into_iter()
1347 .map(|s| DocChunk {
1348 text: s,
1349 headings: chunk.headings.clone(),
1350 doc_items: chunk.doc_items.clone(),
1351 })
1352 .collect()
1353 }
1354
1355 fn merge_push(
1361 &self,
1362 m: &mut PeerMerger,
1363 chunk: DocChunk,
1364 sink: &mut dyn FnMut(DocChunk) -> bool,
1365 ) -> bool {
1366 if m.window.is_empty() {
1367 m.window.push(chunk);
1368 return true;
1369 }
1370 let candidate = DocChunk {
1371 text: m
1372 .window
1373 .iter()
1374 .map(|c| c.text.as_str())
1375 .chain([chunk.text.as_str()])
1376 .collect::<Vec<_>>()
1377 .join("\n"),
1378 headings: m.window[0].headings.clone(),
1379 doc_items: m
1380 .window
1381 .iter()
1382 .flat_map(|c| c.doc_items.iter().cloned())
1383 .chain(chunk.doc_items.iter().cloned())
1384 .collect(),
1385 };
1386 if chunk.headings == m.window[0].headings
1387 && self.count_chunk_tokens(&candidate) <= self.max_tokens()
1388 {
1389 m.window.push(chunk);
1390 m.merged = Some(candidate);
1391 true
1392 } else {
1393 let alive = self.merge_flush(m, sink);
1394 m.window.push(chunk);
1395 alive
1396 }
1397 }
1398
1399 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1403 let alive = if m.window.len() == 1 {
1404 sink(m.window.pop().expect("single-chunk window"))
1405 } else if !m.window.is_empty() {
1406 m.window.clear();
1407 sink(m.merged.take().expect("multi-chunk window has a merge"))
1408 } else {
1409 true
1410 };
1411 m.merged = None;
1412 alive
1413 }
1414}
1415
1416#[derive(Default)]
1418struct PeerMerger {
1419 window: Vec<DocChunk>,
1420 merged: Option<DocChunk>,
1421}
1422
1423fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1433 let mut chunks: Vec<String> = Vec::new();
1434 let mut current = String::new();
1435 let mut current_len = 0usize;
1436
1437 for line in lines {
1438 let mut remaining: Vec<char> = line.chars().collect();
1439 loop {
1440 let rem_str: String = remaining.iter().collect();
1441 let line_tokens = tok.count_tokens(&rem_str);
1442 let available = max_tokens.saturating_sub(current_len);
1443
1444 if line_tokens <= available {
1445 current.push_str(&rem_str);
1446 current_len += line_tokens;
1447 break;
1448 }
1449 if line_tokens <= max_tokens {
1450 chunks.push(std::mem::take(&mut current));
1451 current_len = 0;
1452 continue;
1453 }
1454 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1456 let mut rest = rest;
1457 if take.is_empty() {
1458 if rest.is_empty() {
1459 break;
1460 }
1461 take = rest[..1].iter().collect();
1462 rest = rest[1..].to_vec();
1463 }
1464 current.push('\n');
1465 current.push_str(&take);
1466 chunks.push(std::mem::take(&mut current));
1467 current_len = 0;
1468 remaining = rest;
1469 }
1470 }
1471 if !current.is_empty() {
1472 chunks.push(current);
1473 }
1474 chunks
1475}
1476
1477fn split_by_token_limit<T: ChunkTokenizer>(
1481 text: &[char],
1482 token_limit: usize,
1483 tok: &T,
1484) -> (String, Vec<char>) {
1485 if token_limit == 0 || text.is_empty() {
1486 return (String::new(), text.to_vec());
1487 }
1488 let full: String = text.iter().collect();
1489 if tok.count_tokens(&full) <= token_limit {
1490 return (full, Vec::new());
1491 }
1492 let (mut lo, mut hi) = (0usize, text.len());
1493 let mut best: Option<usize> = None;
1494 while lo <= hi {
1495 let mid = (lo + hi) / 2;
1496 let head: String = text[..mid].iter().collect();
1497 if tok.count_tokens(&head) <= token_limit {
1498 best = Some(mid);
1499 lo = mid + 1;
1500 } else {
1501 if mid == 0 {
1502 break;
1503 }
1504 hi = mid - 1;
1505 }
1506 }
1507 let mut best_idx = match best {
1508 Some(b) if b > 0 => b,
1509 _ => return (String::new(), text.to_vec()),
1510 };
1511 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1513 if pos > 0 {
1514 best_idx = pos;
1515 }
1516 }
1517 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1518}
1519
1520const NON_WS_SPLITTERS: &[&str] = &[
1526 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1527 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1528];
1529
1530pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1534 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1535 let mut counter = |s: &str| -> usize {
1536 if let Some(n) = cache.get(s) {
1537 return *n;
1538 }
1539 let n = tok.count_tokens(s);
1540 cache.insert(s.to_string(), n);
1541 n
1542 };
1543 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1544 chunks
1546 .into_iter()
1547 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1548 .collect()
1549}
1550
1551fn semchunk_rec(
1554 text: &str,
1555 chunk_size: usize,
1556 counter: &mut dyn FnMut(&str) -> usize,
1557) -> Vec<String> {
1558 let (splitter, splitter_is_ws, splits) = split_text(text);
1559
1560 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1561 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1562 cum_lens.push(0usize);
1563 for l in &split_lens {
1564 cum_lens.push(cum_lens.last().unwrap() + l);
1565 }
1566 let num_splits_plus_one = splits.len() + 1;
1567
1568 let mut chunks: Vec<String> = Vec::new();
1569 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1570
1571 for i in 0..splits.len() {
1572 if skips.contains(&i) {
1573 continue;
1574 }
1575 let split = &splits[i];
1576 if counter(split) > chunk_size {
1577 let inner = semchunk_rec(split, chunk_size, counter);
1578 chunks.extend(inner);
1579 } else {
1580 let (end, merged) = merge_splits(
1581 &splits,
1582 &cum_lens,
1583 chunk_size,
1584 &splitter,
1585 counter,
1586 i,
1587 num_splits_plus_one,
1588 );
1589 for j in (i + 1)..end {
1590 skips.insert(j);
1591 }
1592 chunks.push(merged);
1593 }
1594 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1597 if !splitter_is_ws && !is_last {
1598 let with_splitter = format!(
1599 "{}{}",
1600 chunks.last().map(String::as_str).unwrap_or(""),
1601 splitter
1602 );
1603 if counter(&with_splitter) <= chunk_size {
1604 if let Some(last) = chunks.last_mut() {
1605 *last = with_splitter;
1606 } else {
1607 chunks.push(with_splitter);
1608 }
1609 } else {
1610 chunks.push(splitter.clone());
1611 }
1612 }
1613 }
1614 chunks
1615}
1616
1617fn merge_splits(
1620 splits: &[String],
1621 cum_lens: &[usize],
1622 chunk_size: usize,
1623 splitter: &str,
1624 counter: &mut dyn FnMut(&str) -> usize,
1625 start: usize,
1626 high_init: usize,
1627) -> (usize, String) {
1628 let mut average = 0.2f64;
1629 let mut low = start;
1630 let mut high = high_init;
1631 let offset = cum_lens[start];
1632 let mut target = offset as f64 + (chunk_size as f64 * average);
1633
1634 while low < high {
1635 let i = bisect_left(cum_lens, target, low, high);
1636 let midpoint = i.min(high - 1);
1637 let joined = splits[start..midpoint.max(start)].join(splitter);
1638 let tokens = counter(&joined);
1639 let local_cum = cum_lens[midpoint] - offset;
1640 if local_cum > 0 && tokens > 0 {
1641 average = local_cum as f64 / tokens as f64;
1642 target = offset as f64 + (chunk_size as f64 * average);
1643 }
1644 if tokens > chunk_size {
1645 high = midpoint;
1646 } else {
1647 low = midpoint + 1;
1648 }
1649 }
1650 let end = low - 1;
1651 (end, splits[start..end.max(start)].join(splitter))
1652}
1653
1654fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1655 while low < high {
1656 let mid = (low + high) / 2;
1657 if (sorted[mid] as f64) < target {
1658 low = mid + 1;
1659 } else {
1660 high = mid;
1661 }
1662 }
1663 low
1664}
1665
1666fn split_text(text: &str) -> (String, bool, Vec<String>) {
1668 if text.contains('\n') || text.contains('\r') {
1670 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1671 return (splitter.clone(), true, split_on(text, &splitter));
1672 }
1673 if text.contains('\t') {
1675 let splitter = longest_run(text, |c| c == '\t');
1676 return (splitter.clone(), true, split_on(text, &splitter));
1677 }
1678 if text.chars().any(char::is_whitespace) {
1680 let splitter = longest_run(text, char::is_whitespace);
1681 if splitter.chars().count() == 1 {
1682 for preceder in NON_WS_SPLITTERS {
1684 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1685 return (ws, true, parts);
1686 }
1687 }
1688 }
1689 return (splitter.clone(), true, split_on(text, &splitter));
1690 }
1691 for s in NON_WS_SPLITTERS {
1693 if text.contains(s) {
1694 return (s.to_string(), false, split_on(text, s));
1695 }
1696 }
1697 (
1699 String::new(),
1700 true,
1701 text.chars().map(|c| c.to_string()).collect(),
1702 )
1703}
1704
1705fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1707 let mut best = String::new();
1708 let mut cur = String::new();
1709 for c in text.chars() {
1710 if pred(c) {
1711 cur.push(c);
1712 } else {
1713 if cur.chars().count() > best.chars().count() {
1714 best = cur.clone();
1715 }
1716 cur.clear();
1717 }
1718 }
1719 if cur.chars().count() > best.chars().count() {
1720 best = cur;
1721 }
1722 best
1723}
1724
1725fn split_on(text: &str, splitter: &str) -> Vec<String> {
1726 text.split(splitter).map(str::to_string).collect()
1727}
1728
1729fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1733 let chars: Vec<char> = text.chars().collect();
1734 let p: Vec<char> = preceder.chars().collect();
1735 let mut ws: Option<char> = None;
1736 for i in p.len()..chars.len() {
1737 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1738 ws = Some(chars[i]);
1739 break;
1740 }
1741 }
1742 let ws = ws?;
1743 let mut parts = Vec::new();
1744 let mut cur = String::new();
1745 let mut i = 0usize;
1746 while i < chars.len() {
1747 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1748 parts.push(std::mem::take(&mut cur));
1749 i += 1;
1750 continue;
1751 }
1752 cur.push(chars[i]);
1753 i += 1;
1754 }
1755 parts.push(cur);
1756 Some((ws.to_string(), parts))
1757}
1758
1759#[cfg(feature = "chunking")]
1764mod hf {
1765 use super::ChunkTokenizer;
1766
1767 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1772
1773 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1778 if let Some(p) = explicit {
1779 return Ok(p.to_string());
1780 }
1781 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1782 if std::path::Path::new(&resolved).exists() {
1783 return Ok(resolved);
1784 }
1785 Err(format!(
1786 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1787 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1788 scripts/install/download_dependencies.sh (or pass an explicit path)"
1789 ))
1790 }
1791
1792 pub struct HuggingFaceTokenizer {
1796 tok: tokenizers::Tokenizer,
1797 max_tokens: usize,
1798 }
1799
1800 impl HuggingFaceTokenizer {
1801 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1805 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1806 }
1807
1808 pub fn from_file(
1812 path: impl AsRef<std::path::Path>,
1813 max_tokens: usize,
1814 ) -> Result<Self, String> {
1815 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1816 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1817 let _ = tok.with_truncation(None);
1822 tok.with_padding(None);
1823 Ok(Self { tok, max_tokens })
1824 }
1825 }
1826
1827 impl ChunkTokenizer for HuggingFaceTokenizer {
1828 fn count_tokens(&self, text: &str) -> usize {
1829 self.tok
1830 .encode(text, false)
1831 .map(|e| e.get_tokens().len())
1832 .unwrap_or(0)
1833 }
1834 fn max_tokens(&self) -> usize {
1835 self.max_tokens
1836 }
1837 }
1838}
1839
1840#[cfg(feature = "chunking")]
1841pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1842
1843#[cfg(feature = "chunking")]
1848mod window {
1849 use super::DocChunk;
1850 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1851
1852 #[derive(Debug, Clone, Default)]
1854 pub struct Section {
1855 pub heading_path: Vec<String>,
1858 pub words: Vec<String>,
1860 }
1861
1862 impl Section {
1863 pub fn heading_context(&self) -> String {
1866 if self.heading_path.is_empty() {
1867 String::new()
1868 } else {
1869 format!("# {}", self.heading_path.join(" > "))
1870 }
1871 }
1872 }
1873
1874 fn level_index(level: HeadingLevel) -> usize {
1875 match level {
1876 HeadingLevel::H1 => 1,
1877 HeadingLevel::H2 => 2,
1878 HeadingLevel::H3 => 3,
1879 HeadingLevel::H4 => 4,
1880 HeadingLevel::H5 => 5,
1881 HeadingLevel::H6 => 6,
1882 }
1883 }
1884
1885 pub fn parse_sections(markdown: &str) -> Vec<Section> {
1889 parse_sections_with_stack(markdown, Vec::new()).0
1890 }
1891
1892 pub fn parse_sections_with_stack(
1897 markdown: &str,
1898 initial_stack: Vec<String>,
1899 ) -> (Vec<Section>, Vec<String>) {
1900 let mut heading_stack: Vec<String> = initial_stack;
1901 let mut sections: Vec<Section> = Vec::new();
1902 let mut current = Section {
1905 heading_path: heading_stack
1906 .iter()
1907 .filter(|h| !h.is_empty())
1908 .cloned()
1909 .collect(),
1910 words: Vec::new(),
1911 };
1912
1913 let mut in_heading = false;
1914 let mut heading_level = 0usize;
1915 let mut heading_buf = String::new();
1916
1917 let push_words = |section: &mut Section, text: &str| {
1918 for w in text.split_whitespace() {
1919 section.words.push(w.to_string());
1920 }
1921 };
1922
1923 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
1924 if !section.words.is_empty() {
1925 sections.push(std::mem::take(section));
1926 } else {
1927 *section = Section::default();
1928 }
1929 };
1930
1931 for event in Parser::new(markdown) {
1932 match event {
1933 Event::Start(Tag::Heading { level, .. }) => {
1934 in_heading = true;
1935 heading_level = level_index(level);
1936 heading_buf.clear();
1937 }
1938 Event::End(TagEnd::Heading(_)) => {
1939 in_heading = false;
1940 let idx = heading_level.saturating_sub(1);
1942 if heading_stack.len() <= idx {
1943 heading_stack.resize(idx + 1, String::new());
1944 } else {
1945 heading_stack.truncate(idx + 1);
1946 }
1947 heading_stack[idx] = heading_buf.trim().to_string();
1948 flush(&mut sections, &mut current);
1950 current.heading_path = heading_stack
1951 .iter()
1952 .filter(|h| !h.is_empty())
1953 .cloned()
1954 .collect();
1955 }
1956 Event::Text(t) | Event::Code(t) => {
1957 if in_heading {
1958 if !heading_buf.is_empty() {
1959 heading_buf.push(' ');
1960 }
1961 heading_buf.push_str(&t);
1962 } else {
1963 push_words(&mut current, &t);
1964 }
1965 }
1966 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
1968 _ => {}
1969 }
1970 }
1971 flush(&mut sections, &mut current);
1972 (sections, heading_stack)
1973 }
1974
1975 #[derive(Debug, Clone)]
1981 pub struct WindowChunker {
1982 pub max_words: usize,
1984 pub overlap: f32,
1986 }
1987
1988 impl Default for WindowChunker {
1989 fn default() -> Self {
1990 WindowChunker {
1991 max_words: 300,
1992 overlap: 0.05,
1993 }
1994 }
1995 }
1996
1997 impl WindowChunker {
1998 pub fn new(max_words: usize, overlap: f32) -> Self {
1999 WindowChunker { max_words, overlap }
2000 }
2001
2002 fn word_budget(&self) -> usize {
2004 self.max_words.max(1)
2005 }
2006
2007 fn overlap_words(&self, budget: usize) -> usize {
2010 let o = (budget as f32 * self.overlap).round() as usize;
2011 o.min(budget.saturating_sub(1))
2012 }
2013
2014 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2016 let mut chunks = Vec::new();
2017 self.chunk_with(markdown, &mut |c| {
2018 chunks.push(c);
2019 true
2020 });
2021 chunks
2022 }
2023
2024 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2028 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2029 for section in §ions {
2030 if !self.pack_section(section, sink) {
2031 return;
2032 }
2033 }
2034 }
2035
2036 pub fn pack_section(
2040 &self,
2041 section: &Section,
2042 sink: &mut dyn FnMut(DocChunk) -> bool,
2043 ) -> bool {
2044 let words = §ion.words;
2045 if words.is_empty() {
2046 return true;
2047 }
2048 let budget = self.word_budget();
2049 let step = budget - self.overlap_words(budget); let mut start = 0;
2051 loop {
2052 let end = (start + budget).min(words.len());
2053 let chunk = DocChunk {
2054 text: words[start..end].join(" "),
2055 headings: (!section.heading_path.is_empty())
2056 .then(|| section.heading_path.clone()),
2057 doc_items: Vec::new(),
2058 };
2059 if !sink(chunk) {
2060 return false;
2061 }
2062 if end >= words.len() {
2063 return true;
2064 }
2065 start += step;
2066 }
2067 }
2068
2069 pub fn contextualize(chunk: &DocChunk) -> String {
2075 match &chunk.headings {
2076 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2077 _ => chunk.text.clone(),
2078 }
2079 }
2080 }
2081
2082 #[cfg(test)]
2083 mod tests {
2084 use super::*;
2085
2086 #[test]
2087 fn splits_on_headings_and_tracks_path() {
2088 let md = "\
2089intro words
2090# Chapter 1
2091para one
2092## Section 1.1
2093para two
2094# Chapter 2
2095para three";
2096 let secs = parse_sections(md);
2097 assert_eq!(secs.len(), 4);
2099 assert!(secs[0].heading_path.is_empty());
2100 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2101 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2102 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2104 }
2105
2106 #[test]
2107 fn strips_markup_to_plain_words() {
2108 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2109 let secs = parse_sections(md);
2110 let words = &secs[0].words;
2111 assert!(words.contains(&"bold".to_string()));
2112 assert!(words.contains(&"code".to_string()));
2113 assert!(words.contains(&"link".to_string()));
2114 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2116 }
2117
2118 #[test]
2119 fn windows_overlap_and_never_cross_headings() {
2120 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2121 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2122 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2124 let a: Vec<_> = chunks
2126 .iter()
2127 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2128 .collect();
2129 assert_eq!(a.len(), 3);
2130 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2131 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2132 assert!(a[2].text.ends_with(" w24"));
2133 let b: Vec<_> = chunks
2135 .iter()
2136 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2137 .collect();
2138 assert_eq!(b.len(), 1);
2139 assert_eq!(b[0].text, "short tail");
2140 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2141 }
2142
2143 #[test]
2144 fn sink_false_cancels_the_window_walk() {
2145 let md = format!(
2146 "# A\n\n{}\n",
2147 (0..50)
2148 .map(|i| format!("w{i}"))
2149 .collect::<Vec<_>>()
2150 .join(" ")
2151 );
2152 let chunker = WindowChunker::new(10, 0.0);
2153 let mut n = 0;
2154 chunker.chunk_with(&md, &mut |_| {
2155 n += 1;
2156 false
2157 });
2158 assert_eq!(n, 1);
2159 }
2160 }
2161}
2162
2163#[cfg(feature = "chunking")]
2164pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2165
2166#[cfg(test)]
2167mod tests {
2168 use super::*;
2169
2170 struct WordTok(usize);
2172 impl ChunkTokenizer for WordTok {
2173 fn count_tokens(&self, text: &str) -> usize {
2174 text.split_whitespace().count()
2175 }
2176 fn max_tokens(&self) -> usize {
2177 self.0
2178 }
2179 }
2180
2181 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2182 let mut d = DoclingDocument::new("t");
2183 for n in nodes {
2184 d.push(n);
2185 }
2186 d
2187 }
2188
2189 #[test]
2190 fn hierarchical_headings_and_items() {
2191 let doc = doc_with(vec![
2192 Node::Heading {
2193 level: 1,
2194 text: "Title".into(),
2195 },
2196 Node::Paragraph {
2197 text: "Intro".into(),
2198 },
2199 Node::Heading {
2200 level: 2,
2201 text: "Sec".into(),
2202 },
2203 Node::Paragraph {
2204 text: "Body".into(),
2205 },
2206 ]);
2207 let chunks = HierarchicalChunker.chunk(&doc);
2208 assert_eq!(chunks.len(), 2);
2209 assert_eq!(chunks[0].text, "Intro");
2210 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2211 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2212 assert_eq!(
2213 chunks[1].headings.as_deref(),
2214 Some(&["Title".into(), "Sec".into()][..])
2215 );
2216 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2217 }
2218
2219 #[test]
2220 fn heading_shadowing_prunes_deeper_levels() {
2221 let doc = doc_with(vec![
2222 Node::Heading {
2223 level: 2,
2224 text: "A".into(),
2225 },
2226 Node::Heading {
2227 level: 3,
2228 text: "A.1".into(),
2229 },
2230 Node::Heading {
2231 level: 2,
2232 text: "B".into(),
2233 },
2234 Node::Paragraph { text: "p".into() },
2235 ]);
2236 let chunks = HierarchicalChunker.chunk(&doc);
2237 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2238 }
2239
2240 #[test]
2241 fn triplet_table() {
2242 let t = Table {
2243 rows: vec![
2244 vec!["".into(), "Col1".into()],
2245 vec!["Row1".into(), "v".into()],
2246 ],
2247 ..Default::default()
2248 };
2249 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2250 let single = Table {
2253 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2254 ..Default::default()
2255 };
2256 assert_eq!(triplet_table_text(&single), "a = b");
2257 }
2258
2259 #[test]
2260 fn hybrid_merges_small_peers_and_splits_large() {
2261 let doc = doc_with(vec![
2262 Node::Heading {
2263 level: 2,
2264 text: "S".into(),
2265 },
2266 Node::Paragraph { text: "a b".into() },
2267 Node::Paragraph { text: "c d".into() },
2268 ]);
2269 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2270 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2271 assert_eq!(chunks[0].text, "a b\nc d");
2272
2273 let long = "w ".repeat(40).trim().to_string();
2274 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2275 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2276 assert!(chunks.len() > 1, "oversized paragraph splits");
2277 for c in &chunks {
2278 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2279 }
2280 }
2281
2282 #[test]
2283 fn semchunk_prefers_newlines_then_sentences() {
2284 let tok = WordTok(4);
2285 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2286 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2287 }
2288}