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 = format!("```\n{}\n```", unescape_text(text));
505 self.emit(
506 body.clone(),
507 vec![ChunkItem {
508 self_ref,
509 kind: ChunkItemKind::Text,
510 text: body,
511 }],
512 );
513 }
514 Node::Table(t) => {
515 let self_ref = self.alloc.table();
516 let body = triplet_table_text(t);
517 self.emit(
518 body.clone(),
519 vec![ChunkItem {
520 self_ref,
521 kind: ChunkItemKind::Table,
522 text: body,
523 }],
524 );
525 }
526 Node::Picture {
527 caption,
528 description,
529 ..
530 } => {
531 let cap = caption.as_deref().filter(|c| !c.is_empty());
532 let cap_item = cap.map(|c| ChunkItem {
533 self_ref: self.alloc.text(),
534 kind: ChunkItemKind::Text,
535 text: unescape_text(c),
536 });
537 let pic_ref = self.alloc.picture();
538 let desc = description
546 .as_ref()
547 .map(|d| d.text.as_str())
548 .filter(|t| !t.is_empty());
549 match (cap_item, desc) {
550 (Some(cap_item), None) => {
551 let body = cap_item.text.clone();
552 self.emit(body, vec![cap_item]);
553 }
554 (Some(cap_item), Some(desc)) => {
555 let body = format!("{}\n\n{desc}", cap_item.text);
556 let pic_item = ChunkItem {
557 self_ref: pic_ref,
558 kind: ChunkItemKind::Picture,
559 text: desc.to_string(),
560 };
561 self.emit(body, vec![cap_item, pic_item]);
562 }
563 (None, Some(desc)) => {
564 let pic_item = ChunkItem {
565 self_ref: pic_ref,
566 kind: ChunkItemKind::Picture,
567 text: desc.to_string(),
568 };
569 self.emit(desc.to_string(), vec![pic_item]);
570 }
571 (None, None) => {}
572 }
573 }
574 Node::Chart {
575 kind,
576 table,
577 caption,
578 ..
579 } => {
580 let cap = caption.as_deref().filter(|c| !c.is_empty());
581 let cap_item = cap.map(|c| ChunkItem {
582 self_ref: self.alloc.text(),
583 kind: ChunkItemKind::Text,
584 text: unescape_text(c),
585 });
586 let pic_ref = self.alloc.picture();
587 let mut parts: Vec<String> = Vec::new();
591 if let Some(ci) = &cap_item {
592 parts.push(ci.text.clone());
593 }
594 parts.push(humanize_label(kind));
595 let grid = crate::markdown::render_table(table, false);
596 if !grid.is_empty() {
597 parts.push(unescape_text(&grid));
598 }
599 let body = parts.join("\n\n");
600 let pic_item = ChunkItem {
605 self_ref: pic_ref,
606 kind: ChunkItemKind::Picture,
607 text: body.clone(),
608 };
609 let items = match cap_item {
610 Some(mut ci) => {
611 ci.text = String::new();
612 vec![ci, pic_item]
613 }
614 None => vec![pic_item],
615 };
616 self.emit(body, items);
617 }
618 Node::Group { layer: Some(_), .. } => {}
621 Node::Group { children, .. } => {
622 self.alloc.group();
625 self.walk(children);
626 }
627 Node::KeyValueGraph { .. } => {}
629 Node::FieldRegion { items } => {
630 self.alloc.field_region();
633 for item in items {
634 self.alloc.field_item();
635 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
636 let self_ref = self.alloc.text();
637 let body = unescape_text(part);
638 self.emit(
639 body.clone(),
640 vec![ChunkItem {
641 self_ref,
642 kind: ChunkItemKind::Text,
643 text: body,
644 }],
645 );
646 }
647 }
648 }
649 Node::InlineGroup { md_text, runs, .. } => {
650 let self_ref = self.alloc.text();
651 self.emit_inline_with_runs(md_text, self_ref, runs);
652 }
653 Node::TextDump(text) => {
654 let self_ref = self.alloc.text();
655 let body = unescape_text(text);
656 self.emit(
657 body.clone(),
658 vec![ChunkItem {
659 self_ref,
660 kind: ChunkItemKind::Text,
661 text: body,
662 }],
663 );
664 }
665 Node::Located { inner, .. }
667 | Node::Prov { inner, .. }
668 | Node::Track { inner, .. }
669 | Node::Commented { inner, .. } => self.one(inner),
670 Node::PageFurniture { .. } | Node::FurnitureText { .. } => {
674 self.alloc.text();
675 }
676 Node::PictureChildren(children) => {
682 for child in children {
683 match child {
684 Node::PageFurniture { .. } => self.one(child),
687 Node::ListItem { .. } => {
688 self.alloc.group();
689 self.alloc.text();
690 }
691 _ => {
692 self.alloc.text();
693 }
694 }
695 }
696 }
697 Node::CommentSection { .. }
700 | Node::Furniture { .. }
701 | Node::PageBreak
702 | Node::PageInfo { .. }
703 | Node::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 flush(&mut out, &mut plain, true, after_span);
976 out.push((
977 unescape_text(&chars[i..=endp].iter().collect::<String>()),
978 false,
979 ));
980 i = endp + 1;
981 after_span = true;
982 continue;
983 }
984 }
985 }
986 let mut matched = false;
989 for marker in ["***", "**", "*", "~~", "`"] {
990 if rest.starts_with(marker) {
991 let mlen = marker.chars().count();
992 if let Some(end) = find(i + mlen, marker) {
993 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
997 if end > i + mlen && !inner_blank {
998 flush(&mut out, &mut plain, true, after_span);
999 if marker == "`" {
1000 let inner: String = chars[i + 1..end].iter().collect();
1001 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
1002 } else {
1003 out.push((
1004 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
1005 false,
1006 ));
1007 }
1008 i = end + mlen;
1009 after_span = true;
1010 matched = true;
1011 }
1012 }
1013 break;
1014 }
1015 }
1016 if matched {
1017 continue;
1018 }
1019 if rest.starts_with("$$") {
1022 plain.push_str("$$");
1023 i += 2;
1024 continue;
1025 }
1026 if chars[i] == '$' {
1028 if let Some(end) = find(i + 1, "$") {
1029 if end > i + 1 {
1030 flush(&mut out, &mut plain, true, after_span);
1031 let latex: String = chars[i + 1..end].iter().collect();
1032 out.push((format!("$${latex}$$"), false));
1033 i = end + 1;
1034 after_span = true;
1035 continue;
1036 }
1037 }
1038 }
1039 plain.push(chars[i]);
1040 i += 1;
1041 }
1042 flush(&mut out, &mut plain, false, after_span);
1043 if out.is_empty() {
1044 out.push((unescape_text(md), true));
1045 }
1046 out
1047}
1048
1049fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1054 let target = segment.trim();
1055 if target.is_empty() {
1056 return None;
1057 }
1058 let plainish =
1059 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1060 let fully_plain =
1061 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1062 let unmarked: Vec<(&str, bool)> = runs
1063 .iter()
1064 .filter(|r| plainish(r))
1065 .map(|r| (r.text.as_str(), fully_plain(r)))
1066 .collect();
1067 for start in 0..unmarked.len() {
1068 let mut rest = target;
1069 let mut taken: Vec<(String, bool)> = Vec::new();
1070 for (t, fully) in &unmarked[start..] {
1071 let t = t.trim();
1072 if t.is_empty() {
1073 continue;
1074 }
1075 match rest.strip_prefix(t) {
1076 Some(r) => {
1077 taken.push((unescape_text(t), *fully));
1078 rest = r.trim_start();
1079 if rest.is_empty() {
1080 break;
1081 }
1082 }
1083 None => break,
1084 }
1085 }
1086 if rest.is_empty() && taken.len() >= 2 {
1087 let mut merged: Vec<(String, bool)> = Vec::new();
1092 for (t, fully) in taken {
1093 match merged.last_mut() {
1094 Some((last, true)) if fully => {
1095 last.push(' ');
1096 last.push_str(&t);
1097 }
1098 _ => merged.push((t, fully)),
1099 }
1100 }
1101 if merged.len() >= 2 {
1102 return Some(merged.into_iter().map(|(t, _)| t).collect());
1103 }
1104 return None;
1105 }
1106 }
1107 None
1108}
1109
1110fn cell_chunk_text(t: &Table, anchors: &[Vec<(usize, usize)>], r: usize, c: usize) -> String {
1117 let (ar, ac) = anchors
1120 .get(r)
1121 .and_then(|row| row.get(c))
1122 .copied()
1123 .unwrap_or((r, c));
1124 if let Some(blocks) = t
1125 .cell_blocks
1126 .as_ref()
1127 .and_then(|b| b.get(ar))
1128 .and_then(|row| row.get(ac))
1129 .filter(|b| !b.is_empty())
1130 {
1131 let mut parts: Vec<String> = Vec::new();
1132 for node in blocks.iter() {
1133 let part = block_chunk_text(node);
1134 if !part.is_empty() {
1135 parts.push(part);
1136 }
1137 }
1138 return parts.join("\n\n");
1139 }
1140 let flat = t
1141 .rows
1142 .get(r)
1143 .and_then(|row| row.get(c))
1144 .map(String::as_str)
1145 .unwrap_or("");
1146 unescape_text(flat)
1147 .replace("<!-- image -->", "")
1148 .trim()
1149 .to_string()
1150}
1151
1152fn block_chunk_text(node: &Node) -> String {
1154 match node {
1155 Node::Paragraph { text } | Node::ListText { text, .. } => unescape_text(text),
1156 Node::LabeledText { .. } => block_chunk_text(&node.labeled_as_paragraph()),
1157 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1158 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1159 Node::Table(inner) => triplet_table_text(inner),
1160 Node::Picture { caption, .. } => caption
1161 .as_deref()
1162 .filter(|c| !c.is_empty())
1163 .map(unescape_text)
1164 .unwrap_or_default(),
1165 Node::ListItem {
1166 ordered,
1167 number,
1168 text,
1169 ..
1170 } => {
1171 let marker = if *ordered {
1172 format!("{number}.")
1173 } else {
1174 "-".to_string()
1175 };
1176 format!("{marker} {}", unescape_text(text))
1177 }
1178 Node::CheckboxItem { checked, text } => {
1179 let mark = if *checked { "- [x] " } else { "- [ ] " };
1180 format!("{mark}{}", unescape_text(text))
1181 }
1182 Node::Heading { text, .. } => unescape_text(text),
1183 Node::Located { inner, .. }
1184 | Node::Prov { inner, .. }
1185 | Node::Track { inner, .. }
1186 | Node::Commented { inner, .. } => block_chunk_text(inner),
1187 Node::Group { layer: Some(_), .. } => String::new(),
1188 Node::Group { children, .. } => children
1189 .iter()
1190 .map(block_chunk_text)
1191 .filter(|s| !s.is_empty())
1192 .collect::<Vec<_>>()
1193 .join("\n"),
1194 _ => String::new(),
1195 }
1196}
1197
1198fn unescape_text(s: &str) -> String {
1201 s.replace("<", "<")
1202 .replace(">", ">")
1203 .replace("&", "&")
1204 .replace("\\_", "_")
1205}
1206
1207pub trait ChunkTokenizer {
1213 fn count_tokens(&self, text: &str) -> usize;
1215 fn max_tokens(&self) -> usize;
1217}
1218
1219pub struct HybridChunker<T: ChunkTokenizer> {
1223 tokenizer: T,
1224 merge_peers: bool,
1225}
1226
1227impl<T: ChunkTokenizer> HybridChunker<T> {
1228 pub fn new(tokenizer: T) -> Self {
1229 Self {
1230 tokenizer,
1231 merge_peers: true,
1232 }
1233 }
1234
1235 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1237 self.merge_peers = merge_peers;
1238 self
1239 }
1240
1241 pub fn max_tokens(&self) -> usize {
1242 self.tokenizer.max_tokens()
1243 }
1244
1245 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1247 let mut chunks = Vec::new();
1248 self.chunk_with(doc, &mut |c| {
1249 chunks.push(c);
1250 true
1251 });
1252 chunks
1253 }
1254
1255 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1262 let mut merger = PeerMerger::default();
1263 let mut alive = true;
1264 HierarchicalChunker.chunk_with(doc, &mut |c| {
1265 for split in self.split_by_doc_items(c) {
1266 for chunk in self.split_using_plain_text(split) {
1267 if !alive {
1268 return false;
1269 }
1270 alive = if self.merge_peers {
1271 self.merge_push(&mut merger, chunk, sink)
1272 } else {
1273 sink(chunk)
1274 };
1275 }
1276 }
1277 alive
1278 });
1279 if alive {
1280 self.merge_flush(&mut merger, sink);
1281 }
1282 }
1283
1284 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1285 self.tokenizer.count_tokens(&contextualize(chunk))
1286 }
1287
1288 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1291 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1292 let text = if chunk.doc_items.len() == 1 {
1293 chunk.text.clone()
1294 } else {
1295 doc_items
1296 .iter()
1297 .filter(|it| !it.text.is_empty())
1298 .map(|it| it.text.as_str())
1299 .collect::<Vec<_>>()
1300 .join("\n")
1301 };
1302 DocChunk {
1303 text,
1304 headings: chunk.headings.clone(),
1305 doc_items,
1306 }
1307 }
1308
1309 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1310 if chunk.doc_items.is_empty() {
1311 return vec![chunk];
1312 }
1313 let max = self.max_tokens();
1314 let num_items = chunk.doc_items.len();
1315 let mut chunks = Vec::new();
1316 let mut window_start = 0usize;
1317 let mut window_end = 0usize; while window_end < num_items {
1319 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1320 if self.count_chunk_tokens(&new_chunk) <= max {
1321 if window_end < num_items - 1 {
1322 window_end += 1;
1323 continue;
1324 } else {
1325 window_end = num_items; }
1327 } else if window_start == window_end {
1328 window_end += 1;
1331 window_start = window_end;
1332 } else {
1333 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1336 window_start = window_end;
1337 }
1338 chunks.push(new_chunk);
1339 }
1340 chunks
1341 }
1342
1343 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1344 let total = self.count_chunk_tokens(&chunk);
1345 let max = self.max_tokens();
1346 if total <= max {
1347 return vec![chunk];
1348 }
1349 let text_len = self.tokenizer.count_tokens(&chunk.text);
1350 let other_len = total - text_len;
1351 if other_len >= max {
1352 let stripped = DocChunk {
1354 headings: None,
1355 ..chunk
1356 };
1357 return self.split_using_plain_text(stripped);
1358 }
1359 let available = max - other_len;
1360
1361 let segments =
1362 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1363 let lines: Vec<String> = chunk
1370 .text
1371 .split('\n')
1372 .filter(|l| !l.trim().is_empty())
1373 .map(|l| l.to_string())
1374 .collect();
1375 line_chunk_text(&lines, &self.tokenizer, max)
1376 } else {
1377 semchunk(&chunk.text, available, &self.tokenizer)
1378 };
1379 segments
1380 .into_iter()
1381 .map(|s| DocChunk {
1382 text: s,
1383 headings: chunk.headings.clone(),
1384 doc_items: chunk.doc_items.clone(),
1385 })
1386 .collect()
1387 }
1388
1389 fn merge_push(
1404 &self,
1405 m: &mut PeerMerger,
1406 chunk: DocChunk,
1407 sink: &mut dyn FnMut(DocChunk) -> bool,
1408 ) -> bool {
1409 self.merge_feed(m, VecDeque::from([(chunk, None)]), sink)
1410 }
1411
1412 fn merge_feed(
1415 &self,
1416 m: &mut PeerMerger,
1417 mut pending: VecDeque<(DocChunk, Option<usize>)>,
1418 sink: &mut dyn FnMut(DocChunk) -> bool,
1419 ) -> bool {
1420 while let Some((chunk, tokens)) = pending.pop_front() {
1421 let tokens = tokens.unwrap_or_else(|| self.tokenizer.count_tokens(&chunk.text));
1422 if let Err(chunk) = self.merge_extend(m, chunk, tokens) {
1423 let (alive, rest) = self.merge_close(m, sink);
1424 if !alive {
1425 return false;
1426 }
1427 pending.push_front((chunk, Some(tokens)));
1428 for item in rest.into_iter().rev() {
1429 pending.push_front(item);
1430 }
1431 }
1432 }
1433 true
1434 }
1435
1436 fn merge_extend(
1439 &self,
1440 m: &mut PeerMerger,
1441 chunk: DocChunk,
1442 tokens: usize,
1443 ) -> Result<(), DocChunk> {
1444 let max = self.max_tokens() as i64;
1445 if m.window.is_empty() {
1446 m.overhead = self.count_chunk_tokens(&chunk) as i64 - tokens as i64;
1449 m.running = tokens as i64;
1450 m.window.push((chunk, tokens));
1451 return Ok(());
1452 }
1453 if chunk.headings != m.window[0].0.headings {
1454 return Err(chunk);
1455 }
1456 let delim = *m.delim.get_or_insert_with(|| {
1457 let t = &self.tokenizer;
1459 t.count_tokens("a\nb") as i64 - t.count_tokens("a") as i64 - t.count_tokens("b") as i64
1460 });
1461 m.running += tokens as i64 + delim;
1462 if m.running + m.overhead > max {
1463 let exact = self.count_chunk_tokens(&merged_chunk(
1464 m.window.iter().map(|(c, _)| c).chain([&chunk]),
1465 )) as i64;
1466 if exact > max {
1467 return Err(chunk);
1468 }
1469 m.running = exact - m.overhead;
1471 }
1472 m.window.push((chunk, tokens));
1473 Ok(())
1474 }
1475
1476 fn merge_close(
1480 &self,
1481 m: &mut PeerMerger,
1482 sink: &mut dyn FnMut(DocChunk) -> bool,
1483 ) -> (bool, Vec<(DocChunk, Option<usize>)>) {
1484 if m.window.is_empty() {
1485 return (true, Vec::new());
1486 }
1487 let max = self.max_tokens();
1488 let mut fit = m.window.len() - 1;
1489 while fit > 0
1490 && self.count_chunk_tokens(&merged_chunk(m.window[..=fit].iter().map(|(c, _)| c))) > max
1491 {
1492 fit -= 1;
1493 }
1494 let rest = m
1495 .window
1496 .split_off(fit + 1)
1497 .into_iter()
1498 .map(|(c, t)| (c, Some(t)))
1499 .collect();
1500 let out = if m.window.len() == 1 {
1501 m.window.pop().expect("single-chunk window").0
1502 } else {
1503 let merged = merged_chunk(m.window.iter().map(|(c, _)| c));
1504 m.window.clear();
1505 merged
1506 };
1507 (sink(out), rest)
1508 }
1509
1510 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1513 while !m.window.is_empty() {
1514 let (alive, rest) = self.merge_close(m, sink);
1515 if !alive || !self.merge_feed(m, rest.into(), sink) {
1516 return false;
1517 }
1518 }
1519 true
1520 }
1521}
1522
1523fn merged_chunk<'a>(chunks: impl Iterator<Item = &'a DocChunk> + Clone) -> DocChunk {
1526 let mut first = chunks.clone();
1527 DocChunk {
1528 text: chunks
1529 .clone()
1530 .map(|c| c.text.as_str())
1531 .collect::<Vec<_>>()
1532 .join("\n"),
1533 headings: first.next().and_then(|c| c.headings.clone()),
1534 doc_items: chunks.flat_map(|c| c.doc_items.iter().cloned()).collect(),
1535 }
1536}
1537
1538#[derive(Default)]
1543struct PeerMerger {
1544 window: Vec<(DocChunk, usize)>,
1545 running: i64,
1546 overhead: i64,
1547 delim: Option<i64>,
1548}
1549
1550fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1560 let mut chunks: Vec<String> = Vec::new();
1561 let mut current = String::new();
1562 let mut current_len = 0usize;
1563
1564 for line in lines {
1565 let mut remaining: Vec<char> = line.chars().collect();
1566 loop {
1567 let rem_str: String = remaining.iter().collect();
1568 let line_tokens = tok.count_tokens(&rem_str);
1569 let available = max_tokens.saturating_sub(current_len);
1570
1571 if line_tokens <= available {
1572 current.push_str(&rem_str);
1573 current_len += line_tokens;
1574 break;
1575 }
1576 if line_tokens <= max_tokens {
1577 chunks.push(std::mem::take(&mut current));
1578 current_len = 0;
1579 continue;
1580 }
1581 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1583 let mut rest = rest;
1584 if take.is_empty() {
1585 if rest.is_empty() {
1586 break;
1587 }
1588 take = rest[..1].iter().collect();
1589 rest = rest[1..].to_vec();
1590 }
1591 current.push('\n');
1592 current.push_str(&take);
1593 chunks.push(std::mem::take(&mut current));
1594 current_len = 0;
1595 remaining = rest;
1596 }
1597 }
1598 if !current.is_empty() {
1599 chunks.push(current);
1600 }
1601 chunks
1602}
1603
1604fn split_by_token_limit<T: ChunkTokenizer>(
1608 text: &[char],
1609 token_limit: usize,
1610 tok: &T,
1611) -> (String, Vec<char>) {
1612 if token_limit == 0 || text.is_empty() {
1613 return (String::new(), text.to_vec());
1614 }
1615 let full: String = text.iter().collect();
1616 if tok.count_tokens(&full) <= token_limit {
1617 return (full, Vec::new());
1618 }
1619 let (mut lo, mut hi) = (0usize, text.len());
1620 let mut best: Option<usize> = None;
1621 while lo <= hi {
1622 let mid = (lo + hi) / 2;
1623 let head: String = text[..mid].iter().collect();
1624 if tok.count_tokens(&head) <= token_limit {
1625 best = Some(mid);
1626 lo = mid + 1;
1627 } else {
1628 if mid == 0 {
1629 break;
1630 }
1631 hi = mid - 1;
1632 }
1633 }
1634 let mut best_idx = match best {
1635 Some(b) if b > 0 => b,
1636 _ => return (String::new(), text.to_vec()),
1637 };
1638 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1640 if pos > 0 {
1641 best_idx = pos;
1642 }
1643 }
1644 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1645}
1646
1647const NON_WS_SPLITTERS: &[&str] = &[
1653 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1654 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1655];
1656
1657pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1661 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1662 let mut counter = |s: &str| -> usize {
1663 if let Some(n) = cache.get(s) {
1664 return *n;
1665 }
1666 let n = tok.count_tokens(s);
1667 cache.insert(s.to_string(), n);
1668 n
1669 };
1670 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1671 chunks
1673 .into_iter()
1674 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1675 .collect()
1676}
1677
1678fn semchunk_rec(
1681 text: &str,
1682 chunk_size: usize,
1683 counter: &mut dyn FnMut(&str) -> usize,
1684) -> Vec<String> {
1685 let (splitter, splitter_is_ws, splits) = split_text(text);
1686
1687 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1688 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1689 cum_lens.push(0usize);
1690 for l in &split_lens {
1691 cum_lens.push(cum_lens.last().unwrap() + l);
1692 }
1693 let num_splits_plus_one = splits.len() + 1;
1694
1695 let mut chunks: Vec<String> = Vec::new();
1696 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1697
1698 for i in 0..splits.len() {
1699 if skips.contains(&i) {
1700 continue;
1701 }
1702 let split = &splits[i];
1703 if counter(split) > chunk_size {
1704 let inner = semchunk_rec(split, chunk_size, counter);
1705 chunks.extend(inner);
1706 } else {
1707 let (end, merged) = merge_splits(
1708 &splits,
1709 &cum_lens,
1710 chunk_size,
1711 &splitter,
1712 counter,
1713 i,
1714 num_splits_plus_one,
1715 );
1716 for j in (i + 1)..end {
1717 skips.insert(j);
1718 }
1719 chunks.push(merged);
1720 }
1721 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1724 if !splitter_is_ws && !is_last {
1725 let with_splitter = format!(
1726 "{}{}",
1727 chunks.last().map(String::as_str).unwrap_or(""),
1728 splitter
1729 );
1730 if counter(&with_splitter) <= chunk_size {
1731 if let Some(last) = chunks.last_mut() {
1732 *last = with_splitter;
1733 } else {
1734 chunks.push(with_splitter);
1735 }
1736 } else {
1737 chunks.push(splitter.clone());
1738 }
1739 }
1740 }
1741 chunks
1742}
1743
1744fn merge_splits(
1747 splits: &[String],
1748 cum_lens: &[usize],
1749 chunk_size: usize,
1750 splitter: &str,
1751 counter: &mut dyn FnMut(&str) -> usize,
1752 start: usize,
1753 high_init: usize,
1754) -> (usize, String) {
1755 let mut average = 0.2f64;
1756 let mut low = start;
1757 let mut high = high_init;
1758 let offset = cum_lens[start];
1759 let mut target = offset as f64 + (chunk_size as f64 * average);
1760
1761 while low < high {
1762 let i = bisect_left(cum_lens, target, low, high);
1763 let midpoint = i.min(high - 1);
1764 let joined = splits[start..midpoint.max(start)].join(splitter);
1765 let tokens = counter(&joined);
1766 let local_cum = cum_lens[midpoint] - offset;
1767 if local_cum > 0 && tokens > 0 {
1768 average = local_cum as f64 / tokens as f64;
1769 target = offset as f64 + (chunk_size as f64 * average);
1770 }
1771 if tokens > chunk_size {
1772 high = midpoint;
1773 } else {
1774 low = midpoint + 1;
1775 }
1776 }
1777 let end = low - 1;
1778 (end, splits[start..end.max(start)].join(splitter))
1779}
1780
1781fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1782 while low < high {
1783 let mid = (low + high) / 2;
1784 if (sorted[mid] as f64) < target {
1785 low = mid + 1;
1786 } else {
1787 high = mid;
1788 }
1789 }
1790 low
1791}
1792
1793fn split_text(text: &str) -> (String, bool, Vec<String>) {
1795 if text.contains('\n') || text.contains('\r') {
1797 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1798 return (splitter.clone(), true, split_on(text, &splitter));
1799 }
1800 if text.contains('\t') {
1802 let splitter = longest_run(text, |c| c == '\t');
1803 return (splitter.clone(), true, split_on(text, &splitter));
1804 }
1805 if text.chars().any(char::is_whitespace) {
1807 let splitter = longest_run(text, char::is_whitespace);
1808 if splitter.chars().count() == 1 {
1809 for preceder in NON_WS_SPLITTERS {
1811 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1812 return (ws, true, parts);
1813 }
1814 }
1815 }
1816 return (splitter.clone(), true, split_on(text, &splitter));
1817 }
1818 for s in NON_WS_SPLITTERS {
1820 if text.contains(s) {
1821 return (s.to_string(), false, split_on(text, s));
1822 }
1823 }
1824 (
1826 String::new(),
1827 true,
1828 text.chars().map(|c| c.to_string()).collect(),
1829 )
1830}
1831
1832fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1834 let mut best = String::new();
1835 let mut cur = String::new();
1836 for c in text.chars() {
1837 if pred(c) {
1838 cur.push(c);
1839 } else {
1840 if cur.chars().count() > best.chars().count() {
1841 best = cur.clone();
1842 }
1843 cur.clear();
1844 }
1845 }
1846 if cur.chars().count() > best.chars().count() {
1847 best = cur;
1848 }
1849 best
1850}
1851
1852fn split_on(text: &str, splitter: &str) -> Vec<String> {
1853 text.split(splitter).map(str::to_string).collect()
1854}
1855
1856fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1860 let chars: Vec<char> = text.chars().collect();
1861 let p: Vec<char> = preceder.chars().collect();
1862 let mut ws: Option<char> = None;
1863 for i in p.len()..chars.len() {
1864 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1865 ws = Some(chars[i]);
1866 break;
1867 }
1868 }
1869 let ws = ws?;
1870 let mut parts = Vec::new();
1871 let mut cur = String::new();
1872 let mut i = 0usize;
1873 while i < chars.len() {
1874 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1875 parts.push(std::mem::take(&mut cur));
1876 i += 1;
1877 continue;
1878 }
1879 cur.push(chars[i]);
1880 i += 1;
1881 }
1882 parts.push(cur);
1883 Some((ws.to_string(), parts))
1884}
1885
1886#[cfg(feature = "chunking")]
1891mod hf {
1892 use super::ChunkTokenizer;
1893
1894 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1899
1900 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1905 if let Some(p) = explicit {
1906 return Ok(p.to_string());
1907 }
1908 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1909 if std::path::Path::new(&resolved).exists() {
1910 return Ok(resolved);
1911 }
1912 Err(format!(
1913 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1914 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1915 scripts/install/download_dependencies.sh (or pass an explicit path)"
1916 ))
1917 }
1918
1919 pub struct HuggingFaceTokenizer {
1923 tok: tokenizers::Tokenizer,
1924 max_tokens: usize,
1925 }
1926
1927 impl HuggingFaceTokenizer {
1928 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1932 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1933 }
1934
1935 pub fn from_file(
1939 path: impl AsRef<std::path::Path>,
1940 max_tokens: usize,
1941 ) -> Result<Self, String> {
1942 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1943 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1944 let _ = tok.with_truncation(None);
1949 tok.with_padding(None);
1950 Ok(Self { tok, max_tokens })
1951 }
1952 }
1953
1954 impl ChunkTokenizer for HuggingFaceTokenizer {
1955 fn count_tokens(&self, text: &str) -> usize {
1956 self.tok
1957 .encode(text, false)
1958 .map(|e| e.get_tokens().len())
1959 .unwrap_or(0)
1960 }
1961 fn max_tokens(&self) -> usize {
1962 self.max_tokens
1963 }
1964 }
1965}
1966
1967#[cfg(feature = "chunking")]
1968pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1969
1970#[cfg(feature = "chunking")]
1975mod window {
1976 use super::DocChunk;
1977 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1978
1979 #[derive(Debug, Clone, Default)]
1981 pub struct Section {
1982 pub heading_path: Vec<String>,
1985 pub words: Vec<String>,
1987 }
1988
1989 impl Section {
1990 pub fn heading_context(&self) -> String {
1993 if self.heading_path.is_empty() {
1994 String::new()
1995 } else {
1996 format!("# {}", self.heading_path.join(" > "))
1997 }
1998 }
1999 }
2000
2001 fn level_index(level: HeadingLevel) -> usize {
2002 match level {
2003 HeadingLevel::H1 => 1,
2004 HeadingLevel::H2 => 2,
2005 HeadingLevel::H3 => 3,
2006 HeadingLevel::H4 => 4,
2007 HeadingLevel::H5 => 5,
2008 HeadingLevel::H6 => 6,
2009 }
2010 }
2011
2012 pub fn parse_sections(markdown: &str) -> Vec<Section> {
2016 parse_sections_with_stack(markdown, Vec::new()).0
2017 }
2018
2019 pub fn parse_sections_with_stack(
2024 markdown: &str,
2025 initial_stack: Vec<String>,
2026 ) -> (Vec<Section>, Vec<String>) {
2027 let mut heading_stack: Vec<String> = initial_stack;
2028 let mut sections: Vec<Section> = Vec::new();
2029 let mut current = Section {
2032 heading_path: heading_stack
2033 .iter()
2034 .filter(|h| !h.is_empty())
2035 .cloned()
2036 .collect(),
2037 words: Vec::new(),
2038 };
2039
2040 let mut in_heading = false;
2041 let mut heading_level = 0usize;
2042 let mut heading_buf = String::new();
2043
2044 let push_words = |section: &mut Section, text: &str| {
2045 for w in text.split_whitespace() {
2046 section.words.push(w.to_string());
2047 }
2048 };
2049
2050 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
2051 if !section.words.is_empty() {
2052 sections.push(std::mem::take(section));
2053 } else {
2054 *section = Section::default();
2055 }
2056 };
2057
2058 for event in Parser::new(markdown) {
2059 match event {
2060 Event::Start(Tag::Heading { level, .. }) => {
2061 in_heading = true;
2062 heading_level = level_index(level);
2063 heading_buf.clear();
2064 }
2065 Event::End(TagEnd::Heading(_)) => {
2066 in_heading = false;
2067 let idx = heading_level.saturating_sub(1);
2069 if heading_stack.len() <= idx {
2070 heading_stack.resize(idx + 1, String::new());
2071 } else {
2072 heading_stack.truncate(idx + 1);
2073 }
2074 heading_stack[idx] = heading_buf.trim().to_string();
2075 flush(&mut sections, &mut current);
2077 current.heading_path = heading_stack
2078 .iter()
2079 .filter(|h| !h.is_empty())
2080 .cloned()
2081 .collect();
2082 }
2083 Event::Text(t) | Event::Code(t) => {
2084 if in_heading {
2085 if !heading_buf.is_empty() {
2086 heading_buf.push(' ');
2087 }
2088 heading_buf.push_str(&t);
2089 } else {
2090 push_words(&mut current, &t);
2091 }
2092 }
2093 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
2095 _ => {}
2096 }
2097 }
2098 flush(&mut sections, &mut current);
2099 (sections, heading_stack)
2100 }
2101
2102 #[derive(Debug, Clone)]
2108 pub struct WindowChunker {
2109 pub max_words: usize,
2111 pub overlap: f32,
2113 }
2114
2115 impl Default for WindowChunker {
2116 fn default() -> Self {
2117 WindowChunker {
2118 max_words: 300,
2119 overlap: 0.05,
2120 }
2121 }
2122 }
2123
2124 impl WindowChunker {
2125 pub fn new(max_words: usize, overlap: f32) -> Self {
2126 WindowChunker { max_words, overlap }
2127 }
2128
2129 fn word_budget(&self) -> usize {
2131 self.max_words.max(1)
2132 }
2133
2134 fn overlap_words(&self, budget: usize) -> usize {
2137 let o = (budget as f32 * self.overlap).round() as usize;
2138 o.min(budget.saturating_sub(1))
2139 }
2140
2141 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2143 let mut chunks = Vec::new();
2144 self.chunk_with(markdown, &mut |c| {
2145 chunks.push(c);
2146 true
2147 });
2148 chunks
2149 }
2150
2151 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2155 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2156 for section in §ions {
2157 if !self.pack_section(section, sink) {
2158 return;
2159 }
2160 }
2161 }
2162
2163 pub fn pack_section(
2167 &self,
2168 section: &Section,
2169 sink: &mut dyn FnMut(DocChunk) -> bool,
2170 ) -> bool {
2171 let words = §ion.words;
2172 if words.is_empty() {
2173 return true;
2174 }
2175 let budget = self.word_budget();
2176 let step = budget - self.overlap_words(budget); let mut start = 0;
2178 loop {
2179 let end = (start + budget).min(words.len());
2180 let chunk = DocChunk {
2181 text: words[start..end].join(" "),
2182 headings: (!section.heading_path.is_empty())
2183 .then(|| section.heading_path.clone()),
2184 doc_items: Vec::new(),
2185 };
2186 if !sink(chunk) {
2187 return false;
2188 }
2189 if end >= words.len() {
2190 return true;
2191 }
2192 start += step;
2193 }
2194 }
2195
2196 pub fn contextualize(chunk: &DocChunk) -> String {
2202 match &chunk.headings {
2203 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2204 _ => chunk.text.clone(),
2205 }
2206 }
2207 }
2208
2209 #[cfg(test)]
2210 mod tests {
2211 use super::*;
2212
2213 #[test]
2214 fn splits_on_headings_and_tracks_path() {
2215 let md = "\
2216intro words
2217# Chapter 1
2218para one
2219## Section 1.1
2220para two
2221# Chapter 2
2222para three";
2223 let secs = parse_sections(md);
2224 assert_eq!(secs.len(), 4);
2226 assert!(secs[0].heading_path.is_empty());
2227 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2228 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2229 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2231 }
2232
2233 #[test]
2234 fn strips_markup_to_plain_words() {
2235 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2236 let secs = parse_sections(md);
2237 let words = &secs[0].words;
2238 assert!(words.contains(&"bold".to_string()));
2239 assert!(words.contains(&"code".to_string()));
2240 assert!(words.contains(&"link".to_string()));
2241 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2243 }
2244
2245 #[test]
2246 fn windows_overlap_and_never_cross_headings() {
2247 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2248 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2249 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2251 let a: Vec<_> = chunks
2253 .iter()
2254 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2255 .collect();
2256 assert_eq!(a.len(), 3);
2257 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2258 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2259 assert!(a[2].text.ends_with(" w24"));
2260 let b: Vec<_> = chunks
2262 .iter()
2263 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2264 .collect();
2265 assert_eq!(b.len(), 1);
2266 assert_eq!(b[0].text, "short tail");
2267 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2268 }
2269
2270 #[test]
2271 fn sink_false_cancels_the_window_walk() {
2272 let md = format!(
2273 "# A\n\n{}\n",
2274 (0..50)
2275 .map(|i| format!("w{i}"))
2276 .collect::<Vec<_>>()
2277 .join(" ")
2278 );
2279 let chunker = WindowChunker::new(10, 0.0);
2280 let mut n = 0;
2281 chunker.chunk_with(&md, &mut |_| {
2282 n += 1;
2283 false
2284 });
2285 assert_eq!(n, 1);
2286 }
2287 }
2288}
2289
2290#[cfg(feature = "chunking")]
2291pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2292
2293#[cfg(test)]
2294mod tests {
2295 use super::*;
2296
2297 struct WordTok(usize);
2299 impl ChunkTokenizer for WordTok {
2300 fn count_tokens(&self, text: &str) -> usize {
2301 text.split_whitespace().count()
2302 }
2303 fn max_tokens(&self) -> usize {
2304 self.0
2305 }
2306 }
2307
2308 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2309 let mut d = DoclingDocument::new("t");
2310 for n in nodes {
2311 d.push(n);
2312 }
2313 d
2314 }
2315
2316 #[test]
2317 fn hierarchical_headings_and_items() {
2318 let doc = doc_with(vec![
2319 Node::Heading {
2320 level: 1,
2321 text: "Title".into(),
2322 },
2323 Node::Paragraph {
2324 text: "Intro".into(),
2325 },
2326 Node::Heading {
2327 level: 2,
2328 text: "Sec".into(),
2329 },
2330 Node::Paragraph {
2331 text: "Body".into(),
2332 },
2333 ]);
2334 let chunks = HierarchicalChunker.chunk(&doc);
2335 assert_eq!(chunks.len(), 2);
2336 assert_eq!(chunks[0].text, "Intro");
2337 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2338 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2339 assert_eq!(
2340 chunks[1].headings.as_deref(),
2341 Some(&["Title".into(), "Sec".into()][..])
2342 );
2343 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2344 }
2345
2346 #[test]
2349 fn page_furniture_keeps_refs_aligned_with_json() {
2350 let doc = doc_with(vec![
2351 Node::Paragraph {
2352 text: "Before".into(),
2353 },
2354 Node::PageFurniture {
2355 footer: true,
2356 location: [0, 490, 100, 512],
2357 text: "1.10.2".into(),
2358 },
2359 Node::Paragraph {
2360 text: "After".into(),
2361 },
2362 ]);
2363 let chunks = HierarchicalChunker.chunk(&doc);
2364 assert_eq!(chunks.len(), 2);
2365 assert_eq!(chunks[1].text, "After");
2366 let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2367 assert_eq!(json["texts"][2]["text"], "After");
2368 assert_eq!(chunks[1].doc_items[0].self_ref, "#/texts/2");
2369 }
2370
2371 #[test]
2375 fn picture_children_are_not_chunked_but_keep_refs_aligned() {
2376 let doc = doc_with(vec![
2377 Node::Picture {
2378 caption: None,
2379 caption_href: None,
2380 image: None,
2381 classification: None,
2382 description: None,
2383 caption_parent: crate::CaptionParent::Item,
2384 caption_location: None,
2385 },
2386 Node::PictureChildren(vec![
2387 Node::Paragraph {
2388 text: "axis label".into(),
2389 },
2390 Node::ListItem {
2391 ordered: false,
2392 number: 0,
2393 first_in_list: true,
2394 text: "callout".into(),
2395 level: 0,
2396 marker: None,
2397 location: None,
2398 dclx: None,
2399 href: None,
2400 layer: None,
2401 },
2402 ]),
2403 Node::Paragraph {
2404 text: "Body".into(),
2405 },
2406 ]);
2407 let chunks = HierarchicalChunker.chunk(&doc);
2408 assert_eq!(chunks.len(), 1);
2409 assert_eq!(chunks[0].text, "Body");
2410 let json = doc.export_to_json_value();
2411 assert_eq!(json["texts"][2]["text"], "Body");
2412 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/2");
2413 }
2414
2415 #[test]
2416 fn heading_shadowing_prunes_deeper_levels() {
2417 let doc = doc_with(vec![
2418 Node::Heading {
2419 level: 2,
2420 text: "A".into(),
2421 },
2422 Node::Heading {
2423 level: 3,
2424 text: "A.1".into(),
2425 },
2426 Node::Heading {
2427 level: 2,
2428 text: "B".into(),
2429 },
2430 Node::Paragraph { text: "p".into() },
2431 ]);
2432 let chunks = HierarchicalChunker.chunk(&doc);
2433 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2434 }
2435
2436 #[test]
2437 fn triplet_table() {
2438 let t = Table {
2439 rows: vec![
2440 vec!["".into(), "Col1".into()],
2441 vec!["Row1".into(), "v".into()],
2442 ],
2443 ..Default::default()
2444 };
2445 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2446 let single = Table {
2449 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2450 ..Default::default()
2451 };
2452 assert_eq!(triplet_table_text(&single), "a = b");
2453 }
2454
2455 fn quadratic_merge<T: ChunkTokenizer>(
2459 h: &HybridChunker<T>,
2460 chunks: Vec<DocChunk>,
2461 ) -> Vec<DocChunk> {
2462 let mut out = Vec::new();
2463 let mut window: Vec<DocChunk> = Vec::new();
2464 let flush = |window: &mut Vec<DocChunk>, out: &mut Vec<DocChunk>| match window.len() {
2465 0 => {}
2466 1 => out.push(window.pop().unwrap()),
2467 _ => {
2468 out.push(merged_chunk(window.iter()));
2469 window.clear();
2470 }
2471 };
2472 for chunk in chunks {
2473 if window.is_empty() {
2474 window.push(chunk);
2475 continue;
2476 }
2477 let candidate = merged_chunk(window.iter().chain([&chunk]));
2478 if chunk.headings == window[0].headings
2479 && h.count_chunk_tokens(&candidate) <= h.max_tokens()
2480 {
2481 window.push(chunk);
2482 } else {
2483 flush(&mut window, &mut out);
2484 window.push(chunk);
2485 }
2486 }
2487 flush(&mut window, &mut out);
2488 out
2489 }
2490
2491 fn linear_merge<T: ChunkTokenizer>(
2492 h: &HybridChunker<T>,
2493 chunks: Vec<DocChunk>,
2494 ) -> Vec<DocChunk> {
2495 let mut out = Vec::new();
2496 let mut sink = |c| {
2497 out.push(c);
2498 true
2499 };
2500 let mut m = PeerMerger::default();
2501 for c in chunks {
2502 assert!(h.merge_push(&mut m, c, &mut sink));
2503 }
2504 assert!(h.merge_flush(&mut m, &mut sink));
2505 out
2506 }
2507
2508 struct QuadCharTok(usize);
2513 impl ChunkTokenizer for QuadCharTok {
2514 fn count_tokens(&self, text: &str) -> usize {
2515 text.chars().count().div_ceil(4)
2516 }
2517 fn max_tokens(&self) -> usize {
2518 self.0
2519 }
2520 }
2521
2522 fn random_chunks(seed: u64, n: usize) -> Vec<DocChunk> {
2525 let mut state = seed;
2526 let mut next = move |m: u64| {
2527 state = state
2528 .wrapping_mul(6364136223846793005)
2529 .wrapping_add(1442695040888963407);
2530 (state >> 33) % m
2531 };
2532 let headings = [
2533 None,
2534 Some(vec!["A".to_string()]),
2535 Some(vec!["A".into(), "B".into()]),
2536 ];
2537 let mut current = 0;
2538 (0..n)
2539 .map(|i| {
2540 if next(4) == 0 {
2541 current = next(3) as usize;
2542 }
2543 let words = 1 + next(12) as usize;
2544 let text = (0..words)
2545 .map(|w| ["x", "lorem", "ipsum", "dolor", "sit"][(w + i) % 5])
2546 .collect::<Vec<_>>()
2547 .join(" ");
2548 DocChunk {
2549 text,
2550 headings: headings[current].clone(),
2551 doc_items: vec![ChunkItem {
2552 self_ref: format!("#/texts/{i}"),
2553 kind: ChunkItemKind::Text,
2554 text: String::new(),
2555 }],
2556 }
2557 })
2558 .collect()
2559 }
2560
2561 #[test]
2565 fn linear_peer_merge_matches_the_quadratic_one() {
2566 for seed in 0..40 {
2567 let chunks = random_chunks(seed, 60);
2568 for max in [3, 8, 16, 40, 200] {
2569 let h = HybridChunker::new(WordTok(max));
2570 assert_eq!(
2571 linear_merge(&h, chunks.clone()),
2572 quadratic_merge(&h, chunks.clone()),
2573 "words, seed {seed}, max {max}"
2574 );
2575 let h = HybridChunker::new(QuadCharTok(max));
2576 assert_eq!(
2577 linear_merge(&h, chunks.clone()),
2578 quadratic_merge(&h, chunks.clone()),
2579 "chars/4, seed {seed}, max {max}"
2580 );
2581 }
2582 }
2583 }
2584
2585 #[test]
2589 #[ignore]
2590 fn peer_merge_bench() {
2591 let chunks: Vec<DocChunk> = (0..10_000)
2592 .map(|i| DocChunk {
2593 text: format!("Paragraph number {i} is short."),
2594 headings: Some(vec!["Section".into()]),
2595 doc_items: Vec::new(),
2596 })
2597 .collect();
2598 let h = HybridChunker::new(WordTok(8192));
2599 let t = std::time::Instant::now();
2600 let fast = linear_merge(&h, chunks.clone());
2601 let linear = t.elapsed();
2602 let t = std::time::Instant::now();
2603 let slow = quadratic_merge(&h, chunks);
2604 let quadratic = t.elapsed();
2605 assert_eq!(fast, slow);
2606 println!(
2607 "linear {linear:?}, quadratic {quadratic:?}, {} chunks",
2608 fast.len()
2609 );
2610 }
2611
2612 fn cell_table(
2615 num_rows: usize,
2616 num_cols: usize,
2617 cells: &[(&str, usize, usize, usize, bool)],
2618 ) -> Table {
2619 let mut rows = vec![vec![String::new(); num_cols]; num_rows];
2620 for &(text, r, c, span, _) in cells {
2621 for row in rows.iter_mut().skip(r).take(span) {
2622 row[c] = text.into();
2623 }
2624 }
2625 Table {
2626 rows,
2627 cells: Some(
2628 cells
2629 .iter()
2630 .map(|&(text, r, c, span, ched)| crate::TableCell {
2631 text: text.into(),
2632 bbox: None,
2633 start_row: r,
2634 start_col: c,
2635 row_span: span,
2636 col_span: 1,
2637 column_header: ched,
2638 row_header: false,
2639 row_section: false,
2640 })
2641 .collect(),
2642 ),
2643 ..Default::default()
2644 }
2645 }
2646
2647 #[test]
2653 fn markdown_and_triplets_share_the_header_rows() {
2654 type Case<'a> = (
2655 &'a str,
2656 usize,
2657 usize,
2658 Vec<(&'a str, usize, usize, usize, bool)>,
2659 &'a str,
2660 &'a str,
2661 );
2662 let cases: Vec<Case> = vec![
2663 (
2664 "rows under a row-spanning header stay in the body",
2665 2,
2666 2,
2667 vec![("Price", 0, 0, 1, true), ("Category", 0, 1, 2, true), ("10.00", 1, 0, 1, false)],
2668 "| Price | Category |\n| - | - |\n| 10.00 | Category |",
2669 "10.00, Category = Category",
2670 ),
2671 (
2672 "a row header flagged as column header stays in the body",
2673 3,
2674 3,
2675 vec![
2676 ("Year", 0, 0, 1, true),
2677 ("Month", 0, 1, 1, true),
2678 ("Revenue", 0, 2, 1, true),
2679 ("2025", 1, 0, 2, true),
2680 ("January", 1, 1, 1, false),
2681 ("$134", 1, 2, 1, false),
2682 ("February", 2, 1, 1, false),
2683 ("$150", 2, 2, 1, false),
2684 ],
2685 "| Year | Month | Revenue |\n| - | - | - |\n| 2025 | January | $134 |\n| 2025 | February | $150 |",
2686 "2025, Month = January. 2025, Revenue = $134. 2025, Month = February. 2025, Revenue = $150",
2687 ),
2688 (
2689 "stacked header with an unmarked row label",
2690 3,
2691 3,
2692 vec![
2693 ("", 0, 0, 1, true),
2694 ("% of Total", 0, 1, 1, true),
2695 ("% of Total", 0, 2, 1, true),
2696 ("class label", 1, 0, 1, false),
2697 ("Train", 1, 1, 1, true),
2698 ("Test", 1, 2, 1, true),
2699 ("Caption", 2, 0, 1, false),
2700 ("2.5", 2, 1, 1, false),
2701 ("2.4", 2, 2, 1, false),
2702 ],
2703 "| class label | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.5 | 2.4 |",
2704 "Caption, % of Total.Train = 2.5. Caption, % of Total.Test = 2.4",
2705 ),
2706 (
2707 "a partially flagged first row stays the header",
2708 2,
2709 2,
2710 vec![("Name", 0, 0, 1, true), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2711 "| Name | Value |\n| - | - |\n| A | 1 |",
2712 "A, Value = 1",
2713 ),
2714 (
2715 "no flags: Markdown promotes row 0, triplets do not",
2716 2,
2717 2,
2718 vec![("Name", 0, 0, 1, false), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2719 "| Name | Value |\n| - | - |\n| A | 1 |",
2720 "Name, 1 = Value. A, 1 = 1",
2721 ),
2722 (
2723 "row headers flagged as column headers stay in the body",
2724 2,
2725 2,
2726 vec![("2025", 0, 0, 1, true), ("$134", 0, 1, 1, false), ("2026", 1, 0, 1, true), ("$150", 1, 1, 1, false)],
2727 "| | |\n| - | - |\n| 2025 | $134 |\n| 2026 | $150 |",
2728 "2025, 1 = $134. 2026, 1 = $150",
2729 ),
2730 (
2731 "a form with flagged first-column labels is all data",
2732 3,
2733 2,
2734 vec![
2735 ("Name", 0, 0, 1, true),
2736 ("Bob", 0, 1, 1, false),
2737 ("Age", 1, 0, 1, true),
2738 ("42", 1, 1, 1, false),
2739 ("City", 2, 0, 1, true),
2740 ("Paris", 2, 1, 1, false),
2741 ],
2742 "| | |\n| - | - |\n| Name | Bob |\n| Age | 42 |\n| City | Paris |",
2743 "Name, 1 = Bob. Age, 1 = 42. City, 1 = Paris",
2744 ),
2745 (
2746 "a partly flagged row 0 above a stacked header row",
2747 3,
2748 3,
2749 vec![
2750 ("Region", 0, 0, 1, true),
2751 ("2025", 0, 1, 1, false),
2752 ("", 0, 2, 1, false),
2753 ("", 1, 0, 1, false),
2754 ("Q1", 1, 1, 1, true),
2755 ("Q2", 1, 2, 1, true),
2756 ("North", 2, 0, 1, false),
2757 ("1", 2, 1, 1, false),
2758 ("2", 2, 2, 1, false),
2759 ],
2760 "| Region | 2025 - Q1 | Q2 |\n| - | - | - |\n| North | 1 | 2 |",
2761 "North, 2025.Q1 = 1. North, Q2 = 2",
2762 ),
2763 ];
2764 for (name, rows, cols, cells, md, triplets) in cases {
2765 let t = cell_table(rows, cols, &cells);
2766 assert_eq!(
2767 crate::markdown::render_table(&t, true),
2768 md,
2769 "{name}: markdown"
2770 );
2771 assert_eq!(triplet_table_text(&t), triplets, "{name}: triplets");
2772 }
2773 }
2774
2775 #[test]
2780 fn a_column_spanning_rich_cell_repeats_in_every_column() {
2781 let mut t = cell_table(
2782 2,
2783 3,
2784 &[
2785 ("", 0, 0, 1, true),
2786 ("Partner", 0, 1, 1, true),
2787 ("Status", 1, 0, 1, false),
2788 ("Done", 1, 1, 1, false),
2789 ("Open", 1, 2, 1, false),
2790 ],
2791 );
2792 if let Some(cells) = t.cells.as_mut() {
2793 cells[1].col_span = 2;
2794 }
2795 let mut blocks = vec![vec![Vec::new(); 3]; 2];
2796 blocks[0][1] = vec![Node::Paragraph {
2797 text: "Partner".into(),
2798 }];
2799 t.cell_blocks = Some(blocks);
2800 assert_eq!(
2801 t.rows[0][2], "",
2802 "the covered position carries no text of its own"
2803 );
2804 assert_eq!(
2805 triplet_table_text(&t),
2806 "Status, Partner = Done. Status, Partner = Open"
2807 );
2808 let latex = crate::latex::render_table(&t);
2809 assert!(
2810 latex.contains(" & Partner & Partner \\\\ \\hline\nStatus & Done & Open \\\\ \\hline"),
2811 "{latex}"
2812 );
2813 t.rows[0][2] = "Partner".into();
2815 assert_eq!(
2816 crate::markdown::render_table(&t, true),
2817 "| | Partner | Partner |\n| - | - | - |\n| Status | Done | Open |"
2818 );
2819 }
2820
2821 #[test]
2822 fn hybrid_merges_small_peers_and_splits_large() {
2823 let doc = doc_with(vec![
2824 Node::Heading {
2825 level: 2,
2826 text: "S".into(),
2827 },
2828 Node::Paragraph { text: "a b".into() },
2829 Node::Paragraph { text: "c d".into() },
2830 ]);
2831 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2832 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2833 assert_eq!(chunks[0].text, "a b\nc d");
2834
2835 let long = "w ".repeat(40).trim().to_string();
2836 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2837 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2838 assert!(chunks.len() > 1, "oversized paragraph splits");
2839 for c in &chunks {
2840 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2841 }
2842 }
2843
2844 #[test]
2845 fn semchunk_prefers_newlines_then_sentences() {
2846 let tok = WordTok(4);
2847 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2848 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2849 }
2850}