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 if text.len() < word.len() || !text[..word.len()].eq_ignore_ascii_case(word) {
214 return false;
215 }
216 text[word.len()..]
217 .chars()
218 .next()
219 .is_none_or(|c| !c.is_alphanumeric())
220}
221
222fn classify_letter(token: &str) -> Option<Marker> {
224 let upper = token.chars().all(|c| c.is_uppercase());
225 if token.chars().count() == 1 {
226 let is_roman_single = token
227 .chars()
228 .next()
229 .is_some_and(|c| "IVXLCDMivxlcdm".contains(c));
230 let family = match (is_roman_single, upper) {
231 (true, true) => "roman_u",
232 (true, false) => "roman_l",
233 (false, true) => "alpha_u",
234 (false, false) => "alpha_l",
235 };
236 return Some(Marker {
237 family,
238 depth: 1,
239 token: Some(token.to_string()),
240 ambiguous: is_roman_single,
241 });
242 }
243 if is_roman(token) {
246 return Some(Marker {
247 family: if upper { "roman_u" } else { "roman_l" },
248 depth: 1,
249 token: Some(token.to_string()),
250 ambiguous: false,
251 });
252 }
253 None
254}
255
256fn parse_marker(text: &str) -> Option<Marker> {
258 let s = text.trim_start();
259 if s.is_empty() {
260 return None;
261 }
262
263 for kw in ["part", "title", "book"] {
264 if starts_with_word(s, kw) {
265 return Some(Marker::family("part"));
266 }
267 }
268 if starts_with_word(s, "chapter") {
269 return Some(Marker::family("chapter"));
270 }
271 for kw in [
272 "article", "section", "clause", "schedule", "annex", "appendix", "rule",
273 ] {
274 if starts_with_word(s, kw) {
275 return Some(Marker::family("article"));
276 }
277 }
278 if s.starts_with('§') {
280 let after = s.trim_start_matches('§').trim_start();
281 if after.starts_with(|c: char| c.is_ascii_digit()) {
282 return Some(Marker::family("article"));
283 }
284 }
285
286 if let Some((segments, rest)) = take_dotted(s) {
288 if segments >= 2
289 && rest
290 .chars()
291 .next()
292 .is_none_or(|c| matches!(c, '.' | ')' | ']') || c.is_whitespace())
293 {
294 return Some(Marker {
295 family: "dotted",
296 depth: segments,
297 token: None,
298 ambiguous: false,
299 });
300 }
301 }
302 let digits = s.chars().take_while(|c| c.is_ascii_digit()).count();
304 if digits > 0 {
305 let rest = &s[digits..];
306 if rest.starts_with('.') || rest.starts_with(')') {
307 return Some(Marker::family("arabic"));
308 }
309 }
310
311 let after_paren = s.strip_prefix('(').map(str::trim_start).unwrap_or(s);
313 let letters: String = after_paren
314 .chars()
315 .take_while(|c| c.is_alphabetic())
316 .collect();
317 if !letters.is_empty() {
318 let rest = after_paren[letters.len()..].trim_start();
319 if rest.starts_with(')') || rest.starts_with('.') {
320 return classify_letter(&letters);
321 }
322 }
323 None
324}
325
326fn take_dotted(s: &str) -> Option<(usize, &str)> {
329 let mut rest = s;
330 let mut segments = 0;
331 loop {
332 let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
333 if digits == 0 {
334 break;
335 }
336 segments += 1;
337 rest = &rest[digits..];
338 match rest.strip_prefix('.') {
339 Some(r) if r.starts_with(|c: char| c.is_ascii_digit()) => rest = r,
342 _ => break,
343 }
344 }
345 (segments >= 2).then_some((segments, rest))
346}
347
348fn resolve_ambiguous(markers: &mut [Option<Marker>]) {
354 let has = |family: &str, ms: &[Option<Marker>]| {
355 ms.iter()
356 .flatten()
357 .any(|m| !m.ambiguous && m.family == family)
358 };
359 let upper_roman = has("roman_u", markers);
360 let upper_alpha = has("alpha_u", markers);
361 let lower_roman = has("roman_l", markers);
362 let lower_alpha = has("alpha_l", markers);
363
364 for m in markers.iter_mut().flatten() {
365 if !m.ambiguous {
366 continue;
367 }
368 let Some(token) = m.token.as_deref() else {
369 continue;
370 };
371 let upper = token.chars().all(|c| c.is_uppercase());
372 let (has_roman, has_alpha) = if upper {
373 (upper_roman, upper_alpha)
374 } else {
375 (lower_roman, lower_alpha)
376 };
377 let roman = if has_roman && !has_alpha {
378 true
379 } else if has_alpha && !has_roman {
380 false
381 } else {
382 token == "I" || token == "i"
383 };
384 m.family = match (roman, upper) {
385 (true, true) => "roman_u",
386 (true, false) => "roman_l",
387 (false, true) => "alpha_u",
388 (false, false) => "alpha_l",
389 };
390 m.ambiguous = false;
391 }
392}
393
394fn family_rank(family: &str, order: &[String]) -> usize {
395 let key = if family == "dotted" { "arabic" } else { family };
396 order.iter().position(|f| f == key).unwrap_or(order.len()) }
398
399fn infer_from_numbering(
401 heading_texts: &[&str],
402 options: &HeadingHierarchyOptions,
403) -> HashMap<usize, usize> {
404 let order: Vec<String> = options
405 .numbering_schemes
406 .clone()
407 .unwrap_or_else(|| DEFAULT_FAMILY_ORDER.iter().map(|s| s.to_string()).collect());
408 let mut markers: Vec<Option<Marker>> = heading_texts.iter().map(|t| parse_marker(t)).collect();
409 resolve_ambiguous(&mut markers);
410
411 let mut keys: HashMap<usize, (usize, usize)> = HashMap::new();
412 for (i, m) in markers.iter().enumerate() {
413 if let Some(m) = m {
414 keys.insert(i, (family_rank(m.family, &order), m.depth));
415 }
416 }
417 compress_keys(keys)
418}
419
420fn compress_keys<K: Ord + Clone + std::hash::Hash>(
423 keys: HashMap<usize, K>,
424) -> HashMap<usize, usize> {
425 let mut distinct: Vec<K> = keys.values().cloned().collect();
426 distinct.sort();
427 distinct.dedup();
428 let level_of: HashMap<K, usize> = distinct
429 .into_iter()
430 .enumerate()
431 .map(|(i, k)| (k, i + 1))
432 .collect();
433 keys.into_iter().map(|(i, k)| (i, level_of[&k])).collect()
434}
435
436const ITALIC_RATIO: f32 = 0.6;
440
441fn is_all_caps(text: &str) -> bool {
443 let letters: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
444 letters.len() >= 4 && letters.iter().all(|c| c.is_uppercase())
445}
446
447#[derive(Clone, Copy, Debug)]
449struct HeadingStyle {
450 size: f32,
451 weight_cls: u8,
452 italic: bool,
453 caps: bool,
454}
455
456fn heading_style(
461 bbox: [f32; 4],
462 text: &str,
463 glyphs: &[GlyphStyle],
464 options: &HeadingHierarchyOptions,
465) -> Option<HeadingStyle> {
466 let [hl, ht, hr, hb] = bbox;
467 let mut heights: Vec<f32> = Vec::new();
468 let mut weights = [0usize; 3];
469 let mut styled_chars = 0usize;
470 let mut italic_chars = 0usize;
471 for g in glyphs {
472 if g.l < hr && g.r > hl && g.t < hb && g.b > ht {
473 heights.push(g.height);
474 if options.use_font_style && g.styled {
475 weights[g.weight_cls.min(2) as usize] += 1;
476 styled_chars += 1;
477 if g.italic {
478 italic_chars += 1;
479 }
480 }
481 }
482 }
483 if heights.is_empty() {
484 return None;
485 }
486 heights.sort_by(f32::total_cmp);
487 let size = if heights.len() % 2 == 1 {
488 heights[heights.len() / 2]
489 } else {
490 (heights[heights.len() / 2 - 1] + heights[heights.len() / 2]) / 2.0
491 };
492 if !options.use_font_style {
493 return Some(HeadingStyle {
494 size,
495 weight_cls: 0,
496 italic: false,
497 caps: false,
498 });
499 }
500 let weight_cls = (0u8..3)
502 .max_by_key(|&cls| (weights[cls as usize], cls))
503 .unwrap_or(0);
504 Some(HeadingStyle {
505 size,
506 weight_cls,
507 italic: styled_chars > 0 && italic_chars as f32 / styled_chars as f32 >= ITALIC_RATIO,
508 caps: is_all_caps(text),
509 })
510}
511
512fn cluster_sizes(mut sizes: Vec<f32>, tolerance: f32) -> Vec<(f32, usize)> {
516 sizes.sort_by(|a, b| b.total_cmp(a));
517 sizes.dedup();
518 let mut clusters = Vec::with_capacity(sizes.len());
519 let mut index = 0usize;
520 let mut previous: Option<f32> = None;
521 for size in sizes {
522 if let Some(prev) = previous {
523 if (prev - size) > tolerance * prev {
524 index += 1;
525 }
526 }
527 clusters.push((size, index));
528 previous = Some(size);
529 }
530 clusters
531}
532
533fn infer_from_style(
535 headings: &[HeadingRef],
536 glyph_styles: &HashMap<usize, Vec<GlyphStyle>>,
537 options: &HeadingHierarchyOptions,
538) -> HashMap<usize, usize> {
539 if glyph_styles.is_empty() {
540 return HashMap::new();
541 }
542 let mut styles: HashMap<usize, HeadingStyle> = HashMap::new();
543 for (i, h) in headings.iter().enumerate() {
544 let Some(glyphs) = glyph_styles.get(&h.page_no) else {
545 continue;
546 };
547 let Some(bbox) = h.bbox_points else { continue };
548 if let Some(style) = heading_style(bbox, &h.text, glyphs, options) {
549 styles.insert(i, style);
550 }
551 }
552 if styles.is_empty() {
553 return HashMap::new();
554 }
555 let clusters = cluster_sizes(
556 styles.values().map(|s| s.size).collect(),
557 options.style_size_tolerance,
558 );
559 let cluster_of = |size: f32| -> usize {
560 clusters
561 .iter()
562 .find(|(s, _)| *s == size)
563 .map(|(_, c)| *c)
564 .unwrap_or(0)
565 };
566 let keys: HashMap<usize, (usize, i8, bool, bool)> = styles
570 .into_iter()
571 .map(|(i, s)| {
572 (
573 i,
574 (cluster_of(s.size), -(s.weight_cls as i8), s.italic, !s.caps),
575 )
576 })
577 .collect();
578 compress_keys(keys)
579}
580
581fn norm(text: &str) -> String {
585 let collapsed = text
586 .split_whitespace()
587 .collect::<Vec<_>>()
588 .join(" ")
589 .to_lowercase();
590 collapsed
591 .trim_matches(|c: char| !c.is_alphanumeric())
592 .to_string()
593}
594
595fn strip_marker(text: &str) -> String {
599 let s = text.trim_start();
600 let matched_len = leading_marker_len(s);
601 match matched_len {
602 Some(n) => {
603 let rest = &s[n..];
604 let trimmed = rest.trim_start_matches(|c: char| {
605 c.is_whitespace() || matches!(c, '.' | ':' | ')' | '-')
606 });
607 trimmed.to_string()
608 }
609 None => text.to_string(),
610 }
611}
612
613fn leading_marker_len(s: &str) -> Option<usize> {
615 for kw in [
617 "chapter", "article", "section", "clause", "schedule", "annex", "appendix", "rule", "part",
618 "title", "book",
619 ] {
620 if starts_with_word(s, kw) {
621 let mut i = kw.len();
622 let bytes = s.as_bytes();
623 while i < bytes.len()
624 && (bytes[i].is_ascii_whitespace() || bytes[i] == b'.' || bytes[i] == b':')
625 {
626 i += 1;
627 }
628 while i < bytes.len()
629 && (bytes[i].is_ascii_digit() || b"ivxlcdmIVXLCDM".contains(&bytes[i]))
630 {
631 i += 1;
632 }
633 return Some(i);
634 }
635 }
636 if s.starts_with('§') {
638 let rest = s.trim_start_matches('§');
639 let ws = rest.len() - rest.trim_start().len();
640 let rest2 = rest.trim_start();
641 let num = rest2
642 .bytes()
643 .take_while(|b| b.is_ascii_digit() || *b == b'.')
644 .count();
645 if num > 0 {
646 return Some(s.len() - rest.len() + ws + num);
647 }
648 }
649 let (paren, body) = match s.strip_prefix('(') {
651 Some(r) => (1, r),
652 None => (0, s),
653 };
654 let digits = body.bytes().take_while(|b| b.is_ascii_digit()).count();
655 if digits > 0 {
656 let mut i = digits;
657 let b = body.as_bytes();
658 while i < b.len() && b[i] == b'.' {
659 let d = body[i + 1..]
660 .bytes()
661 .take_while(|x| x.is_ascii_digit())
662 .count();
663 if d == 0 {
664 break;
665 }
666 i += 1 + d;
667 }
668 if i < b.len() && (b[i] == b')' || b[i] == b'.') {
669 i += 1;
670 }
671 return Some(paren + i);
672 }
673 let letters = body.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
675 if (1..=2).contains(&letters) {
676 let b = body.as_bytes();
677 if letters < b.len() && (b[letters] == b')' || b[letters] == b'.') {
678 return Some(paren + letters + 1);
679 }
680 }
681 None
682}
683
684fn similarity(a: &str, b: &str) -> f32 {
687 let a: Vec<char> = a.chars().collect();
688 let b: Vec<char> = b.chars().collect();
689 if a.is_empty() && b.is_empty() {
690 return 1.0;
691 }
692 let mut b2j: HashMap<char, Vec<usize>> = HashMap::new();
693 for (j, &c) in b.iter().enumerate() {
694 b2j.entry(c).or_default().push(j);
695 }
696 let mut matches = 0usize;
697 let mut queue = vec![(0usize, a.len(), 0usize, b.len())];
698 while let Some((alo, ahi, blo, bhi)) = queue.pop() {
699 let (mut besti, mut bestj, mut bestsize) = (alo, blo, 0usize);
701 let mut j2len: HashMap<usize, usize> = HashMap::new();
702 for (i, ch) in a.iter().enumerate().take(ahi).skip(alo) {
703 let mut newj2len: HashMap<usize, usize> = HashMap::new();
704 if let Some(js) = b2j.get(ch) {
705 for &j in js {
706 if j < blo {
707 continue;
708 }
709 if j >= bhi {
710 break;
711 }
712 let k = j
713 .checked_sub(1)
714 .and_then(|p| j2len.get(&p))
715 .copied()
716 .unwrap_or(0)
717 + 1;
718 newj2len.insert(j, k);
719 if k > bestsize {
720 besti = i + 1 - k;
721 bestj = j + 1 - k;
722 bestsize = k;
723 }
724 }
725 }
726 j2len = newj2len;
727 }
728 if bestsize == 0 {
729 continue;
730 }
731 matches += bestsize;
732 if besti > alo && bestj > blo {
733 queue.push((alo, besti, blo, bestj));
734 }
735 if besti + bestsize < ahi && bestj + bestsize < bhi {
736 queue.push((besti + bestsize, ahi, bestj + bestsize, bhi));
737 }
738 }
739 (2.0 * matches as f32) / (a.len() + b.len()) as f32
740}
741
742fn match_score(cand_text: &str, bm_title: &str) -> f32 {
747 let mut variants_a = vec![norm(cand_text), norm(&strip_marker(cand_text))];
748 let mut variants_b = vec![norm(bm_title), norm(&strip_marker(bm_title))];
749 variants_a.retain(|v| !v.is_empty());
750 variants_b.retain(|v| !v.is_empty());
751 variants_a.dedup();
752 variants_b.dedup();
753 let mut best: f32 = 0.0;
754 for a in &variants_a {
755 for b in &variants_b {
756 best = best.max(similarity(a, b));
757 if a.chars().count() >= 4
758 && b.chars().count() >= 4
759 && (a.contains(b.as_str()) || b.contains(a.as_str()))
760 {
761 best = best.max(0.92);
762 }
763 }
764 }
765 best
766}
767
768struct HeadingRef {
772 node_idx: usize,
774 text: String,
776 page_no: usize,
778 bbox_points: Option<[f32; 4]>,
780 is_list_item: bool,
782}
783
784fn collect(nodes: &[Node], with_list_items: bool) -> Vec<HeadingRef> {
786 let mut out = Vec::new();
787 let mut page_no = 0usize;
788 let mut page_w = 0f32;
789 let mut page_h = 0f32;
790 let denorm = |loc: [u16; 4], w: f32, h: f32| -> Option<[f32; 4]> {
791 (w > 0.0 && h > 0.0).then(|| {
792 [
793 loc[0] as f32 / 512.0 * w,
794 loc[1] as f32 / 512.0 * h,
795 loc[2] as f32 / 512.0 * w,
796 loc[3] as f32 / 512.0 * h,
797 ]
798 })
799 };
800 for (idx, node) in nodes.iter().enumerate() {
801 match node {
802 Node::PageInfo {
803 page_no: p,
804 width,
805 height,
806 } => {
807 page_no = *p;
808 page_w = *width;
809 page_h = *height;
810 }
811 Node::Located { location, inner } => {
812 if let Node::Heading { text, .. } = inner.as_ref() {
813 out.push(HeadingRef {
814 node_idx: idx,
815 text: text.clone(),
816 page_no,
817 bbox_points: denorm(*location, page_w, page_h),
818 is_list_item: false,
819 });
820 }
821 }
822 Node::Heading { text, .. } => out.push(HeadingRef {
823 node_idx: idx,
824 text: text.clone(),
825 page_no,
826 bbox_points: None,
827 is_list_item: false,
828 }),
829 Node::ListItem {
830 ordered,
831 number,
832 text,
833 location,
834 ..
835 } if with_list_items => {
836 let text = if *ordered {
839 format!("{number}. {text}")
840 } else {
841 text.clone()
842 };
843 out.push(HeadingRef {
844 node_idx: idx,
845 text,
846 page_no,
847 bbox_points: location.and_then(|loc| denorm(loc, page_w, page_h)),
848 is_list_item: true,
849 });
850 }
851 _ => {}
852 }
853 }
854 out
855}
856
857pub(crate) fn heading_pages(nodes: &[Node]) -> Vec<usize> {
860 let mut pages: Vec<usize> = collect(nodes, false).iter().map(|h| h.page_no).collect();
861 pages.sort_unstable();
862 pages.dedup();
863 pages.retain(|&p| p > 0);
864 pages
865}
866
867fn infer_from_bookmarks(
870 candidates: &[HeadingRef],
871 outline: &[OutlineItem],
872 options: &HeadingHierarchyOptions,
873) -> HashMap<usize, usize> {
874 let mut claimed: Vec<bool> = vec![false; candidates.len()];
875 let mut matches: Vec<(usize, usize)> = Vec::new(); for bm in outline {
878 let title = bm.title.trim();
879 if title.is_empty() {
880 continue;
881 }
882 let threshold = if bm.page_no.is_none() {
884 (options.bookmark_match_threshold + 0.1).min(1.0)
885 } else {
886 options.bookmark_match_threshold
887 };
888 let mut best: Option<(usize, f32, f32)> = None; for (idx, cand) in candidates.iter().enumerate() {
890 if claimed[idx] {
891 continue;
892 }
893 if let (Some(bp), cp) = (bm.page_no, cand.page_no) {
894 if cp != 0 && cp != bp {
895 continue;
896 }
897 }
898 let score = match_score(&cand.text, title);
899 if score < threshold {
900 continue;
901 }
902 let dist = match (cand.bbox_points.map(|b| b[1]), bm.y_top) {
903 (Some(top), Some(y)) => (top - y).abs(),
904 _ => f32::INFINITY,
905 };
906 let better = match best {
907 None => true,
908 Some((_, bs, bd)) => score > bs + 1e-6 || ((score - bs).abs() <= 1e-6 && dist < bd),
909 };
910 if better {
911 best = Some((idx, score, dist));
912 }
913 }
914 if let Some((idx, _, _)) = best {
915 claimed[idx] = true;
916 matches.push((idx, bm.level));
917 }
918 }
919 if matches.is_empty() {
920 return HashMap::new();
921 }
922 compress_keys(matches.into_iter().collect())
924}
925
926pub(crate) fn apply(
932 nodes: &mut [Node],
933 outline: &[OutlineItem],
934 glyph_styles: &HashMap<usize, Vec<GlyphStyle>>,
935 options: &HeadingHierarchyOptions,
936) {
937 if !options.enabled {
938 return;
939 }
940
941 let mut bookmark_levels: HashMap<usize, usize> = HashMap::new(); if options.use_bookmarks && !outline.is_empty() {
945 let candidates = collect(nodes, true);
946 let matched = infer_from_bookmarks(&candidates, outline, options);
947 for (cand_idx, level) in matched {
948 let cand = &candidates[cand_idx];
949 if cand.is_list_item {
950 promote_list_item(nodes, cand.node_idx, &cand.text);
951 }
952 bookmark_levels.insert(cand.node_idx, level);
953 }
954 }
955
956 let headings = collect(nodes, false);
957 if headings.is_empty() {
958 return;
959 }
960
961 let mut levels: HashMap<usize, usize> = HashMap::new(); for (i, h) in headings.iter().enumerate() {
964 if let Some(level) = bookmark_levels.get(&h.node_idx) {
965 levels.insert(i, *level);
966 }
967 }
968 if options.use_numbering {
969 let texts: Vec<&str> = headings.iter().map(|h| h.text.as_str()).collect();
970 for (i, level) in infer_from_numbering(&texts, options) {
971 levels.entry(i).or_insert(level);
972 }
973 }
974 if options.use_style && !glyph_styles.is_empty() {
975 for (i, level) in infer_from_style(&headings, glyph_styles, options) {
976 levels.entry(i).or_insert(level);
977 }
978 }
979
980 for (i, h) in headings.iter().enumerate() {
981 let Some(&level) = levels.get(&i) else {
982 continue;
983 };
984 let semantic = level.clamp(1, options.max_level.max(1) as usize);
985 let rendered = (semantic + 1).min(u8::MAX as usize) as u8;
988 set_heading_level(&mut nodes[h.node_idx], rendered);
989 }
990}
991
992fn set_heading_level(node: &mut Node, new_level: u8) {
993 match node {
994 Node::Heading { level, .. } => *level = new_level,
995 Node::Located { inner, .. } => {
996 if let Node::Heading { level, .. } = inner.as_mut() {
997 *level = new_level;
998 }
999 }
1000 _ => {}
1001 }
1002}
1003
1004fn promote_list_item(nodes: &mut [Node], idx: usize, text: &str) {
1009 let Node::ListItem {
1010 first_in_list,
1011 location,
1012 ..
1013 } = &nodes[idx]
1014 else {
1015 return;
1016 };
1017 let was_first = *first_in_list;
1018 let loc = *location;
1019 let heading = Node::Heading {
1020 level: 2,
1021 text: text.to_string(),
1022 };
1023 nodes[idx] = match loc {
1024 Some(location) => Node::Located {
1025 location,
1026 inner: Box::new(heading),
1027 },
1028 None => heading,
1029 };
1030 if was_first {
1031 if let Some(Node::ListItem { first_in_list, .. }) = nodes.get_mut(idx + 1) {
1032 *first_in_list = true;
1033 }
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040
1041 fn heading(loc: [u16; 4], text: &str) -> Node {
1042 Node::Located {
1043 location: loc,
1044 inner: Box::new(Node::Heading {
1045 level: 2,
1046 text: text.to_string(),
1047 }),
1048 }
1049 }
1050
1051 fn page(no: usize) -> Node {
1052 Node::PageInfo {
1053 page_no: no,
1054 width: 512.0,
1055 height: 512.0,
1056 }
1057 }
1058
1059 fn levels(nodes: &[Node]) -> Vec<u8> {
1060 nodes
1061 .iter()
1062 .filter_map(|n| match n {
1063 Node::Located { inner, .. } => match inner.as_ref() {
1064 Node::Heading { level, .. } => Some(*level),
1065 _ => None,
1066 },
1067 Node::Heading { level, .. } => Some(*level),
1068 _ => None,
1069 })
1070 .collect()
1071 }
1072
1073 #[test]
1074 fn roman_validator_matches_difflib_regex() {
1075 for ok in ["I", "iv", "XIV", "MCMXCIX", "iii", "C"] {
1076 assert!(is_roman(ok), "{ok}");
1077 }
1078 for bad in ["", "IIII", "VX", "ABC", "Summary", "IC"] {
1079 assert!(!is_roman(bad), "{bad}");
1080 }
1081 }
1082
1083 #[test]
1084 fn markers_parse_the_docling_families() {
1085 let fam = |t: &str| parse_marker(t).map(|m| (m.family, m.depth));
1086 assert_eq!(fam("PART I — General"), Some(("part", 1)));
1087 assert_eq!(fam("Chapter 2: Scope"), Some(("chapter", 1)));
1088 assert_eq!(fam("Article 5"), Some(("article", 1)));
1089 assert_eq!(fam("§ 12 Something"), Some(("article", 1)));
1090 assert_eq!(fam("1. Introduction"), Some(("arabic", 1)));
1091 assert_eq!(fam("2) Also arabic"), Some(("arabic", 1)));
1092 assert_eq!(fam("1.1 Scope"), Some(("dotted", 2)));
1093 assert_eq!(fam("2.3.1 Deep"), Some(("dotted", 3)));
1094 assert_eq!(fam("A. Annex-ish"), Some(("alpha_u", 1)));
1095 assert_eq!(fam("(a) item"), Some(("alpha_l", 1)));
1096 assert_eq!(fam("(iv) sub"), Some(("roman_l", 1)));
1097 assert_eq!(fam("IV. Chapter"), Some(("roman_u", 1)));
1098 assert_eq!(fam("Summary."), None);
1100 assert_eq!(fam("Overview"), None);
1101 }
1102
1103 #[test]
1104 fn ambiguous_single_letters_resolve_from_document_context() {
1105 let texts = ["I. One", "II. Two", "V. Five"];
1107 let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1108 assert_eq!(map[&0], map[&2]);
1110 let texts = ["B. Bee", "C. Sea", "D. Dee"];
1112 let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1113 assert_eq!(map[&0], map[&1]);
1114 assert_eq!(map[&1], map[&2]);
1115 }
1116
1117 #[test]
1118 fn numbering_levels_compress_to_contiguous() {
1119 let texts = ["PART I", "1.1 Scope", "1.1.1 Detail", "No marker"];
1122 let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1123 assert_eq!(map[&0], 1);
1124 assert_eq!(map[&1], 2);
1125 assert_eq!(map[&2], 3);
1126 assert!(!map.contains_key(&3));
1127 }
1128
1129 #[test]
1130 fn similarity_behaves_like_difflib_ratio() {
1131 assert_eq!(similarity("abc", "abc"), 1.0);
1132 assert_eq!(similarity("", ""), 1.0);
1133 assert_eq!(similarity("abc", "xyz"), 0.0);
1134 assert!((similarity("abcd", "bcde") - 0.75).abs() < 1e-6);
1136 }
1137
1138 #[test]
1139 fn bookmark_titles_match_with_and_without_markers() {
1140 assert!(match_score("1.1 Definitions", "Definitions") >= 0.9);
1141 assert!(match_score("ARTICLE 5 Payment Terms", "Payment Terms") >= 0.9);
1142 assert!(match_score("Introduction", "Conclusion") < 0.8);
1143 }
1144
1145 #[test]
1146 fn apply_assigns_numbering_levels_end_to_end() {
1147 let mut nodes = vec![
1148 page(1),
1149 heading([10, 10, 200, 20], "1. Introduction"),
1150 heading([10, 40, 200, 50], "1.1 Scope"),
1151 heading([10, 70, 200, 80], "Unnumbered"),
1152 ];
1153 apply(
1154 &mut nodes,
1155 &[],
1156 &HashMap::new(),
1157 &HeadingHierarchyOptions::enabled(true),
1158 );
1159 assert_eq!(levels(&nodes), vec![2, 3, 2]);
1162 }
1163
1164 #[test]
1165 fn apply_is_inert_when_disabled() {
1166 let mut nodes = vec![page(1), heading([10, 10, 200, 20], "1.1.1 Deep")];
1167 apply(
1168 &mut nodes,
1169 &[],
1170 &HashMap::new(),
1171 &HeadingHierarchyOptions::default(),
1172 );
1173 assert_eq!(levels(&nodes), vec![2]);
1174 }
1175
1176 #[test]
1177 fn bookmarks_win_over_numbering_and_promote_list_items() {
1178 let outline = vec![
1179 OutlineItem {
1180 title: "1. Introduction".into(),
1181 level: 0,
1182 page_no: Some(1),
1183 y_top: None,
1184 },
1185 OutlineItem {
1186 title: "Hidden Heading".into(),
1187 level: 1,
1188 page_no: Some(1),
1189 y_top: None,
1190 },
1191 ];
1192 let mut nodes = vec![
1193 page(1),
1194 heading([10, 10, 200, 20], "1. Introduction"),
1197 Node::ListItem {
1198 ordered: false,
1199 number: 0,
1200 first_in_list: true,
1201 text: "Hidden Heading".into(),
1202 level: 0,
1203 marker: None,
1204 location: Some([10, 40, 200, 50]),
1205 dclx: None,
1206 href: None,
1207 layer: None,
1208 },
1209 Node::ListItem {
1210 ordered: false,
1211 number: 0,
1212 first_in_list: false,
1213 text: "a real item".into(),
1214 level: 0,
1215 marker: None,
1216 location: Some([10, 70, 200, 80]),
1217 dclx: None,
1218 href: None,
1219 layer: None,
1220 },
1221 ];
1222 apply(
1223 &mut nodes,
1224 &outline,
1225 &HashMap::new(),
1226 &HeadingHierarchyOptions::enabled(true),
1227 );
1228 assert_eq!(levels(&nodes), vec![2, 3]);
1231 match &nodes[3] {
1232 Node::ListItem {
1233 first_in_list,
1234 text,
1235 ..
1236 } => {
1237 assert!(*first_in_list, "sibling re-opens the list");
1238 assert_eq!(text, "a real item");
1239 }
1240 other => panic!("expected the sibling list item, got {other:?}"),
1241 }
1242 }
1243
1244 #[test]
1245 fn style_ranks_by_size_then_prominence() {
1246 let glyphs = vec![
1248 GlyphStyle {
1249 l: 10.0,
1250 t: 10.0,
1251 r: 100.0,
1252 b: 28.0,
1253 height: 18.0,
1254 weight_cls: 2,
1255 italic: false,
1256 styled: true,
1257 },
1258 GlyphStyle {
1259 l: 10.0,
1260 t: 60.0,
1261 r: 100.0,
1262 b: 72.0,
1263 height: 12.0,
1264 weight_cls: 2,
1265 italic: false,
1266 styled: true,
1267 },
1268 GlyphStyle {
1269 l: 10.0,
1270 t: 110.0,
1271 r: 100.0,
1272 b: 122.0,
1273 height: 12.0,
1274 weight_cls: 0,
1275 italic: false,
1276 styled: true,
1277 },
1278 ];
1279 let mut styles = HashMap::new();
1280 styles.insert(1usize, glyphs);
1281 let mut nodes = vec![
1282 page(1),
1283 heading([10, 10, 200, 28], "Big Title Words"),
1284 heading([10, 60, 200, 72], "Bold Twelve"),
1285 heading([10, 110, 200, 122], "Plain Twelve"),
1286 ];
1287 apply(
1289 &mut nodes,
1290 &[],
1291 &styles,
1292 &HeadingHierarchyOptions::enabled(true),
1293 );
1294 assert_eq!(levels(&nodes), vec![2, 3, 4]);
1295 }
1296
1297 #[test]
1298 fn strip_marker_removes_leading_numbering() {
1299 assert_eq!(strip_marker("1.1 Definitions"), "Definitions");
1300 assert_eq!(strip_marker("ARTICLE 5 - Payment"), "Payment");
1301 assert_eq!(strip_marker("(a) item"), "item");
1302 assert_eq!(strip_marker("No marker here"), "No marker here");
1303 }
1304}