use crate::blocks::{Block, BlockKind};
use crate::text::TextSpan;
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct SpanWord {
pub text: String,
pub bbox: [f64; 4],
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct PageWord {
pub text: String,
pub bbox: [f64; 4],
pub block: usize,
pub span: usize,
pub size: f64,
pub bold: bool,
pub italic: bool,
pub font: Option<String>,
pub color: Option<[u8; 3]>,
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct BlockFeatures {
pub kind: &'static str,
pub level: Option<u32>,
pub n_chars: usize,
pub n_words: usize,
pub n_lines: usize,
pub size: f64,
pub size_ratio: f64,
pub bold: f64,
pub italic: f64,
pub all_caps: bool,
pub font: Option<String>,
pub x_frac: f64,
pub width_frac: f64,
pub y_frac: f64,
pub gap_above_em: f64,
pub gap_below_em: f64,
pub ends_terminal: bool,
pub ends_colon: bool,
pub marker: Option<&'static str>,
pub caption: Option<&'static str>,
pub section_number: bool,
pub prev_kind: Option<&'static str>,
pub next_kind: Option<&'static str>,
pub prev_ends_terminal: Option<bool>,
pub prev_size_ratio: Option<f64>,
pub next_x_frac: Option<f64>,
pub next_size_ratio: Option<f64>,
pub repeats_in_document: Option<u32>,
pub first_on_page: bool,
pub last_on_page: bool,
pub title_case: bool,
pub number_kind: u8,
pub number_depth: u8,
pub ends_comma: bool,
pub width_vs_column: f64,
pub font_is_the_body_face: Option<bool>,
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct PageFeatures {
pub body_size: f64,
pub blocks: Vec<BlockFeatures>,
pub words: Vec<PageWord>,
}
#[doc(hidden)]
pub const BLOCK_FEATURES: [&str; 30] = [
"size_ratio",
"bold",
"italic",
"all_caps",
"n_chars",
"n_words",
"n_lines",
"x_frac",
"width_frac",
"y_frac",
"gap_above_em",
"gap_below_em",
"ends_terminal",
"ends_colon",
"section_number",
"has_marker",
"has_caption",
"first_on_page",
"last_on_page",
"prev_ends_terminal",
"prev_size_ratio",
"next_x_frac",
"next_size_ratio",
"repeats_in_document",
"font_is_the_body_face",
"title_case",
"number_kind",
"number_depth",
"width_vs_column",
"ends_comma",
];
fn flag(value: bool) -> f64 {
if value {
1.0
} else {
0.0
}
}
fn opt(value: Option<f64>) -> f64 {
value.unwrap_or(f64::NAN)
}
#[doc(hidden)]
pub fn block_vector(b: &BlockFeatures) -> [f64; 30] {
[
b.size_ratio,
b.bold,
b.italic,
flag(b.all_caps),
b.n_chars as f64,
b.n_words as f64,
b.n_lines as f64,
b.x_frac,
b.width_frac,
b.y_frac,
b.gap_above_em,
b.gap_below_em,
flag(b.ends_terminal),
flag(b.ends_colon),
flag(b.section_number),
flag(b.marker.is_some()),
flag(b.caption.is_some()),
flag(b.first_on_page),
flag(b.last_on_page),
opt(b.prev_ends_terminal.map(flag)),
opt(b.prev_size_ratio),
opt(b.next_x_frac),
opt(b.next_size_ratio),
opt(b.repeats_in_document.map(f64::from)),
opt(b.font_is_the_body_face.map(flag)),
flag(b.title_case),
f64::from(b.number_kind),
f64::from(b.number_depth),
b.width_vs_column,
flag(b.ends_comma),
]
}
pub(crate) fn title_case(text: &str) -> bool {
let words: Vec<&str> = text
.split_whitespace()
.filter(|word| word.chars().any(char::is_alphabetic))
.collect();
if words.len() < 2
|| text
.chars()
.filter(|c| c.is_alphabetic())
.all(char::is_uppercase)
{
return false;
}
let capitalised = words
.iter()
.filter(|word| word.chars().next().is_some_and(char::is_uppercase))
.count();
capitalised * 10 >= words.len() * 6
}
pub(crate) fn numbering(text: &str) -> (u8, u8) {
let head = text.trim_start();
let (first, rest) = match head.find(char::is_whitespace) {
Some(at) => (&head[..at], &head[at..]),
None => return (0, 0),
};
if first.is_empty() || rest.is_empty() {
return (0, 0);
}
let body = first
.strip_suffix('.')
.or_else(|| first.strip_suffix(')'))
.unwrap_or(first);
let delimited = body.len() < first.len();
if !body.is_empty()
&& body.chars().all(|c| c.is_ascii_digit() || c == '.')
&& body.chars().next().is_some_and(|c| c.is_ascii_digit())
&& !body.ends_with('.')
{
let depth = body.split('.').filter(|part| !part.is_empty()).count();
return (1, depth.min(u8::MAX as usize) as u8);
}
if delimited
&& !body.is_empty()
&& body
.chars()
.all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M'))
{
return (2, 1);
}
if delimited && body.chars().count() == 1 && body.chars().all(|c| c.is_ascii_alphabetic()) {
return (3, 1);
}
(0, 0)
}
fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
[
a[0].min(b[0]),
a[1].min(b[1]),
a[2].max(b[2]),
a[3].max(b[3]),
]
}
#[doc(hidden)]
pub fn words_of(span: &TextSpan) -> Vec<SpanWord> {
let chars: Vec<char> = span.text.chars().collect();
let n = chars.len();
let glyphs = &span.char_bounds;
let g = glyphs.len();
let span_box = [span.x, span.y, span.x + span.width, span.y + span.height];
let mut word_of_char: Vec<Option<usize>> = Vec::with_capacity(n);
let mut texts: Vec<String> = Vec::new();
let mut in_word = false;
for &c in &chars {
if c.is_whitespace() {
in_word = false;
word_of_char.push(None);
} else {
if !in_word {
texts.push(String::new());
in_word = true;
}
let w = texts.len() - 1;
texts[w].push(c);
word_of_char.push(Some(w));
}
}
if texts.is_empty() {
return Vec::new();
}
let mut boxes: Vec<Option<[f64; 4]>> = vec![None; texts.len()];
if g == 0 || n == 0 {
boxes.fill(Some(span_box));
} else {
for (glyph_index, glyph_box) in glyphs.iter().enumerate() {
let owner = (0..n)
.find(|&c| c * g / n == glyph_index)
.or_else(|| (0..n).rev().find(|&c| c * g / n <= glyph_index));
let Some(c) = owner else { continue };
let word = (c..n)
.take_while(|&k| k * g / n == glyph_index || k == c)
.find_map(|k| word_of_char[k]);
if let Some(w) = word {
boxes[w] = Some(match boxes[w] {
Some(b) => union(b, *glyph_box),
None => *glyph_box,
});
}
}
}
texts
.into_iter()
.zip(boxes)
.map(|(text, bbox)| SpanWord {
text,
bbox: bbox.unwrap_or(span_box),
})
.collect()
}
#[doc(hidden)]
pub fn kind_name(kind: &BlockKind) -> (&'static str, Option<u32>) {
match kind {
BlockKind::Paragraph => ("paragraph", None),
BlockKind::Heading { level } => ("heading", Some(u32::from(*level))),
BlockKind::ListItem { level, .. } => ("list_item", Some(u32::from(*level))),
BlockKind::TableCell { .. } => ("table_cell", None),
BlockKind::Header => ("header", None),
BlockKind::Footer => ("footer", None),
BlockKind::Caption => ("caption", None),
#[allow(unreachable_patterns)]
_ => ("other", None),
}
}
fn median(mut v: Vec<f64>) -> f64 {
if v.is_empty() {
return 0.0;
}
v.sort_by(|a, b| a.total_cmp(b));
v[v.len() / 2]
}
fn glyph_sizes<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Vec<f64> {
spans
.flat_map(|s| {
let k = s.char_bounds.len().max(1);
std::iter::repeat_n(s.font_size, k)
})
.collect()
}
fn glyph_fraction(spans: &[TextSpan], pick: impl Fn(&TextSpan) -> bool) -> f64 {
let (mut yes, mut all) = (0usize, 0usize);
for s in spans {
let k = s.char_bounds.len().max(1);
all += k;
if pick(s) {
yes += k;
}
}
if all == 0 {
0.0
} else {
yes as f64 / all as f64
}
}
fn marker(text: &str) -> Option<&'static str> {
let t = text.trim_start();
let first = t.split_whitespace().next()?;
if matches!(
first,
"\u{2022}" | "-" | "\u{2013}" | "\u{2014}" | "*" | "\u{b7}" | "\u{25aa}" | "\u{25e6}"
) {
return Some("bullet");
}
let body = first.trim_end_matches(['.', ')', ':']);
if body != first && !body.is_empty() {
if body.chars().all(|c| c.is_ascii_digit()) {
return Some("number");
}
if body.len() == 1 && body.chars().all(|c| c.is_ascii_alphabetic()) {
return Some("letter");
}
}
None
}
fn caption(text: &str) -> Option<&'static str> {
let mut words = text.split_whitespace();
let first = words.next()?;
let second = words.next().unwrap_or("");
let numbered = second.chars().next().is_some_and(|c| c.is_ascii_digit());
match first {
"Table" if numbered => Some("table"),
"Figure" | "Fig." if numbered => Some("figure"),
_ => None,
}
}
fn section_number(text: &str) -> bool {
let Some(first) = text.split_whitespace().next() else {
return false;
};
let body = first.trim_end_matches('.');
body.contains('.')
&& body
.split('.')
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
#[cfg(test)]
thread_local! {
static PAGE_FEATURES_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub fn page_features_calls() -> u64 {
PAGE_FEATURES_CALLS.with(|calls| calls.get())
}
#[cfg(test)]
pub fn reset_page_features_calls() {
PAGE_FEATURES_CALLS.with(|calls| calls.set(0));
}
#[doc(hidden)]
pub fn page_features(blocks: &[&Block], width: f64, height: f64) -> PageFeatures {
#[cfg(test)]
PAGE_FEATURES_CALLS.with(|calls| calls.set(calls.get() + 1));
let body = median(glyph_sizes(blocks.iter().flat_map(|b| b.spans.iter())));
let n = blocks.len();
let sizes: Vec<f64> = blocks
.iter()
.map(|b| median(glyph_sizes(b.spans.iter())))
.collect();
let ratio = |i: usize| if body > 0.0 { sizes[i] / body } else { 0.0 };
let x_frac = |i: usize| {
if width > 0.0 {
blocks[i].bbox[0] / width
} else {
0.0
}
};
let terminal = |i: usize| blocks[i].text.trim().ends_with(['.', '!', '?']);
let mut paragraph_widths: Vec<f64> = blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::Paragraph))
.map(|b| b.bbox[2] - b.bbox[0])
.filter(|w| *w > 0.0)
.collect();
paragraph_widths.sort_by(f64::total_cmp);
let column = paragraph_widths
.get(paragraph_widths.len() / 2)
.copied()
.unwrap_or(f64::NAN);
let mut out_blocks = Vec::with_capacity(n);
let mut out_words = Vec::new();
for (i, b) in blocks.iter().enumerate() {
let (kind, level) = kind_name(&b.kind);
let size = sizes[i];
let em = if size > 0.0 { size } else { body.max(1.0) };
let gap_above = if i > 0 && blocks[i - 1].page == b.page {
(blocks[i - 1].bbox[1] - b.bbox[3]) / em
} else {
(height - b.bbox[3]) / em
};
let gap_below = if i + 1 < n && blocks[i + 1].page == b.page {
(b.bbox[1] - blocks[i + 1].bbox[3]) / em
} else {
b.bbox[1] / em
};
let text = b.text.trim();
let letters: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
let mut lines: Vec<f64> = b.spans.iter().map(|s| (s.y * 2.0).round() / 2.0).collect();
lines.sort_by(|a, c| a.total_cmp(c));
lines.dedup();
out_blocks.push(BlockFeatures {
kind,
level,
n_chars: text.chars().count(),
n_words: text.split_whitespace().count(),
n_lines: lines.len(),
size,
size_ratio: ratio(i),
bold: glyph_fraction(&b.spans, |s| s.is_bold),
italic: glyph_fraction(&b.spans, |s| s.is_italic),
all_caps: !letters.is_empty() && letters.iter().all(|c| c.is_uppercase()),
font: b.spans.iter().find_map(|s| s.font_name.clone()),
x_frac: x_frac(i),
width_frac: if width > 0.0 {
(b.bbox[2] - b.bbox[0]) / width
} else {
0.0
},
y_frac: if height > 0.0 {
b.bbox[1] / height
} else {
0.0
},
gap_above_em: gap_above,
gap_below_em: gap_below,
ends_terminal: text.ends_with(['.', '!', '?']),
ends_colon: text.ends_with(':'),
marker: marker(text),
caption: caption(text),
section_number: section_number(text),
prev_kind: (i > 0).then(|| kind_name(&blocks[i - 1].kind).0),
next_kind: (i + 1 < n).then(|| kind_name(&blocks[i + 1].kind).0),
prev_ends_terminal: (i > 0).then(|| terminal(i - 1)),
prev_size_ratio: (i > 0).then(|| ratio(i - 1)),
next_x_frac: (i + 1 < n).then(|| x_frac(i + 1)),
next_size_ratio: (i + 1 < n).then(|| ratio(i + 1)),
repeats_in_document: None,
first_on_page: i == 0,
last_on_page: i + 1 == n,
title_case: title_case(text),
number_kind: numbering(text).0,
number_depth: numbering(text).1,
ends_comma: text.ends_with(','),
width_vs_column: if column > 0.0 {
(b.bbox[2] - b.bbox[0]) / column
} else {
f64::NAN
},
font_is_the_body_face: None,
});
let mut run: Option<PageWord> = None;
let mut prev_ends_ws = false;
for (s, span) in b.spans.iter().enumerate() {
let span_starts_ws = span.text.chars().next().is_some_and(char::is_whitespace);
for (j, w) in words_of(span).into_iter().enumerate() {
let frag = PageWord {
text: w.text,
bbox: w.bbox,
block: i,
span: s,
size: span.font_size,
bold: span.is_bold,
italic: span.is_italic,
font: span.font_name.clone(),
color: span.color.map(|[r, g, b, _]| [r, g, b]),
};
let boundary_ws = j > 0 || span_starts_ws || prev_ends_ws;
let join = match &run {
None => false,
Some(last) => {
let em = last.size.max(frag.size).max(f64::MIN_POSITIVE);
let gap = frag.bbox[0] - last.bbox[2];
let drift = (frag.bbox[1] - last.bbox[1]).abs();
let same_line = last.bbox[1] < frag.bbox[3] && last.bbox[3] > frag.bbox[1];
!boundary_ws
&& same_line
&& last.bold == frag.bold
&& last.italic == frag.italic
&& last.font == frag.font
&& last.color == frag.color
&& (last.size - frag.size).abs() <= 0.1 * em
&& gap > -0.1 * em
&& crate::words_join::pieces_join(gap, drift, em)
}
};
if join {
let last = run.as_mut().expect("a join has a run");
last.text.push_str(&frag.text);
last.bbox[0] = last.bbox[0].min(frag.bbox[0]);
last.bbox[1] = last.bbox[1].min(frag.bbox[1]);
last.bbox[2] = last.bbox[2].max(frag.bbox[2]);
last.bbox[3] = last.bbox[3].max(frag.bbox[3]);
last.size = last.size.max(frag.size);
} else {
if let Some(word) = run.replace(frag) {
out_words.push(word);
}
}
}
prev_ends_ws = span
.text
.chars()
.next_back()
.is_some_and(char::is_whitespace);
}
if let Some(word) = run.take() {
out_words.push(word);
}
}
PageFeatures {
body_size: body,
blocks: out_blocks,
words: out_words,
}
}
fn normalise(text: &str) -> String {
text.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DocumentFacts {
pages_with: std::collections::HashMap<String, u32>,
body_face: Option<String>,
}
impl DocumentFacts {
pub fn of<'a, P, T>(pages: P) -> Self
where
P: IntoIterator<Item = T>,
T: IntoIterator<Item = &'a str>,
{
let mut pages_with = std::collections::HashMap::new();
for texts in pages {
let seen: std::collections::HashSet<String> = texts
.into_iter()
.map(normalise)
.filter(|t| !t.is_empty())
.collect();
for key in seen {
*pages_with.entry(key).or_insert(0) += 1;
}
}
DocumentFacts {
pages_with,
body_face: None,
}
}
pub fn of_texts_and_faces<'a, P, T, F>(pages: P, faces: F) -> Self
where
P: IntoIterator<Item = T>,
T: IntoIterator<Item = &'a str>,
F: IntoIterator<Item = (Option<String>, usize)>,
{
let mut facts = DocumentFacts::of(pages);
let mut order: Vec<String> = Vec::new();
let mut by_face: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (face, chars) in faces {
if let Some(face) = face {
let seen = by_face.entry(face.clone()).or_insert_with(|| {
order.push(face);
0
});
*seen += chars.max(1);
}
}
facts.body_face = order
.into_iter()
.fold(None::<(String, usize)>, |best, face| {
let chars = by_face.get(&face).copied().unwrap_or(0);
match &best {
Some((_, most)) if *most >= chars => best,
_ => Some((face, chars)),
}
})
.map(|(face, _)| face)
.or_else(|| Some(String::new()));
facts
}
pub fn body_face(&self) -> Option<&str> {
self.body_face.as_deref()
}
pub fn repeats(&self, text: &str) -> u32 {
let key = normalise(text);
if key.is_empty() {
0
} else {
self.pages_with.get(&key).copied().unwrap_or(0)
}
}
pub fn fill(&self, page: &mut PageFeatures, texts: &[String]) {
for (block, text) in page.blocks.iter_mut().zip(texts) {
block.repeats_in_document = Some(self.repeats(text));
block.font_is_the_body_face = match (&self.body_face, &block.font) {
(Some(body), face) => Some(face.as_deref().unwrap_or("") == body),
(None, _) => None,
};
}
}
}
#[doc(hidden)]
pub fn fill_repeats(pages: &mut [(PageFeatures, Vec<String>)]) {
let facts = DocumentFacts::of_texts_and_faces(
pages
.iter()
.map(|(_, texts)| texts.iter().map(String::as_str)),
pages.iter().flat_map(|(page, _)| {
page.blocks
.iter()
.map(|block| (block.font.clone(), block.n_chars))
}),
);
for (page, texts) in pages.iter_mut() {
facts.fill(page, texts);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_letter_marker_is_ascii_like_the_training_regex() {
assert_eq!(numbering("A. The first part"), (3, 1));
assert_eq!(numbering("b) and the second"), (3, 1));
assert_eq!(numbering("É. Résumé"), (0, 0));
assert_eq!(numbering("Ω. Omega"), (0, 0));
assert_eq!(numbering("1.2.3 Deep"), (1, 3));
assert_eq!(numbering("IV. Fourth"), (2, 1));
assert_eq!(numbering("Plain text"), (0, 0));
}
#[test]
fn a_tie_between_two_faces_falls_to_the_first_one_seen() {
let faces = || {
[
(Some("Helvetica".to_string()), 100usize),
(Some("Times".to_string()), 100usize),
]
};
for _ in 0..64 {
let facts = DocumentFacts::of_texts_and_faces(std::iter::empty::<Vec<&str>>(), faces());
assert_eq!(
facts.body_face(),
Some("Helvetica"),
"the tie fell the other way, so this feature is a coin"
);
}
let facts = DocumentFacts::of_texts_and_faces(
std::iter::empty::<Vec<&str>>(),
[
(Some("Helvetica".to_string()), 100usize),
(Some("Times".to_string()), 101usize),
],
);
assert_eq!(facts.body_face(), Some("Times"));
}
#[test]
fn a_document_that_names_no_face_has_the_empty_one() {
let facts = DocumentFacts::of_texts_and_faces(
std::iter::empty::<Vec<&str>>(),
[(None, 40usize), (None, 12usize)],
);
assert_eq!(facts.body_face(), Some(""));
let texts_only = DocumentFacts::of(std::iter::empty::<Vec<&str>>());
assert_eq!(texts_only.body_face(), None);
}
}