Skip to main content

docling_pdf/
heading_hierarchy.rs

1//! Section-header level inference for the PDF/image pipeline (#302 — the
2//! port of docling's `HeadingHierarchyModel`).
3//!
4//! The layout model classifies regions as `section_header` without a level,
5//! so every heading the PDF path emits lands at the same depth and the
6//! document hierarchy is flattened (Roman-numeral parts and Arabic-numeral
7//! subsections collapse together). When enabled, this stage runs on the
8//! assembled document — right after reading-order assembly, like docling's —
9//! and assigns each heading a level from, in precedence order:
10//!
11//! 1. **bookmarks** — the PDF outline ([`crate::outline`]), the document's
12//!    own declared hierarchy. Bookmarks are fuzzily matched (title + page) to
13//!    detected headings; a confidently matched heading takes the bookmark's
14//!    depth, and a confidently matched *list item* is promoted to a heading
15//!    (layout models often mis-classify a heading as a list item).
16//! 2. **numbering** — legal/outline numbering such as `PART I → 1. → 1.1 →
17//!    (a) → (i)`. The primary signal for headings without a bookmark match.
18//! 3. **style** — the heading's visual style, read from the PDF text layer's
19//!    glyphs ([`GlyphStyle`], gathered by the pdfium backend): font size
20//!    first — with near-equal sizes merged, since the measured height of the
21//!    same font varies with descenders — then weight, slant and letter case.
22//!
23//! Apart from promoting a confidently bookmark-matched list item, the stage
24//! only rewrites heading levels — it never adds, removes or reorders items,
25//! and headings with no applicable signal keep their level. Docling's
26//! semantic level `N` corresponds to our [`Node::Heading`] `level: N + 1`
27//! (docling's Markdown serializer renders `section_header` level 1 as `##`,
28//! which is exactly what our assembler already emits for every heading).
29//!
30//! Divergence from docling noted for the record: style is aggregated over
31//! *glyphs* rather than parsed text-line cells (same signal, finer
32//! granularity), and OCR-only headings carry no style at all (docling reads
33//! OCR cell heights; our glyph pass reads the digital text layer only).
34
35use std::collections::HashMap;
36
37use docling_core::Node;
38
39use crate::outline::OutlineItem;
40
41/// Options for the heading-hierarchy stage (docling's
42/// `HeadingHierarchyOptions`, defaults included).
43#[derive(Clone, Debug)]
44pub struct HeadingHierarchyOptions {
45    /// Master switch. Off by default (docling parity): all detected headings
46    /// keep the assembler's level and the output is byte-for-byte unchanged.
47    pub enabled: bool,
48    /// Use the PDF outline (bookmarks/ToC) as the authoritative signal.
49    pub use_bookmarks: bool,
50    /// Use legal/outline numbering for headings without a bookmark match.
51    pub use_numbering: bool,
52    /// Use visual style (font size, and below) as the last-resort signal.
53    pub use_style: bool,
54    /// Refine the style fallback with font weight/slant (from the embedded
55    /// font names) and all-caps detection.
56    pub use_font_style: bool,
57    /// Relative difference below which two heading font sizes count as one
58    /// size (absorbs descender-driven measurement noise).
59    pub style_size_tolerance: f32,
60    /// Maximum semantic heading level to assign; deeper levels clamp.
61    pub max_level: u8,
62    /// Minimum fuzzy title similarity (0..1) for a bookmark to match a
63    /// heading/list item.
64    pub bookmark_match_threshold: f32,
65    /// Override of the numbering-scheme precedence (highest level first);
66    /// known schemes: `part`, `chapter`, `article`, `roman_u`, `arabic`,
67    /// `alpha_u`, `alpha_l`, `roman_l`. `None` = the default legal ordering.
68    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    /// The plumbed-everywhere form: default options with the master switch.
89    pub fn enabled(on: bool) -> Self {
90        Self {
91            enabled: on,
92            ..Self::default()
93        }
94    }
95}
96
97/// One text-layer glyph's box and style, in top-left page points — the
98/// stage's input for the style signal, gathered per page by the pdfium
99/// backend (each glyph counts as one character in the style vote).
100#[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    /// Line height (font ascent + descent at the glyph's size) — the
107    /// font-size proxy, matching docling's text-line cell height.
108    pub height: f32,
109    /// 0 light/regular, 1 medium/semibold, 2 bold+ (from the font name).
110    pub weight_cls: u8,
111    pub italic: bool,
112    /// Whether the font name carried any recognizable style at all.
113    pub styled: bool,
114}
115
116/// Default precedence of numbering schemes, highest hierarchy level first.
117/// `dotted` shares the `arabic` rank and orders below it by segment depth.
118const DEFAULT_FAMILY_ORDER: [&str; 8] = [
119    "part",    // PART I / TITLE I / BOOK I
120    "chapter", // CHAPTER 1
121    "article", // ARTICLE 1 / SECTION 1 / Clause / § 1
122    "roman_u", // I. II. III.
123    "arabic",  // 1. 2. 3.  (and dotted 1.1, 1.1.1 by depth)
124    "alpha_u", // A. B. C.
125    "alpha_l", // (a) (b) (c)
126    "roman_l", // (i) (ii) (iii)
127];
128
129// ------------------------------------------------------------------ markers
130
131/// A parsed leading numbering marker.
132#[derive(Clone, Debug, PartialEq)]
133struct Marker {
134    family: &'static str,
135    /// Dotted-decimal segment count; 1 for everything else.
136    depth: usize,
137    /// Raw alpha/Roman token, kept for ambiguity resolution.
138    token: Option<String>,
139    /// Single-letter Roman/alpha that needs document context to resolve.
140    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
154/// Canonical Roman-numeral validator (1..3999), case-insensitive — the
155/// hand-rolled equivalent of `M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})
156/// (IX|IV|V?I{0,3})` on a non-empty token.
157fn 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    // M{0,4}
164    let mut m = 0;
165    while i < s.len() && s[i] == b'M' && m < 4 {
166        i += 1;
167        m += 1;
168    }
169    // (CM|CD|D?C{0,3})
170    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    // (XC|XL|L?X{0,3})
183    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    // (IX|IV|V?I{0,3})
196    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
211/// Whether `text` starts with `word` as a whole word (ASCII case-insensitive).
212fn 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
222/// Classify a bare alpha/Roman token (`A`, `iv`, `i` …) into a marker.
223fn 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    // Multi-letter tokens only count as numbering if they are valid Roman
244    // numerals; otherwise they are plain words ("Summary."), not numbering.
245    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
256/// Extract the leading numbering marker from a heading, or `None`.
257fn 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    // § 1 / §§ 1.2
279    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    // Dotted decimal outline (1.1, 1.1.1, …) terminated by space/end/punct.
287    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    // Single Arabic index (1. / 2)).
303    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    // Single/multi letter marker, optionally parenthesized: (a) / A. / (iv).
312    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
326/// Parse a leading `N(.N)+` run: `(segment count, rest)`. `None` when the
327/// text does not start with at least `N.N`.
328fn 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            // Only continue when a digit follows the dot — `1.` is arabic,
340            // not dotted.
341            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
348/// Resolve single-letter Roman/alpha markers in place using document-wide
349/// evidence: a lone `I.` is Roman when the document also contains unambiguous
350/// Roman markers and alpha when it contains unambiguous alpha markers. When
351/// evidence is absent or conflicting, `I`/`i` default to Roman (the common
352/// legal case) and other letters to alpha.
353fn 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()) // unknown scheme → lowest priority
397}
398
399/// Map heading index → level from numbering markers (relative, compressed).
400fn 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
420/// Compress the distinct sort keys actually present into contiguous 1-based
421/// levels, so a document that starts at "1." is not forced to start deep.
422fn 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
436// -------------------------------------------------------------------- style
437
438/// Share of a heading's styled characters that must be italic to count.
439const ITALIC_RATIO: f32 = 0.6;
440
441/// Whether the text is written in capitals (ignoring digits and punctuation).
442fn 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/// The visual style of one heading — the ranking key of the style fallback.
448#[derive(Clone, Copy, Debug)]
449struct HeadingStyle {
450    size: f32,
451    weight_cls: u8,
452    italic: bool,
453    caps: bool,
454}
455
456/// Derive a heading's style from the glyphs overlapping its box. Weight and
457/// slant are a character-weighted vote (a heading can mix a regular "1.1 "
458/// with a bold title); glyphs without font style contribute size only, so the
459/// ranking degrades to font size.
460fn 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    // On a tie, the heavier class wins: emphasis makes a heading stand out.
501    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
512/// Group font sizes into clusters (largest first) and map each size to its
513/// cluster index; consecutive sizes within `tolerance` (relative) merge, to
514/// absorb descender-driven measurement noise.
515fn 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
533/// Map heading index → level from heading styles (most prominent = level 1).
534fn 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    // Order by size cluster, then by how much the heading stands out within
567    // its size: heavier before lighter, upright before italic, capitals
568    // before mixed case.
569    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
581// ---------------------------------------------------------------- bookmarks
582
583/// Lower-case, collapse whitespace, trim outer punctuation for matching.
584fn 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
595/// Strip one leading numbering marker before fuzzy-matching a title, so a
596/// bookmark "Definitions" matches an on-page heading "1.1 Definitions" (the
597/// port of docling's `_LEADING_MARKER`).
598fn 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
613/// Byte length of the leading marker in `s`, `None` when there is none.
614fn leading_marker_len(s: &str) -> Option<usize> {
615    // Keyword + optional `[\s.:]*[0-9ivxlcdm]*` tail.
616    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    // §+ \s* [0-9.]+
637    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    // \(? \d+(\.\d+)* [).]?
650    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    // \(? [A-Za-z]{1,2} [).]
674    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
684/// `difflib.SequenceMatcher.ratio()` for two short strings: 2·M / T over the
685/// Ratcliff–Obershelp matching blocks (no junk heuristic — titles are short).
686fn 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        // find_longest_match(alo, ahi, blo, bhi)
700        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
742/// Fuzzy similarity in 0..1 between a detected heading and a bookmark title.
743/// Both are compared with and without their leading numbering marker, and
744/// containment of one normalized title in the other boosts the score
745/// (bookmarks are frequently truncated).
746fn 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
768// ------------------------------------------------------------------- stage
769
770/// A heading (or promotable list item) found in the node stream.
771struct HeadingRef {
772    /// Index into the node vec.
773    node_idx: usize,
774    /// Plain text (markers reconstructed for ordered list items).
775    text: String,
776    /// 1-based page.
777    page_no: usize,
778    /// `[l, t, r, b]` in top-left page points, when the node carries one.
779    bbox_points: Option<[f32; 4]>,
780    /// Whether this is a list item (a bookmark match promotes it).
781    is_list_item: bool,
782}
783
784/// Collect heading/list-item candidates with their page geometry.
785fn 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                // Reconstruct the enumeration marker the assembler folded into
837                // `number`, so bookmark titles that carry it still match.
838                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
857/// The 1-based pages that contain headings — what the style glyph pass needs
858/// to read. Empty when the stage would have nothing to do.
859pub(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
867/// Match the PDF outline to candidates; returns `candidate index → level`
868/// (compressed, 1-based). Mirrors docling's `_infer_from_bookmarks`.
869fn 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(); // (candidate, raw level)
876
877    for bm in outline {
878        let title = bm.title.trim();
879        if title.is_empty() {
880            continue;
881        }
882        // A cross-page (page-less) match must be stronger.
883        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; // (idx, score, dist)
889        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 the raw bookmark depths actually used into contiguous levels.
923    compress_keys(matches.into_iter().collect())
924}
925
926/// Run the stage: assign heading levels in place on the assembled node
927/// stream. Precedence: bookmarks > numbering > style; headings with no
928/// applicable signal keep their level; a confidently bookmark-matched list
929/// item is promoted to a heading. `outline` and `glyph_styles` may be empty
930/// (signals degrade individually, docling parity).
931pub(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    // Bookmark pass first: it may promote list items, changing the set of
942    // headings, so it has to run before the heading list is (re)collected.
943    let mut bookmark_levels: HashMap<usize, usize> = HashMap::new(); // node_idx → level
944    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(); // heading index → level
962                                                            // Bookmarks are authoritative: seed first so nothing overrides them.
963    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        // Our `Node::Heading` level is the rendered Markdown depth: docling's
986        // semantic level N serializes as N+1 hashes (`##` for level 1).
987        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
1004/// Promote a bookmark-matched list item to a heading in place: same text and
1005/// position, now a `Heading` (the level is assigned by the caller). The
1006/// following sibling inherits `first_in_list` when the promoted item opened
1007/// its list, so adjacent lists keep their separation.
1008fn 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        // Plain words are not numbering.
1099        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        // With unambiguous Roman evidence, a lone "V." reads as Roman.
1106        let texts = ["I. One", "II. Two", "V. Five"];
1107        let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1108        // All three land on the same (roman_u) level.
1109        assert_eq!(map[&0], map[&2]);
1110        // With alpha evidence instead, "C." reads as alpha.
1111        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        // part > dotted-2 > dotted-3: distinct keys → levels 1, 2, 3 even
1120        // though the arabic family rank sits far from `part`.
1121        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        // difflib: SequenceMatcher(None, "abcd", "bcde").ratio() == 0.75
1135        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        // Semantic 1/2 render as Markdown levels 2/3; the unnumbered heading
1160        // keeps the assembler's level.
1161        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            // Numbering alone would put this on level 1 too, but the bookmark
1195            // is authoritative and the depths compress from the outline.
1196            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        // The matched list item became a level-2 (semantic 2 → rendered 3)
1229        // heading; the trailing sibling re-opens its list.
1230        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        // Two size clusters (18pt vs 12pt); within 12pt, bold beats regular.
1247        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        // Page is 512x512 points in these tests, so locations ≈ points.
1288        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}