1use std::collections::{BTreeMap, VecDeque};
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 } | Node::ListText { text, .. } => {
446 let t = text.trim();
447 let self_ref = self.alloc.text();
448 if let Some(inner) = t
451 .strip_prefix("$$")
452 .and_then(|s| s.strip_suffix("$$"))
453 .filter(|s| !s.is_empty())
454 {
455 let body = format!("$${inner}$$");
456 self.emit(
457 body.clone(),
458 vec![ChunkItem {
459 self_ref,
460 kind: ChunkItemKind::Text,
461 text: body,
462 }],
463 );
464 return;
465 }
466 self.emit_inline(text, self_ref);
467 }
468 Node::Caption { text, .. } => {
471 let self_ref = self.alloc.text();
472 self.emit_inline(text, self_ref);
473 }
474 Node::LabeledText { .. } => self.one(&node.labeled_as_paragraph()),
475 Node::CheckboxItem { checked, text } => {
476 let self_ref = self.alloc.text();
477 let mark = if *checked { "- [x] " } else { "- [ ] " };
478 let body = format!("{mark}{}", unescape_text(text));
479 self.emit(
480 body.clone(),
481 vec![ChunkItem {
482 self_ref,
483 kind: ChunkItemKind::Text,
484 text: body,
485 }],
486 );
487 }
488 Node::Formula { latex, .. } => {
491 let self_ref = self.alloc.text();
492 let body = format!("$${}$$", latex);
493 self.emit(
494 body.clone(),
495 vec![ChunkItem {
496 self_ref,
497 kind: ChunkItemKind::Text,
498 text: body,
499 }],
500 );
501 }
502 Node::Code { text, .. } => {
503 let self_ref = self.alloc.text();
504 let body = crate::markdown::code_fence("", &unescape_text(text));
505 self.emit(
506 body.clone(),
507 vec![ChunkItem {
508 self_ref,
509 kind: ChunkItemKind::Text,
510 text: body,
511 }],
512 );
513 }
514 Node::Table(t) => {
515 let self_ref = self.alloc.table();
516 let body = triplet_table_text(t);
517 self.emit(
518 body.clone(),
519 vec![ChunkItem {
520 self_ref,
521 kind: ChunkItemKind::Table,
522 text: body,
523 }],
524 );
525 }
526 Node::Picture {
527 caption,
528 description,
529 ..
530 } => {
531 let cap = caption.as_deref().filter(|c| !c.is_empty());
532 let cap_item = cap.map(|c| ChunkItem {
533 self_ref: self.alloc.text(),
534 kind: ChunkItemKind::Text,
535 text: unescape_text(c),
536 });
537 let pic_ref = self.alloc.picture();
538 let desc = description
546 .as_ref()
547 .map(|d| d.text.as_str())
548 .filter(|t| !t.is_empty());
549 match (cap_item, desc) {
550 (Some(cap_item), None) => {
551 let body = cap_item.text.clone();
552 self.emit(body, vec![cap_item]);
553 }
554 (Some(cap_item), Some(desc)) => {
555 let body = format!("{}\n\n{desc}", cap_item.text);
556 let pic_item = ChunkItem {
557 self_ref: pic_ref,
558 kind: ChunkItemKind::Picture,
559 text: desc.to_string(),
560 };
561 self.emit(body, vec![cap_item, pic_item]);
562 }
563 (None, Some(desc)) => {
564 let pic_item = ChunkItem {
565 self_ref: pic_ref,
566 kind: ChunkItemKind::Picture,
567 text: desc.to_string(),
568 };
569 self.emit(desc.to_string(), vec![pic_item]);
570 }
571 (None, None) => {}
572 }
573 }
574 Node::Chart {
575 kind,
576 table,
577 caption,
578 ..
579 } => {
580 let cap = caption.as_deref().filter(|c| !c.is_empty());
581 let cap_item = cap.map(|c| ChunkItem {
582 self_ref: self.alloc.text(),
583 kind: ChunkItemKind::Text,
584 text: unescape_text(c),
585 });
586 let pic_ref = self.alloc.picture();
587 let mut parts: Vec<String> = Vec::new();
591 if let Some(ci) = &cap_item {
592 parts.push(ci.text.clone());
593 }
594 parts.push(humanize_label(kind));
595 let grid = crate::markdown::render_table(table, false);
596 if !grid.is_empty() {
597 parts.push(unescape_text(&grid));
598 }
599 let body = parts.join("\n\n");
600 let pic_item = ChunkItem {
605 self_ref: pic_ref,
606 kind: ChunkItemKind::Picture,
607 text: body.clone(),
608 };
609 let items = match cap_item {
610 Some(mut ci) => {
611 ci.text = String::new();
612 vec![ci, pic_item]
613 }
614 None => vec![pic_item],
615 };
616 self.emit(body, items);
617 }
618 Node::Group { layer: Some(_), .. } => {}
621 Node::Group { children, .. } => {
622 self.alloc.group();
625 self.walk(children);
626 }
627 Node::KeyValueGraph { .. } => {}
629 Node::FieldRegion { items } => {
630 self.alloc.field_region();
633 for item in items {
634 self.alloc.field_item();
635 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
636 let self_ref = self.alloc.text();
637 let body = unescape_text(part);
638 self.emit(
639 body.clone(),
640 vec![ChunkItem {
641 self_ref,
642 kind: ChunkItemKind::Text,
643 text: body,
644 }],
645 );
646 }
647 }
648 }
649 Node::InlineGroup { md_text, runs, .. } => {
650 let self_ref = self.alloc.text();
651 self.emit_inline_with_runs(md_text, self_ref, runs);
652 }
653 Node::TextDump(text) => {
654 let self_ref = self.alloc.text();
655 let body = unescape_text(text);
656 self.emit(
657 body.clone(),
658 vec![ChunkItem {
659 self_ref,
660 kind: ChunkItemKind::Text,
661 text: body,
662 }],
663 );
664 }
665 Node::Located { inner, .. }
667 | Node::Prov { inner, .. }
668 | Node::Track { inner, .. }
669 | Node::Commented { inner, .. } => self.one(inner),
670 Node::PageFurniture { .. } | Node::FurnitureText { .. } => {
674 self.alloc.text();
675 }
676 Node::PictureChildren(children) => {
682 for child in children {
683 match child {
684 Node::PageFurniture { .. } => self.one(child),
687 Node::ListItem { .. } => {
688 self.alloc.group();
689 self.alloc.text();
690 }
691 _ => {
692 self.alloc.text();
693 }
694 }
695 }
696 }
697 Node::CommentSection { .. }
700 | Node::Furniture { .. }
701 | Node::PageBreak
702 | Node::PageInfo { .. }
703 | Node::Anchor { .. }
704 | Node::DoclangOnly(_) => {}
705 Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
708 }
709 }
710}
711
712fn level_of(node: &Node) -> u8 {
713 match node {
714 Node::ListItem { level, .. } => *level,
715 _ => 0,
716 }
717}
718
719fn render_list(items: &[Node]) -> String {
722 let mut lines: Vec<String> = Vec::new();
723 for item in items {
724 let Node::ListItem {
725 ordered,
726 number,
727 text,
728 level,
729 layer,
730 ..
731 } = item
732 else {
733 continue;
734 };
735 if layer.is_some() {
736 continue;
737 }
738 let indent = " ".repeat(*level as usize);
739 let marker = if *ordered {
740 format!("{number}.")
741 } else {
742 "-".to_string()
743 };
744 lines.push(format!(
745 "{indent}{marker} {}",
746 unescape_text(&strip_image_markers(text))
747 ));
748 }
749 lines.join("\n")
750}
751
752fn strip_image_markers(text: &str) -> String {
757 if !text.contains("<!-- image -->") {
758 return text.to_string();
759 }
760 let cleaned: Vec<&str> = text
761 .split('\n')
762 .map(str::trim_end)
763 .filter(|l| *l != "<!-- image -->")
764 .collect();
765 cleaned.join("\n").trim_end().to_string()
766}
767
768fn humanize_label(label: &str) -> String {
771 let text = label.replace('_', " ");
772 let mut chars = text.chars();
773 match chars.next() {
774 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
775 None => text,
776 }
777}
778
779fn triplet_table_text(t: &Table) -> String {
786 let anchors = t.span_anchors();
787 let rows: Vec<Vec<String>> = t
788 .rows
789 .iter()
790 .enumerate()
791 .map(|(ri, r)| {
792 (0..r.len())
793 .map(|ci| cell_chunk_text(t, &anchors, ri, ci))
794 .collect()
795 })
796 .collect();
797 let num_rows = rows.len();
798 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
799 if num_rows == 0 || num_cols == 0 {
800 return String::new();
801 }
802 let cell = |r: usize, c: usize| -> &str {
803 rows.get(r)
804 .and_then(|row| row.get(c))
805 .map(String::as_str)
806 .unwrap_or("")
807 };
808
809 let num_headers = t.column_header_row_count().min(num_rows);
813
814 let columns: Vec<String> = if num_headers > 0 {
817 (0..num_cols)
818 .map(|c| {
819 let mut name = String::new();
820 for r in 0..num_headers {
821 if !name.is_empty() {
822 name.push('.');
823 }
824 name.push_str(cell(r, c));
825 }
826 name
827 })
828 .collect()
829 } else {
830 (0..num_cols).map(|c| c.to_string()).collect()
831 };
832 let data_rows = num_headers..num_rows;
833 let n_data = data_rows.len();
834
835 if n_data == 0 {
837 return columns
838 .iter()
839 .map(|s| s.trim())
840 .filter(|s| !s.is_empty())
841 .collect::<Vec<_>>()
842 .join(". ");
843 }
844
845 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
846 let text = if num_cols == 1 {
847 let col_name = data(0, 0).trim().to_string();
850 if n_data == 1 {
851 col_name
852 } else {
853 (1..n_data)
854 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
855 .collect::<Vec<_>>()
856 .join(". ")
857 }
858 } else {
859 let mut parts = Vec::new();
861 for r in 0..n_data {
862 for (c, col_name) in columns.iter().enumerate().skip(1) {
863 parts.push(format!(
864 "{}, {} = {}",
865 data(r, 0).trim(),
866 col_name.trim(),
867 data(r, c).trim()
868 ));
869 }
870 }
871 parts.join(". ")
872 };
873 if !text.is_empty() {
874 return text;
875 }
876
877 (0..n_data)
880 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
881 .map(|(r, c)| data(r, c).trim())
882 .filter(|s| !s.is_empty())
883 .collect::<Vec<_>>()
884 .join(". ")
885}
886
887fn inline_segments(md: &str) -> Vec<String> {
895 inline_segments_tagged(md)
896 .into_iter()
897 .map(|(t, _)| t)
898 .collect()
899}
900
901fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
905 let chars: Vec<char> = md.chars().collect();
906 let n = chars.len();
907 let find = |from: usize, pat: &str| -> Option<usize> {
908 let hay: String = chars[from..].iter().collect();
909 hay.find(pat).map(|p| from + hay[..p].chars().count())
910 };
911 let mut out: Vec<(String, bool)> = Vec::new();
912 let mut plain = String::new();
913 let mut after_span = false;
914
915 fn flush(
916 out: &mut Vec<(String, bool)>,
917 plain: &mut String,
918 before_span: bool,
919 after_span: bool,
920 ) {
921 let mut p = std::mem::take(plain);
922 if after_span {
923 if let Some(rest) = p.strip_prefix(' ') {
924 p = rest.to_string();
925 }
926 }
927 if before_span {
928 if let Some(rest) = p.strip_suffix(' ') {
929 p = rest.to_string();
930 }
931 }
932 if !p.is_empty() {
933 out.push((unescape_text(&p), true));
934 }
935 }
936
937 let mut i = 0;
938 while i < n {
939 let rest: String = chars[i..].iter().collect();
940 if chars[i] == '[' && !rest.starts_with("[](") {
944 let balanced = |c: usize| {
948 let mut d = 0i32;
949 for &ch in &chars[i + 1..c] {
950 match ch {
951 '[' => d += 1,
952 ']' => d -= 1,
953 _ => {}
954 }
955 }
956 d == 0
957 };
958 if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
959 let mut depth = 0usize;
960 let mut url_end = None;
961 for (k, &c) in chars.iter().enumerate().skip(close + 2) {
962 match c {
963 '(' => depth += 1,
964 ')' => {
965 if depth == 0 {
966 url_end = Some(k);
967 break;
968 }
969 depth -= 1;
970 }
971 _ => {}
972 }
973 }
974 if let Some(endp) = url_end {
975 let url: String = chars[close + 2..endp].iter().collect();
978 if !crate::hyperlink::is_safe_hyperlink(&url) {
979 plain.extend(&chars[i + 1..close]);
980 i = endp + 1;
981 continue;
982 }
983 flush(&mut out, &mut plain, true, after_span);
984 out.push((
985 unescape_text(&chars[i..=endp].iter().collect::<String>()),
986 false,
987 ));
988 i = endp + 1;
989 after_span = true;
990 continue;
991 }
992 }
993 }
994 if chars[i] == '`' {
998 if let Some((s, e, after)) = crate::markdown::code_span_bounds(&chars, i) {
999 if e > s && !chars[s..e].iter().all(|c| c.is_whitespace()) {
1000 flush(&mut out, &mut plain, true, after_span);
1001 let inner: String = chars[s..e].iter().collect();
1002 out.push((
1003 crate::markdown::code_fence("", &unescape_text(&inner)),
1004 false,
1005 ));
1006 i = after;
1007 after_span = true;
1008 continue;
1009 }
1010 }
1011 }
1012 let mut matched = false;
1014 for marker in ["***", "**", "*", "~~"] {
1015 if rest.starts_with(marker) {
1016 let mlen = marker.chars().count();
1017 if let Some(end) = find(i + mlen, marker) {
1018 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
1022 if end > i + mlen && !inner_blank {
1023 flush(&mut out, &mut plain, true, after_span);
1024 out.push((
1025 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
1026 false,
1027 ));
1028 i = end + mlen;
1029 after_span = true;
1030 matched = true;
1031 }
1032 }
1033 break;
1034 }
1035 }
1036 if matched {
1037 continue;
1038 }
1039 if rest.starts_with("$$") {
1042 plain.push_str("$$");
1043 i += 2;
1044 continue;
1045 }
1046 if chars[i] == '$' {
1048 if let Some(end) = find(i + 1, "$") {
1049 if end > i + 1 {
1050 flush(&mut out, &mut plain, true, after_span);
1051 let latex: String = chars[i + 1..end].iter().collect();
1052 out.push((format!("$${latex}$$"), false));
1053 i = end + 1;
1054 after_span = true;
1055 continue;
1056 }
1057 }
1058 }
1059 plain.push(chars[i]);
1060 i += 1;
1061 }
1062 flush(&mut out, &mut plain, false, after_span);
1063 if out.is_empty() {
1064 out.push((unescape_text(md), true));
1065 }
1066 out
1067}
1068
1069fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1074 let target = segment.trim();
1075 if target.is_empty() {
1076 return None;
1077 }
1078 let plainish =
1079 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1080 let fully_plain =
1081 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1082 let unmarked: Vec<(&str, bool)> = runs
1083 .iter()
1084 .filter(|r| plainish(r))
1085 .map(|r| (r.text.as_str(), fully_plain(r)))
1086 .collect();
1087 for start in 0..unmarked.len() {
1088 let mut rest = target;
1089 let mut taken: Vec<(String, bool)> = Vec::new();
1090 for (t, fully) in &unmarked[start..] {
1091 let t = t.trim();
1092 if t.is_empty() {
1093 continue;
1094 }
1095 match rest.strip_prefix(t) {
1096 Some(r) => {
1097 taken.push((unescape_text(t), *fully));
1098 rest = r.trim_start();
1099 if rest.is_empty() {
1100 break;
1101 }
1102 }
1103 None => break,
1104 }
1105 }
1106 if rest.is_empty() && taken.len() >= 2 {
1107 let mut merged: Vec<(String, bool)> = Vec::new();
1112 for (t, fully) in taken {
1113 match merged.last_mut() {
1114 Some((last, true)) if fully => {
1115 last.push(' ');
1116 last.push_str(&t);
1117 }
1118 _ => merged.push((t, fully)),
1119 }
1120 }
1121 if merged.len() >= 2 {
1122 return Some(merged.into_iter().map(|(t, _)| t).collect());
1123 }
1124 return None;
1125 }
1126 }
1127 None
1128}
1129
1130fn cell_chunk_text(t: &Table, anchors: &[Vec<(usize, usize)>], r: usize, c: usize) -> String {
1137 let (ar, ac) = anchors
1140 .get(r)
1141 .and_then(|row| row.get(c))
1142 .copied()
1143 .unwrap_or((r, c));
1144 if let Some(blocks) = t
1145 .cell_blocks
1146 .as_ref()
1147 .and_then(|b| b.get(ar))
1148 .and_then(|row| row.get(ac))
1149 .filter(|b| !b.is_empty())
1150 {
1151 let mut parts: Vec<String> = Vec::new();
1152 for node in blocks.iter() {
1153 let part = block_chunk_text(node);
1154 if !part.is_empty() {
1155 parts.push(part);
1156 }
1157 }
1158 return parts.join("\n\n");
1159 }
1160 let flat = t
1161 .rows
1162 .get(r)
1163 .and_then(|row| row.get(c))
1164 .map(String::as_str)
1165 .unwrap_or("");
1166 unescape_text(flat)
1167 .replace("<!-- image -->", "")
1168 .trim()
1169 .to_string()
1170}
1171
1172fn block_chunk_text(node: &Node) -> String {
1174 match node {
1175 Node::Paragraph { text } | Node::ListText { text, .. } => unescape_text(text),
1176 Node::LabeledText { .. } => block_chunk_text(&node.labeled_as_paragraph()),
1177 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1178 Node::Code { text, .. } => crate::markdown::code_fence("", &unescape_text(text)),
1179 Node::Table(inner) => triplet_table_text(inner),
1180 Node::Picture { caption, .. } => caption
1181 .as_deref()
1182 .filter(|c| !c.is_empty())
1183 .map(unescape_text)
1184 .unwrap_or_default(),
1185 Node::ListItem {
1186 ordered,
1187 number,
1188 text,
1189 ..
1190 } => {
1191 let marker = if *ordered {
1192 format!("{number}.")
1193 } else {
1194 "-".to_string()
1195 };
1196 format!("{marker} {}", unescape_text(text))
1197 }
1198 Node::CheckboxItem { checked, text } => {
1199 let mark = if *checked { "- [x] " } else { "- [ ] " };
1200 format!("{mark}{}", unescape_text(text))
1201 }
1202 Node::Heading { text, .. } => unescape_text(text),
1203 Node::Located { inner, .. }
1204 | Node::Prov { inner, .. }
1205 | Node::Track { inner, .. }
1206 | Node::Commented { inner, .. } => block_chunk_text(inner),
1207 Node::Group { layer: Some(_), .. } => String::new(),
1208 Node::Group { children, .. } => children
1209 .iter()
1210 .map(block_chunk_text)
1211 .filter(|s| !s.is_empty())
1212 .collect::<Vec<_>>()
1213 .join("\n"),
1214 _ => String::new(),
1215 }
1216}
1217
1218fn unescape_text(s: &str) -> String {
1221 s.replace("<", "<")
1222 .replace(">", ">")
1223 .replace("&", "&")
1224 .replace("\\_", "_")
1225}
1226
1227pub trait ChunkTokenizer {
1233 fn count_tokens(&self, text: &str) -> usize;
1235 fn max_tokens(&self) -> usize;
1237}
1238
1239pub struct HybridChunker<T: ChunkTokenizer> {
1243 tokenizer: T,
1244 merge_peers: bool,
1245}
1246
1247impl<T: ChunkTokenizer> HybridChunker<T> {
1248 pub fn new(tokenizer: T) -> Self {
1249 Self {
1250 tokenizer,
1251 merge_peers: true,
1252 }
1253 }
1254
1255 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1257 self.merge_peers = merge_peers;
1258 self
1259 }
1260
1261 pub fn max_tokens(&self) -> usize {
1262 self.tokenizer.max_tokens()
1263 }
1264
1265 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1267 let mut chunks = Vec::new();
1268 self.chunk_with(doc, &mut |c| {
1269 chunks.push(c);
1270 true
1271 });
1272 chunks
1273 }
1274
1275 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1282 let mut merger = PeerMerger::default();
1283 let mut alive = true;
1284 HierarchicalChunker.chunk_with(doc, &mut |c| {
1285 for split in self.split_by_doc_items(c) {
1286 for chunk in self.split_using_plain_text(split) {
1287 if !alive {
1288 return false;
1289 }
1290 alive = if self.merge_peers {
1291 self.merge_push(&mut merger, chunk, sink)
1292 } else {
1293 sink(chunk)
1294 };
1295 }
1296 }
1297 alive
1298 });
1299 if alive {
1300 self.merge_flush(&mut merger, sink);
1301 }
1302 }
1303
1304 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1305 self.tokenizer.count_tokens(&contextualize(chunk))
1306 }
1307
1308 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1311 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1312 let text = if chunk.doc_items.len() == 1 {
1313 chunk.text.clone()
1314 } else {
1315 doc_items
1316 .iter()
1317 .filter(|it| !it.text.is_empty())
1318 .map(|it| it.text.as_str())
1319 .collect::<Vec<_>>()
1320 .join("\n")
1321 };
1322 DocChunk {
1323 text,
1324 headings: chunk.headings.clone(),
1325 doc_items,
1326 }
1327 }
1328
1329 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1330 if chunk.doc_items.is_empty() {
1331 return vec![chunk];
1332 }
1333 let max = self.max_tokens();
1334 let num_items = chunk.doc_items.len();
1335 let mut chunks = Vec::new();
1336 let mut window_start = 0usize;
1337 let mut window_end = 0usize; while window_end < num_items {
1339 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1340 if self.count_chunk_tokens(&new_chunk) <= max {
1341 if window_end < num_items - 1 {
1342 window_end += 1;
1343 continue;
1344 } else {
1345 window_end = num_items; }
1347 } else if window_start == window_end {
1348 window_end += 1;
1351 window_start = window_end;
1352 } else {
1353 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1356 window_start = window_end;
1357 }
1358 chunks.push(new_chunk);
1359 }
1360 chunks
1361 }
1362
1363 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1364 let total = self.count_chunk_tokens(&chunk);
1365 let max = self.max_tokens();
1366 if total <= max {
1367 return vec![chunk];
1368 }
1369 let text_len = self.tokenizer.count_tokens(&chunk.text);
1370 let other_len = total - text_len;
1371 if other_len >= max {
1372 let stripped = DocChunk {
1374 headings: None,
1375 ..chunk
1376 };
1377 return self.split_using_plain_text(stripped);
1378 }
1379 let available = max - other_len;
1380
1381 let segments =
1382 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1383 let lines: Vec<String> = chunk
1390 .text
1391 .split('\n')
1392 .filter(|l| !l.trim().is_empty())
1393 .map(|l| l.to_string())
1394 .collect();
1395 line_chunk_text(&lines, &self.tokenizer, max)
1396 } else {
1397 semchunk(&chunk.text, available, &self.tokenizer)
1398 };
1399 segments
1400 .into_iter()
1401 .map(|s| DocChunk {
1402 text: s,
1403 headings: chunk.headings.clone(),
1404 doc_items: chunk.doc_items.clone(),
1405 })
1406 .collect()
1407 }
1408
1409 fn merge_push(
1424 &self,
1425 m: &mut PeerMerger,
1426 chunk: DocChunk,
1427 sink: &mut dyn FnMut(DocChunk) -> bool,
1428 ) -> bool {
1429 self.merge_feed(m, VecDeque::from([(chunk, None)]), sink)
1430 }
1431
1432 fn merge_feed(
1435 &self,
1436 m: &mut PeerMerger,
1437 mut pending: VecDeque<(DocChunk, Option<usize>)>,
1438 sink: &mut dyn FnMut(DocChunk) -> bool,
1439 ) -> bool {
1440 while let Some((chunk, tokens)) = pending.pop_front() {
1441 let tokens = tokens.unwrap_or_else(|| self.tokenizer.count_tokens(&chunk.text));
1442 if let Err(chunk) = self.merge_extend(m, chunk, tokens) {
1443 let (alive, rest) = self.merge_close(m, sink);
1444 if !alive {
1445 return false;
1446 }
1447 pending.push_front((chunk, Some(tokens)));
1448 for item in rest.into_iter().rev() {
1449 pending.push_front(item);
1450 }
1451 }
1452 }
1453 true
1454 }
1455
1456 fn merge_extend(
1459 &self,
1460 m: &mut PeerMerger,
1461 chunk: DocChunk,
1462 tokens: usize,
1463 ) -> Result<(), DocChunk> {
1464 let max = self.max_tokens() as i64;
1465 if m.window.is_empty() {
1466 m.overhead = self.count_chunk_tokens(&chunk) as i64 - tokens as i64;
1469 m.running = tokens as i64;
1470 m.window.push((chunk, tokens));
1471 return Ok(());
1472 }
1473 if chunk.headings != m.window[0].0.headings {
1474 return Err(chunk);
1475 }
1476 let delim = *m.delim.get_or_insert_with(|| {
1477 let t = &self.tokenizer;
1479 t.count_tokens("a\nb") as i64 - t.count_tokens("a") as i64 - t.count_tokens("b") as i64
1480 });
1481 m.running += tokens as i64 + delim;
1482 if m.running + m.overhead > max {
1483 let exact = self.count_chunk_tokens(&merged_chunk(
1484 m.window.iter().map(|(c, _)| c).chain([&chunk]),
1485 )) as i64;
1486 if exact > max {
1487 return Err(chunk);
1488 }
1489 m.running = exact - m.overhead;
1491 }
1492 m.window.push((chunk, tokens));
1493 Ok(())
1494 }
1495
1496 fn merge_close(
1500 &self,
1501 m: &mut PeerMerger,
1502 sink: &mut dyn FnMut(DocChunk) -> bool,
1503 ) -> (bool, Vec<(DocChunk, Option<usize>)>) {
1504 if m.window.is_empty() {
1505 return (true, Vec::new());
1506 }
1507 let max = self.max_tokens();
1508 let mut fit = m.window.len() - 1;
1509 while fit > 0
1510 && self.count_chunk_tokens(&merged_chunk(m.window[..=fit].iter().map(|(c, _)| c))) > max
1511 {
1512 fit -= 1;
1513 }
1514 let rest = m
1515 .window
1516 .split_off(fit + 1)
1517 .into_iter()
1518 .map(|(c, t)| (c, Some(t)))
1519 .collect();
1520 let out = if m.window.len() == 1 {
1521 m.window.pop().expect("single-chunk window").0
1522 } else {
1523 let merged = merged_chunk(m.window.iter().map(|(c, _)| c));
1524 m.window.clear();
1525 merged
1526 };
1527 (sink(out), rest)
1528 }
1529
1530 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1533 while !m.window.is_empty() {
1534 let (alive, rest) = self.merge_close(m, sink);
1535 if !alive || !self.merge_feed(m, rest.into(), sink) {
1536 return false;
1537 }
1538 }
1539 true
1540 }
1541}
1542
1543fn merged_chunk<'a>(chunks: impl Iterator<Item = &'a DocChunk> + Clone) -> DocChunk {
1546 let mut first = chunks.clone();
1547 DocChunk {
1548 text: chunks
1549 .clone()
1550 .map(|c| c.text.as_str())
1551 .collect::<Vec<_>>()
1552 .join("\n"),
1553 headings: first.next().and_then(|c| c.headings.clone()),
1554 doc_items: chunks.flat_map(|c| c.doc_items.iter().cloned()).collect(),
1555 }
1556}
1557
1558#[derive(Default)]
1563struct PeerMerger {
1564 window: Vec<(DocChunk, usize)>,
1565 running: i64,
1566 overhead: i64,
1567 delim: Option<i64>,
1568}
1569
1570fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1580 let mut chunks: Vec<String> = Vec::new();
1581 let mut current = String::new();
1582 let mut current_len = 0usize;
1583
1584 for line in lines {
1585 let mut remaining: Vec<char> = line.chars().collect();
1586 loop {
1587 let rem_str: String = remaining.iter().collect();
1588 let line_tokens = tok.count_tokens(&rem_str);
1589 let available = max_tokens.saturating_sub(current_len);
1590
1591 if line_tokens <= available {
1592 current.push_str(&rem_str);
1593 current_len += line_tokens;
1594 break;
1595 }
1596 if line_tokens <= max_tokens {
1597 chunks.push(std::mem::take(&mut current));
1598 current_len = 0;
1599 continue;
1600 }
1601 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1603 let mut rest = rest;
1604 if take.is_empty() {
1605 if rest.is_empty() {
1606 break;
1607 }
1608 take = rest[..1].iter().collect();
1609 rest = rest[1..].to_vec();
1610 }
1611 current.push('\n');
1612 current.push_str(&take);
1613 chunks.push(std::mem::take(&mut current));
1614 current_len = 0;
1615 remaining = rest;
1616 }
1617 }
1618 if !current.is_empty() {
1619 chunks.push(current);
1620 }
1621 chunks
1622}
1623
1624fn split_by_token_limit<T: ChunkTokenizer>(
1628 text: &[char],
1629 token_limit: usize,
1630 tok: &T,
1631) -> (String, Vec<char>) {
1632 if token_limit == 0 || text.is_empty() {
1633 return (String::new(), text.to_vec());
1634 }
1635 let full: String = text.iter().collect();
1636 if tok.count_tokens(&full) <= token_limit {
1637 return (full, Vec::new());
1638 }
1639 let (mut lo, mut hi) = (0usize, text.len());
1640 let mut best: Option<usize> = None;
1641 while lo <= hi {
1642 let mid = (lo + hi) / 2;
1643 let head: String = text[..mid].iter().collect();
1644 if tok.count_tokens(&head) <= token_limit {
1645 best = Some(mid);
1646 lo = mid + 1;
1647 } else {
1648 if mid == 0 {
1649 break;
1650 }
1651 hi = mid - 1;
1652 }
1653 }
1654 let mut best_idx = match best {
1655 Some(b) if b > 0 => b,
1656 _ => return (String::new(), text.to_vec()),
1657 };
1658 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1660 if pos > 0 {
1661 best_idx = pos;
1662 }
1663 }
1664 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1665}
1666
1667const NON_WS_SPLITTERS: &[&str] = &[
1673 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1674 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1675];
1676
1677pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1681 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1682 let mut counter = |s: &str| -> usize {
1683 if let Some(n) = cache.get(s) {
1684 return *n;
1685 }
1686 let n = tok.count_tokens(s);
1687 cache.insert(s.to_string(), n);
1688 n
1689 };
1690 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1691 chunks
1693 .into_iter()
1694 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1695 .collect()
1696}
1697
1698fn semchunk_rec(
1701 text: &str,
1702 chunk_size: usize,
1703 counter: &mut dyn FnMut(&str) -> usize,
1704) -> Vec<String> {
1705 let (splitter, splitter_is_ws, splits) = split_text(text);
1706
1707 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1708 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1709 cum_lens.push(0usize);
1710 for l in &split_lens {
1711 cum_lens.push(cum_lens.last().unwrap() + l);
1712 }
1713 let num_splits_plus_one = splits.len() + 1;
1714
1715 let mut chunks: Vec<String> = Vec::new();
1716 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1717
1718 for i in 0..splits.len() {
1719 if skips.contains(&i) {
1720 continue;
1721 }
1722 let split = &splits[i];
1723 if counter(split) > chunk_size {
1724 let inner = semchunk_rec(split, chunk_size, counter);
1725 chunks.extend(inner);
1726 } else {
1727 let (end, merged) = merge_splits(
1728 &splits,
1729 &cum_lens,
1730 chunk_size,
1731 &splitter,
1732 counter,
1733 i,
1734 num_splits_plus_one,
1735 );
1736 for j in (i + 1)..end {
1737 skips.insert(j);
1738 }
1739 chunks.push(merged);
1740 }
1741 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1744 if !splitter_is_ws && !is_last {
1745 let with_splitter = format!(
1746 "{}{}",
1747 chunks.last().map(String::as_str).unwrap_or(""),
1748 splitter
1749 );
1750 if counter(&with_splitter) <= chunk_size {
1751 if let Some(last) = chunks.last_mut() {
1752 *last = with_splitter;
1753 } else {
1754 chunks.push(with_splitter);
1755 }
1756 } else {
1757 chunks.push(splitter.clone());
1758 }
1759 }
1760 }
1761 chunks
1762}
1763
1764fn merge_splits(
1767 splits: &[String],
1768 cum_lens: &[usize],
1769 chunk_size: usize,
1770 splitter: &str,
1771 counter: &mut dyn FnMut(&str) -> usize,
1772 start: usize,
1773 high_init: usize,
1774) -> (usize, String) {
1775 let mut average = 0.2f64;
1776 let mut low = start;
1777 let mut high = high_init;
1778 let offset = cum_lens[start];
1779 let mut target = offset as f64 + (chunk_size as f64 * average);
1780
1781 while low < high {
1782 let i = bisect_left(cum_lens, target, low, high);
1783 let midpoint = i.min(high - 1);
1784 let joined = splits[start..midpoint.max(start)].join(splitter);
1785 let tokens = counter(&joined);
1786 let local_cum = cum_lens[midpoint] - offset;
1787 if local_cum > 0 && tokens > 0 {
1788 average = local_cum as f64 / tokens as f64;
1789 target = offset as f64 + (chunk_size as f64 * average);
1790 }
1791 if tokens > chunk_size {
1792 high = midpoint;
1793 } else {
1794 low = midpoint + 1;
1795 }
1796 }
1797 let end = low - 1;
1798 (end, splits[start..end.max(start)].join(splitter))
1799}
1800
1801fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1802 while low < high {
1803 let mid = (low + high) / 2;
1804 if (sorted[mid] as f64) < target {
1805 low = mid + 1;
1806 } else {
1807 high = mid;
1808 }
1809 }
1810 low
1811}
1812
1813fn split_text(text: &str) -> (String, bool, Vec<String>) {
1815 if text.contains('\n') || text.contains('\r') {
1817 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1818 return (splitter.clone(), true, split_on(text, &splitter));
1819 }
1820 if text.contains('\t') {
1822 let splitter = longest_run(text, |c| c == '\t');
1823 return (splitter.clone(), true, split_on(text, &splitter));
1824 }
1825 if text.chars().any(char::is_whitespace) {
1827 let splitter = longest_run(text, char::is_whitespace);
1828 if splitter.chars().count() == 1 {
1829 for preceder in NON_WS_SPLITTERS {
1831 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1832 return (ws, true, parts);
1833 }
1834 }
1835 }
1836 return (splitter.clone(), true, split_on(text, &splitter));
1837 }
1838 for s in NON_WS_SPLITTERS {
1840 if text.contains(s) {
1841 return (s.to_string(), false, split_on(text, s));
1842 }
1843 }
1844 (
1846 String::new(),
1847 true,
1848 text.chars().map(|c| c.to_string()).collect(),
1849 )
1850}
1851
1852fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1854 let mut best = String::new();
1855 let mut cur = String::new();
1856 for c in text.chars() {
1857 if pred(c) {
1858 cur.push(c);
1859 } else {
1860 if cur.chars().count() > best.chars().count() {
1861 best = cur.clone();
1862 }
1863 cur.clear();
1864 }
1865 }
1866 if cur.chars().count() > best.chars().count() {
1867 best = cur;
1868 }
1869 best
1870}
1871
1872fn split_on(text: &str, splitter: &str) -> Vec<String> {
1873 text.split(splitter).map(str::to_string).collect()
1874}
1875
1876fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1880 let chars: Vec<char> = text.chars().collect();
1881 let p: Vec<char> = preceder.chars().collect();
1882 let mut ws: Option<char> = None;
1883 for i in p.len()..chars.len() {
1884 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1885 ws = Some(chars[i]);
1886 break;
1887 }
1888 }
1889 let ws = ws?;
1890 let mut parts = Vec::new();
1891 let mut cur = String::new();
1892 let mut i = 0usize;
1893 while i < chars.len() {
1894 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1895 parts.push(std::mem::take(&mut cur));
1896 i += 1;
1897 continue;
1898 }
1899 cur.push(chars[i]);
1900 i += 1;
1901 }
1902 parts.push(cur);
1903 Some((ws.to_string(), parts))
1904}
1905
1906#[cfg(feature = "chunking")]
1911mod hf {
1912 use super::ChunkTokenizer;
1913
1914 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1919
1920 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1925 if let Some(p) = explicit {
1926 return Ok(p.to_string());
1927 }
1928 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1929 if std::path::Path::new(&resolved).exists() {
1930 return Ok(resolved);
1931 }
1932 Err(format!(
1933 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1934 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1935 scripts/install/download_dependencies.sh (or pass an explicit path)"
1936 ))
1937 }
1938
1939 pub struct HuggingFaceTokenizer {
1943 tok: tokenizers::Tokenizer,
1944 max_tokens: usize,
1945 }
1946
1947 impl HuggingFaceTokenizer {
1948 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1952 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1953 }
1954
1955 pub fn from_file(
1959 path: impl AsRef<std::path::Path>,
1960 max_tokens: usize,
1961 ) -> Result<Self, String> {
1962 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1963 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1964 let _ = tok.with_truncation(None);
1969 tok.with_padding(None);
1970 Ok(Self { tok, max_tokens })
1971 }
1972 }
1973
1974 impl ChunkTokenizer for HuggingFaceTokenizer {
1975 fn count_tokens(&self, text: &str) -> usize {
1976 self.tok
1977 .encode(text, false)
1978 .map(|e| e.get_tokens().len())
1979 .unwrap_or(0)
1980 }
1981 fn max_tokens(&self) -> usize {
1982 self.max_tokens
1983 }
1984 }
1985}
1986
1987#[cfg(feature = "chunking")]
1988pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1989
1990#[cfg(feature = "chunking")]
1995mod window {
1996 use super::DocChunk;
1997 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1998
1999 #[derive(Debug, Clone, Default)]
2001 pub struct Section {
2002 pub heading_path: Vec<String>,
2005 pub words: Vec<String>,
2007 }
2008
2009 impl Section {
2010 pub fn heading_context(&self) -> String {
2013 if self.heading_path.is_empty() {
2014 String::new()
2015 } else {
2016 format!("# {}", self.heading_path.join(" > "))
2017 }
2018 }
2019 }
2020
2021 fn level_index(level: HeadingLevel) -> usize {
2022 match level {
2023 HeadingLevel::H1 => 1,
2024 HeadingLevel::H2 => 2,
2025 HeadingLevel::H3 => 3,
2026 HeadingLevel::H4 => 4,
2027 HeadingLevel::H5 => 5,
2028 HeadingLevel::H6 => 6,
2029 }
2030 }
2031
2032 pub fn parse_sections(markdown: &str) -> Vec<Section> {
2036 parse_sections_with_stack(markdown, Vec::new()).0
2037 }
2038
2039 pub fn parse_sections_with_stack(
2044 markdown: &str,
2045 initial_stack: Vec<String>,
2046 ) -> (Vec<Section>, Vec<String>) {
2047 let mut heading_stack: Vec<String> = initial_stack;
2048 let mut sections: Vec<Section> = Vec::new();
2049 let mut current = Section {
2052 heading_path: heading_stack
2053 .iter()
2054 .filter(|h| !h.is_empty())
2055 .cloned()
2056 .collect(),
2057 words: Vec::new(),
2058 };
2059
2060 let mut in_heading = false;
2061 let mut heading_level = 0usize;
2062 let mut heading_buf = String::new();
2063
2064 let push_words = |section: &mut Section, text: &str| {
2065 for w in text.split_whitespace() {
2066 section.words.push(w.to_string());
2067 }
2068 };
2069
2070 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
2071 if !section.words.is_empty() {
2072 sections.push(std::mem::take(section));
2073 } else {
2074 *section = Section::default();
2075 }
2076 };
2077
2078 for event in Parser::new(markdown) {
2079 match event {
2080 Event::Start(Tag::Heading { level, .. }) => {
2081 in_heading = true;
2082 heading_level = level_index(level);
2083 heading_buf.clear();
2084 }
2085 Event::End(TagEnd::Heading(_)) => {
2086 in_heading = false;
2087 let idx = heading_level.saturating_sub(1);
2089 if heading_stack.len() <= idx {
2090 heading_stack.resize(idx + 1, String::new());
2091 } else {
2092 heading_stack.truncate(idx + 1);
2093 }
2094 heading_stack[idx] = heading_buf.trim().to_string();
2095 flush(&mut sections, &mut current);
2097 current.heading_path = heading_stack
2098 .iter()
2099 .filter(|h| !h.is_empty())
2100 .cloned()
2101 .collect();
2102 }
2103 Event::Text(t) | Event::Code(t) => {
2104 if in_heading {
2105 if !heading_buf.is_empty() {
2106 heading_buf.push(' ');
2107 }
2108 heading_buf.push_str(&t);
2109 } else {
2110 push_words(&mut current, &t);
2111 }
2112 }
2113 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
2115 _ => {}
2116 }
2117 }
2118 flush(&mut sections, &mut current);
2119 (sections, heading_stack)
2120 }
2121
2122 #[derive(Debug, Clone)]
2128 pub struct WindowChunker {
2129 pub max_words: usize,
2131 pub overlap: f32,
2133 }
2134
2135 impl Default for WindowChunker {
2136 fn default() -> Self {
2137 WindowChunker {
2138 max_words: 300,
2139 overlap: 0.05,
2140 }
2141 }
2142 }
2143
2144 impl WindowChunker {
2145 pub fn new(max_words: usize, overlap: f32) -> Self {
2146 WindowChunker { max_words, overlap }
2147 }
2148
2149 fn word_budget(&self) -> usize {
2151 self.max_words.max(1)
2152 }
2153
2154 fn overlap_words(&self, budget: usize) -> usize {
2157 let o = (budget as f32 * self.overlap).round() as usize;
2158 o.min(budget.saturating_sub(1))
2159 }
2160
2161 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2163 let mut chunks = Vec::new();
2164 self.chunk_with(markdown, &mut |c| {
2165 chunks.push(c);
2166 true
2167 });
2168 chunks
2169 }
2170
2171 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2175 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2176 for section in §ions {
2177 if !self.pack_section(section, sink) {
2178 return;
2179 }
2180 }
2181 }
2182
2183 pub fn pack_section(
2187 &self,
2188 section: &Section,
2189 sink: &mut dyn FnMut(DocChunk) -> bool,
2190 ) -> bool {
2191 let words = §ion.words;
2192 if words.is_empty() {
2193 return true;
2194 }
2195 let budget = self.word_budget();
2196 let step = budget - self.overlap_words(budget); let mut start = 0;
2198 loop {
2199 let end = (start + budget).min(words.len());
2200 let chunk = DocChunk {
2201 text: words[start..end].join(" "),
2202 headings: (!section.heading_path.is_empty())
2203 .then(|| section.heading_path.clone()),
2204 doc_items: Vec::new(),
2205 };
2206 if !sink(chunk) {
2207 return false;
2208 }
2209 if end >= words.len() {
2210 return true;
2211 }
2212 start += step;
2213 }
2214 }
2215
2216 pub fn contextualize(chunk: &DocChunk) -> String {
2222 match &chunk.headings {
2223 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2224 _ => chunk.text.clone(),
2225 }
2226 }
2227 }
2228
2229 #[cfg(test)]
2230 mod tests {
2231 use super::*;
2232
2233 #[test]
2234 fn splits_on_headings_and_tracks_path() {
2235 let md = "\
2236intro words
2237# Chapter 1
2238para one
2239## Section 1.1
2240para two
2241# Chapter 2
2242para three";
2243 let secs = parse_sections(md);
2244 assert_eq!(secs.len(), 4);
2246 assert!(secs[0].heading_path.is_empty());
2247 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2248 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2249 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2251 }
2252
2253 #[test]
2254 fn strips_markup_to_plain_words() {
2255 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2256 let secs = parse_sections(md);
2257 let words = &secs[0].words;
2258 assert!(words.contains(&"bold".to_string()));
2259 assert!(words.contains(&"code".to_string()));
2260 assert!(words.contains(&"link".to_string()));
2261 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2263 }
2264
2265 #[test]
2266 fn windows_overlap_and_never_cross_headings() {
2267 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2268 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2269 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2271 let a: Vec<_> = chunks
2273 .iter()
2274 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2275 .collect();
2276 assert_eq!(a.len(), 3);
2277 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2278 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2279 assert!(a[2].text.ends_with(" w24"));
2280 let b: Vec<_> = chunks
2282 .iter()
2283 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2284 .collect();
2285 assert_eq!(b.len(), 1);
2286 assert_eq!(b[0].text, "short tail");
2287 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2288 }
2289
2290 #[test]
2291 fn sink_false_cancels_the_window_walk() {
2292 let md = format!(
2293 "# A\n\n{}\n",
2294 (0..50)
2295 .map(|i| format!("w{i}"))
2296 .collect::<Vec<_>>()
2297 .join(" ")
2298 );
2299 let chunker = WindowChunker::new(10, 0.0);
2300 let mut n = 0;
2301 chunker.chunk_with(&md, &mut |_| {
2302 n += 1;
2303 false
2304 });
2305 assert_eq!(n, 1);
2306 }
2307 }
2308}
2309
2310#[cfg(feature = "chunking")]
2311pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2312
2313#[cfg(test)]
2314mod tests {
2315 use super::*;
2316
2317 struct WordTok(usize);
2319 impl ChunkTokenizer for WordTok {
2320 fn count_tokens(&self, text: &str) -> usize {
2321 text.split_whitespace().count()
2322 }
2323 fn max_tokens(&self) -> usize {
2324 self.0
2325 }
2326 }
2327
2328 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2329 let mut d = DoclingDocument::new("t");
2330 for n in nodes {
2331 d.push(n);
2332 }
2333 d
2334 }
2335
2336 #[test]
2337 fn hierarchical_headings_and_items() {
2338 let doc = doc_with(vec![
2339 Node::Heading {
2340 level: 1,
2341 text: "Title".into(),
2342 },
2343 Node::Paragraph {
2344 text: "Intro".into(),
2345 },
2346 Node::Heading {
2347 level: 2,
2348 text: "Sec".into(),
2349 },
2350 Node::Paragraph {
2351 text: "Body".into(),
2352 },
2353 ]);
2354 let chunks = HierarchicalChunker.chunk(&doc);
2355 assert_eq!(chunks.len(), 2);
2356 assert_eq!(chunks[0].text, "Intro");
2357 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2358 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2359 assert_eq!(
2360 chunks[1].headings.as_deref(),
2361 Some(&["Title".into(), "Sec".into()][..])
2362 );
2363 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2364 }
2365
2366 #[test]
2369 fn page_furniture_keeps_refs_aligned_with_json() {
2370 let doc = doc_with(vec![
2371 Node::Paragraph {
2372 text: "Before".into(),
2373 },
2374 Node::PageFurniture {
2375 footer: true,
2376 location: [0, 490, 100, 512],
2377 text: "1.10.2".into(),
2378 },
2379 Node::Paragraph {
2380 text: "After".into(),
2381 },
2382 ]);
2383 let chunks = HierarchicalChunker.chunk(&doc);
2384 assert_eq!(chunks.len(), 2);
2385 assert_eq!(chunks[1].text, "After");
2386 let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2387 assert_eq!(json["texts"][2]["text"], "After");
2388 assert_eq!(chunks[1].doc_items[0].self_ref, "#/texts/2");
2389 }
2390
2391 #[test]
2395 fn picture_children_are_not_chunked_but_keep_refs_aligned() {
2396 let doc = doc_with(vec![
2397 Node::Picture {
2398 caption: None,
2399 caption_href: None,
2400 image: None,
2401 classification: None,
2402 description: None,
2403 caption_parent: crate::CaptionParent::Item,
2404 caption_location: None,
2405 },
2406 Node::PictureChildren(vec![
2407 Node::Paragraph {
2408 text: "axis label".into(),
2409 },
2410 Node::ListItem {
2411 ordered: false,
2412 number: 0,
2413 first_in_list: true,
2414 text: "callout".into(),
2415 level: 0,
2416 marker: None,
2417 location: None,
2418 dclx: None,
2419 href: None,
2420 layer: None,
2421 },
2422 ]),
2423 Node::Paragraph {
2424 text: "Body".into(),
2425 },
2426 ]);
2427 let chunks = HierarchicalChunker.chunk(&doc);
2428 assert_eq!(chunks.len(), 1);
2429 assert_eq!(chunks[0].text, "Body");
2430 let json = doc.export_to_json_value();
2431 assert_eq!(json["texts"][2]["text"], "Body");
2432 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/2");
2433 }
2434
2435 #[test]
2436 fn heading_shadowing_prunes_deeper_levels() {
2437 let doc = doc_with(vec![
2438 Node::Heading {
2439 level: 2,
2440 text: "A".into(),
2441 },
2442 Node::Heading {
2443 level: 3,
2444 text: "A.1".into(),
2445 },
2446 Node::Heading {
2447 level: 2,
2448 text: "B".into(),
2449 },
2450 Node::Paragraph { text: "p".into() },
2451 ]);
2452 let chunks = HierarchicalChunker.chunk(&doc);
2453 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2454 }
2455
2456 #[test]
2457 fn triplet_table() {
2458 let t = Table {
2459 rows: vec![
2460 vec!["".into(), "Col1".into()],
2461 vec!["Row1".into(), "v".into()],
2462 ],
2463 ..Default::default()
2464 };
2465 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2466 let single = Table {
2469 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2470 ..Default::default()
2471 };
2472 assert_eq!(triplet_table_text(&single), "a = b");
2473 }
2474
2475 fn quadratic_merge<T: ChunkTokenizer>(
2479 h: &HybridChunker<T>,
2480 chunks: Vec<DocChunk>,
2481 ) -> Vec<DocChunk> {
2482 let mut out = Vec::new();
2483 let mut window: Vec<DocChunk> = Vec::new();
2484 let flush = |window: &mut Vec<DocChunk>, out: &mut Vec<DocChunk>| match window.len() {
2485 0 => {}
2486 1 => out.push(window.pop().unwrap()),
2487 _ => {
2488 out.push(merged_chunk(window.iter()));
2489 window.clear();
2490 }
2491 };
2492 for chunk in chunks {
2493 if window.is_empty() {
2494 window.push(chunk);
2495 continue;
2496 }
2497 let candidate = merged_chunk(window.iter().chain([&chunk]));
2498 if chunk.headings == window[0].headings
2499 && h.count_chunk_tokens(&candidate) <= h.max_tokens()
2500 {
2501 window.push(chunk);
2502 } else {
2503 flush(&mut window, &mut out);
2504 window.push(chunk);
2505 }
2506 }
2507 flush(&mut window, &mut out);
2508 out
2509 }
2510
2511 fn linear_merge<T: ChunkTokenizer>(
2512 h: &HybridChunker<T>,
2513 chunks: Vec<DocChunk>,
2514 ) -> Vec<DocChunk> {
2515 let mut out = Vec::new();
2516 let mut sink = |c| {
2517 out.push(c);
2518 true
2519 };
2520 let mut m = PeerMerger::default();
2521 for c in chunks {
2522 assert!(h.merge_push(&mut m, c, &mut sink));
2523 }
2524 assert!(h.merge_flush(&mut m, &mut sink));
2525 out
2526 }
2527
2528 struct QuadCharTok(usize);
2533 impl ChunkTokenizer for QuadCharTok {
2534 fn count_tokens(&self, text: &str) -> usize {
2535 text.chars().count().div_ceil(4)
2536 }
2537 fn max_tokens(&self) -> usize {
2538 self.0
2539 }
2540 }
2541
2542 fn random_chunks(seed: u64, n: usize) -> Vec<DocChunk> {
2545 let mut state = seed;
2546 let mut next = move |m: u64| {
2547 state = state
2548 .wrapping_mul(6364136223846793005)
2549 .wrapping_add(1442695040888963407);
2550 (state >> 33) % m
2551 };
2552 let headings = [
2553 None,
2554 Some(vec!["A".to_string()]),
2555 Some(vec!["A".into(), "B".into()]),
2556 ];
2557 let mut current = 0;
2558 (0..n)
2559 .map(|i| {
2560 if next(4) == 0 {
2561 current = next(3) as usize;
2562 }
2563 let words = 1 + next(12) as usize;
2564 let text = (0..words)
2565 .map(|w| ["x", "lorem", "ipsum", "dolor", "sit"][(w + i) % 5])
2566 .collect::<Vec<_>>()
2567 .join(" ");
2568 DocChunk {
2569 text,
2570 headings: headings[current].clone(),
2571 doc_items: vec![ChunkItem {
2572 self_ref: format!("#/texts/{i}"),
2573 kind: ChunkItemKind::Text,
2574 text: String::new(),
2575 }],
2576 }
2577 })
2578 .collect()
2579 }
2580
2581 #[test]
2585 fn linear_peer_merge_matches_the_quadratic_one() {
2586 for seed in 0..40 {
2587 let chunks = random_chunks(seed, 60);
2588 for max in [3, 8, 16, 40, 200] {
2589 let h = HybridChunker::new(WordTok(max));
2590 assert_eq!(
2591 linear_merge(&h, chunks.clone()),
2592 quadratic_merge(&h, chunks.clone()),
2593 "words, seed {seed}, max {max}"
2594 );
2595 let h = HybridChunker::new(QuadCharTok(max));
2596 assert_eq!(
2597 linear_merge(&h, chunks.clone()),
2598 quadratic_merge(&h, chunks.clone()),
2599 "chars/4, seed {seed}, max {max}"
2600 );
2601 }
2602 }
2603 }
2604
2605 #[test]
2609 #[ignore]
2610 fn peer_merge_bench() {
2611 let chunks: Vec<DocChunk> = (0..10_000)
2612 .map(|i| DocChunk {
2613 text: format!("Paragraph number {i} is short."),
2614 headings: Some(vec!["Section".into()]),
2615 doc_items: Vec::new(),
2616 })
2617 .collect();
2618 let h = HybridChunker::new(WordTok(8192));
2619 let t = std::time::Instant::now();
2620 let fast = linear_merge(&h, chunks.clone());
2621 let linear = t.elapsed();
2622 let t = std::time::Instant::now();
2623 let slow = quadratic_merge(&h, chunks);
2624 let quadratic = t.elapsed();
2625 assert_eq!(fast, slow);
2626 println!(
2627 "linear {linear:?}, quadratic {quadratic:?}, {} chunks",
2628 fast.len()
2629 );
2630 }
2631
2632 fn cell_table(
2635 num_rows: usize,
2636 num_cols: usize,
2637 cells: &[(&str, usize, usize, usize, bool)],
2638 ) -> Table {
2639 let mut rows = vec![vec![String::new(); num_cols]; num_rows];
2640 for &(text, r, c, span, _) in cells {
2641 for row in rows.iter_mut().skip(r).take(span) {
2642 row[c] = text.into();
2643 }
2644 }
2645 Table {
2646 rows,
2647 cells: Some(
2648 cells
2649 .iter()
2650 .map(|&(text, r, c, span, ched)| crate::TableCell {
2651 text: text.into(),
2652 bbox: None,
2653 start_row: r,
2654 start_col: c,
2655 row_span: span,
2656 col_span: 1,
2657 column_header: ched,
2658 row_header: false,
2659 row_section: false,
2660 })
2661 .collect(),
2662 ),
2663 ..Default::default()
2664 }
2665 }
2666
2667 #[test]
2673 fn markdown_and_triplets_share_the_header_rows() {
2674 type Case<'a> = (
2675 &'a str,
2676 usize,
2677 usize,
2678 Vec<(&'a str, usize, usize, usize, bool)>,
2679 &'a str,
2680 &'a str,
2681 );
2682 let cases: Vec<Case> = vec![
2683 (
2684 "rows under a row-spanning header stay in the body",
2685 2,
2686 2,
2687 vec![("Price", 0, 0, 1, true), ("Category", 0, 1, 2, true), ("10.00", 1, 0, 1, false)],
2688 "| Price | Category |\n| - | - |\n| 10.00 | Category |",
2689 "10.00, Category = Category",
2690 ),
2691 (
2692 "a row header flagged as column header stays in the body",
2693 3,
2694 3,
2695 vec![
2696 ("Year", 0, 0, 1, true),
2697 ("Month", 0, 1, 1, true),
2698 ("Revenue", 0, 2, 1, true),
2699 ("2025", 1, 0, 2, true),
2700 ("January", 1, 1, 1, false),
2701 ("$134", 1, 2, 1, false),
2702 ("February", 2, 1, 1, false),
2703 ("$150", 2, 2, 1, false),
2704 ],
2705 "| Year | Month | Revenue |\n| - | - | - |\n| 2025 | January | $134 |\n| 2025 | February | $150 |",
2706 "2025, Month = January. 2025, Revenue = $134. 2025, Month = February. 2025, Revenue = $150",
2707 ),
2708 (
2709 "stacked header with an unmarked row label",
2710 3,
2711 3,
2712 vec![
2713 ("", 0, 0, 1, true),
2714 ("% of Total", 0, 1, 1, true),
2715 ("% of Total", 0, 2, 1, true),
2716 ("class label", 1, 0, 1, false),
2717 ("Train", 1, 1, 1, true),
2718 ("Test", 1, 2, 1, true),
2719 ("Caption", 2, 0, 1, false),
2720 ("2.5", 2, 1, 1, false),
2721 ("2.4", 2, 2, 1, false),
2722 ],
2723 "| class label | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.5 | 2.4 |",
2724 "Caption, % of Total.Train = 2.5. Caption, % of Total.Test = 2.4",
2725 ),
2726 (
2727 "a partially flagged first row stays the header",
2728 2,
2729 2,
2730 vec![("Name", 0, 0, 1, true), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2731 "| Name | Value |\n| - | - |\n| A | 1 |",
2732 "A, Value = 1",
2733 ),
2734 (
2735 "no flags: Markdown promotes row 0, triplets do not",
2736 2,
2737 2,
2738 vec![("Name", 0, 0, 1, false), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2739 "| Name | Value |\n| - | - |\n| A | 1 |",
2740 "Name, 1 = Value. A, 1 = 1",
2741 ),
2742 (
2743 "row headers flagged as column headers stay in the body",
2744 2,
2745 2,
2746 vec![("2025", 0, 0, 1, true), ("$134", 0, 1, 1, false), ("2026", 1, 0, 1, true), ("$150", 1, 1, 1, false)],
2747 "| | |\n| - | - |\n| 2025 | $134 |\n| 2026 | $150 |",
2748 "2025, 1 = $134. 2026, 1 = $150",
2749 ),
2750 (
2751 "a form with flagged first-column labels is all data",
2752 3,
2753 2,
2754 vec![
2755 ("Name", 0, 0, 1, true),
2756 ("Bob", 0, 1, 1, false),
2757 ("Age", 1, 0, 1, true),
2758 ("42", 1, 1, 1, false),
2759 ("City", 2, 0, 1, true),
2760 ("Paris", 2, 1, 1, false),
2761 ],
2762 "| | |\n| - | - |\n| Name | Bob |\n| Age | 42 |\n| City | Paris |",
2763 "Name, 1 = Bob. Age, 1 = 42. City, 1 = Paris",
2764 ),
2765 (
2766 "a partly flagged row 0 above a stacked header row",
2767 3,
2768 3,
2769 vec![
2770 ("Region", 0, 0, 1, true),
2771 ("2025", 0, 1, 1, false),
2772 ("", 0, 2, 1, false),
2773 ("", 1, 0, 1, false),
2774 ("Q1", 1, 1, 1, true),
2775 ("Q2", 1, 2, 1, true),
2776 ("North", 2, 0, 1, false),
2777 ("1", 2, 1, 1, false),
2778 ("2", 2, 2, 1, false),
2779 ],
2780 "| Region | 2025 - Q1 | Q2 |\n| - | - | - |\n| North | 1 | 2 |",
2781 "North, 2025.Q1 = 1. North, Q2 = 2",
2782 ),
2783 ];
2784 for (name, rows, cols, cells, md, triplets) in cases {
2785 let t = cell_table(rows, cols, &cells);
2786 assert_eq!(
2787 crate::markdown::render_table(&t, true),
2788 md,
2789 "{name}: markdown"
2790 );
2791 assert_eq!(triplet_table_text(&t), triplets, "{name}: triplets");
2792 }
2793 }
2794
2795 #[test]
2800 fn a_column_spanning_rich_cell_repeats_in_every_column() {
2801 let mut t = cell_table(
2802 2,
2803 3,
2804 &[
2805 ("", 0, 0, 1, true),
2806 ("Partner", 0, 1, 1, true),
2807 ("Status", 1, 0, 1, false),
2808 ("Done", 1, 1, 1, false),
2809 ("Open", 1, 2, 1, false),
2810 ],
2811 );
2812 if let Some(cells) = t.cells.as_mut() {
2813 cells[1].col_span = 2;
2814 }
2815 let mut blocks = vec![vec![Vec::new(); 3]; 2];
2816 blocks[0][1] = vec![Node::Paragraph {
2817 text: "Partner".into(),
2818 }];
2819 t.cell_blocks = Some(blocks);
2820 assert_eq!(
2821 t.rows[0][2], "",
2822 "the covered position carries no text of its own"
2823 );
2824 assert_eq!(
2825 triplet_table_text(&t),
2826 "Status, Partner = Done. Status, Partner = Open"
2827 );
2828 let latex = crate::latex::render_table(&t);
2829 assert!(
2830 latex.contains(" & Partner & Partner \\\\ \\hline\nStatus & Done & Open \\\\ \\hline"),
2831 "{latex}"
2832 );
2833 t.rows[0][2] = "Partner".into();
2835 assert_eq!(
2836 crate::markdown::render_table(&t, true),
2837 "| | Partner | Partner |\n| - | - | - |\n| Status | Done | Open |"
2838 );
2839 }
2840
2841 #[test]
2842 fn hybrid_merges_small_peers_and_splits_large() {
2843 let doc = doc_with(vec![
2844 Node::Heading {
2845 level: 2,
2846 text: "S".into(),
2847 },
2848 Node::Paragraph { text: "a b".into() },
2849 Node::Paragraph { text: "c d".into() },
2850 ]);
2851 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2852 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2853 assert_eq!(chunks[0].text, "a b\nc d");
2854
2855 let long = "w ".repeat(40).trim().to_string();
2856 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2857 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2858 assert!(chunks.len() > 1, "oversized paragraph splits");
2859 for c in &chunks {
2860 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2861 }
2862 }
2863
2864 #[test]
2865 fn semchunk_prefers_newlines_then_sentences() {
2866 let tok = WordTok(4);
2867 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2868 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2869 }
2870}