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    // `word.len()` is a byte count taken from a *different* string, so it can
214    // land inside a multi-byte char of `text` — `Note 1\u{a0}Overview` against
215    // the 7-byte `chapter` splits the no-break space at bytes 6..8 (#377), and
216    // a direct slice panics there. `get` returns `None` instead, which is also
217    // the right answer: `text` cannot start with an ASCII word of that length
218    // if byte `word.len()` is mid-character.
219    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
231/// Classify a bare alpha/Roman token (`A`, `iv`, `i` …) into a marker.
232fn 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    // Multi-letter tokens only count as numbering if they are valid Roman
253    // numerals; otherwise they are plain words ("Summary."), not numbering.
254    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
265/// Extract the leading numbering marker from a heading, or `None`.
266fn 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    // § 1 / §§ 1.2
288    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    // Dotted decimal outline (1.1, 1.1.1, …) terminated by space/end/punct.
296    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    // Single Arabic index (1. / 2)).
312    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    // Single/multi letter marker, optionally parenthesized: (a) / A. / (iv).
321    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
335/// Parse a leading `N(.N)+` run: `(segment count, rest)`. `None` when the
336/// text does not start with at least `N.N`.
337fn 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            // Only continue when a digit follows the dot — `1.` is arabic,
349            // not dotted.
350            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
357/// Resolve single-letter Roman/alpha markers in place using document-wide
358/// evidence: a lone `I.` is Roman when the document also contains unambiguous
359/// Roman markers and alpha when it contains unambiguous alpha markers. When
360/// evidence is absent or conflicting, `I`/`i` default to Roman (the common
361/// legal case) and other letters to alpha.
362fn 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()) // unknown scheme → lowest priority
406}
407
408/// Map heading index → level from numbering markers (relative, compressed).
409fn 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
429/// Compress the distinct sort keys actually present into contiguous 1-based
430/// levels, so a document that starts at "1." is not forced to start deep.
431fn 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
445// -------------------------------------------------------------------- style
446
447/// Share of a heading's styled characters that must be italic to count.
448const ITALIC_RATIO: f32 = 0.6;
449
450/// Whether the text is written in capitals (ignoring digits and punctuation).
451fn 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/// The visual style of one heading — the ranking key of the style fallback.
457#[derive(Clone, Copy, Debug)]
458struct HeadingStyle {
459    size: f32,
460    weight_cls: u8,
461    italic: bool,
462    caps: bool,
463}
464
465/// Derive a heading's style from the glyphs overlapping its box. Weight and
466/// slant are a character-weighted vote (a heading can mix a regular "1.1 "
467/// with a bold title); glyphs without font style contribute size only, so the
468/// ranking degrades to font size.
469fn 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    // On a tie, the heavier class wins: emphasis makes a heading stand out.
510    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
521/// Group font sizes into clusters (largest first) and map each size to its
522/// cluster index; consecutive sizes within `tolerance` (relative) merge, to
523/// absorb descender-driven measurement noise.
524fn 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
542/// Map heading index → level from heading styles (most prominent = level 1).
543fn 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    // Order by size cluster, then by how much the heading stands out within
576    // its size: heavier before lighter, upright before italic, capitals
577    // before mixed case.
578    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
590// ---------------------------------------------------------------- bookmarks
591
592/// Lower-case, collapse whitespace, trim outer punctuation for matching.
593fn 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
604/// Strip one leading numbering marker before fuzzy-matching a title, so a
605/// bookmark "Definitions" matches an on-page heading "1.1 Definitions" (the
606/// port of docling's `_LEADING_MARKER`).
607fn 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
622/// Byte length of the leading marker in `s`, `None` when there is none.
623fn leading_marker_len(s: &str) -> Option<usize> {
624    // Keyword + optional `[\s.:]*[0-9ivxlcdm]*` tail.
625    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    // §+ \s* [0-9.]+
646    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    // \(? \d+(\.\d+)* [).]?
659    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    // \(? [A-Za-z]{1,2} [).]
683    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
693/// `difflib.SequenceMatcher.ratio()` for two short strings: 2·M / T over the
694/// Ratcliff–Obershelp matching blocks (no junk heuristic — titles are short).
695fn 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        // find_longest_match(alo, ahi, blo, bhi)
709        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
751/// Fuzzy similarity in 0..1 between a detected heading and a bookmark title.
752/// Both are compared with and without their leading numbering marker, and
753/// containment of one normalized title in the other boosts the score
754/// (bookmarks are frequently truncated).
755fn 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
777// ------------------------------------------------------------------- stage
778
779/// A heading (or promotable list item) found in the node stream.
780struct HeadingRef {
781    /// Index into the node vec.
782    node_idx: usize,
783    /// Plain text (markers reconstructed for ordered list items).
784    text: String,
785    /// 1-based page.
786    page_no: usize,
787    /// `[l, t, r, b]` in top-left page points, when the node carries one.
788    bbox_points: Option<[f32; 4]>,
789    /// Whether this is a list item (a bookmark match promotes it).
790    is_list_item: bool,
791}
792
793/// Collect heading/list-item candidates with their page geometry.
794fn 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                // Reconstruct the enumeration marker the assembler folded into
846                // `number`, so bookmark titles that carry it still match.
847                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
866/// The 1-based pages that contain headings — what the style glyph pass needs
867/// to read. Empty when the stage would have nothing to do.
868pub(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
876/// Match the PDF outline to candidates; returns `candidate index → level`
877/// (compressed, 1-based). Mirrors docling's `_infer_from_bookmarks`.
878fn 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(); // (candidate, raw level)
885
886    for bm in outline {
887        let title = bm.title.trim();
888        if title.is_empty() {
889            continue;
890        }
891        // A cross-page (page-less) match must be stronger.
892        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; // (idx, score, dist)
898        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 the raw bookmark depths actually used into contiguous levels.
932    compress_keys(matches.into_iter().collect())
933}
934
935/// Run the stage: assign heading levels in place on the assembled node
936/// stream. Precedence: bookmarks > numbering > style; headings with no
937/// applicable signal keep their level; a confidently bookmark-matched list
938/// item is promoted to a heading. `outline` and `glyph_styles` may be empty
939/// (signals degrade individually, docling parity).
940pub(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    // Bookmark pass first: it may promote list items, changing the set of
951    // headings, so it has to run before the heading list is (re)collected.
952    let mut bookmark_levels: HashMap<usize, usize> = HashMap::new(); // node_idx → level
953    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(); // heading index → level
971                                                            // Bookmarks are authoritative: seed first so nothing overrides them.
972    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        // Our `Node::Heading` level is the rendered Markdown depth: docling's
995        // semantic level N serializes as N+1 hashes (`##` for level 1).
996        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
1013/// Promote a bookmark-matched list item to a heading in place: same text and
1014/// position, now a `Heading` (the level is assigned by the caller). The
1015/// following sibling inherits `first_in_list` when the promoted item opened
1016/// its list, so adjacent lists keep their separation.
1017fn 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    /// #377: a keyword probe whose byte length falls inside a multi-byte
1051    /// character of the heading must answer `false`, not panic — the
1052    /// reporter's `Note 1\u{a0}Overview` (no-break space at bytes 6..8 vs the
1053    /// 7-byte `chapter`), and every offset of a few non-ASCII characters.
1054    #[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        // The positive cases and the whole-word rule are unchanged.
1066        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        // Plain words are not numbering.
1131        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        // With unambiguous Roman evidence, a lone "V." reads as Roman.
1138        let texts = ["I. One", "II. Two", "V. Five"];
1139        let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
1140        // All three land on the same (roman_u) level.
1141        assert_eq!(map[&0], map[&2]);
1142        // With alpha evidence instead, "C." reads as alpha.
1143        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        // part > dotted-2 > dotted-3: distinct keys → levels 1, 2, 3 even
1152        // though the arabic family rank sits far from `part`.
1153        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        // difflib: SequenceMatcher(None, "abcd", "bcde").ratio() == 0.75
1167        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        // Semantic 1/2 render as Markdown levels 2/3; the unnumbered heading
1192        // keeps the assembler's level.
1193        assert_eq!(levels(&nodes), vec![2, 3, 2]);
1194    }
1195
1196    /// #377 end to end: headings and bookmark titles with no-break spaces
1197    /// and non-ASCII letters go through the numbering *and* bookmark passes
1198    /// without a panic; the numbered ones still get their levels.
1199    #[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            // Numbering alone would put this on level 1 too, but the bookmark
1266            // is authoritative and the depths compress from the outline.
1267            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        // The matched list item became a level-2 (semantic 2 → rendered 3)
1300        // heading; the trailing sibling re-opens its list.
1301        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        // Two size clusters (18pt vs 12pt); within 12pt, bold beats regular.
1318        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        // Page is 512x512 points in these tests, so locations ≈ points.
1359        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}