use std::collections::HashMap;
use docling_core::Node;
use crate::outline::OutlineItem;
#[derive(Clone, Debug)]
pub struct HeadingHierarchyOptions {
pub enabled: bool,
pub use_bookmarks: bool,
pub use_numbering: bool,
pub use_style: bool,
pub use_font_style: bool,
pub style_size_tolerance: f32,
pub max_level: u8,
pub bookmark_match_threshold: f32,
pub numbering_schemes: Option<Vec<String>>,
}
impl Default for HeadingHierarchyOptions {
fn default() -> Self {
Self {
enabled: false,
use_bookmarks: true,
use_numbering: true,
use_style: true,
use_font_style: true,
style_size_tolerance: 0.05,
max_level: 6,
bookmark_match_threshold: 0.8,
numbering_schemes: None,
}
}
}
impl HeadingHierarchyOptions {
pub fn enabled(on: bool) -> Self {
Self {
enabled: on,
..Self::default()
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct GlyphStyle {
pub l: f32,
pub t: f32,
pub r: f32,
pub b: f32,
pub height: f32,
pub weight_cls: u8,
pub italic: bool,
pub styled: bool,
}
const DEFAULT_FAMILY_ORDER: [&str; 8] = [
"part", "chapter", "article", "roman_u", "arabic", "alpha_u", "alpha_l", "roman_l", ];
#[derive(Clone, Debug, PartialEq)]
struct Marker {
family: &'static str,
depth: usize,
token: Option<String>,
ambiguous: bool,
}
impl Marker {
fn family(family: &'static str) -> Self {
Marker {
family,
depth: 1,
token: None,
ambiguous: false,
}
}
}
fn is_roman(token: &str) -> bool {
if token.is_empty() || !token.is_ascii() {
return false;
}
let s: Vec<u8> = token.bytes().map(|b| b.to_ascii_uppercase()).collect();
let mut i = 0;
let mut m = 0;
while i < s.len() && s[i] == b'M' && m < 4 {
i += 1;
m += 1;
}
if s[i..].starts_with(b"CM") || s[i..].starts_with(b"CD") {
i += 2;
} else {
if i < s.len() && s[i] == b'D' {
i += 1;
}
let mut c = 0;
while i < s.len() && s[i] == b'C' && c < 3 {
i += 1;
c += 1;
}
}
if s[i..].starts_with(b"XC") || s[i..].starts_with(b"XL") {
i += 2;
} else {
if i < s.len() && s[i] == b'L' {
i += 1;
}
let mut x = 0;
while i < s.len() && s[i] == b'X' && x < 3 {
i += 1;
x += 1;
}
}
if s[i..].starts_with(b"IX") || s[i..].starts_with(b"IV") {
i += 2;
} else {
if i < s.len() && s[i] == b'V' {
i += 1;
}
let mut n = 0;
while i < s.len() && s[i] == b'I' && n < 3 {
i += 1;
n += 1;
}
}
i == s.len()
}
fn starts_with_word(text: &str, word: &str) -> bool {
let Some(head) = text.get(..word.len()) else {
return false;
};
if !head.eq_ignore_ascii_case(word) {
return false;
}
text[word.len()..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric())
}
fn classify_letter(token: &str) -> Option<Marker> {
let upper = token.chars().all(|c| c.is_uppercase());
if token.chars().count() == 1 {
let is_roman_single = token
.chars()
.next()
.is_some_and(|c| "IVXLCDMivxlcdm".contains(c));
let family = match (is_roman_single, upper) {
(true, true) => "roman_u",
(true, false) => "roman_l",
(false, true) => "alpha_u",
(false, false) => "alpha_l",
};
return Some(Marker {
family,
depth: 1,
token: Some(token.to_string()),
ambiguous: is_roman_single,
});
}
if is_roman(token) {
return Some(Marker {
family: if upper { "roman_u" } else { "roman_l" },
depth: 1,
token: Some(token.to_string()),
ambiguous: false,
});
}
None
}
fn parse_marker(text: &str) -> Option<Marker> {
let s = text.trim_start();
if s.is_empty() {
return None;
}
for kw in ["part", "title", "book"] {
if starts_with_word(s, kw) {
return Some(Marker::family("part"));
}
}
if starts_with_word(s, "chapter") {
return Some(Marker::family("chapter"));
}
for kw in [
"article", "section", "clause", "schedule", "annex", "appendix", "rule",
] {
if starts_with_word(s, kw) {
return Some(Marker::family("article"));
}
}
if s.starts_with('§') {
let after = s.trim_start_matches('§').trim_start();
if after.starts_with(|c: char| c.is_ascii_digit()) {
return Some(Marker::family("article"));
}
}
if let Some((segments, rest)) = take_dotted(s) {
if segments >= 2
&& rest
.chars()
.next()
.is_none_or(|c| matches!(c, '.' | ')' | ']') || c.is_whitespace())
{
return Some(Marker {
family: "dotted",
depth: segments,
token: None,
ambiguous: false,
});
}
}
let digits = s.chars().take_while(|c| c.is_ascii_digit()).count();
if digits > 0 {
let rest = &s[digits..];
if rest.starts_with('.') || rest.starts_with(')') {
return Some(Marker::family("arabic"));
}
}
let after_paren = s.strip_prefix('(').map(str::trim_start).unwrap_or(s);
let letters: String = after_paren
.chars()
.take_while(|c| c.is_alphabetic())
.collect();
if !letters.is_empty() {
let rest = after_paren[letters.len()..].trim_start();
if rest.starts_with(')') || rest.starts_with('.') {
return classify_letter(&letters);
}
}
None
}
fn take_dotted(s: &str) -> Option<(usize, &str)> {
let mut rest = s;
let mut segments = 0;
loop {
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
if digits == 0 {
break;
}
segments += 1;
rest = &rest[digits..];
match rest.strip_prefix('.') {
Some(r) if r.starts_with(|c: char| c.is_ascii_digit()) => rest = r,
_ => break,
}
}
(segments >= 2).then_some((segments, rest))
}
fn resolve_ambiguous(markers: &mut [Option<Marker>]) {
let has = |family: &str, ms: &[Option<Marker>]| {
ms.iter()
.flatten()
.any(|m| !m.ambiguous && m.family == family)
};
let upper_roman = has("roman_u", markers);
let upper_alpha = has("alpha_u", markers);
let lower_roman = has("roman_l", markers);
let lower_alpha = has("alpha_l", markers);
for m in markers.iter_mut().flatten() {
if !m.ambiguous {
continue;
}
let Some(token) = m.token.as_deref() else {
continue;
};
let upper = token.chars().all(|c| c.is_uppercase());
let (has_roman, has_alpha) = if upper {
(upper_roman, upper_alpha)
} else {
(lower_roman, lower_alpha)
};
let roman = if has_roman && !has_alpha {
true
} else if has_alpha && !has_roman {
false
} else {
token == "I" || token == "i"
};
m.family = match (roman, upper) {
(true, true) => "roman_u",
(true, false) => "roman_l",
(false, true) => "alpha_u",
(false, false) => "alpha_l",
};
m.ambiguous = false;
}
}
fn family_rank(family: &str, order: &[String]) -> usize {
let key = if family == "dotted" { "arabic" } else { family };
order.iter().position(|f| f == key).unwrap_or(order.len()) }
fn infer_from_numbering(
heading_texts: &[&str],
options: &HeadingHierarchyOptions,
) -> HashMap<usize, usize> {
let order: Vec<String> = options
.numbering_schemes
.clone()
.unwrap_or_else(|| DEFAULT_FAMILY_ORDER.iter().map(|s| s.to_string()).collect());
let mut markers: Vec<Option<Marker>> = heading_texts.iter().map(|t| parse_marker(t)).collect();
resolve_ambiguous(&mut markers);
let mut keys: HashMap<usize, (usize, usize)> = HashMap::new();
for (i, m) in markers.iter().enumerate() {
if let Some(m) = m {
keys.insert(i, (family_rank(m.family, &order), m.depth));
}
}
compress_keys(keys)
}
fn compress_keys<K: Ord + Clone + std::hash::Hash>(
keys: HashMap<usize, K>,
) -> HashMap<usize, usize> {
let mut distinct: Vec<K> = keys.values().cloned().collect();
distinct.sort();
distinct.dedup();
let level_of: HashMap<K, usize> = distinct
.into_iter()
.enumerate()
.map(|(i, k)| (k, i + 1))
.collect();
keys.into_iter().map(|(i, k)| (i, level_of[&k])).collect()
}
const ITALIC_RATIO: f32 = 0.6;
fn is_all_caps(text: &str) -> bool {
let letters: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
letters.len() >= 4 && letters.iter().all(|c| c.is_uppercase())
}
#[derive(Clone, Copy, Debug)]
struct HeadingStyle {
size: f32,
weight_cls: u8,
italic: bool,
caps: bool,
}
fn heading_style(
bbox: [f32; 4],
text: &str,
glyphs: &[GlyphStyle],
options: &HeadingHierarchyOptions,
) -> Option<HeadingStyle> {
let [hl, ht, hr, hb] = bbox;
let mut heights: Vec<f32> = Vec::new();
let mut weights = [0usize; 3];
let mut styled_chars = 0usize;
let mut italic_chars = 0usize;
for g in glyphs {
if g.l < hr && g.r > hl && g.t < hb && g.b > ht {
heights.push(g.height);
if options.use_font_style && g.styled {
weights[g.weight_cls.min(2) as usize] += 1;
styled_chars += 1;
if g.italic {
italic_chars += 1;
}
}
}
}
if heights.is_empty() {
return None;
}
heights.sort_by(f32::total_cmp);
let size = if heights.len() % 2 == 1 {
heights[heights.len() / 2]
} else {
(heights[heights.len() / 2 - 1] + heights[heights.len() / 2]) / 2.0
};
if !options.use_font_style {
return Some(HeadingStyle {
size,
weight_cls: 0,
italic: false,
caps: false,
});
}
let weight_cls = (0u8..3)
.max_by_key(|&cls| (weights[cls as usize], cls))
.unwrap_or(0);
Some(HeadingStyle {
size,
weight_cls,
italic: styled_chars > 0 && italic_chars as f32 / styled_chars as f32 >= ITALIC_RATIO,
caps: is_all_caps(text),
})
}
fn cluster_sizes(mut sizes: Vec<f32>, tolerance: f32) -> Vec<(f32, usize)> {
sizes.sort_by(|a, b| b.total_cmp(a));
sizes.dedup();
let mut clusters = Vec::with_capacity(sizes.len());
let mut index = 0usize;
let mut previous: Option<f32> = None;
for size in sizes {
if let Some(prev) = previous {
if (prev - size) > tolerance * prev {
index += 1;
}
}
clusters.push((size, index));
previous = Some(size);
}
clusters
}
fn infer_from_style(
headings: &[HeadingRef],
glyph_styles: &HashMap<usize, Vec<GlyphStyle>>,
options: &HeadingHierarchyOptions,
) -> HashMap<usize, usize> {
if glyph_styles.is_empty() {
return HashMap::new();
}
let mut styles: HashMap<usize, HeadingStyle> = HashMap::new();
for (i, h) in headings.iter().enumerate() {
let Some(glyphs) = glyph_styles.get(&h.page_no) else {
continue;
};
let Some(bbox) = h.bbox_points else { continue };
if let Some(style) = heading_style(bbox, &h.text, glyphs, options) {
styles.insert(i, style);
}
}
if styles.is_empty() {
return HashMap::new();
}
let clusters = cluster_sizes(
styles.values().map(|s| s.size).collect(),
options.style_size_tolerance,
);
let cluster_of = |size: f32| -> usize {
clusters
.iter()
.find(|(s, _)| *s == size)
.map(|(_, c)| *c)
.unwrap_or(0)
};
let keys: HashMap<usize, (usize, i8, bool, bool)> = styles
.into_iter()
.map(|(i, s)| {
(
i,
(cluster_of(s.size), -(s.weight_cls as i8), s.italic, !s.caps),
)
})
.collect();
compress_keys(keys)
}
fn norm(text: &str) -> String {
let collapsed = text
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
collapsed
.trim_matches(|c: char| !c.is_alphanumeric())
.to_string()
}
fn strip_marker(text: &str) -> String {
let s = text.trim_start();
let matched_len = leading_marker_len(s);
match matched_len {
Some(n) => {
let rest = &s[n..];
let trimmed = rest.trim_start_matches(|c: char| {
c.is_whitespace() || matches!(c, '.' | ':' | ')' | '-')
});
trimmed.to_string()
}
None => text.to_string(),
}
}
fn leading_marker_len(s: &str) -> Option<usize> {
for kw in [
"chapter", "article", "section", "clause", "schedule", "annex", "appendix", "rule", "part",
"title", "book",
] {
if starts_with_word(s, kw) {
let mut i = kw.len();
let bytes = s.as_bytes();
while i < bytes.len()
&& (bytes[i].is_ascii_whitespace() || bytes[i] == b'.' || bytes[i] == b':')
{
i += 1;
}
while i < bytes.len()
&& (bytes[i].is_ascii_digit() || b"ivxlcdmIVXLCDM".contains(&bytes[i]))
{
i += 1;
}
return Some(i);
}
}
if s.starts_with('§') {
let rest = s.trim_start_matches('§');
let ws = rest.len() - rest.trim_start().len();
let rest2 = rest.trim_start();
let num = rest2
.bytes()
.take_while(|b| b.is_ascii_digit() || *b == b'.')
.count();
if num > 0 {
return Some(s.len() - rest.len() + ws + num);
}
}
let (paren, body) = match s.strip_prefix('(') {
Some(r) => (1, r),
None => (0, s),
};
let digits = body.bytes().take_while(|b| b.is_ascii_digit()).count();
if digits > 0 {
let mut i = digits;
let b = body.as_bytes();
while i < b.len() && b[i] == b'.' {
let d = body[i + 1..]
.bytes()
.take_while(|x| x.is_ascii_digit())
.count();
if d == 0 {
break;
}
i += 1 + d;
}
if i < b.len() && (b[i] == b')' || b[i] == b'.') {
i += 1;
}
return Some(paren + i);
}
let letters = body.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
if (1..=2).contains(&letters) {
let b = body.as_bytes();
if letters < b.len() && (b[letters] == b')' || b[letters] == b'.') {
return Some(paren + letters + 1);
}
}
None
}
fn similarity(a: &str, b: &str) -> f32 {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() && b.is_empty() {
return 1.0;
}
let mut b2j: HashMap<char, Vec<usize>> = HashMap::new();
for (j, &c) in b.iter().enumerate() {
b2j.entry(c).or_default().push(j);
}
let mut matches = 0usize;
let mut queue = vec![(0usize, a.len(), 0usize, b.len())];
while let Some((alo, ahi, blo, bhi)) = queue.pop() {
let (mut besti, mut bestj, mut bestsize) = (alo, blo, 0usize);
let mut j2len: HashMap<usize, usize> = HashMap::new();
for (i, ch) in a.iter().enumerate().take(ahi).skip(alo) {
let mut newj2len: HashMap<usize, usize> = HashMap::new();
if let Some(js) = b2j.get(ch) {
for &j in js {
if j < blo {
continue;
}
if j >= bhi {
break;
}
let k = j
.checked_sub(1)
.and_then(|p| j2len.get(&p))
.copied()
.unwrap_or(0)
+ 1;
newj2len.insert(j, k);
if k > bestsize {
besti = i + 1 - k;
bestj = j + 1 - k;
bestsize = k;
}
}
}
j2len = newj2len;
}
if bestsize == 0 {
continue;
}
matches += bestsize;
if besti > alo && bestj > blo {
queue.push((alo, besti, blo, bestj));
}
if besti + bestsize < ahi && bestj + bestsize < bhi {
queue.push((besti + bestsize, ahi, bestj + bestsize, bhi));
}
}
(2.0 * matches as f32) / (a.len() + b.len()) as f32
}
fn match_score(cand_text: &str, bm_title: &str) -> f32 {
let mut variants_a = vec![norm(cand_text), norm(&strip_marker(cand_text))];
let mut variants_b = vec![norm(bm_title), norm(&strip_marker(bm_title))];
variants_a.retain(|v| !v.is_empty());
variants_b.retain(|v| !v.is_empty());
variants_a.dedup();
variants_b.dedup();
let mut best: f32 = 0.0;
for a in &variants_a {
for b in &variants_b {
best = best.max(similarity(a, b));
if a.chars().count() >= 4
&& b.chars().count() >= 4
&& (a.contains(b.as_str()) || b.contains(a.as_str()))
{
best = best.max(0.92);
}
}
}
best
}
struct HeadingRef {
node_idx: usize,
text: String,
page_no: usize,
bbox_points: Option<[f32; 4]>,
is_list_item: bool,
}
fn collect(nodes: &[Node], with_list_items: bool) -> Vec<HeadingRef> {
let mut out = Vec::new();
let mut page_no = 0usize;
let mut page_w = 0f32;
let mut page_h = 0f32;
let denorm = |loc: [u16; 4], w: f32, h: f32| -> Option<[f32; 4]> {
(w > 0.0 && h > 0.0).then(|| {
[
loc[0] as f32 / 512.0 * w,
loc[1] as f32 / 512.0 * h,
loc[2] as f32 / 512.0 * w,
loc[3] as f32 / 512.0 * h,
]
})
};
for (idx, node) in nodes.iter().enumerate() {
match node {
Node::PageInfo {
page_no: p,
width,
height,
} => {
page_no = *p;
page_w = *width;
page_h = *height;
}
Node::Located { location, inner } => {
if let Node::Heading { text, .. } = inner.as_ref() {
out.push(HeadingRef {
node_idx: idx,
text: text.clone(),
page_no,
bbox_points: denorm(*location, page_w, page_h),
is_list_item: false,
});
}
}
Node::Heading { text, .. } => out.push(HeadingRef {
node_idx: idx,
text: text.clone(),
page_no,
bbox_points: None,
is_list_item: false,
}),
Node::ListItem {
ordered,
number,
text,
location,
..
} if with_list_items => {
let text = if *ordered {
format!("{number}. {text}")
} else {
text.clone()
};
out.push(HeadingRef {
node_idx: idx,
text,
page_no,
bbox_points: location.and_then(|loc| denorm(loc, page_w, page_h)),
is_list_item: true,
});
}
_ => {}
}
}
out
}
pub(crate) fn heading_pages(nodes: &[Node]) -> Vec<usize> {
let mut pages: Vec<usize> = collect(nodes, false).iter().map(|h| h.page_no).collect();
pages.sort_unstable();
pages.dedup();
pages.retain(|&p| p > 0);
pages
}
fn infer_from_bookmarks(
candidates: &[HeadingRef],
outline: &[OutlineItem],
options: &HeadingHierarchyOptions,
) -> HashMap<usize, usize> {
let mut claimed: Vec<bool> = vec![false; candidates.len()];
let mut matches: Vec<(usize, usize)> = Vec::new();
for bm in outline {
let title = bm.title.trim();
if title.is_empty() {
continue;
}
let threshold = if bm.page_no.is_none() {
(options.bookmark_match_threshold + 0.1).min(1.0)
} else {
options.bookmark_match_threshold
};
let mut best: Option<(usize, f32, f32)> = None; for (idx, cand) in candidates.iter().enumerate() {
if claimed[idx] {
continue;
}
if let (Some(bp), cp) = (bm.page_no, cand.page_no) {
if cp != 0 && cp != bp {
continue;
}
}
let score = match_score(&cand.text, title);
if score < threshold {
continue;
}
let dist = match (cand.bbox_points.map(|b| b[1]), bm.y_top) {
(Some(top), Some(y)) => (top - y).abs(),
_ => f32::INFINITY,
};
let better = match best {
None => true,
Some((_, bs, bd)) => score > bs + 1e-6 || ((score - bs).abs() <= 1e-6 && dist < bd),
};
if better {
best = Some((idx, score, dist));
}
}
if let Some((idx, _, _)) = best {
claimed[idx] = true;
matches.push((idx, bm.level));
}
}
if matches.is_empty() {
return HashMap::new();
}
compress_keys(matches.into_iter().collect())
}
pub(crate) fn apply(
nodes: &mut [Node],
outline: &[OutlineItem],
glyph_styles: &HashMap<usize, Vec<GlyphStyle>>,
options: &HeadingHierarchyOptions,
) {
if !options.enabled {
return;
}
let mut bookmark_levels: HashMap<usize, usize> = HashMap::new(); if options.use_bookmarks && !outline.is_empty() {
let candidates = collect(nodes, true);
let matched = infer_from_bookmarks(&candidates, outline, options);
for (cand_idx, level) in matched {
let cand = &candidates[cand_idx];
if cand.is_list_item {
promote_list_item(nodes, cand.node_idx, &cand.text);
}
bookmark_levels.insert(cand.node_idx, level);
}
}
let headings = collect(nodes, false);
if headings.is_empty() {
return;
}
let mut levels: HashMap<usize, usize> = HashMap::new(); for (i, h) in headings.iter().enumerate() {
if let Some(level) = bookmark_levels.get(&h.node_idx) {
levels.insert(i, *level);
}
}
if options.use_numbering {
let texts: Vec<&str> = headings.iter().map(|h| h.text.as_str()).collect();
for (i, level) in infer_from_numbering(&texts, options) {
levels.entry(i).or_insert(level);
}
}
if options.use_style && !glyph_styles.is_empty() {
for (i, level) in infer_from_style(&headings, glyph_styles, options) {
levels.entry(i).or_insert(level);
}
}
for (i, h) in headings.iter().enumerate() {
let Some(&level) = levels.get(&i) else {
continue;
};
let semantic = level.clamp(1, options.max_level.max(1) as usize);
let rendered = (semantic + 1).min(u8::MAX as usize) as u8;
set_heading_level(&mut nodes[h.node_idx], rendered);
}
}
fn set_heading_level(node: &mut Node, new_level: u8) {
match node {
Node::Heading { level, .. } => *level = new_level,
Node::Located { inner, .. } => {
if let Node::Heading { level, .. } = inner.as_mut() {
*level = new_level;
}
}
_ => {}
}
}
fn promote_list_item(nodes: &mut [Node], idx: usize, text: &str) {
let Node::ListItem {
first_in_list,
location,
..
} = &nodes[idx]
else {
return;
};
let was_first = *first_in_list;
let loc = *location;
let heading = Node::Heading {
level: 2,
text: text.to_string(),
};
nodes[idx] = match loc {
Some(location) => Node::Located {
location,
inner: Box::new(heading),
},
None => heading,
};
if was_first {
if let Some(Node::ListItem { first_in_list, .. }) = nodes.get_mut(idx + 1) {
*first_in_list = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keyword_probe_never_slices_mid_char() {
assert!(!starts_with_word("Note 1\u{a0}Overview", "chapter"));
for word in ["chapter", "section", "part", "article", "appendix", "annex"] {
for k in 0..12 {
for ch in ['\u{a0}', '\u{e9}', '\u{3a9}', '\u{1f600}'] {
let text = format!("{}{ch}Overview", "x".repeat(k));
assert!(!starts_with_word(&text, word), "{text:?} vs {word}");
}
}
}
assert!(starts_with_word("Chapter\u{a0}1", "chapter"));
assert!(starts_with_word("CHAPTER 2 Scope", "chapter"));
assert!(starts_with_word("Section", "section"));
assert!(!starts_with_word("Chapters", "chapter"));
assert!(!starts_with_word("Chapt", "chapter"));
}
fn heading(loc: [u16; 4], text: &str) -> Node {
Node::Located {
location: loc,
inner: Box::new(Node::Heading {
level: 2,
text: text.to_string(),
}),
}
}
fn page(no: usize) -> Node {
Node::PageInfo {
page_no: no,
width: 512.0,
height: 512.0,
}
}
fn levels(nodes: &[Node]) -> Vec<u8> {
nodes
.iter()
.filter_map(|n| match n {
Node::Located { inner, .. } => match inner.as_ref() {
Node::Heading { level, .. } => Some(*level),
_ => None,
},
Node::Heading { level, .. } => Some(*level),
_ => None,
})
.collect()
}
#[test]
fn roman_validator_matches_difflib_regex() {
for ok in ["I", "iv", "XIV", "MCMXCIX", "iii", "C"] {
assert!(is_roman(ok), "{ok}");
}
for bad in ["", "IIII", "VX", "ABC", "Summary", "IC"] {
assert!(!is_roman(bad), "{bad}");
}
}
#[test]
fn markers_parse_the_docling_families() {
let fam = |t: &str| parse_marker(t).map(|m| (m.family, m.depth));
assert_eq!(fam("PART I — General"), Some(("part", 1)));
assert_eq!(fam("Chapter 2: Scope"), Some(("chapter", 1)));
assert_eq!(fam("Article 5"), Some(("article", 1)));
assert_eq!(fam("§ 12 Something"), Some(("article", 1)));
assert_eq!(fam("1. Introduction"), Some(("arabic", 1)));
assert_eq!(fam("2) Also arabic"), Some(("arabic", 1)));
assert_eq!(fam("1.1 Scope"), Some(("dotted", 2)));
assert_eq!(fam("2.3.1 Deep"), Some(("dotted", 3)));
assert_eq!(fam("A. Annex-ish"), Some(("alpha_u", 1)));
assert_eq!(fam("(a) item"), Some(("alpha_l", 1)));
assert_eq!(fam("(iv) sub"), Some(("roman_l", 1)));
assert_eq!(fam("IV. Chapter"), Some(("roman_u", 1)));
assert_eq!(fam("Summary."), None);
assert_eq!(fam("Overview"), None);
}
#[test]
fn ambiguous_single_letters_resolve_from_document_context() {
let texts = ["I. One", "II. Two", "V. Five"];
let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
assert_eq!(map[&0], map[&2]);
let texts = ["B. Bee", "C. Sea", "D. Dee"];
let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
assert_eq!(map[&0], map[&1]);
assert_eq!(map[&1], map[&2]);
}
#[test]
fn numbering_levels_compress_to_contiguous() {
let texts = ["PART I", "1.1 Scope", "1.1.1 Detail", "No marker"];
let map = infer_from_numbering(&texts.map(|t| t), &HeadingHierarchyOptions::default());
assert_eq!(map[&0], 1);
assert_eq!(map[&1], 2);
assert_eq!(map[&2], 3);
assert!(!map.contains_key(&3));
}
#[test]
fn similarity_behaves_like_difflib_ratio() {
assert_eq!(similarity("abc", "abc"), 1.0);
assert_eq!(similarity("", ""), 1.0);
assert_eq!(similarity("abc", "xyz"), 0.0);
assert!((similarity("abcd", "bcde") - 0.75).abs() < 1e-6);
}
#[test]
fn bookmark_titles_match_with_and_without_markers() {
assert!(match_score("1.1 Definitions", "Definitions") >= 0.9);
assert!(match_score("ARTICLE 5 Payment Terms", "Payment Terms") >= 0.9);
assert!(match_score("Introduction", "Conclusion") < 0.8);
}
#[test]
fn apply_assigns_numbering_levels_end_to_end() {
let mut nodes = vec![
page(1),
heading([10, 10, 200, 20], "1. Introduction"),
heading([10, 40, 200, 50], "1.1 Scope"),
heading([10, 70, 200, 80], "Unnumbered"),
];
apply(
&mut nodes,
&[],
&HashMap::new(),
&HeadingHierarchyOptions::enabled(true),
);
assert_eq!(levels(&nodes), vec![2, 3, 2]);
}
#[test]
fn apply_survives_multibyte_headings_and_bookmarks() {
let mut nodes = vec![
page(1),
heading([10, 10, 200, 20], "Note 1\u{a0}Overview"),
heading([10, 40, 200, 50], "1.\u{a0}Einf\u{fc}hrung"),
heading(
[10, 70, 200, 80],
"1.1\u{a0}\u{dc}berblick \u{2014} Teil\u{a0}A",
),
heading([10, 100, 200, 110], "Chapter\u{a0}2\u{a0}\u{3a9}mega"),
heading([10, 130, 200, 140], "\u{1f600} Anhang"),
];
let outline = vec![
OutlineItem {
title: "Note\u{a0}1 Overview".into(),
level: 0,
page_no: Some(1),
y_top: None,
},
OutlineItem {
title: "Einf\u{fc}hrung".into(),
level: 1,
page_no: Some(1),
y_top: None,
},
];
apply(
&mut nodes,
&outline,
&HashMap::new(),
&HeadingHierarchyOptions::enabled(true),
);
assert_eq!(levels(&nodes).len(), 5);
}
#[test]
fn apply_is_inert_when_disabled() {
let mut nodes = vec![page(1), heading([10, 10, 200, 20], "1.1.1 Deep")];
apply(
&mut nodes,
&[],
&HashMap::new(),
&HeadingHierarchyOptions::default(),
);
assert_eq!(levels(&nodes), vec![2]);
}
#[test]
fn bookmarks_win_over_numbering_and_promote_list_items() {
let outline = vec![
OutlineItem {
title: "1. Introduction".into(),
level: 0,
page_no: Some(1),
y_top: None,
},
OutlineItem {
title: "Hidden Heading".into(),
level: 1,
page_no: Some(1),
y_top: None,
},
];
let mut nodes = vec![
page(1),
heading([10, 10, 200, 20], "1. Introduction"),
Node::ListItem {
ordered: false,
number: 0,
first_in_list: true,
text: "Hidden Heading".into(),
level: 0,
marker: None,
location: Some([10, 40, 200, 50]),
dclx: None,
href: None,
layer: None,
},
Node::ListItem {
ordered: false,
number: 0,
first_in_list: false,
text: "a real item".into(),
level: 0,
marker: None,
location: Some([10, 70, 200, 80]),
dclx: None,
href: None,
layer: None,
},
];
apply(
&mut nodes,
&outline,
&HashMap::new(),
&HeadingHierarchyOptions::enabled(true),
);
assert_eq!(levels(&nodes), vec![2, 3]);
match &nodes[3] {
Node::ListItem {
first_in_list,
text,
..
} => {
assert!(*first_in_list, "sibling re-opens the list");
assert_eq!(text, "a real item");
}
other => panic!("expected the sibling list item, got {other:?}"),
}
}
#[test]
fn style_ranks_by_size_then_prominence() {
let glyphs = vec![
GlyphStyle {
l: 10.0,
t: 10.0,
r: 100.0,
b: 28.0,
height: 18.0,
weight_cls: 2,
italic: false,
styled: true,
},
GlyphStyle {
l: 10.0,
t: 60.0,
r: 100.0,
b: 72.0,
height: 12.0,
weight_cls: 2,
italic: false,
styled: true,
},
GlyphStyle {
l: 10.0,
t: 110.0,
r: 100.0,
b: 122.0,
height: 12.0,
weight_cls: 0,
italic: false,
styled: true,
},
];
let mut styles = HashMap::new();
styles.insert(1usize, glyphs);
let mut nodes = vec![
page(1),
heading([10, 10, 200, 28], "Big Title Words"),
heading([10, 60, 200, 72], "Bold Twelve"),
heading([10, 110, 200, 122], "Plain Twelve"),
];
apply(
&mut nodes,
&[],
&styles,
&HeadingHierarchyOptions::enabled(true),
);
assert_eq!(levels(&nodes), vec![2, 3, 4]);
}
#[test]
fn strip_marker_removes_leading_numbering() {
assert_eq!(strip_marker("1.1 Definitions"), "Definitions");
assert_eq!(strip_marker("ARTICLE 5 - Payment"), "Payment");
assert_eq!(strip_marker("(a) item"), "item");
assert_eq!(strip_marker("No marker here"), "No marker here");
}
}