use std::cmp::Ordering;
use crate::blocks::{dominant_font_size, Block, BlockKind};
#[cfg(test)]
use crate::table_trace::TableOrigin;
const SIZE_TOLERANCE: f64 = 0.05;
const MAX_HEADING_LINES: usize = 3;
const CAPITALS_FLOOR: f64 = 0.9;
const MAX_SPLIT_WHITE_EM: f64 = 3.0;
const MAX_HEADING_LEVEL: u8 = 3;
const MIN_CAPS_LETTERS: usize = 4;
const LINE_STEP_EM: f64 = 0.25;
const INDENT_STEP_EM: f64 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Cluster {
style: Style,
indent: i32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Style {
size: f64,
bold: bool,
caps: bool,
}
impl Style {
fn rank(&self) -> (f64, bool) {
(self.size, self.bold)
}
fn ranks_with(&self, other: &Style) -> bool {
(self.size - other.size).abs()
<= SIZE_TOLERANCE * self.size.min(other.size).max(f64::MIN_POSITIVE)
&& self.bold == other.bold
}
fn outranks(&self, other: &Style) -> bool {
if self.ranks_with(other) {
return false;
}
match self.rank().0.partial_cmp(&other.rank().0) {
Some(Ordering::Greater) => true,
Some(Ordering::Less) => false,
_ => self.bold && !other.bold,
}
}
}
struct Metrics {
style: Style,
page: usize,
chars: usize,
lines: usize,
left: f64,
right: f64,
space_above: Option<f64>,
}
fn is_all_caps(text: &str) -> bool {
let mut cased = 0usize;
for c in text.chars() {
if c.is_lowercase() {
return false;
}
if c.is_uppercase() {
cased += 1;
}
}
cased >= MIN_CAPS_LETTERS
}
fn line_count(block: &Block) -> usize {
let size = dominant_font_size(block.spans.iter()).max(f64::MIN_POSITIVE);
let mut baselines: Vec<f64> = block.spans.iter().map(|span| span.y).collect();
baselines.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let mut lines = 0usize;
let mut last: Option<f64> = None;
for y in baselines {
if last.is_none_or(|previous: f64| (y - previous).abs() > LINE_STEP_EM * size) {
lines += 1;
last = Some(y);
}
}
lines.max(1)
}
fn is_bold(block: &Block) -> bool {
let mut bold = 0usize;
let mut total = 0usize;
for span in &block.spans {
let chars = span.text.chars().filter(|c| !c.is_whitespace()).count();
total += chars;
if span.is_bold {
bold += chars;
}
}
total > 0 && bold * 2 > total
}
fn size_bands(sizes: &[(f64, usize)]) -> Vec<f64> {
let mut sorted: Vec<(f64, usize)> = sizes.iter().copied().filter(|(s, _)| *s > 0.0).collect();
sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
let mut bands: Vec<Vec<(f64, usize)>> = Vec::new();
for entry in sorted {
match bands.last_mut() {
Some(band)
if entry.0 - band.last().expect("a band is never empty").0
<= SIZE_TOLERANCE * band.last().expect("a band is never empty").0 =>
{
band.push(entry)
}
_ => bands.push(vec![entry]),
}
}
bands
.into_iter()
.map(|band| {
let weight: usize = band.iter().map(|(_, chars)| *chars).sum();
if weight == 0 {
return band[band.len() / 2].0;
}
band.iter()
.map(|(size, chars)| size * *chars as f64)
.sum::<f64>()
/ weight as f64
})
.collect()
}
fn band_of(bands: &[f64], size: f64) -> f64 {
bands
.iter()
.copied()
.min_by(|a, b| {
(a - size)
.abs()
.partial_cmp(&(b - size).abs())
.unwrap_or(Ordering::Equal)
})
.unwrap_or(size)
}
fn metrics_of(blocks: &[Block]) -> Vec<Metrics> {
let raw: Vec<(f64, usize)> = blocks
.iter()
.map(|block| {
(
dominant_font_size(block.spans.iter()),
block.text.chars().filter(|c| !c.is_whitespace()).count(),
)
})
.collect();
let bands = size_bands(&raw);
let mut previous: Option<(usize, f64)> = None;
let mut out = Vec::with_capacity(blocks.len());
for (block, (size, chars)) in blocks.iter().zip(raw.iter().copied()) {
let space_above = match previous {
Some((page, bottom)) if page == block.page => Some(bottom - block.bbox[3]),
_ => None,
}
.filter(|gap| *gap >= 0.0);
previous = Some((block.page, block.bbox[1]));
out.push(Metrics {
page: block.page,
style: Style {
size: band_of(&bands, size),
bold: is_bold(block),
caps: is_all_caps(&block.text),
},
chars,
lines: line_count(block),
left: block.bbox[0],
right: block.bbox[2],
space_above,
});
}
out
}
fn body_style(metrics: &[Metrics]) -> Option<Style> {
let mut groups: Vec<(Style, usize)> = Vec::new();
for entry in metrics {
match groups.iter_mut().find(|(style, _)| *style == entry.style) {
Some((_, weight)) => *weight += entry.chars,
None => groups.push((entry.style, entry.chars)),
}
}
groups
.into_iter()
.max_by(|a, b| {
a.1.cmp(&b.1).then_with(|| {
a.0.size
.partial_cmp(&b.0.size)
.unwrap_or(Ordering::Equal)
.reverse()
})
})
.map(|(style, _)| style)
}
fn names_something(text: &str) -> bool {
text.chars().any(char::is_alphabetic)
}
fn stands_above(style: &Style, body: &Style) -> bool {
style.outranks(body) || (capitals_hold(style, body) && style.caps && !body.caps)
}
fn capitals_hold(style: &Style, body: &Style) -> bool {
style.ranks_with(body)
|| (body.size > 0.0
&& style.size >= CAPITALS_FLOOR * body.size
&& style.size <= body.size * (1.0 + SIZE_TOLERANCE)
&& style.bold == body.bold)
}
fn column_left(block: &Metrics, metrics: &[Metrics], body: &Style) -> f64 {
let body_on_page: Vec<&Metrics> = metrics
.iter()
.filter(|m| m.style == *body && m.page == block.page)
.collect();
let overlapping: Vec<f64> = body_on_page
.iter()
.filter(|m| m.right.min(block.right) > m.left.max(block.left))
.map(|m| m.left)
.collect();
let mut lefts = if !overlapping.is_empty() {
overlapping
} else if !body_on_page.is_empty() {
body_on_page.iter().map(|m| m.left).collect()
} else {
metrics
.iter()
.filter(|m| m.style == *body)
.map(|m| m.left)
.collect()
};
if lefts.is_empty() {
return block.left;
}
lefts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
lefts[lefts.len() / 2]
}
fn space_above(metrics: &[&Metrics]) -> f64 {
let spaced: Vec<f64> = metrics.iter().filter_map(|m| m.space_above).collect();
if spaced.is_empty() {
return 0.0;
}
spaced.iter().sum::<f64>() / spaced.len() as f64
}
pub(crate) fn classify(blocks: &mut Vec<Block>) {
if blocks.len() < 2 {
return;
}
let metrics = metrics_of(blocks);
let Some(body) = body_style(&metrics) else {
return;
};
let em = body.size.max(f64::MIN_POSITIVE);
let set_apart = set_apart_by_the_page(blocks, &metrics, em);
let candidates: Vec<usize> = (0..blocks.len())
.filter(|i| {
matches!(blocks[*i].kind, BlockKind::Paragraph)
&& metrics[*i].lines <= MAX_HEADING_LINES
&& names_something(&blocks[*i].text)
&& (stands_above(&metrics[*i].style, &body) || set_apart.contains(i))
})
.collect();
if candidates.is_empty() {
return;
}
let key_of = |i: usize| Cluster {
style: metrics[i].style,
indent: ((column_left(&metrics[i], &metrics, &body) - metrics[i].left)
/ (INDENT_STEP_EM * em))
.round() as i32,
};
let mut clusters: Vec<(Cluster, Vec<&Metrics>)> = Vec::new();
for i in candidates.iter().copied() {
let key = key_of(i);
match clusters.iter_mut().find(|(cluster, _)| *cluster == key) {
Some((_, members)) => members.push(&metrics[i]),
None => clusters.push((key, vec![&metrics[i]])),
}
}
clusters.sort_by(|a, b| {
b.0.style
.size
.partial_cmp(&a.0.style.size)
.unwrap_or(Ordering::Equal)
.then_with(|| b.0.style.bold.cmp(&a.0.style.bold))
.then_with(|| b.0.indent.cmp(&a.0.indent))
.then_with(|| {
space_above(&b.1)
.partial_cmp(&space_above(&a.1))
.unwrap_or(Ordering::Equal)
})
.then_with(|| b.0.style.caps.cmp(&a.0.style.caps))
});
let levels: Vec<(Cluster, u8)> = clusters
.iter()
.enumerate()
.map(|(rank, (cluster, _))| {
let level = rank.saturating_add(1).min(MAX_HEADING_LEVEL as usize);
(*cluster, level as u8)
})
.collect();
for i in candidates {
let key = key_of(i);
if let Some((_, level)) = levels.iter().find(|(cluster, _)| *cluster == key) {
blocks[i].kind = BlockKind::Heading { level: *level };
}
}
put_split_headings_together(blocks, &metrics, em);
}
fn set_apart_by_the_page(blocks: &[Block], metrics: &[Metrics], em: f64) -> Vec<usize> {
let mut out = Vec::new();
for i in 0..blocks.len() {
if !matches!(blocks[i].kind, BlockKind::Paragraph) || metrics[i].lines != 1 {
continue;
}
let measure = metrics
.iter()
.filter(|m| m.page == metrics[i].page)
.map(|m| m.right - m.left)
.fold(f64::NEG_INFINITY, f64::max);
if !(measure > 0.0 && (metrics[i].right - metrics[i].left) < TITLE_MEASURE * measure) {
continue;
}
if ends_sentence(&blocks[i].text) || blocks[i].text.trim_end().ends_with(':') {
continue;
}
if metrics[i].space_above.is_none() {
continue;
}
let Some(previous) = i.checked_sub(1) else {
continue;
};
if metrics[previous].page != metrics[i].page {
continue;
}
let finished = match &blocks[previous].kind {
BlockKind::Paragraph => ends_sentence(&blocks[previous].text),
BlockKind::ListItem { .. } | BlockKind::TableCell { .. } => true,
BlockKind::Heading { .. } | BlockKind::Caption => true,
BlockKind::Figure => true,
BlockKind::Header | BlockKind::Footer => false,
};
if !finished {
continue;
}
let Some(next) = metrics.get(i + 1) else {
continue;
};
if next.page != metrics[i].page
|| !matches!(
blocks[i + 1].kind,
BlockKind::Paragraph | BlockKind::ListItem { .. } | BlockKind::TableCell { .. }
)
|| (next.left - metrics[i].left).abs() > 0.5 * em
{
continue;
}
out.push(i);
}
out
}
const TITLE_MEASURE: f64 = 0.8;
fn put_split_headings_together(blocks: &mut Vec<Block>, metrics: &[Metrics], em: f64) {
let mut source: Vec<usize> = (0..blocks.len()).collect();
let mut i = 0;
while i + 1 < blocks.len() {
let (first, next) = (&blocks[i], &blocks[i + 1]);
let joins = matches!(first.kind, BlockKind::Heading { .. })
&& matches!(next.kind, BlockKind::Heading { .. })
&& first.page == next.page
&& metrics[source[i]].style == metrics[source[i + 1]].style
&& !ends_sentence(&first.text)
&& (first.bbox[1] - next.bbox[3]) <= MAX_SPLIT_WHITE_EM * em
&& next.bbox[3] <= first.bbox[1];
if !joins {
i += 1;
continue;
}
let tail = blocks.remove(i + 1);
source.remove(i + 1);
let head = &mut blocks[i];
head.text = format!("{} {}", head.text.trim_end(), tail.text.trim_start());
head.bbox = [
head.bbox[0].min(tail.bbox[0]),
head.bbox[1].min(tail.bbox[1]),
head.bbox[2].max(tail.bbox[2]),
head.bbox[3].max(tail.bbox[3]),
];
head.spans.extend(tail.spans);
}
}
fn ends_sentence(text: &str) -> bool {
matches!(
text.trim_end()
.trim_end_matches(['"', '\'', '\u{201d}', '\u{2019}', ')', ']'])
.chars()
.next_back(),
Some('.') | Some('!') | Some('?') | Some(';')
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::TextSpan;
fn span(text: &str, x: f64, y: f64, size: f64, bold: bool) -> TextSpan {
TextSpan {
text: text.to_string(),
x,
y,
width: size * 0.5 * text.chars().count() as f64,
height: size,
font_size: size,
font_name: None,
is_bold: bold,
is_italic: false,
color: None,
width_source: crate::text::WidthSource::Estimate,
char_bounds: Vec::new(),
..TextSpan::default()
}
}
fn block(text: &str, y: f64, size: f64, bold: bool) -> Block {
let mut block = Block {
kind: BlockKind::Paragraph,
page: 0,
bbox: [0.0; 4],
text: text.to_string(),
spans: vec![span(text, 72.0, y, size, bold)],
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
};
crate::blocks::recompute_bbox(&mut block);
block
}
#[test]
fn near_equal_sizes_are_one_band_and_a_real_step_is_two() {
let bands = size_bands(&[(11.9997, 400), (12.0004, 300), (12.0, 200), (14.0, 40)]);
assert_eq!(bands.len(), 2, "{bands:?}");
assert!((bands[0] - 12.0).abs() < 0.01, "{bands:?}");
assert!((bands[1] - 14.0).abs() < 0.01, "{bands:?}");
}
#[test]
fn a_chain_of_near_equal_sizes_stays_one_band() {
let bands = size_bands(&[(10.0, 100), (10.4, 100), (10.8, 100)]);
assert_eq!(bands.len(), 1, "{bands:?}");
}
#[test]
fn capitals_need_enough_letters_to_mean_it() {
assert!(is_all_caps("SCOPE"));
assert!(is_all_caps("SECTION A: THE APPLICANT"));
assert!(!is_all_caps("Scope"), "one capital is a sentence opening");
assert!(!is_all_caps("II"), "two letters say nothing");
assert!(!is_all_caps("2.1"), "digits are not capitals");
}
#[test]
fn lines_are_counted_from_the_baselines_the_spans_sit_on() {
let mut one = block("a heading", 700.0, 12.0, false);
assert_eq!(line_count(&one), 1);
one.spans
.push(span("a second line", 72.0, 688.0, 12.0, false));
assert_eq!(line_count(&one), 2);
one.spans.push(span("1", 200.0, 689.0, 7.0, false));
assert_eq!(line_count(&one), 2);
}
#[test]
fn the_body_is_the_style_that_carries_the_characters_not_the_blocks() {
let mut blocks: Vec<Block> = (0..6)
.map(|i| block("Heading", 700.0 - i as f64 * 40.0, 10.0, true))
.collect();
blocks.push(block(
&"A sentence of running text that is far longer than any of them. ".repeat(4),
400.0,
10.0,
false,
));
let metrics = metrics_of(&blocks);
let body = body_style(&metrics).expect("a body");
assert!(!body.bold, "the headings outvoted the body");
}
#[test]
fn a_block_with_no_letter_in_it_is_not_a_heading() {
let mut blocks = vec![
block("%#\"", 700.0, 14.0, false),
block("01\"", 680.0, 14.0, false),
block("Section one", 660.0, 14.0, false),
block(
&"A sentence of running text that is far longer than any of them. ".repeat(4),
400.0,
10.0,
false,
),
];
classify(&mut blocks);
assert_eq!(
blocks[0].kind,
BlockKind::Paragraph,
"a tick label became a heading"
);
assert_eq!(
blocks[1].kind,
BlockKind::Paragraph,
"a tick label became a heading"
);
assert_eq!(blocks[2].kind, BlockKind::Heading { level: 1 });
}
#[test]
fn a_one_block_document_has_no_heading() {
let mut blocks = vec![block("A note", 700.0, 24.0, true)];
classify(&mut blocks);
assert_eq!(blocks[0].kind, BlockKind::Paragraph);
}
}