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::DoclangOnly(_) => {}
704 Node::ListItem { .. } => self.sibling_lists(std::slice::from_ref(node)),
707 }
708 }
709}
710
711fn level_of(node: &Node) -> u8 {
712 match node {
713 Node::ListItem { level, .. } => *level,
714 _ => 0,
715 }
716}
717
718fn render_list(items: &[Node]) -> String {
721 let mut lines: Vec<String> = Vec::new();
722 for item in items {
723 let Node::ListItem {
724 ordered,
725 number,
726 text,
727 level,
728 layer,
729 ..
730 } = item
731 else {
732 continue;
733 };
734 if layer.is_some() {
735 continue;
736 }
737 let indent = " ".repeat(*level as usize);
738 let marker = if *ordered {
739 format!("{number}.")
740 } else {
741 "-".to_string()
742 };
743 lines.push(format!(
744 "{indent}{marker} {}",
745 unescape_text(&strip_image_markers(text))
746 ));
747 }
748 lines.join("\n")
749}
750
751fn strip_image_markers(text: &str) -> String {
756 if !text.contains("<!-- image -->") {
757 return text.to_string();
758 }
759 let cleaned: Vec<&str> = text
760 .split('\n')
761 .map(str::trim_end)
762 .filter(|l| *l != "<!-- image -->")
763 .collect();
764 cleaned.join("\n").trim_end().to_string()
765}
766
767fn humanize_label(label: &str) -> String {
770 let text = label.replace('_', " ");
771 let mut chars = text.chars();
772 match chars.next() {
773 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
774 None => text,
775 }
776}
777
778fn triplet_table_text(t: &Table) -> String {
785 let anchors = t.span_anchors();
786 let rows: Vec<Vec<String>> = t
787 .rows
788 .iter()
789 .enumerate()
790 .map(|(ri, r)| {
791 (0..r.len())
792 .map(|ci| cell_chunk_text(t, &anchors, ri, ci))
793 .collect()
794 })
795 .collect();
796 let num_rows = rows.len();
797 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
798 if num_rows == 0 || num_cols == 0 {
799 return String::new();
800 }
801 let cell = |r: usize, c: usize| -> &str {
802 rows.get(r)
803 .and_then(|row| row.get(c))
804 .map(String::as_str)
805 .unwrap_or("")
806 };
807
808 let num_headers = t.column_header_row_count().min(num_rows);
812
813 let columns: Vec<String> = if num_headers > 0 {
816 (0..num_cols)
817 .map(|c| {
818 let mut name = String::new();
819 for r in 0..num_headers {
820 if !name.is_empty() {
821 name.push('.');
822 }
823 name.push_str(cell(r, c));
824 }
825 name
826 })
827 .collect()
828 } else {
829 (0..num_cols).map(|c| c.to_string()).collect()
830 };
831 let data_rows = num_headers..num_rows;
832 let n_data = data_rows.len();
833
834 if n_data == 0 {
836 return columns
837 .iter()
838 .map(|s| s.trim())
839 .filter(|s| !s.is_empty())
840 .collect::<Vec<_>>()
841 .join(". ");
842 }
843
844 let data = |r: usize, c: usize| -> &str { cell(num_headers + r, c) };
845 let text = if num_cols == 1 {
846 let col_name = data(0, 0).trim().to_string();
849 if n_data == 1 {
850 col_name
851 } else {
852 (1..n_data)
853 .map(|r| format!("{col_name} = {}", data(r, 0).trim()))
854 .collect::<Vec<_>>()
855 .join(". ")
856 }
857 } else {
858 let mut parts = Vec::new();
860 for r in 0..n_data {
861 for (c, col_name) in columns.iter().enumerate().skip(1) {
862 parts.push(format!(
863 "{}, {} = {}",
864 data(r, 0).trim(),
865 col_name.trim(),
866 data(r, c).trim()
867 ));
868 }
869 }
870 parts.join(". ")
871 };
872 if !text.is_empty() {
873 return text;
874 }
875
876 (0..n_data)
879 .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
880 .map(|(r, c)| data(r, c).trim())
881 .filter(|s| !s.is_empty())
882 .collect::<Vec<_>>()
883 .join(". ")
884}
885
886fn inline_segments(md: &str) -> Vec<String> {
894 inline_segments_tagged(md)
895 .into_iter()
896 .map(|(t, _)| t)
897 .collect()
898}
899
900fn inline_segments_tagged(md: &str) -> Vec<(String, bool)> {
904 let chars: Vec<char> = md.chars().collect();
905 let n = chars.len();
906 let find = |from: usize, pat: &str| -> Option<usize> {
907 let hay: String = chars[from..].iter().collect();
908 hay.find(pat).map(|p| from + hay[..p].chars().count())
909 };
910 let mut out: Vec<(String, bool)> = Vec::new();
911 let mut plain = String::new();
912 let mut after_span = false;
913
914 fn flush(
915 out: &mut Vec<(String, bool)>,
916 plain: &mut String,
917 before_span: bool,
918 after_span: bool,
919 ) {
920 let mut p = std::mem::take(plain);
921 if after_span {
922 if let Some(rest) = p.strip_prefix(' ') {
923 p = rest.to_string();
924 }
925 }
926 if before_span {
927 if let Some(rest) = p.strip_suffix(' ') {
928 p = rest.to_string();
929 }
930 }
931 if !p.is_empty() {
932 out.push((unescape_text(&p), true));
933 }
934 }
935
936 let mut i = 0;
937 while i < n {
938 let rest: String = chars[i..].iter().collect();
939 if chars[i] == '[' && !rest.starts_with("[](") {
943 let balanced = |c: usize| {
947 let mut d = 0i32;
948 for &ch in &chars[i + 1..c] {
949 match ch {
950 '[' => d += 1,
951 ']' => d -= 1,
952 _ => {}
953 }
954 }
955 d == 0
956 };
957 if let Some(close) = find(i + 1, "](").filter(|&c| balanced(c)) {
958 let mut depth = 0usize;
959 let mut url_end = None;
960 for (k, &c) in chars.iter().enumerate().skip(close + 2) {
961 match c {
962 '(' => depth += 1,
963 ')' => {
964 if depth == 0 {
965 url_end = Some(k);
966 break;
967 }
968 depth -= 1;
969 }
970 _ => {}
971 }
972 }
973 if let Some(endp) = url_end {
974 flush(&mut out, &mut plain, true, after_span);
975 out.push((
976 unescape_text(&chars[i..=endp].iter().collect::<String>()),
977 false,
978 ));
979 i = endp + 1;
980 after_span = true;
981 continue;
982 }
983 }
984 }
985 let mut matched = false;
988 for marker in ["***", "**", "*", "~~", "`"] {
989 if rest.starts_with(marker) {
990 let mlen = marker.chars().count();
991 if let Some(end) = find(i + mlen, marker) {
992 let inner_blank = chars[i + mlen..end].iter().all(|c| c.is_whitespace());
996 if end > i + mlen && !inner_blank {
997 flush(&mut out, &mut plain, true, after_span);
998 if marker == "`" {
999 let inner: String = chars[i + 1..end].iter().collect();
1000 out.push((format!("```\n{}\n```", unescape_text(&inner)), false));
1001 } else {
1002 out.push((
1003 unescape_text(&chars[i..end + mlen].iter().collect::<String>()),
1004 false,
1005 ));
1006 }
1007 i = end + mlen;
1008 after_span = true;
1009 matched = true;
1010 }
1011 }
1012 break;
1013 }
1014 }
1015 if matched {
1016 continue;
1017 }
1018 if rest.starts_with("$$") {
1021 plain.push_str("$$");
1022 i += 2;
1023 continue;
1024 }
1025 if chars[i] == '$' {
1027 if let Some(end) = find(i + 1, "$") {
1028 if end > i + 1 {
1029 flush(&mut out, &mut plain, true, after_span);
1030 let latex: String = chars[i + 1..end].iter().collect();
1031 out.push((format!("$${latex}$$"), false));
1032 i = end + 1;
1033 after_span = true;
1034 continue;
1035 }
1036 }
1037 }
1038 plain.push(chars[i]);
1039 i += 1;
1040 }
1041 flush(&mut out, &mut plain, false, after_span);
1042 if out.is_empty() {
1043 out.push((unescape_text(md), true));
1044 }
1045 out
1046}
1047
1048fn split_plain_by_runs(segment: &str, runs: &[crate::InlineRun]) -> Option<Vec<String>> {
1053 let target = segment.trim();
1054 if target.is_empty() {
1055 return None;
1056 }
1057 let plainish =
1058 |r: &crate::InlineRun| !r.bold && !r.italic && !r.strike && !r.code && !r.formula;
1059 let fully_plain =
1060 |r: &crate::InlineRun| plainish(r) && !r.underline && r.script == crate::Script::Baseline;
1061 let unmarked: Vec<(&str, bool)> = runs
1062 .iter()
1063 .filter(|r| plainish(r))
1064 .map(|r| (r.text.as_str(), fully_plain(r)))
1065 .collect();
1066 for start in 0..unmarked.len() {
1067 let mut rest = target;
1068 let mut taken: Vec<(String, bool)> = Vec::new();
1069 for (t, fully) in &unmarked[start..] {
1070 let t = t.trim();
1071 if t.is_empty() {
1072 continue;
1073 }
1074 match rest.strip_prefix(t) {
1075 Some(r) => {
1076 taken.push((unescape_text(t), *fully));
1077 rest = r.trim_start();
1078 if rest.is_empty() {
1079 break;
1080 }
1081 }
1082 None => break,
1083 }
1084 }
1085 if rest.is_empty() && taken.len() >= 2 {
1086 let mut merged: Vec<(String, bool)> = Vec::new();
1091 for (t, fully) in taken {
1092 match merged.last_mut() {
1093 Some((last, true)) if fully => {
1094 last.push(' ');
1095 last.push_str(&t);
1096 }
1097 _ => merged.push((t, fully)),
1098 }
1099 }
1100 if merged.len() >= 2 {
1101 return Some(merged.into_iter().map(|(t, _)| t).collect());
1102 }
1103 return None;
1104 }
1105 }
1106 None
1107}
1108
1109fn cell_chunk_text(t: &Table, anchors: &[Vec<(usize, usize)>], r: usize, c: usize) -> String {
1116 let (ar, ac) = anchors
1119 .get(r)
1120 .and_then(|row| row.get(c))
1121 .copied()
1122 .unwrap_or((r, c));
1123 if let Some(blocks) = t
1124 .cell_blocks
1125 .as_ref()
1126 .and_then(|b| b.get(ar))
1127 .and_then(|row| row.get(ac))
1128 .filter(|b| !b.is_empty())
1129 {
1130 let mut parts: Vec<String> = Vec::new();
1131 for node in blocks.iter() {
1132 let part = block_chunk_text(node);
1133 if !part.is_empty() {
1134 parts.push(part);
1135 }
1136 }
1137 return parts.join("\n\n");
1138 }
1139 let flat = t
1140 .rows
1141 .get(r)
1142 .and_then(|row| row.get(c))
1143 .map(String::as_str)
1144 .unwrap_or("");
1145 unescape_text(flat)
1146 .replace("<!-- image -->", "")
1147 .trim()
1148 .to_string()
1149}
1150
1151fn block_chunk_text(node: &Node) -> String {
1153 match node {
1154 Node::Paragraph { text } | Node::ListText { text, .. } => unescape_text(text),
1155 Node::LabeledText { .. } => block_chunk_text(&node.labeled_as_paragraph()),
1156 Node::InlineGroup { md_text, .. } => unescape_text(md_text),
1157 Node::Code { text, .. } => format!("```\n{}\n```", unescape_text(text)),
1158 Node::Table(inner) => triplet_table_text(inner),
1159 Node::Picture { caption, .. } => caption
1160 .as_deref()
1161 .filter(|c| !c.is_empty())
1162 .map(unescape_text)
1163 .unwrap_or_default(),
1164 Node::ListItem {
1165 ordered,
1166 number,
1167 text,
1168 ..
1169 } => {
1170 let marker = if *ordered {
1171 format!("{number}.")
1172 } else {
1173 "-".to_string()
1174 };
1175 format!("{marker} {}", unescape_text(text))
1176 }
1177 Node::CheckboxItem { checked, text } => {
1178 let mark = if *checked { "- [x] " } else { "- [ ] " };
1179 format!("{mark}{}", unescape_text(text))
1180 }
1181 Node::Heading { text, .. } => unescape_text(text),
1182 Node::Located { inner, .. }
1183 | Node::Prov { inner, .. }
1184 | Node::Track { inner, .. }
1185 | Node::Commented { inner, .. } => block_chunk_text(inner),
1186 Node::Group { layer: Some(_), .. } => String::new(),
1187 Node::Group { children, .. } => children
1188 .iter()
1189 .map(block_chunk_text)
1190 .filter(|s| !s.is_empty())
1191 .collect::<Vec<_>>()
1192 .join("\n"),
1193 _ => String::new(),
1194 }
1195}
1196
1197fn unescape_text(s: &str) -> String {
1200 s.replace("<", "<")
1201 .replace(">", ">")
1202 .replace("&", "&")
1203 .replace("\\_", "_")
1204}
1205
1206pub trait ChunkTokenizer {
1212 fn count_tokens(&self, text: &str) -> usize;
1214 fn max_tokens(&self) -> usize;
1216}
1217
1218pub struct HybridChunker<T: ChunkTokenizer> {
1222 tokenizer: T,
1223 merge_peers: bool,
1224}
1225
1226impl<T: ChunkTokenizer> HybridChunker<T> {
1227 pub fn new(tokenizer: T) -> Self {
1228 Self {
1229 tokenizer,
1230 merge_peers: true,
1231 }
1232 }
1233
1234 pub fn with_merge_peers(mut self, merge_peers: bool) -> Self {
1236 self.merge_peers = merge_peers;
1237 self
1238 }
1239
1240 pub fn max_tokens(&self) -> usize {
1241 self.tokenizer.max_tokens()
1242 }
1243
1244 pub fn chunk(&self, doc: &DoclingDocument) -> Vec<DocChunk> {
1246 let mut chunks = Vec::new();
1247 self.chunk_with(doc, &mut |c| {
1248 chunks.push(c);
1249 true
1250 });
1251 chunks
1252 }
1253
1254 pub fn chunk_with(&self, doc: &DoclingDocument, sink: &mut dyn FnMut(DocChunk) -> bool) {
1261 let mut merger = PeerMerger::default();
1262 let mut alive = true;
1263 HierarchicalChunker.chunk_with(doc, &mut |c| {
1264 for split in self.split_by_doc_items(c) {
1265 for chunk in self.split_using_plain_text(split) {
1266 if !alive {
1267 return false;
1268 }
1269 alive = if self.merge_peers {
1270 self.merge_push(&mut merger, chunk, sink)
1271 } else {
1272 sink(chunk)
1273 };
1274 }
1275 }
1276 alive
1277 });
1278 if alive {
1279 self.merge_flush(&mut merger, sink);
1280 }
1281 }
1282
1283 fn count_chunk_tokens(&self, chunk: &DocChunk) -> usize {
1284 self.tokenizer.count_tokens(&contextualize(chunk))
1285 }
1286
1287 fn window_chunk(&self, chunk: &DocChunk, start: usize, end: usize) -> DocChunk {
1290 let doc_items: Vec<ChunkItem> = chunk.doc_items[start..=end].to_vec();
1291 let text = if chunk.doc_items.len() == 1 {
1292 chunk.text.clone()
1293 } else {
1294 doc_items
1295 .iter()
1296 .filter(|it| !it.text.is_empty())
1297 .map(|it| it.text.as_str())
1298 .collect::<Vec<_>>()
1299 .join("\n")
1300 };
1301 DocChunk {
1302 text,
1303 headings: chunk.headings.clone(),
1304 doc_items,
1305 }
1306 }
1307
1308 fn split_by_doc_items(&self, chunk: DocChunk) -> Vec<DocChunk> {
1309 if chunk.doc_items.is_empty() {
1310 return vec![chunk];
1311 }
1312 let max = self.max_tokens();
1313 let num_items = chunk.doc_items.len();
1314 let mut chunks = Vec::new();
1315 let mut window_start = 0usize;
1316 let mut window_end = 0usize; while window_end < num_items {
1318 let mut new_chunk = self.window_chunk(&chunk, window_start, window_end);
1319 if self.count_chunk_tokens(&new_chunk) <= max {
1320 if window_end < num_items - 1 {
1321 window_end += 1;
1322 continue;
1323 } else {
1324 window_end = num_items; }
1326 } else if window_start == window_end {
1327 window_end += 1;
1330 window_start = window_end;
1331 } else {
1332 new_chunk = self.window_chunk(&chunk, window_start, window_end - 1);
1335 window_start = window_end;
1336 }
1337 chunks.push(new_chunk);
1338 }
1339 chunks
1340 }
1341
1342 fn split_using_plain_text(&self, chunk: DocChunk) -> Vec<DocChunk> {
1343 let total = self.count_chunk_tokens(&chunk);
1344 let max = self.max_tokens();
1345 if total <= max {
1346 return vec![chunk];
1347 }
1348 let text_len = self.tokenizer.count_tokens(&chunk.text);
1349 let other_len = total - text_len;
1350 if other_len >= max {
1351 let stripped = DocChunk {
1353 headings: None,
1354 ..chunk
1355 };
1356 return self.split_using_plain_text(stripped);
1357 }
1358 let available = max - other_len;
1359
1360 let segments =
1361 if chunk.doc_items.len() == 1 && chunk.doc_items[0].kind == ChunkItemKind::Table {
1362 let lines: Vec<String> = chunk
1369 .text
1370 .split('\n')
1371 .filter(|l| !l.trim().is_empty())
1372 .map(|l| l.to_string())
1373 .collect();
1374 line_chunk_text(&lines, &self.tokenizer, max)
1375 } else {
1376 semchunk(&chunk.text, available, &self.tokenizer)
1377 };
1378 segments
1379 .into_iter()
1380 .map(|s| DocChunk {
1381 text: s,
1382 headings: chunk.headings.clone(),
1383 doc_items: chunk.doc_items.clone(),
1384 })
1385 .collect()
1386 }
1387
1388 fn merge_push(
1403 &self,
1404 m: &mut PeerMerger,
1405 chunk: DocChunk,
1406 sink: &mut dyn FnMut(DocChunk) -> bool,
1407 ) -> bool {
1408 self.merge_feed(m, VecDeque::from([(chunk, None)]), sink)
1409 }
1410
1411 fn merge_feed(
1414 &self,
1415 m: &mut PeerMerger,
1416 mut pending: VecDeque<(DocChunk, Option<usize>)>,
1417 sink: &mut dyn FnMut(DocChunk) -> bool,
1418 ) -> bool {
1419 while let Some((chunk, tokens)) = pending.pop_front() {
1420 let tokens = tokens.unwrap_or_else(|| self.tokenizer.count_tokens(&chunk.text));
1421 if let Err(chunk) = self.merge_extend(m, chunk, tokens) {
1422 let (alive, rest) = self.merge_close(m, sink);
1423 if !alive {
1424 return false;
1425 }
1426 pending.push_front((chunk, Some(tokens)));
1427 for item in rest.into_iter().rev() {
1428 pending.push_front(item);
1429 }
1430 }
1431 }
1432 true
1433 }
1434
1435 fn merge_extend(
1438 &self,
1439 m: &mut PeerMerger,
1440 chunk: DocChunk,
1441 tokens: usize,
1442 ) -> Result<(), DocChunk> {
1443 let max = self.max_tokens() as i64;
1444 if m.window.is_empty() {
1445 m.overhead = self.count_chunk_tokens(&chunk) as i64 - tokens as i64;
1448 m.running = tokens as i64;
1449 m.window.push((chunk, tokens));
1450 return Ok(());
1451 }
1452 if chunk.headings != m.window[0].0.headings {
1453 return Err(chunk);
1454 }
1455 let delim = *m.delim.get_or_insert_with(|| {
1456 let t = &self.tokenizer;
1458 t.count_tokens("a\nb") as i64 - t.count_tokens("a") as i64 - t.count_tokens("b") as i64
1459 });
1460 m.running += tokens as i64 + delim;
1461 if m.running + m.overhead > max {
1462 let exact = self.count_chunk_tokens(&merged_chunk(
1463 m.window.iter().map(|(c, _)| c).chain([&chunk]),
1464 )) as i64;
1465 if exact > max {
1466 return Err(chunk);
1467 }
1468 m.running = exact - m.overhead;
1470 }
1471 m.window.push((chunk, tokens));
1472 Ok(())
1473 }
1474
1475 fn merge_close(
1479 &self,
1480 m: &mut PeerMerger,
1481 sink: &mut dyn FnMut(DocChunk) -> bool,
1482 ) -> (bool, Vec<(DocChunk, Option<usize>)>) {
1483 if m.window.is_empty() {
1484 return (true, Vec::new());
1485 }
1486 let max = self.max_tokens();
1487 let mut fit = m.window.len() - 1;
1488 while fit > 0
1489 && self.count_chunk_tokens(&merged_chunk(m.window[..=fit].iter().map(|(c, _)| c))) > max
1490 {
1491 fit -= 1;
1492 }
1493 let rest = m
1494 .window
1495 .split_off(fit + 1)
1496 .into_iter()
1497 .map(|(c, t)| (c, Some(t)))
1498 .collect();
1499 let out = if m.window.len() == 1 {
1500 m.window.pop().expect("single-chunk window").0
1501 } else {
1502 let merged = merged_chunk(m.window.iter().map(|(c, _)| c));
1503 m.window.clear();
1504 merged
1505 };
1506 (sink(out), rest)
1507 }
1508
1509 fn merge_flush(&self, m: &mut PeerMerger, sink: &mut dyn FnMut(DocChunk) -> bool) -> bool {
1512 while !m.window.is_empty() {
1513 let (alive, rest) = self.merge_close(m, sink);
1514 if !alive || !self.merge_feed(m, rest.into(), sink) {
1515 return false;
1516 }
1517 }
1518 true
1519 }
1520}
1521
1522fn merged_chunk<'a>(chunks: impl Iterator<Item = &'a DocChunk> + Clone) -> DocChunk {
1525 let mut first = chunks.clone();
1526 DocChunk {
1527 text: chunks
1528 .clone()
1529 .map(|c| c.text.as_str())
1530 .collect::<Vec<_>>()
1531 .join("\n"),
1532 headings: first.next().and_then(|c| c.headings.clone()),
1533 doc_items: chunks.flat_map(|c| c.doc_items.iter().cloned()).collect(),
1534 }
1535}
1536
1537#[derive(Default)]
1542struct PeerMerger {
1543 window: Vec<(DocChunk, usize)>,
1544 running: i64,
1545 overhead: i64,
1546 delim: Option<i64>,
1547}
1548
1549fn line_chunk_text<T: ChunkTokenizer>(lines: &[String], tok: &T, max_tokens: usize) -> Vec<String> {
1559 let mut chunks: Vec<String> = Vec::new();
1560 let mut current = String::new();
1561 let mut current_len = 0usize;
1562
1563 for line in lines {
1564 let mut remaining: Vec<char> = line.chars().collect();
1565 loop {
1566 let rem_str: String = remaining.iter().collect();
1567 let line_tokens = tok.count_tokens(&rem_str);
1568 let available = max_tokens.saturating_sub(current_len);
1569
1570 if line_tokens <= available {
1571 current.push_str(&rem_str);
1572 current_len += line_tokens;
1573 break;
1574 }
1575 if line_tokens <= max_tokens {
1576 chunks.push(std::mem::take(&mut current));
1577 current_len = 0;
1578 continue;
1579 }
1580 let (mut take, rest) = split_by_token_limit(&remaining, available, tok);
1582 let mut rest = rest;
1583 if take.is_empty() {
1584 if rest.is_empty() {
1585 break;
1586 }
1587 take = rest[..1].iter().collect();
1588 rest = rest[1..].to_vec();
1589 }
1590 current.push('\n');
1591 current.push_str(&take);
1592 chunks.push(std::mem::take(&mut current));
1593 current_len = 0;
1594 remaining = rest;
1595 }
1596 }
1597 if !current.is_empty() {
1598 chunks.push(current);
1599 }
1600 chunks
1601}
1602
1603fn split_by_token_limit<T: ChunkTokenizer>(
1607 text: &[char],
1608 token_limit: usize,
1609 tok: &T,
1610) -> (String, Vec<char>) {
1611 if token_limit == 0 || text.is_empty() {
1612 return (String::new(), text.to_vec());
1613 }
1614 let full: String = text.iter().collect();
1615 if tok.count_tokens(&full) <= token_limit {
1616 return (full, Vec::new());
1617 }
1618 let (mut lo, mut hi) = (0usize, text.len());
1619 let mut best: Option<usize> = None;
1620 while lo <= hi {
1621 let mid = (lo + hi) / 2;
1622 let head: String = text[..mid].iter().collect();
1623 if tok.count_tokens(&head) <= token_limit {
1624 best = Some(mid);
1625 lo = mid + 1;
1626 } else {
1627 if mid == 0 {
1628 break;
1629 }
1630 hi = mid - 1;
1631 }
1632 }
1633 let mut best_idx = match best {
1634 Some(b) if b > 0 => b,
1635 _ => return (String::new(), text.to_vec()),
1636 };
1637 if let Some(pos) = text[..best_idx].iter().rposition(|c| *c == ' ') {
1639 if pos > 0 {
1640 best_idx = pos;
1641 }
1642 }
1643 (text[..best_idx].iter().collect(), text[best_idx..].to_vec())
1644}
1645
1646const NON_WS_SPLITTERS: &[&str] = &[
1652 ".", "?", "!", "*", ";", ",", "(", ")", "[", "]", "\u{201c}", "\u{201d}", "\u{2018}",
1653 "\u{2019}", "'", "\"", "`", ":", "\u{2014}", "\u{2026}", "/", "\\", "\u{2013}", "&", "-",
1654];
1655
1656pub fn semchunk<T: ChunkTokenizer>(text: &str, chunk_size: usize, tok: &T) -> Vec<String> {
1660 let mut cache: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1661 let mut counter = |s: &str| -> usize {
1662 if let Some(n) = cache.get(s) {
1663 return *n;
1664 }
1665 let n = tok.count_tokens(s);
1666 cache.insert(s.to_string(), n);
1667 n
1668 };
1669 let chunks = semchunk_rec(text, chunk_size, &mut counter);
1670 chunks
1672 .into_iter()
1673 .filter(|c| !c.is_empty() && !c.chars().all(char::is_whitespace))
1674 .collect()
1675}
1676
1677fn semchunk_rec(
1680 text: &str,
1681 chunk_size: usize,
1682 counter: &mut dyn FnMut(&str) -> usize,
1683) -> Vec<String> {
1684 let (splitter, splitter_is_ws, splits) = split_text(text);
1685
1686 let split_lens: Vec<usize> = splits.iter().map(|s| s.chars().count()).collect();
1687 let mut cum_lens = Vec::with_capacity(splits.len() + 1);
1688 cum_lens.push(0usize);
1689 for l in &split_lens {
1690 cum_lens.push(cum_lens.last().unwrap() + l);
1691 }
1692 let num_splits_plus_one = splits.len() + 1;
1693
1694 let mut chunks: Vec<String> = Vec::new();
1695 let mut skips: std::collections::HashSet<usize> = std::collections::HashSet::new();
1696
1697 for i in 0..splits.len() {
1698 if skips.contains(&i) {
1699 continue;
1700 }
1701 let split = &splits[i];
1702 if counter(split) > chunk_size {
1703 let inner = semchunk_rec(split, chunk_size, counter);
1704 chunks.extend(inner);
1705 } else {
1706 let (end, merged) = merge_splits(
1707 &splits,
1708 &cum_lens,
1709 chunk_size,
1710 &splitter,
1711 counter,
1712 i,
1713 num_splits_plus_one,
1714 );
1715 for j in (i + 1)..end {
1716 skips.insert(j);
1717 }
1718 chunks.push(merged);
1719 }
1720 let is_last = i == splits.len() - 1 || ((i + 1)..splits.len()).all(|j| skips.contains(&j));
1723 if !splitter_is_ws && !is_last {
1724 let with_splitter = format!(
1725 "{}{}",
1726 chunks.last().map(String::as_str).unwrap_or(""),
1727 splitter
1728 );
1729 if counter(&with_splitter) <= chunk_size {
1730 if let Some(last) = chunks.last_mut() {
1731 *last = with_splitter;
1732 } else {
1733 chunks.push(with_splitter);
1734 }
1735 } else {
1736 chunks.push(splitter.clone());
1737 }
1738 }
1739 }
1740 chunks
1741}
1742
1743fn merge_splits(
1746 splits: &[String],
1747 cum_lens: &[usize],
1748 chunk_size: usize,
1749 splitter: &str,
1750 counter: &mut dyn FnMut(&str) -> usize,
1751 start: usize,
1752 high_init: usize,
1753) -> (usize, String) {
1754 let mut average = 0.2f64;
1755 let mut low = start;
1756 let mut high = high_init;
1757 let offset = cum_lens[start];
1758 let mut target = offset as f64 + (chunk_size as f64 * average);
1759
1760 while low < high {
1761 let i = bisect_left(cum_lens, target, low, high);
1762 let midpoint = i.min(high - 1);
1763 let joined = splits[start..midpoint.max(start)].join(splitter);
1764 let tokens = counter(&joined);
1765 let local_cum = cum_lens[midpoint] - offset;
1766 if local_cum > 0 && tokens > 0 {
1767 average = local_cum as f64 / tokens as f64;
1768 target = offset as f64 + (chunk_size as f64 * average);
1769 }
1770 if tokens > chunk_size {
1771 high = midpoint;
1772 } else {
1773 low = midpoint + 1;
1774 }
1775 }
1776 let end = low - 1;
1777 (end, splits[start..end.max(start)].join(splitter))
1778}
1779
1780fn bisect_left(sorted: &[usize], target: f64, mut low: usize, mut high: usize) -> usize {
1781 while low < high {
1782 let mid = (low + high) / 2;
1783 if (sorted[mid] as f64) < target {
1784 low = mid + 1;
1785 } else {
1786 high = mid;
1787 }
1788 }
1789 low
1790}
1791
1792fn split_text(text: &str) -> (String, bool, Vec<String>) {
1794 if text.contains('\n') || text.contains('\r') {
1796 let splitter = longest_run(text, |c| c == '\n' || c == '\r');
1797 return (splitter.clone(), true, split_on(text, &splitter));
1798 }
1799 if text.contains('\t') {
1801 let splitter = longest_run(text, |c| c == '\t');
1802 return (splitter.clone(), true, split_on(text, &splitter));
1803 }
1804 if text.chars().any(char::is_whitespace) {
1806 let splitter = longest_run(text, char::is_whitespace);
1807 if splitter.chars().count() == 1 {
1808 for preceder in NON_WS_SPLITTERS {
1810 if let Some((ws, parts)) = split_after_preceder(text, preceder) {
1811 return (ws, true, parts);
1812 }
1813 }
1814 }
1815 return (splitter.clone(), true, split_on(text, &splitter));
1816 }
1817 for s in NON_WS_SPLITTERS {
1819 if text.contains(s) {
1820 return (s.to_string(), false, split_on(text, s));
1821 }
1822 }
1823 (
1825 String::new(),
1826 true,
1827 text.chars().map(|c| c.to_string()).collect(),
1828 )
1829}
1830
1831fn longest_run(text: &str, pred: impl Fn(char) -> bool) -> String {
1833 let mut best = String::new();
1834 let mut cur = String::new();
1835 for c in text.chars() {
1836 if pred(c) {
1837 cur.push(c);
1838 } else {
1839 if cur.chars().count() > best.chars().count() {
1840 best = cur.clone();
1841 }
1842 cur.clear();
1843 }
1844 }
1845 if cur.chars().count() > best.chars().count() {
1846 best = cur;
1847 }
1848 best
1849}
1850
1851fn split_on(text: &str, splitter: &str) -> Vec<String> {
1852 text.split(splitter).map(str::to_string).collect()
1853}
1854
1855fn split_after_preceder(text: &str, preceder: &str) -> Option<(String, Vec<String>)> {
1859 let chars: Vec<char> = text.chars().collect();
1860 let p: Vec<char> = preceder.chars().collect();
1861 let mut ws: Option<char> = None;
1862 for i in p.len()..chars.len() {
1863 if chars[i].is_whitespace() && chars[i - p.len()..i] == p[..] {
1864 ws = Some(chars[i]);
1865 break;
1866 }
1867 }
1868 let ws = ws?;
1869 let mut parts = Vec::new();
1870 let mut cur = String::new();
1871 let mut i = 0usize;
1872 while i < chars.len() {
1873 if chars[i] == ws && i >= p.len() && chars[i - p.len()..i] == p[..] {
1874 parts.push(std::mem::take(&mut cur));
1875 i += 1;
1876 continue;
1877 }
1878 cur.push(chars[i]);
1879 i += 1;
1880 }
1881 parts.push(cur);
1882 Some((ws.to_string(), parts))
1883}
1884
1885#[cfg(feature = "chunking")]
1890mod hf {
1891 use super::ChunkTokenizer;
1892
1893 pub const DEFAULT_TOKENIZER_PATH: &str = ".models/chunk/tokenizer.json";
1898
1899 pub fn resolve_tokenizer_path(explicit: Option<&str>) -> Result<String, String> {
1904 if let Some(p) = explicit {
1905 return Ok(p.to_string());
1906 }
1907 let resolved = crate::assets::resolve(DEFAULT_TOKENIZER_PATH);
1908 if std::path::Path::new(&resolved).exists() {
1909 return Ok(resolved);
1910 }
1911 Err(format!(
1912 "the hybrid chunker needs a HuggingFace tokenizer.json: none passed and \
1913 {DEFAULT_TOKENIZER_PATH} does not exist — run \
1914 scripts/install/download_dependencies.sh (or pass an explicit path)"
1915 ))
1916 }
1917
1918 pub struct HuggingFaceTokenizer {
1922 tok: tokenizers::Tokenizer,
1923 max_tokens: usize,
1924 }
1925
1926 impl HuggingFaceTokenizer {
1927 pub fn resolve(path: Option<&str>, max_tokens: usize) -> Result<Self, String> {
1931 Self::from_file(resolve_tokenizer_path(path)?, max_tokens)
1932 }
1933
1934 pub fn from_file(
1938 path: impl AsRef<std::path::Path>,
1939 max_tokens: usize,
1940 ) -> Result<Self, String> {
1941 let mut tok = tokenizers::Tokenizer::from_file(path.as_ref())
1942 .map_err(|e| format!("failed to load tokenizer: {e}"))?;
1943 let _ = tok.with_truncation(None);
1948 tok.with_padding(None);
1949 Ok(Self { tok, max_tokens })
1950 }
1951 }
1952
1953 impl ChunkTokenizer for HuggingFaceTokenizer {
1954 fn count_tokens(&self, text: &str) -> usize {
1955 self.tok
1956 .encode(text, false)
1957 .map(|e| e.get_tokens().len())
1958 .unwrap_or(0)
1959 }
1960 fn max_tokens(&self) -> usize {
1961 self.max_tokens
1962 }
1963 }
1964}
1965
1966#[cfg(feature = "chunking")]
1967pub use hf::{resolve_tokenizer_path, HuggingFaceTokenizer, DEFAULT_TOKENIZER_PATH};
1968
1969#[cfg(feature = "chunking")]
1974mod window {
1975 use super::DocChunk;
1976 use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
1977
1978 #[derive(Debug, Clone, Default)]
1980 pub struct Section {
1981 pub heading_path: Vec<String>,
1984 pub words: Vec<String>,
1986 }
1987
1988 impl Section {
1989 pub fn heading_context(&self) -> String {
1992 if self.heading_path.is_empty() {
1993 String::new()
1994 } else {
1995 format!("# {}", self.heading_path.join(" > "))
1996 }
1997 }
1998 }
1999
2000 fn level_index(level: HeadingLevel) -> usize {
2001 match level {
2002 HeadingLevel::H1 => 1,
2003 HeadingLevel::H2 => 2,
2004 HeadingLevel::H3 => 3,
2005 HeadingLevel::H4 => 4,
2006 HeadingLevel::H5 => 5,
2007 HeadingLevel::H6 => 6,
2008 }
2009 }
2010
2011 pub fn parse_sections(markdown: &str) -> Vec<Section> {
2015 parse_sections_with_stack(markdown, Vec::new()).0
2016 }
2017
2018 pub fn parse_sections_with_stack(
2023 markdown: &str,
2024 initial_stack: Vec<String>,
2025 ) -> (Vec<Section>, Vec<String>) {
2026 let mut heading_stack: Vec<String> = initial_stack;
2027 let mut sections: Vec<Section> = Vec::new();
2028 let mut current = Section {
2031 heading_path: heading_stack
2032 .iter()
2033 .filter(|h| !h.is_empty())
2034 .cloned()
2035 .collect(),
2036 words: Vec::new(),
2037 };
2038
2039 let mut in_heading = false;
2040 let mut heading_level = 0usize;
2041 let mut heading_buf = String::new();
2042
2043 let push_words = |section: &mut Section, text: &str| {
2044 for w in text.split_whitespace() {
2045 section.words.push(w.to_string());
2046 }
2047 };
2048
2049 let flush = |sections: &mut Vec<Section>, section: &mut Section| {
2050 if !section.words.is_empty() {
2051 sections.push(std::mem::take(section));
2052 } else {
2053 *section = Section::default();
2054 }
2055 };
2056
2057 for event in Parser::new(markdown) {
2058 match event {
2059 Event::Start(Tag::Heading { level, .. }) => {
2060 in_heading = true;
2061 heading_level = level_index(level);
2062 heading_buf.clear();
2063 }
2064 Event::End(TagEnd::Heading(_)) => {
2065 in_heading = false;
2066 let idx = heading_level.saturating_sub(1);
2068 if heading_stack.len() <= idx {
2069 heading_stack.resize(idx + 1, String::new());
2070 } else {
2071 heading_stack.truncate(idx + 1);
2072 }
2073 heading_stack[idx] = heading_buf.trim().to_string();
2074 flush(&mut sections, &mut current);
2076 current.heading_path = heading_stack
2077 .iter()
2078 .filter(|h| !h.is_empty())
2079 .cloned()
2080 .collect();
2081 }
2082 Event::Text(t) | Event::Code(t) => {
2083 if in_heading {
2084 if !heading_buf.is_empty() {
2085 heading_buf.push(' ');
2086 }
2087 heading_buf.push_str(&t);
2088 } else {
2089 push_words(&mut current, &t);
2090 }
2091 }
2092 Event::SoftBreak | Event::HardBreak | Event::Rule => {}
2094 _ => {}
2095 }
2096 }
2097 flush(&mut sections, &mut current);
2098 (sections, heading_stack)
2099 }
2100
2101 #[derive(Debug, Clone)]
2107 pub struct WindowChunker {
2108 pub max_words: usize,
2110 pub overlap: f32,
2112 }
2113
2114 impl Default for WindowChunker {
2115 fn default() -> Self {
2116 WindowChunker {
2117 max_words: 300,
2118 overlap: 0.05,
2119 }
2120 }
2121 }
2122
2123 impl WindowChunker {
2124 pub fn new(max_words: usize, overlap: f32) -> Self {
2125 WindowChunker { max_words, overlap }
2126 }
2127
2128 fn word_budget(&self) -> usize {
2130 self.max_words.max(1)
2131 }
2132
2133 fn overlap_words(&self, budget: usize) -> usize {
2136 let o = (budget as f32 * self.overlap).round() as usize;
2137 o.min(budget.saturating_sub(1))
2138 }
2139
2140 pub fn chunk(&self, markdown: &str) -> Vec<DocChunk> {
2142 let mut chunks = Vec::new();
2143 self.chunk_with(markdown, &mut |c| {
2144 chunks.push(c);
2145 true
2146 });
2147 chunks
2148 }
2149
2150 pub fn chunk_with(&self, markdown: &str, sink: &mut dyn FnMut(DocChunk) -> bool) {
2154 let (sections, _) = parse_sections_with_stack(markdown, Vec::new());
2155 for section in §ions {
2156 if !self.pack_section(section, sink) {
2157 return;
2158 }
2159 }
2160 }
2161
2162 pub fn pack_section(
2166 &self,
2167 section: &Section,
2168 sink: &mut dyn FnMut(DocChunk) -> bool,
2169 ) -> bool {
2170 let words = §ion.words;
2171 if words.is_empty() {
2172 return true;
2173 }
2174 let budget = self.word_budget();
2175 let step = budget - self.overlap_words(budget); let mut start = 0;
2177 loop {
2178 let end = (start + budget).min(words.len());
2179 let chunk = DocChunk {
2180 text: words[start..end].join(" "),
2181 headings: (!section.heading_path.is_empty())
2182 .then(|| section.heading_path.clone()),
2183 doc_items: Vec::new(),
2184 };
2185 if !sink(chunk) {
2186 return false;
2187 }
2188 if end >= words.len() {
2189 return true;
2190 }
2191 start += step;
2192 }
2193 }
2194
2195 pub fn contextualize(chunk: &DocChunk) -> String {
2201 match &chunk.headings {
2202 Some(h) if !h.is_empty() => format!("# {}\n\n{}", h.join(" > "), chunk.text),
2203 _ => chunk.text.clone(),
2204 }
2205 }
2206 }
2207
2208 #[cfg(test)]
2209 mod tests {
2210 use super::*;
2211
2212 #[test]
2213 fn splits_on_headings_and_tracks_path() {
2214 let md = "\
2215intro words
2216# Chapter 1
2217para one
2218## Section 1.1
2219para two
2220# Chapter 2
2221para three";
2222 let secs = parse_sections(md);
2223 assert_eq!(secs.len(), 4);
2225 assert!(secs[0].heading_path.is_empty());
2226 assert_eq!(secs[1].heading_path, vec!["Chapter 1"]);
2227 assert_eq!(secs[2].heading_path, vec!["Chapter 1", "Section 1.1"]);
2228 assert_eq!(secs[3].heading_path, vec!["Chapter 2"]);
2230 }
2231
2232 #[test]
2233 fn strips_markup_to_plain_words() {
2234 let md = "# T\n\nSome **bold** and `code` and [a link](http://x).";
2235 let secs = parse_sections(md);
2236 let words = &secs[0].words;
2237 assert!(words.contains(&"bold".to_string()));
2238 assert!(words.contains(&"code".to_string()));
2239 assert!(words.contains(&"link".to_string()));
2240 assert!(!words.iter().any(|w| w.contains('*') || w.contains('`')));
2242 }
2243
2244 #[test]
2245 fn windows_overlap_and_never_cross_headings() {
2246 let body: Vec<String> = (0..25).map(|i| format!("w{i}")).collect();
2247 let md = format!("# A\n\n{}\n\n# B\n\nshort tail\n", body.join(" "));
2248 let chunker = WindowChunker::new(10, 0.2); let chunks = chunker.chunk(&md);
2250 let a: Vec<_> = chunks
2252 .iter()
2253 .filter(|c| c.headings.as_deref() == Some(&["A".to_string()][..]))
2254 .collect();
2255 assert_eq!(a.len(), 3);
2256 assert!(a[0].text.starts_with("w0 ") && a[0].text.ends_with(" w9"));
2257 assert!(a[1].text.starts_with("w8 "), "overlap carries 2 words");
2258 assert!(a[2].text.ends_with(" w24"));
2259 let b: Vec<_> = chunks
2261 .iter()
2262 .filter(|c| c.headings.as_deref() == Some(&["B".to_string()][..]))
2263 .collect();
2264 assert_eq!(b.len(), 1);
2265 assert_eq!(b[0].text, "short tail");
2266 assert_eq!(WindowChunker::contextualize(b[0]), "# B\n\nshort tail");
2267 }
2268
2269 #[test]
2270 fn sink_false_cancels_the_window_walk() {
2271 let md = format!(
2272 "# A\n\n{}\n",
2273 (0..50)
2274 .map(|i| format!("w{i}"))
2275 .collect::<Vec<_>>()
2276 .join(" ")
2277 );
2278 let chunker = WindowChunker::new(10, 0.0);
2279 let mut n = 0;
2280 chunker.chunk_with(&md, &mut |_| {
2281 n += 1;
2282 false
2283 });
2284 assert_eq!(n, 1);
2285 }
2286 }
2287}
2288
2289#[cfg(feature = "chunking")]
2290pub use window::{parse_sections, parse_sections_with_stack, Section, WindowChunker};
2291
2292#[cfg(test)]
2293mod tests {
2294 use super::*;
2295
2296 struct WordTok(usize);
2298 impl ChunkTokenizer for WordTok {
2299 fn count_tokens(&self, text: &str) -> usize {
2300 text.split_whitespace().count()
2301 }
2302 fn max_tokens(&self) -> usize {
2303 self.0
2304 }
2305 }
2306
2307 fn doc_with(nodes: Vec<Node>) -> DoclingDocument {
2308 let mut d = DoclingDocument::new("t");
2309 for n in nodes {
2310 d.push(n);
2311 }
2312 d
2313 }
2314
2315 #[test]
2316 fn hierarchical_headings_and_items() {
2317 let doc = doc_with(vec![
2318 Node::Heading {
2319 level: 1,
2320 text: "Title".into(),
2321 },
2322 Node::Paragraph {
2323 text: "Intro".into(),
2324 },
2325 Node::Heading {
2326 level: 2,
2327 text: "Sec".into(),
2328 },
2329 Node::Paragraph {
2330 text: "Body".into(),
2331 },
2332 ]);
2333 let chunks = HierarchicalChunker.chunk(&doc);
2334 assert_eq!(chunks.len(), 2);
2335 assert_eq!(chunks[0].text, "Intro");
2336 assert_eq!(chunks[0].headings.as_deref(), Some(&["Title".into()][..]));
2337 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/1");
2338 assert_eq!(
2339 chunks[1].headings.as_deref(),
2340 Some(&["Title".into(), "Sec".into()][..])
2341 );
2342 assert_eq!(contextualize(&chunks[1]), "Title\nSec\nBody");
2343 }
2344
2345 #[test]
2348 fn page_furniture_keeps_refs_aligned_with_json() {
2349 let doc = doc_with(vec![
2350 Node::Paragraph {
2351 text: "Before".into(),
2352 },
2353 Node::PageFurniture {
2354 footer: true,
2355 location: [0, 490, 100, 512],
2356 text: "1.10.2".into(),
2357 },
2358 Node::Paragraph {
2359 text: "After".into(),
2360 },
2361 ]);
2362 let chunks = HierarchicalChunker.chunk(&doc);
2363 assert_eq!(chunks.len(), 2);
2364 assert_eq!(chunks[1].text, "After");
2365 let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2366 assert_eq!(json["texts"][2]["text"], "After");
2367 assert_eq!(chunks[1].doc_items[0].self_ref, "#/texts/2");
2368 }
2369
2370 #[test]
2374 fn picture_children_are_not_chunked_but_keep_refs_aligned() {
2375 let doc = doc_with(vec![
2376 Node::Picture {
2377 caption: None,
2378 caption_href: None,
2379 image: None,
2380 classification: None,
2381 description: None,
2382 caption_parent: crate::CaptionParent::Item,
2383 caption_location: None,
2384 },
2385 Node::PictureChildren(vec![
2386 Node::Paragraph {
2387 text: "axis label".into(),
2388 },
2389 Node::ListItem {
2390 ordered: false,
2391 number: 0,
2392 first_in_list: true,
2393 text: "callout".into(),
2394 level: 0,
2395 marker: None,
2396 location: None,
2397 dclx: None,
2398 href: None,
2399 layer: None,
2400 },
2401 ]),
2402 Node::Paragraph {
2403 text: "Body".into(),
2404 },
2405 ]);
2406 let chunks = HierarchicalChunker.chunk(&doc);
2407 assert_eq!(chunks.len(), 1);
2408 assert_eq!(chunks[0].text, "Body");
2409 let json = doc.export_to_json_value();
2410 assert_eq!(json["texts"][2]["text"], "Body");
2411 assert_eq!(chunks[0].doc_items[0].self_ref, "#/texts/2");
2412 }
2413
2414 #[test]
2415 fn heading_shadowing_prunes_deeper_levels() {
2416 let doc = doc_with(vec![
2417 Node::Heading {
2418 level: 2,
2419 text: "A".into(),
2420 },
2421 Node::Heading {
2422 level: 3,
2423 text: "A.1".into(),
2424 },
2425 Node::Heading {
2426 level: 2,
2427 text: "B".into(),
2428 },
2429 Node::Paragraph { text: "p".into() },
2430 ]);
2431 let chunks = HierarchicalChunker.chunk(&doc);
2432 assert_eq!(chunks[0].headings.as_deref(), Some(&["B".into()][..]));
2433 }
2434
2435 #[test]
2436 fn triplet_table() {
2437 let t = Table {
2438 rows: vec![
2439 vec!["".into(), "Col1".into()],
2440 vec!["Row1".into(), "v".into()],
2441 ],
2442 ..Default::default()
2443 };
2444 assert_eq!(triplet_table_text(&t), "Row1, Col1 = v");
2445 let single = Table {
2448 rows: vec![vec!["H".into()], vec!["a".into()], vec!["b".into()]],
2449 ..Default::default()
2450 };
2451 assert_eq!(triplet_table_text(&single), "a = b");
2452 }
2453
2454 fn quadratic_merge<T: ChunkTokenizer>(
2458 h: &HybridChunker<T>,
2459 chunks: Vec<DocChunk>,
2460 ) -> Vec<DocChunk> {
2461 let mut out = Vec::new();
2462 let mut window: Vec<DocChunk> = Vec::new();
2463 let flush = |window: &mut Vec<DocChunk>, out: &mut Vec<DocChunk>| match window.len() {
2464 0 => {}
2465 1 => out.push(window.pop().unwrap()),
2466 _ => {
2467 out.push(merged_chunk(window.iter()));
2468 window.clear();
2469 }
2470 };
2471 for chunk in chunks {
2472 if window.is_empty() {
2473 window.push(chunk);
2474 continue;
2475 }
2476 let candidate = merged_chunk(window.iter().chain([&chunk]));
2477 if chunk.headings == window[0].headings
2478 && h.count_chunk_tokens(&candidate) <= h.max_tokens()
2479 {
2480 window.push(chunk);
2481 } else {
2482 flush(&mut window, &mut out);
2483 window.push(chunk);
2484 }
2485 }
2486 flush(&mut window, &mut out);
2487 out
2488 }
2489
2490 fn linear_merge<T: ChunkTokenizer>(
2491 h: &HybridChunker<T>,
2492 chunks: Vec<DocChunk>,
2493 ) -> Vec<DocChunk> {
2494 let mut out = Vec::new();
2495 let mut sink = |c| {
2496 out.push(c);
2497 true
2498 };
2499 let mut m = PeerMerger::default();
2500 for c in chunks {
2501 assert!(h.merge_push(&mut m, c, &mut sink));
2502 }
2503 assert!(h.merge_flush(&mut m, &mut sink));
2504 out
2505 }
2506
2507 struct QuadCharTok(usize);
2512 impl ChunkTokenizer for QuadCharTok {
2513 fn count_tokens(&self, text: &str) -> usize {
2514 text.chars().count().div_ceil(4)
2515 }
2516 fn max_tokens(&self) -> usize {
2517 self.0
2518 }
2519 }
2520
2521 fn random_chunks(seed: u64, n: usize) -> Vec<DocChunk> {
2524 let mut state = seed;
2525 let mut next = move |m: u64| {
2526 state = state
2527 .wrapping_mul(6364136223846793005)
2528 .wrapping_add(1442695040888963407);
2529 (state >> 33) % m
2530 };
2531 let headings = [
2532 None,
2533 Some(vec!["A".to_string()]),
2534 Some(vec!["A".into(), "B".into()]),
2535 ];
2536 let mut current = 0;
2537 (0..n)
2538 .map(|i| {
2539 if next(4) == 0 {
2540 current = next(3) as usize;
2541 }
2542 let words = 1 + next(12) as usize;
2543 let text = (0..words)
2544 .map(|w| ["x", "lorem", "ipsum", "dolor", "sit"][(w + i) % 5])
2545 .collect::<Vec<_>>()
2546 .join(" ");
2547 DocChunk {
2548 text,
2549 headings: headings[current].clone(),
2550 doc_items: vec![ChunkItem {
2551 self_ref: format!("#/texts/{i}"),
2552 kind: ChunkItemKind::Text,
2553 text: String::new(),
2554 }],
2555 }
2556 })
2557 .collect()
2558 }
2559
2560 #[test]
2564 fn linear_peer_merge_matches_the_quadratic_one() {
2565 for seed in 0..40 {
2566 let chunks = random_chunks(seed, 60);
2567 for max in [3, 8, 16, 40, 200] {
2568 let h = HybridChunker::new(WordTok(max));
2569 assert_eq!(
2570 linear_merge(&h, chunks.clone()),
2571 quadratic_merge(&h, chunks.clone()),
2572 "words, seed {seed}, max {max}"
2573 );
2574 let h = HybridChunker::new(QuadCharTok(max));
2575 assert_eq!(
2576 linear_merge(&h, chunks.clone()),
2577 quadratic_merge(&h, chunks.clone()),
2578 "chars/4, seed {seed}, max {max}"
2579 );
2580 }
2581 }
2582 }
2583
2584 #[test]
2588 #[ignore]
2589 fn peer_merge_bench() {
2590 let chunks: Vec<DocChunk> = (0..10_000)
2591 .map(|i| DocChunk {
2592 text: format!("Paragraph number {i} is short."),
2593 headings: Some(vec!["Section".into()]),
2594 doc_items: Vec::new(),
2595 })
2596 .collect();
2597 let h = HybridChunker::new(WordTok(8192));
2598 let t = std::time::Instant::now();
2599 let fast = linear_merge(&h, chunks.clone());
2600 let linear = t.elapsed();
2601 let t = std::time::Instant::now();
2602 let slow = quadratic_merge(&h, chunks);
2603 let quadratic = t.elapsed();
2604 assert_eq!(fast, slow);
2605 println!(
2606 "linear {linear:?}, quadratic {quadratic:?}, {} chunks",
2607 fast.len()
2608 );
2609 }
2610
2611 fn cell_table(
2614 num_rows: usize,
2615 num_cols: usize,
2616 cells: &[(&str, usize, usize, usize, bool)],
2617 ) -> Table {
2618 let mut rows = vec![vec![String::new(); num_cols]; num_rows];
2619 for &(text, r, c, span, _) in cells {
2620 for row in rows.iter_mut().skip(r).take(span) {
2621 row[c] = text.into();
2622 }
2623 }
2624 Table {
2625 rows,
2626 cells: Some(
2627 cells
2628 .iter()
2629 .map(|&(text, r, c, span, ched)| crate::TableCell {
2630 text: text.into(),
2631 bbox: None,
2632 start_row: r,
2633 start_col: c,
2634 row_span: span,
2635 col_span: 1,
2636 column_header: ched,
2637 row_header: false,
2638 row_section: false,
2639 })
2640 .collect(),
2641 ),
2642 ..Default::default()
2643 }
2644 }
2645
2646 #[test]
2652 fn markdown_and_triplets_share_the_header_rows() {
2653 type Case<'a> = (
2654 &'a str,
2655 usize,
2656 usize,
2657 Vec<(&'a str, usize, usize, usize, bool)>,
2658 &'a str,
2659 &'a str,
2660 );
2661 let cases: Vec<Case> = vec![
2662 (
2663 "rows under a row-spanning header stay in the body",
2664 2,
2665 2,
2666 vec![("Price", 0, 0, 1, true), ("Category", 0, 1, 2, true), ("10.00", 1, 0, 1, false)],
2667 "| Price | Category |\n| - | - |\n| 10.00 | Category |",
2668 "10.00, Category = Category",
2669 ),
2670 (
2671 "a row header flagged as column header stays in the body",
2672 3,
2673 3,
2674 vec![
2675 ("Year", 0, 0, 1, true),
2676 ("Month", 0, 1, 1, true),
2677 ("Revenue", 0, 2, 1, true),
2678 ("2025", 1, 0, 2, true),
2679 ("January", 1, 1, 1, false),
2680 ("$134", 1, 2, 1, false),
2681 ("February", 2, 1, 1, false),
2682 ("$150", 2, 2, 1, false),
2683 ],
2684 "| Year | Month | Revenue |\n| - | - | - |\n| 2025 | January | $134 |\n| 2025 | February | $150 |",
2685 "2025, Month = January. 2025, Revenue = $134. 2025, Month = February. 2025, Revenue = $150",
2686 ),
2687 (
2688 "stacked header with an unmarked row label",
2689 3,
2690 3,
2691 vec![
2692 ("", 0, 0, 1, true),
2693 ("% of Total", 0, 1, 1, true),
2694 ("% of Total", 0, 2, 1, true),
2695 ("class label", 1, 0, 1, false),
2696 ("Train", 1, 1, 1, true),
2697 ("Test", 1, 2, 1, true),
2698 ("Caption", 2, 0, 1, false),
2699 ("2.5", 2, 1, 1, false),
2700 ("2.4", 2, 2, 1, false),
2701 ],
2702 "| class label | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.5 | 2.4 |",
2703 "Caption, % of Total.Train = 2.5. Caption, % of Total.Test = 2.4",
2704 ),
2705 (
2706 "a partially flagged first row stays the header",
2707 2,
2708 2,
2709 vec![("Name", 0, 0, 1, true), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2710 "| Name | Value |\n| - | - |\n| A | 1 |",
2711 "A, Value = 1",
2712 ),
2713 (
2714 "no flags: Markdown promotes row 0, triplets do not",
2715 2,
2716 2,
2717 vec![("Name", 0, 0, 1, false), ("Value", 0, 1, 1, false), ("A", 1, 0, 1, false), ("1", 1, 1, 1, false)],
2718 "| Name | Value |\n| - | - |\n| A | 1 |",
2719 "Name, 1 = Value. A, 1 = 1",
2720 ),
2721 (
2722 "row headers flagged as column headers stay in the body",
2723 2,
2724 2,
2725 vec![("2025", 0, 0, 1, true), ("$134", 0, 1, 1, false), ("2026", 1, 0, 1, true), ("$150", 1, 1, 1, false)],
2726 "| | |\n| - | - |\n| 2025 | $134 |\n| 2026 | $150 |",
2727 "2025, 1 = $134. 2026, 1 = $150",
2728 ),
2729 (
2730 "a form with flagged first-column labels is all data",
2731 3,
2732 2,
2733 vec![
2734 ("Name", 0, 0, 1, true),
2735 ("Bob", 0, 1, 1, false),
2736 ("Age", 1, 0, 1, true),
2737 ("42", 1, 1, 1, false),
2738 ("City", 2, 0, 1, true),
2739 ("Paris", 2, 1, 1, false),
2740 ],
2741 "| | |\n| - | - |\n| Name | Bob |\n| Age | 42 |\n| City | Paris |",
2742 "Name, 1 = Bob. Age, 1 = 42. City, 1 = Paris",
2743 ),
2744 (
2745 "a partly flagged row 0 above a stacked header row",
2746 3,
2747 3,
2748 vec![
2749 ("Region", 0, 0, 1, true),
2750 ("2025", 0, 1, 1, false),
2751 ("", 0, 2, 1, false),
2752 ("", 1, 0, 1, false),
2753 ("Q1", 1, 1, 1, true),
2754 ("Q2", 1, 2, 1, true),
2755 ("North", 2, 0, 1, false),
2756 ("1", 2, 1, 1, false),
2757 ("2", 2, 2, 1, false),
2758 ],
2759 "| Region | 2025 - Q1 | Q2 |\n| - | - | - |\n| North | 1 | 2 |",
2760 "North, 2025.Q1 = 1. North, Q2 = 2",
2761 ),
2762 ];
2763 for (name, rows, cols, cells, md, triplets) in cases {
2764 let t = cell_table(rows, cols, &cells);
2765 assert_eq!(
2766 crate::markdown::render_table(&t, true),
2767 md,
2768 "{name}: markdown"
2769 );
2770 assert_eq!(triplet_table_text(&t), triplets, "{name}: triplets");
2771 }
2772 }
2773
2774 #[test]
2779 fn a_column_spanning_rich_cell_repeats_in_every_column() {
2780 let mut t = cell_table(
2781 2,
2782 3,
2783 &[
2784 ("", 0, 0, 1, true),
2785 ("Partner", 0, 1, 1, true),
2786 ("Status", 1, 0, 1, false),
2787 ("Done", 1, 1, 1, false),
2788 ("Open", 1, 2, 1, false),
2789 ],
2790 );
2791 if let Some(cells) = t.cells.as_mut() {
2792 cells[1].col_span = 2;
2793 }
2794 let mut blocks = vec![vec![Vec::new(); 3]; 2];
2795 blocks[0][1] = vec![Node::Paragraph {
2796 text: "Partner".into(),
2797 }];
2798 t.cell_blocks = Some(blocks);
2799 assert_eq!(
2800 t.rows[0][2], "",
2801 "the covered position carries no text of its own"
2802 );
2803 assert_eq!(
2804 triplet_table_text(&t),
2805 "Status, Partner = Done. Status, Partner = Open"
2806 );
2807 let latex = crate::latex::render_table(&t);
2808 assert!(
2809 latex.contains(" & Partner & Partner \\\\ \\hline\nStatus & Done & Open \\\\ \\hline"),
2810 "{latex}"
2811 );
2812 t.rows[0][2] = "Partner".into();
2814 assert_eq!(
2815 crate::markdown::render_table(&t, true),
2816 "| | Partner | Partner |\n| - | - | - |\n| Status | Done | Open |"
2817 );
2818 }
2819
2820 #[test]
2821 fn hybrid_merges_small_peers_and_splits_large() {
2822 let doc = doc_with(vec![
2823 Node::Heading {
2824 level: 2,
2825 text: "S".into(),
2826 },
2827 Node::Paragraph { text: "a b".into() },
2828 Node::Paragraph { text: "c d".into() },
2829 ]);
2830 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2831 assert_eq!(chunks.len(), 1, "peers under one heading merge");
2832 assert_eq!(chunks[0].text, "a b\nc d");
2833
2834 let long = "w ".repeat(40).trim().to_string();
2835 let doc = doc_with(vec![Node::Paragraph { text: long }]);
2836 let chunks = HybridChunker::new(WordTok(16)).chunk(&doc);
2837 assert!(chunks.len() > 1, "oversized paragraph splits");
2838 for c in &chunks {
2839 assert!(WordTok(16).count_tokens(&contextualize(c)) <= 16);
2840 }
2841 }
2842
2843 #[test]
2844 fn semchunk_prefers_newlines_then_sentences() {
2845 let tok = WordTok(4);
2846 let out = semchunk("one two three. four five six\nseven eight", 4, &tok);
2847 assert!(out.iter().all(|c| tok.count_tokens(c) <= 4), "{out:?}");
2848 }
2849}