1use std::collections::HashMap;
36
37use docling_core::Node;
38
39use crate::outline::OutlineItem;
40
41#[derive(Clone, Debug)]
44pub struct HeadingHierarchyOptions {
45 pub enabled: bool,
48 pub use_bookmarks: bool,
50 pub use_numbering: bool,
52 pub use_style: bool,
54 pub use_font_style: bool,
57 pub style_size_tolerance: f32,
60 pub max_level: u8,
62 pub bookmark_match_threshold: f32,
65 pub numbering_schemes: Option<Vec<String>>,
69}
70
71impl Default for HeadingHierarchyOptions {
72 fn default() -> Self {
73 Self {
74 enabled: false,
75 use_bookmarks: true,
76 use_numbering: true,
77 use_style: true,
78 use_font_style: true,
79 style_size_tolerance: 0.05,
80 max_level: 6,
81 bookmark_match_threshold: 0.8,
82 numbering_schemes: None,
83 }
84 }
85}
86
87impl HeadingHierarchyOptions {
88 pub fn enabled(on: bool) -> Self {
90 Self {
91 enabled: on,
92 ..Self::default()
93 }
94 }
95}
96
97#[derive(Clone, Copy, Debug)]
101pub(crate) struct GlyphStyle {
102 pub l: f32,
103 pub t: f32,
104 pub r: f32,
105 pub b: f32,
106 pub height: f32,
109 pub weight_cls: u8,
111 pub italic: bool,
112 pub styled: bool,
114}
115
116const DEFAULT_FAMILY_ORDER: [&str; 8] = [
119 "part", "chapter", "article", "roman_u", "arabic", "alpha_u", "alpha_l", "roman_l", ];
128
129#[derive(Clone, Debug, PartialEq)]
133struct Marker {
134 family: &'static str,
135 depth: usize,
137 token: Option<String>,
139 ambiguous: bool,
141}
142
143impl Marker {
144 fn family(family: &'static str) -> Self {
145 Marker {
146 family,
147 depth: 1,
148 token: None,
149 ambiguous: false,
150 }
151 }
152}
153
154fn is_roman(token: &str) -> bool {
158 if token.is_empty() || !token.is_ascii() {
159 return false;
160 }
161 let s: Vec<u8> = token.bytes().map(|b| b.to_ascii_uppercase()).collect();
162 let mut i = 0;
163 let mut m = 0;
165 while i < s.len() && s[i] == b'M' && m < 4 {
166 i += 1;
167 m += 1;
168 }
169 if s[i..].starts_with(b"CM") || s[i..].starts_with(b"CD") {
171 i += 2;
172 } else {
173 if i < s.len() && s[i] == b'D' {
174 i += 1;
175 }
176 let mut c = 0;
177 while i < s.len() && s[i] == b'C' && c < 3 {
178 i += 1;
179 c += 1;
180 }
181 }
182 if s[i..].starts_with(b"XC") || s[i..].starts_with(b"XL") {
184 i += 2;
185 } else {
186 if i < s.len() && s[i] == b'L' {
187 i += 1;
188 }
189 let mut x = 0;
190 while i < s.len() && s[i] == b'X' && x < 3 {
191 i += 1;
192 x += 1;
193 }
194 }
195 if s[i..].starts_with(b"IX") || s[i..].starts_with(b"IV") {
197 i += 2;
198 } else {
199 if i < s.len() && s[i] == b'V' {
200 i += 1;
201 }
202 let mut n = 0;
203 while i < s.len() && s[i] == b'I' && n < 3 {
204 i += 1;
205 n += 1;
206 }
207 }
208 i == s.len()
209}
210
211fn starts_with_word(text: &str, word: &str) -> bool {
213 let Some(head) = text.get(..word.len()) else {
220 return false;
221 };
222 if !head.eq_ignore_ascii_case(word) {
223 return false;
224 }
225 text[word.len()..]
226 .chars()
227 .next()
228 .is_none_or(|c| !c.is_alphanumeric())
229}
230
231fn classify_letter(token: &str) -> Option<Marker> {
233 let upper = token.chars().all(|c| c.is_uppercase());
234 if token.chars().count() == 1 {
235 let is_roman_single = token
236 .chars()
237 .next()
238 .is_some_and(|c| "IVXLCDMivxlcdm".contains(c));
239 let family = match (is_roman_single, upper) {
240 (true, true) => "roman_u",
241 (true, false) => "roman_l",
242 (false, true) => "alpha_u",
243 (false, false) => "alpha_l",
244 };
245 return Some(Marker {
246 family,
247 depth: 1,
248 token: Some(token.to_string()),
249 ambiguous: is_roman_single,
250 });
251 }
252 if is_roman(token) {
255 return Some(Marker {
256 family: if upper { "roman_u" } else { "roman_l" },
257 depth: 1,
258 token: Some(token.to_string()),
259 ambiguous: false,
260 });
261 }
262 None
263}
264
265fn parse_marker(text: &str) -> Option<Marker> {
267 let s = text.trim_start();
268 if s.is_empty() {
269 return None;
270 }
271
272 for kw in ["part", "title", "book"] {
273 if starts_with_word(s, kw) {
274 return Some(Marker::family("part"));
275 }
276 }
277 if starts_with_word(s, "chapter") {
278 return Some(Marker::family("chapter"));
279 }
280 for kw in [
281 "article", "section", "clause", "schedule", "annex", "appendix", "rule",
282 ] {
283 if starts_with_word(s, kw) {
284 return Some(Marker::family("article"));
285 }
286 }
287 if s.starts_with('§') {
289 let after = s.trim_start_matches('§').trim_start();
290 if after.starts_with(|c: char| c.is_ascii_digit()) {
291 return Some(Marker::family("article"));
292 }
293 }
294
295 if let Some((segments, rest)) = take_dotted(s) {
297 if segments >= 2
298 && rest
299 .chars()
300 .next()
301 .is_none_or(|c| matches!(c, '.' | ')' | ']') || c.is_whitespace())
302 {
303 return Some(Marker {
304 family: "dotted",
305 depth: segments,
306 token: None,
307 ambiguous: false,
308 });
309 }
310 }
311 let digits = s.chars().take_while(|c| c.is_ascii_digit()).count();
313 if digits > 0 {
314 let rest = &s[digits..];
315 if rest.starts_with('.') || rest.starts_with(')') {
316 return Some(Marker::family("arabic"));
317 }
318 }
319
320 let after_paren = s.strip_prefix('(').map(str::trim_start).unwrap_or(s);
322 let letters: String = after_paren
323 .chars()
324 .take_while(|c| c.is_alphabetic())
325 .collect();
326 if !letters.is_empty() {
327 let rest = after_paren[letters.len()..].trim_start();
328 if rest.starts_with(')') || rest.starts_with('.') {
329 return classify_letter(&letters);
330 }
331 }
332 None
333}
334
335fn take_dotted(s: &str) -> Option<(usize, &str)> {
338 let mut rest = s;
339 let mut segments = 0;
340 loop {
341 let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
342 if digits == 0 {
343 break;
344 }
345 segments += 1;
346 rest = &rest[digits..];
347 match rest.strip_prefix('.') {
348 Some(r) if r.starts_with(|c: char| c.is_ascii_digit()) => rest = r,
351 _ => break,
352 }
353 }
354 (segments >= 2).then_some((segments, rest))
355}
356
357fn resolve_ambiguous(markers: &mut [Option<Marker>]) {
363 let has = |family: &str, ms: &[Option<Marker>]| {
364 ms.iter()
365 .flatten()
366 .any(|m| !m.ambiguous && m.family == family)
367 };
368 let upper_roman = has("roman_u", markers);
369 let upper_alpha = has("alpha_u", markers);
370 let lower_roman = has("roman_l", markers);
371 let lower_alpha = has("alpha_l", markers);
372
373 for m in markers.iter_mut().flatten() {
374 if !m.ambiguous {
375 continue;
376 }
377 let Some(token) = m.token.as_deref() else {
378 continue;
379 };
380 let upper = token.chars().all(|c| c.is_uppercase());
381 let (has_roman, has_alpha) = if upper {
382 (upper_roman, upper_alpha)
383 } else {
384 (lower_roman, lower_alpha)
385 };
386 let roman = if has_roman && !has_alpha {
387 true
388 } else if has_alpha && !has_roman {
389 false
390 } else {
391 token == "I" || token == "i"
392 };
393 m.family = match (roman, upper) {
394 (true, true) => "roman_u",
395 (true, false) => "roman_l",
396 (false, true) => "alpha_u",
397 (false, false) => "alpha_l",
398 };
399 m.ambiguous = false;
400 }
401}
402
403fn family_rank(family: &str, order: &[String]) -> usize {
404 let key = if family == "dotted" { "arabic" } else { family };
405 order.iter().position(|f| f == key).unwrap_or(order.len()) }
407
408fn infer_from_numbering(
410 heading_texts: &[&str],
411 options: &HeadingHierarchyOptions,
412) -> HashMap<usize, usize> {
413 let order: Vec<String> = options
414 .numbering_schemes
415 .clone()
416 .unwrap_or_else(|| DEFAULT_FAMILY_ORDER.iter().map(|s| s.to_string()).collect());
417 let mut markers: Vec<Option<Marker>> = heading_texts.iter().map(|t| parse_marker(t)).collect();
418 resolve_ambiguous(&mut markers);
419
420 let mut keys: HashMap<usize, (usize, usize)> = HashMap::new();
421 for (i, m) in markers.iter().enumerate() {
422 if let Some(m) = m {
423 keys.insert(i, (family_rank(m.family, &order), m.depth));
424 }
425 }
426 compress_keys(keys)
427}
428
429fn compress_keys<K: Ord + Clone + std::hash::Hash>(
432 keys: HashMap<usize, K>,
433) -> HashMap<usize, usize> {
434 let mut distinct: Vec<K> = keys.values().cloned().collect();
435 distinct.sort();
436 distinct.dedup();
437 let level_of: HashMap<K, usize> = distinct
438 .into_iter()
439 .enumerate()
440 .map(|(i, k)| (k, i + 1))
441 .collect();
442 keys.into_iter().map(|(i, k)| (i, level_of[&k])).collect()
443}
444
445const ITALIC_RATIO: f32 = 0.6;
449
450fn is_all_caps(text: &str) -> bool {
452 let letters: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
453 letters.len() >= 4 && letters.iter().all(|c| c.is_uppercase())
454}
455
456#[derive(Clone, Copy, Debug)]
458struct HeadingStyle {
459 size: f32,
460 weight_cls: u8,
461 italic: bool,
462 caps: bool,
463}
464
465fn heading_style(
470 bbox: [f32; 4],
471 text: &str,
472 glyphs: &[GlyphStyle],
473 options: &HeadingHierarchyOptions,
474) -> Option<HeadingStyle> {
475 let [hl, ht, hr, hb] = bbox;
476 let mut heights: Vec<f32> = Vec::new();
477 let mut weights = [0usize; 3];
478 let mut styled_chars = 0usize;
479 let mut italic_chars = 0usize;
480 for g in glyphs {
481 if g.l < hr && g.r > hl && g.t < hb && g.b > ht {
482 heights.push(g.height);
483 if options.use_font_style && g.styled {
484 weights[g.weight_cls.min(2) as usize] += 1;
485 styled_chars += 1;
486 if g.italic {
487 italic_chars += 1;
488 }
489 }
490 }
491 }
492 if heights.is_empty() {
493 return None;
494 }
495 heights.sort_by(f32::total_cmp);
496 let size = if heights.len() % 2 == 1 {
497 heights[heights.len() / 2]
498 } else {
499 (heights[heights.len() / 2 - 1] + heights[heights.len() / 2]) / 2.0
500 };
501 if !options.use_font_style {
502 return Some(HeadingStyle {
503 size,
504 weight_cls: 0,
505 italic: false,
506 caps: false,
507 });
508 }
509 let weight_cls = (0u8..3)
511 .max_by_key(|&cls| (weights[cls as usize], cls))
512 .unwrap_or(0);
513 Some(HeadingStyle {
514 size,
515 weight_cls,
516 italic: styled_chars > 0 && italic_chars as f32 / styled_chars as f32 >= ITALIC_RATIO,
517 caps: is_all_caps(text),
518 })
519}
520
521fn cluster_sizes(mut sizes: Vec<f32>, tolerance: f32) -> Vec<(f32, usize)> {
525 sizes.sort_by(|a, b| b.total_cmp(a));
526 sizes.dedup();
527 let mut clusters = Vec::with_capacity(sizes.len());
528 let mut index = 0usize;
529 let mut previous: Option<f32> = None;
530 for size in sizes {
531 if let Some(prev) = previous {
532 if (prev - size) > tolerance * prev {
533 index += 1;
534 }
535 }
536 clusters.push((size, index));
537 previous = Some(size);
538 }
539 clusters
540}
541
542fn infer_from_style(
544 headings: &[HeadingRef],
545 glyph_styles: &HashMap<usize, Vec<GlyphStyle>>,
546 options: &HeadingHierarchyOptions,
547) -> HashMap<usize, usize> {
548 if glyph_styles.is_empty() {
549 return HashMap::new();
550 }
551 let mut styles: HashMap<usize, HeadingStyle> = HashMap::new();
552 for (i, h) in headings.iter().enumerate() {
553 let Some(glyphs) = glyph_styles.get(&h.page_no) else {
554 continue;
555 };
556 let Some(bbox) = h.bbox_points else { continue };
557 if let Some(style) = heading_style(bbox, &h.text, glyphs, options) {
558 styles.insert(i, style);
559 }
560 }
561 if styles.is_empty() {
562 return HashMap::new();
563 }
564 let clusters = cluster_sizes(
565 styles.values().map(|s| s.size).collect(),
566 options.style_size_tolerance,
567 );
568 let cluster_of = |size: f32| -> usize {
569 clusters
570 .iter()
571 .find(|(s, _)| *s == size)
572 .map(|(_, c)| *c)
573 .unwrap_or(0)
574 };
575 let keys: HashMap<usize, (usize, i8, bool, bool)> = styles
579 .into_iter()
580 .map(|(i, s)| {
581 (
582 i,
583 (cluster_of(s.size), -(s.weight_cls as i8), s.italic, !s.caps),
584 )
585 })
586 .collect();
587 compress_keys(keys)
588}
589
590fn norm(text: &str) -> String {
594 let collapsed = text
595 .split_whitespace()
596 .collect::<Vec<_>>()
597 .join(" ")
598 .to_lowercase();
599 collapsed
600 .trim_matches(|c: char| !c.is_alphanumeric())
601 .to_string()
602}
603
604fn strip_marker(text: &str) -> String {
608 let s = text.trim_start();
609 let matched_len = leading_marker_len(s);
610 match matched_len {
611 Some(n) => {
612 let rest = &s[n..];
613 let trimmed = rest.trim_start_matches(|c: char| {
614 c.is_whitespace() || matches!(c, '.' | ':' | ')' | '-')
615 });
616 trimmed.to_string()
617 }
618 None => text.to_string(),
619 }
620}
621
622fn leading_marker_len(s: &str) -> Option<usize> {
624 for kw in [
626 "chapter", "article", "section", "clause", "schedule", "annex", "appendix", "rule", "part",
627 "title", "book",
628 ] {
629 if starts_with_word(s, kw) {
630 let mut i = kw.len();
631 let bytes = s.as_bytes();
632 while i < bytes.len()
633 && (bytes[i].is_ascii_whitespace() || bytes[i] == b'.' || bytes[i] == b':')
634 {
635 i += 1;
636 }
637 while i < bytes.len()
638 && (bytes[i].is_ascii_digit() || b"ivxlcdmIVXLCDM".contains(&bytes[i]))
639 {
640 i += 1;
641 }
642 return Some(i);
643 }
644 }
645 if s.starts_with('§') {
647 let rest = s.trim_start_matches('§');
648 let ws = rest.len() - rest.trim_start().len();
649 let rest2 = rest.trim_start();
650 let num = rest2
651 .bytes()
652 .take_while(|b| b.is_ascii_digit() || *b == b'.')
653 .count();
654 if num > 0 {
655 return Some(s.len() - rest.len() + ws + num);
656 }
657 }
658 let (paren, body) = match s.strip_prefix('(') {
660 Some(r) => (1, r),
661 None => (0, s),
662 };
663 let digits = body.bytes().take_while(|b| b.is_ascii_digit()).count();
664 if digits > 0 {
665 let mut i = digits;
666 let b = body.as_bytes();
667 while i < b.len() && b[i] == b'.' {
668 let d = body[i + 1..]
669 .bytes()
670 .take_while(|x| x.is_ascii_digit())
671 .count();
672 if d == 0 {
673 break;
674 }
675 i += 1 + d;
676 }
677 if i < b.len() && (b[i] == b')' || b[i] == b'.') {
678 i += 1;
679 }
680 return Some(paren + i);
681 }
682 let letters = body.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
684 if (1..=2).contains(&letters) {
685 let b = body.as_bytes();
686 if letters < b.len() && (b[letters] == b')' || b[letters] == b'.') {
687 return Some(paren + letters + 1);
688 }
689 }
690 None
691}
692
693fn similarity(a: &str, b: &str) -> f32 {
696 let a: Vec<char> = a.chars().collect();
697 let b: Vec<char> = b.chars().collect();
698 if a.is_empty() && b.is_empty() {
699 return 1.0;
700 }
701 let mut b2j: HashMap<char, Vec<usize>> = HashMap::new();
702 for (j, &c) in b.iter().enumerate() {
703 b2j.entry(c).or_default().push(j);
704 }
705 let mut matches = 0usize;
706 let mut queue = vec![(0usize, a.len(), 0usize, b.len())];
707 while let Some((alo, ahi, blo, bhi)) = queue.pop() {
708 let (mut besti, mut bestj, mut bestsize) = (alo, blo, 0usize);
710 let mut j2len: HashMap<usize, usize> = HashMap::new();
711 for (i, ch) in a.iter().enumerate().take(ahi).skip(alo) {
712 let mut newj2len: HashMap<usize, usize> = HashMap::new();
713 if let Some(js) = b2j.get(ch) {
714 for &j in js {
715 if j < blo {
716 continue;
717 }
718 if j >= bhi {
719 break;
720 }
721 let k = j
722 .checked_sub(1)
723 .and_then(|p| j2len.get(&p))
724 .copied()
725 .unwrap_or(0)
726 + 1;
727 newj2len.insert(j, k);
728 if k > bestsize {
729 besti = i + 1 - k;
730 bestj = j + 1 - k;
731 bestsize = k;
732 }
733 }
734 }
735 j2len = newj2len;
736 }
737 if bestsize == 0 {
738 continue;
739 }
740 matches += bestsize;
741 if besti > alo && bestj > blo {
742 queue.push((alo, besti, blo, bestj));
743 }
744 if besti + bestsize < ahi && bestj + bestsize < bhi {
745 queue.push((besti + bestsize, ahi, bestj + bestsize, bhi));
746 }
747 }
748 (2.0 * matches as f32) / (a.len() + b.len()) as f32
749}
750
751fn match_score(cand_text: &str, bm_title: &str) -> f32 {
756 let mut variants_a = vec![norm(cand_text), norm(&strip_marker(cand_text))];
757 let mut variants_b = vec![norm(bm_title), norm(&strip_marker(bm_title))];
758 variants_a.retain(|v| !v.is_empty());
759 variants_b.retain(|v| !v.is_empty());
760 variants_a.dedup();
761 variants_b.dedup();
762 let mut best: f32 = 0.0;
763 for a in &variants_a {
764 for b in &variants_b {
765 best = best.max(similarity(a, b));
766 if a.chars().count() >= 4
767 && b.chars().count() >= 4
768 && (a.contains(b.as_str()) || b.contains(a.as_str()))
769 {
770 best = best.max(0.92);
771 }
772 }
773 }
774 best
775}
776
777struct HeadingRef {
781 node_idx: usize,
783 text: String,
785 page_no: usize,
787 bbox_points: Option<[f32; 4]>,
789 is_list_item: bool,
791}
792
793fn collect(nodes: &[Node], with_list_items: bool) -> Vec<HeadingRef> {
795 let mut out = Vec::new();
796 let mut page_no = 0usize;
797 let mut page_w = 0f32;
798 let mut page_h = 0f32;
799 let denorm = |loc: [u16; 4], w: f32, h: f32| -> Option<[f32; 4]> {
800 (w > 0.0 && h > 0.0).then(|| {
801 [
802 loc[0] as f32 / 512.0 * w,
803 loc[1] as f32 / 512.0 * h,
804 loc[2] as f32 / 512.0 * w,
805 loc[3] as f32 / 512.0 * h,
806 ]
807 })
808 };
809 for (idx, node) in nodes.iter().enumerate() {
810 match node {
811 Node::PageInfo {
812 page_no: p,
813 width,
814 height,
815 } => {
816 page_no = *p;
817 page_w = *width;
818 page_h = *height;
819 }
820 Node::Located { location, inner } => {
821 if let Node::Heading { text, .. } = inner.as_ref() {
822 out.push(HeadingRef {
823 node_idx: idx,
824 text: text.clone(),
825 page_no,
826 bbox_points: denorm(*location, page_w, page_h),
827 is_list_item: false,
828 });
829 }
830 }
831 Node::Heading { text, .. } => out.push(HeadingRef {
832 node_idx: idx,
833 text: text.clone(),
834 page_no,
835 bbox_points: None,
836 is_list_item: false,
837 }),
838 Node::ListItem {
839 ordered,
840 number,
841 text,
842 location,
843 ..
844 } if with_list_items => {
845 let text = if *ordered {
848 format!("{number}. {text}")
849 } else {
850 text.clone()
851 };
852 out.push(HeadingRef {
853 node_idx: idx,
854 text,
855 page_no,
856 bbox_points: location.and_then(|loc| denorm(loc, page_w, page_h)),
857 is_list_item: true,
858 });
859 }
860 _ => {}
861 }
862 }
863 out
864}
865
866pub(crate) fn heading_pages(nodes: &[Node]) -> Vec<usize> {
869 let mut pages: Vec<usize> = collect(nodes, false).iter().map(|h| h.page_no).collect();
870 pages.sort_unstable();
871 pages.dedup();
872 pages.retain(|&p| p > 0);
873 pages
874}
875
876fn infer_from_bookmarks(
879 candidates: &[HeadingRef],
880 outline: &[OutlineItem],
881 options: &HeadingHierarchyOptions,
882) -> HashMap<usize, usize> {
883 let mut claimed: Vec<bool> = vec![false; candidates.len()];
884 let mut matches: Vec<(usize, usize)> = Vec::new(); for bm in outline {
887 let title = bm.title.trim();
888 if title.is_empty() {
889 continue;
890 }
891 let threshold = if bm.page_no.is_none() {
893 (options.bookmark_match_threshold + 0.1).min(1.0)
894 } else {
895 options.bookmark_match_threshold
896 };
897 let mut best: Option<(usize, f32, f32)> = None; for (idx, cand) in candidates.iter().enumerate() {
899 if claimed[idx] {
900 continue;
901 }
902 if let (Some(bp), cp) = (bm.page_no, cand.page_no) {
903 if cp != 0 && cp != bp {
904 continue;
905 }
906 }
907 let score = match_score(&cand.text, title);
908 if score < threshold {
909 continue;
910 }
911 let dist = match (cand.bbox_points.map(|b| b[1]), bm.y_top) {
912 (Some(top), Some(y)) => (top - y).abs(),
913 _ => f32::INFINITY,
914 };
915 let better = match best {
916 None => true,
917 Some((_, bs, bd)) => score > bs + 1e-6 || ((score - bs).abs() <= 1e-6 && dist < bd),
918 };
919 if better {
920 best = Some((idx, score, dist));
921 }
922 }
923 if let Some((idx, _, _)) = best {
924 claimed[idx] = true;
925 matches.push((idx, bm.level));
926 }
927 }
928 if matches.is_empty() {
929 return HashMap::new();
930 }
931 compress_keys(matches.into_iter().collect())
933}
934
935pub(crate) fn apply(
941 nodes: &mut [Node],
942 outline: &[OutlineItem],
943 glyph_styles: &HashMap<usize, Vec<GlyphStyle>>,
944 options: &HeadingHierarchyOptions,
945) {
946 if !options.enabled {
947 return;
948 }
949
950 let mut bookmark_levels: HashMap<usize, usize> = HashMap::new(); if options.use_bookmarks && !outline.is_empty() {
954 let candidates = collect(nodes, true);
955 let matched = infer_from_bookmarks(&candidates, outline, options);
956 for (cand_idx, level) in matched {
957 let cand = &candidates[cand_idx];
958 if cand.is_list_item {
959 promote_list_item(nodes, cand.node_idx, &cand.text);
960 }
961 bookmark_levels.insert(cand.node_idx, level);
962 }
963 }
964
965 let headings = collect(nodes, false);
966 if headings.is_empty() {
967 return;
968 }
969
970 let mut levels: HashMap<usize, usize> = HashMap::new(); for (i, h) in headings.iter().enumerate() {
973 if let Some(level) = bookmark_levels.get(&h.node_idx) {
974 levels.insert(i, *level);
975 }
976 }
977 if options.use_numbering {
978 let texts: Vec<&str> = headings.iter().map(|h| h.text.as_str()).collect();
979 for (i, level) in infer_from_numbering(&texts, options) {
980 levels.entry(i).or_insert(level);
981 }
982 }
983 if options.use_style && !glyph_styles.is_empty() {
984 for (i, level) in infer_from_style(&headings, glyph_styles, options) {
985 levels.entry(i).or_insert(level);
986 }
987 }
988
989 for (i, h) in headings.iter().enumerate() {
990 let Some(&level) = levels.get(&i) else {
991 continue;
992 };
993 let semantic = level.clamp(1, options.max_level.max(1) as usize);
994 let rendered = (semantic + 1).min(u8::MAX as usize) as u8;
997 set_heading_level(&mut nodes[h.node_idx], rendered);
998 }
999}
1000
1001fn set_heading_level(node: &mut Node, new_level: u8) {
1002 match node {
1003 Node::Heading { level, .. } => *level = new_level,
1004 Node::Located { inner, .. } => {
1005 if let Node::Heading { level, .. } = inner.as_mut() {
1006 *level = new_level;
1007 }
1008 }
1009 _ => {}
1010 }
1011}
1012
1013fn promote_list_item(nodes: &mut [Node], idx: usize, text: &str) {
1018 let Node::ListItem {
1019 first_in_list,
1020 location,
1021 ..
1022 } = &nodes[idx]
1023 else {
1024 return;
1025 };
1026 let was_first = *first_in_list;
1027 let loc = *location;
1028 let heading = Node::Heading {
1029 level: 2,
1030 text: text.to_string(),
1031 };
1032 nodes[idx] = match loc {
1033 Some(location) => Node::Located {
1034 location,
1035 inner: Box::new(heading),
1036 },
1037 None => heading,
1038 };
1039 if was_first {
1040 if let Some(Node::ListItem { first_in_list, .. }) = nodes.get_mut(idx + 1) {
1041 *first_in_list = true;
1042 }
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049
1050 #[test]
1055 fn keyword_probe_never_slices_mid_char() {
1056 assert!(!starts_with_word("Note 1\u{a0}Overview", "chapter"));
1057 for word in ["chapter", "section", "part", "article", "appendix", "annex"] {
1058 for k in 0..12 {
1059 for ch in ['\u{a0}', '\u{e9}', '\u{3a9}', '\u{1f600}'] {
1060 let text = format!("{}{ch}Overview", "x".repeat(k));
1061 assert!(!starts_with_word(&text, word), "{text:?} vs {word}");
1062 }
1063 }
1064 }
1065 assert!(starts_with_word("Chapter\u{a0}1", "chapter"));
1067 assert!(starts_with_word("CHAPTER 2 Scope", "chapter"));
1068 assert!(starts_with_word("Section", "section"));
1069 assert!(!starts_with_word("Chapters", "chapter"));
1070 assert!(!starts_with_word("Chapt", "chapter"));
1071 }
1072
1073 fn heading(loc: [u16; 4], text: &str) -> Node {
1074 Node::Located {
1075 location: loc,
1076 inner: Box::new(Node::Heading {
1077 level: 2,
1078 text: text.to_string(),
1079 }),
1080 }
1081 }
1082
1083 fn page(no: usize) -> Node {
1084 Node::PageInfo {
1085 page_no: no,
1086 width: 512.0,
1087 height: 512.0,
1088 }
1089 }
1090
1091 fn levels(nodes: &[Node]) -> Vec<u8> {
1092 nodes
1093 .iter()
1094 .filter_map(|n| match n {
1095 Node::Located { inner, .. } => match inner.as_ref() {
1096 Node::Heading { level, .. } => Some(*level),
1097 _ => None,
1098 },
1099 Node::Heading { level, .. } => Some(*level),
1100 _ => None,
1101 })
1102 .collect()
1103 }
1104
1105 #[test]
1106 fn roman_validator_matches_difflib_regex() {
1107 for ok in ["I", "iv", "XIV", "MCMXCIX", "iii", "C"] {
1108 assert!(is_roman(ok), "{ok}");
1109 }
1110 for bad in ["", "IIII", "VX", "ABC", "Summary", "IC"] {
1111 assert!(!is_roman(bad), "{bad}");
1112 }
1113 }
1114
1115 #[test]
1116 fn markers_parse_the_docling_families() {
1117 let fam = |t: &str| parse_marker(t).map(|m| (m.family, m.depth));
1118 assert_eq!(fam("PART I — General"), Some(("part", 1)));
1119 assert_eq!(fam("Chapter 2: Scope"), Some(("chapter", 1)));
1120 assert_eq!(fam("Article 5"), Some(("article", 1)));
1121 assert_eq!(fam("§ 12 Something"), Some(("article", 1)));
1122 assert_eq!(fam("1. Introduction"), Some(("arabic", 1)));
1123 assert_eq!(fam("2) Also arabic"), Some(("arabic", 1)));
1124 assert_eq!(fam("1.1 Scope"), Some(("dotted", 2)));
1125 assert_eq!(fam("2.3.1 Deep"), Some(("dotted", 3)));
1126 assert_eq!(fam("A. Annex-ish"), Some(("alpha_u", 1)));
1127 assert_eq!(fam("(a) item"), Some(("alpha_l", 1)));
1128 assert_eq!(fam("(iv) sub"), Some(("roman_l", 1)));
1129 assert_eq!(fam("IV. Chapter"), Some(("roman_u", 1)));
1130 assert_eq!(fam("Summary."), None);
1132 assert_eq!(fam("Overview"), None);
1133 }
1134
1135 #[test]
1136 fn ambiguous_single_letters_resolve_from_document_context() {
1137 let texts = ["I. One", "II. Two", "V. Five"];
1139 let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1140 assert_eq!(map[&0], map[&2]);
1142 let texts = ["B. Bee", "C. Sea", "D. Dee"];
1144 let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1145 assert_eq!(map[&0], map[&1]);
1146 assert_eq!(map[&1], map[&2]);
1147 }
1148
1149 #[test]
1150 fn numbering_levels_compress_to_contiguous() {
1151 let texts = ["PART I", "1.1 Scope", "1.1.1 Detail", "No marker"];
1154 let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1155 assert_eq!(map[&0], 1);
1156 assert_eq!(map[&1], 2);
1157 assert_eq!(map[&2], 3);
1158 assert!(!map.contains_key(&3));
1159 }
1160
1161 #[test]
1162 fn similarity_behaves_like_difflib_ratio() {
1163 assert_eq!(similarity("abc", "abc"), 1.0);
1164 assert_eq!(similarity("", ""), 1.0);
1165 assert_eq!(similarity("abc", "xyz"), 0.0);
1166 assert!((similarity("abcd", "bcde") - 0.75).abs() < 1e-6);
1168 }
1169
1170 #[test]
1171 fn bookmark_titles_match_with_and_without_markers() {
1172 assert!(match_score("1.1 Definitions", "Definitions") >= 0.9);
1173 assert!(match_score("ARTICLE 5 Payment Terms", "Payment Terms") >= 0.9);
1174 assert!(match_score("Introduction", "Conclusion") < 0.8);
1175 }
1176
1177 #[test]
1178 fn apply_assigns_numbering_levels_end_to_end() {
1179 let mut nodes = vec![
1180 page(1),
1181 heading([10, 10, 200, 20], "1. Introduction"),
1182 heading([10, 40, 200, 50], "1.1 Scope"),
1183 heading([10, 70, 200, 80], "Unnumbered"),
1184 ];
1185 apply(
1186 &mut nodes,
1187 &[],
1188 &HashMap::new(),
1189 &HeadingHierarchyOptions::enabled(true),
1190 );
1191 assert_eq!(levels(&nodes), vec![2, 3, 2]);
1194 }
1195
1196 #[test]
1200 fn apply_survives_multibyte_headings_and_bookmarks() {
1201 let mut nodes = vec![
1202 page(1),
1203 heading([10, 10, 200, 20], "Note 1\u{a0}Overview"),
1204 heading([10, 40, 200, 50], "1.\u{a0}Einf\u{fc}hrung"),
1205 heading(
1206 [10, 70, 200, 80],
1207 "1.1\u{a0}\u{dc}berblick \u{2014} Teil\u{a0}A",
1208 ),
1209 heading([10, 100, 200, 110], "Chapter\u{a0}2\u{a0}\u{3a9}mega"),
1210 heading([10, 130, 200, 140], "\u{1f600} Anhang"),
1211 ];
1212 let outline = vec![
1213 OutlineItem {
1214 title: "Note\u{a0}1 Overview".into(),
1215 level: 0,
1216 page_no: Some(1),
1217 y_top: None,
1218 },
1219 OutlineItem {
1220 title: "Einf\u{fc}hrung".into(),
1221 level: 1,
1222 page_no: Some(1),
1223 y_top: None,
1224 },
1225 ];
1226 apply(
1227 &mut nodes,
1228 &outline,
1229 &HashMap::new(),
1230 &HeadingHierarchyOptions::enabled(true),
1231 );
1232 assert_eq!(levels(&nodes).len(), 5);
1233 }
1234
1235 #[test]
1236 fn apply_is_inert_when_disabled() {
1237 let mut nodes = vec![page(1), heading([10, 10, 200, 20], "1.1.1 Deep")];
1238 apply(
1239 &mut nodes,
1240 &[],
1241 &HashMap::new(),
1242 &HeadingHierarchyOptions::default(),
1243 );
1244 assert_eq!(levels(&nodes), vec![2]);
1245 }
1246
1247 #[test]
1248 fn bookmarks_win_over_numbering_and_promote_list_items() {
1249 let outline = vec![
1250 OutlineItem {
1251 title: "1. Introduction".into(),
1252 level: 0,
1253 page_no: Some(1),
1254 y_top: None,
1255 },
1256 OutlineItem {
1257 title: "Hidden Heading".into(),
1258 level: 1,
1259 page_no: Some(1),
1260 y_top: None,
1261 },
1262 ];
1263 let mut nodes = vec![
1264 page(1),
1265 heading([10, 10, 200, 20], "1. Introduction"),
1268 Node::ListItem {
1269 ordered: false,
1270 number: 0,
1271 first_in_list: true,
1272 text: "Hidden Heading".into(),
1273 level: 0,
1274 marker: None,
1275 location: Some([10, 40, 200, 50]),
1276 dclx: None,
1277 href: None,
1278 layer: None,
1279 },
1280 Node::ListItem {
1281 ordered: false,
1282 number: 0,
1283 first_in_list: false,
1284 text: "a real item".into(),
1285 level: 0,
1286 marker: None,
1287 location: Some([10, 70, 200, 80]),
1288 dclx: None,
1289 href: None,
1290 layer: None,
1291 },
1292 ];
1293 apply(
1294 &mut nodes,
1295 &outline,
1296 &HashMap::new(),
1297 &HeadingHierarchyOptions::enabled(true),
1298 );
1299 assert_eq!(levels(&nodes), vec![2, 3]);
1302 match &nodes[3] {
1303 Node::ListItem {
1304 first_in_list,
1305 text,
1306 ..
1307 } => {
1308 assert!(*first_in_list, "sibling re-opens the list");
1309 assert_eq!(text, "a real item");
1310 }
1311 other => panic!("expected the sibling list item, got {other:?}"),
1312 }
1313 }
1314
1315 #[test]
1316 fn style_ranks_by_size_then_prominence() {
1317 let glyphs = vec![
1319 GlyphStyle {
1320 l: 10.0,
1321 t: 10.0,
1322 r: 100.0,
1323 b: 28.0,
1324 height: 18.0,
1325 weight_cls: 2,
1326 italic: false,
1327 styled: true,
1328 },
1329 GlyphStyle {
1330 l: 10.0,
1331 t: 60.0,
1332 r: 100.0,
1333 b: 72.0,
1334 height: 12.0,
1335 weight_cls: 2,
1336 italic: false,
1337 styled: true,
1338 },
1339 GlyphStyle {
1340 l: 10.0,
1341 t: 110.0,
1342 r: 100.0,
1343 b: 122.0,
1344 height: 12.0,
1345 weight_cls: 0,
1346 italic: false,
1347 styled: true,
1348 },
1349 ];
1350 let mut styles = HashMap::new();
1351 styles.insert(1usize, glyphs);
1352 let mut nodes = vec![
1353 page(1),
1354 heading([10, 10, 200, 28], "Big Title Words"),
1355 heading([10, 60, 200, 72], "Bold Twelve"),
1356 heading([10, 110, 200, 122], "Plain Twelve"),
1357 ];
1358 apply(
1360 &mut nodes,
1361 &[],
1362 &styles,
1363 &HeadingHierarchyOptions::enabled(true),
1364 );
1365 assert_eq!(levels(&nodes), vec![2, 3, 4]);
1366 }
1367
1368 #[test]
1369 fn strip_marker_removes_leading_numbering() {
1370 assert_eq!(strip_marker("1.1 Definitions"), "Definitions");
1371 assert_eq!(strip_marker("ARTICLE 5 - Payment"), "Payment");
1372 assert_eq!(strip_marker("(a) item"), "item");
1373 assert_eq!(strip_marker("No marker here"), "No marker here");
1374 }
1375}