use std::collections::HashMap;
use super::features::PageWord;
#[doc(hidden)]
pub const LINE_FEATURES: [&str; 23] = [
"marker_bullet",
"marker_ordered",
"marker_figure",
"first_token_chars",
"first_gap_em",
"indent_em",
"indent_vs_prev_em",
"prev_short_em",
"prev_ends_terminal",
"prev_runs_on",
"width_frac",
"size_ratio",
"bold",
"line_index",
"lines_in_block",
"gap_above_em",
"block_is_list",
"block_size_ratio",
"first_is_lone_symbol",
"same_head_on_page",
"block_is_table_cell",
"max_gap_em",
"prev_block_ends_colon",
];
const BULLETS: &[char] = &[
'\u{2022}', '\u{2023}', '\u{2043}', '\u{2219}', '\u{b7}', '\u{25aa}', '\u{25ab}', '\u{25a0}',
'\u{25a1}', '\u{25cf}', '\u{25cb}', '\u{25e6}', '\u{25c6}', '\u{25c7}', '\u{2756}', '\u{2713}',
'\u{2714}',
];
const DASHES: &[&str] = &["-", "\u{2013}", "\u{2014}"];
const FIGURE_SIGNS: &[char] = &[
'$', '\u{20ac}', '\u{a3}', '\u{a5}', '\u{a2}', '%', '\u{2030}', '#', '+', '=',
];
const RUNS_ON_ENDINGS: &[char] = &[',', ';', ':', '-', '\u{2013}', '\u{2014}'];
const RUNS_ON_WORDS: &[&str] = &["and", "or", "of", "the", "to"];
#[doc(hidden)]
pub fn lines_of(block: usize, words: &[PageWord]) -> Vec<Vec<&PageWord>> {
let ink: Vec<&PageWord> = words.iter().filter(|w| w.block == block).collect();
group_sorted_words(ink)
}
fn group_sorted_words(mut ink: Vec<&PageWord>) -> Vec<Vec<&PageWord>> {
ink.sort_by(|a, b| {
(-a.bbox[3])
.partial_cmp(&(-b.bbox[3]))
.unwrap_or(std::cmp::Ordering::Equal)
.then(
a.bbox[0]
.partial_cmp(&b.bbox[0])
.unwrap_or(std::cmp::Ordering::Equal),
)
});
let mut lines: Vec<Vec<&PageWord>> = Vec::new();
for word in ink {
let bx = word.bbox;
let mut placed = false;
for line in lines.iter_mut() {
let low = line.iter().map(|w| w.bbox[1]).fold(f64::INFINITY, f64::min);
let high = line
.iter()
.map(|w| w.bbox[3])
.fold(f64::NEG_INFINITY, f64::max);
let mut shorter = (bx[3] - bx[1]).min(high - low);
if shorter == 0.0 {
shorter = 1.0;
}
let share = (bx[3].min(high) - bx[1].max(low)) / shorter;
if share > 0.5 {
line.push(word);
placed = true;
break;
}
}
if !placed {
lines.push(vec![word]);
}
}
for line in lines.iter_mut() {
line.sort_by(|a, b| {
a.bbox[0]
.partial_cmp(&b.bbox[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
}
in_reading_order(lines.into_iter().flat_map(across_a_column).collect())
}
fn partition_words(words: &[PageWord], n_blocks: usize) -> Vec<Vec<&PageWord>> {
let mut out: Vec<Vec<&PageWord>> = vec![Vec::new(); n_blocks];
for word in words {
if word.block < n_blocks {
out[word.block].push(word);
}
}
out
}
#[doc(hidden)]
pub fn grouped_lines(words: &[PageWord], n_blocks: usize) -> Vec<Vec<Vec<&PageWord>>> {
partition_words(words, n_blocks)
.into_iter()
.map(group_sorted_words)
.collect()
}
fn in_reading_order<'a>(pieces: Vec<Vec<&'a PageWord>>) -> Vec<Vec<&'a PageWord>> {
if pieces.len() < 2 {
return pieces;
}
struct Column<'a> {
left: f64,
right: f64,
pieces: Vec<Vec<&'a PageWord>>,
}
let mut columns: Vec<Column<'a>> = Vec::new();
for piece in pieces {
let left = piece
.iter()
.map(|w| w.bbox[0])
.fold(f64::INFINITY, f64::min);
let right = piece
.iter()
.map(|w| w.bbox[2])
.fold(f64::NEG_INFINITY, f64::max);
let mine = columns.iter().position(|column| {
let mut width = (right - left).min(column.right - column.left);
if width == 0.0 {
width = 1.0;
}
(right.min(column.right) - left.max(column.left)) / width > 0.5
});
match mine {
Some(k) => {
columns[k].left = columns[k].left.min(left);
columns[k].right = columns[k].right.max(right);
columns[k].pieces.push(piece);
}
None => columns.push(Column {
left,
right,
pieces: vec![piece],
}),
}
}
columns.sort_by(|a, b| {
a.left
.partial_cmp(&b.left)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut out = Vec::new();
for mut column in columns {
column.pieces.sort_by(|a, b| {
let top = |p: &Vec<&PageWord>| {
-p.iter()
.map(|w| w.bbox[3])
.fold(f64::NEG_INFINITY, f64::max)
};
top(a)
.partial_cmp(&top(b))
.unwrap_or(std::cmp::Ordering::Equal)
});
out.extend(column.pieces);
}
out
}
const COLUMN_GAP_EM: f64 = 1.5;
fn across_a_column(line: Vec<&PageWord>) -> Vec<Vec<&PageWord>> {
if line.len() < 2 {
return vec![line];
}
let mut heights: Vec<f64> = line.iter().map(|w| w.bbox[3] - w.bbox[1]).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut height = heights[heights.len() / 2];
if height == 0.0 {
height = 1.0;
}
let mut out: Vec<Vec<&PageWord>> = Vec::new();
let mut run: Vec<&PageWord> = vec![line[0]];
for pair in line.windows(2) {
let (before, word) = (pair[0], pair[1]);
if (word.bbox[0] - before.bbox[2]) / height >= COLUMN_GAP_EM {
out.push(std::mem::take(&mut run));
}
run.push(word);
}
out.push(run);
out
}
pub fn line_boxes(block: usize, words: &[PageWord]) -> Vec<([f64; 4], f64)> {
line_boxes_on_lines(&lines_of(block, words))
}
#[doc(hidden)]
pub fn line_boxes_on_lines(lines: &[Vec<&PageWord>]) -> Vec<([f64; 4], f64)> {
lines
.iter()
.map(|line| (line_box(line), line_size(line)))
.collect()
}
pub fn line_boxes_and_sizes(block: usize, words: &[PageWord]) -> Vec<([f64; 4], f64)> {
lines_of(block, words)
.iter()
.filter(|line| !line.is_empty())
.map(|line| {
let mut sizes: Vec<f64> = line.iter().map(|w| w.size).collect();
sizes.sort_by(f64::total_cmp);
(line_box(line), sizes[sizes.len() / 2])
})
.collect()
}
fn line_box(line: &[&PageWord]) -> [f64; 4] {
[
line.iter().map(|w| w.bbox[0]).fold(f64::INFINITY, f64::min),
line.iter().map(|w| w.bbox[1]).fold(f64::INFINITY, f64::min),
line.iter()
.map(|w| w.bbox[2])
.fold(f64::NEG_INFINITY, f64::max),
line.iter()
.map(|w| w.bbox[3])
.fold(f64::NEG_INFINITY, f64::max),
]
}
fn line_size(line: &[&PageWord]) -> f64 {
let mut sizes: Vec<f64> = line.iter().map(|w| w.size).collect();
sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let size = sizes[sizes.len() / 2];
if size == 0.0 {
1.0
} else {
size
}
}
fn marker_kind(token: &str) -> Option<&'static str> {
let first = token.chars().next()?;
let count = token.chars().count();
if FIGURE_SIGNS.contains(&first) {
return Some("figure");
}
if (count == 1 || count <= 2) && BULLETS.contains(&first) {
return Some("bullet");
}
if DASHES.contains(&token) {
return Some("bullet");
}
if is_ordinal(token) {
return Some("ordered");
}
None
}
fn is_roman(s: &str) -> bool {
let n = s.chars().count();
(1..=6).contains(&n) && s.chars().all(|c| "ivxlcIVXLC".contains(c))
}
fn is_ordinal(token: &str) -> bool {
let digits =
|s: &str| (1..=3).contains(&s.chars().count()) && s.chars().all(|c| c.is_ascii_digit());
let letter = |s: &str| s.chars().count() == 1 && s.chars().all(|c| c.is_ascii_alphabetic());
if let Some(inner) = token.strip_prefix('[').and_then(|t| t.strip_suffix(']')) {
return digits(inner);
}
if let Some(inner) = token.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
if digits(inner) || letter(inner) || is_roman(inner) {
return true;
}
}
let Some(body) = token.strip_suffix(['.', ')']) else {
return false;
};
let body = body.strip_prefix('(').unwrap_or(body);
digits(body) || letter(body) || is_roman(body)
}
fn runs_on(text: &str) -> bool {
let text = text.trim_end();
if text.ends_with(RUNS_ON_ENDINGS) {
return true;
}
text.split_whitespace()
.next_back()
.is_some_and(|last| RUNS_ON_WORDS.contains(&last))
}
fn round_half_even(x: f64) -> i64 {
x.round_ties_even() as i64
}
#[doc(hidden)]
pub fn heads_on_page(n_blocks: usize, words: &[PageWord]) -> HashMap<(String, i64), u32> {
heads_on_grouped(&grouped_lines(words, n_blocks))
}
#[doc(hidden)]
pub fn heads_on_grouped(grouped: &[Vec<Vec<&PageWord>>]) -> HashMap<(String, i64), u32> {
let mut heads = HashMap::new();
for lines in grouped {
for line in lines {
if let Some(first) = line.first() {
*heads
.entry((first.text.clone(), round_half_even(first.bbox[0])))
.or_insert(0) += 1;
}
}
}
heads
}
#[doc(hidden)]
pub struct LineContext<'a> {
pub bbox: [f64; 4],
pub kind: &'a str,
pub size: f64,
pub body_size: f64,
pub prev_ends_colon: Option<bool>,
}
#[doc(hidden)]
pub fn line_vectors(
block: usize,
context: &LineContext<'_>,
words: &[PageWord],
heads: &HashMap<(String, i64), u32>,
) -> Vec<[f64; 23]> {
line_vectors_on_lines(&lines_of(block, words), context, heads)
}
#[doc(hidden)]
pub fn line_vectors_on_lines(
lines: &[Vec<&PageWord>],
context: &LineContext<'_>,
heads: &HashMap<(String, i64), u32>,
) -> Vec<[f64; 23]> {
let bx = context.bbox;
let measure = (bx[2] - bx[0]).max(1.0);
let body = if context.body_size == 0.0 {
1.0
} else {
context.body_size
};
let mut out = Vec::with_capacity(lines.len());
for (at, line) in lines.iter().enumerate() {
let bxl = line_box(line);
let size = line_size(line);
let first = line.first().map_or("", |w| w.text.as_str());
let kind = marker_kind(first);
let gap = if line.len() > 1 {
(line[1].bbox[0] - line[0].bbox[2]) / size
} else {
f64::NAN
};
let (indent_vs_prev, prev_short, prev_terminal, prev_runs, gap_above) = if at > 0 {
let prev = &lines[at - 1];
let pbox = line_box(prev);
let ptext = prev
.iter()
.map(|w| w.text.as_str())
.collect::<Vec<_>>()
.join(" ");
(
(bxl[0] - pbox[0]) / size,
(bx[2] - pbox[2]) / size,
if ptext.trim_end().ends_with(['.', '!', '?']) {
1.0
} else {
0.0
},
if runs_on(&ptext) { 1.0 } else { 0.0 },
(pbox[1] - bxl[3]) / size,
)
} else {
(f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN)
};
let bold = line.iter().filter(|w| w.bold).count() as f64 / line.len() as f64;
let first_chars = first.chars().count();
let lone_symbol =
first_chars == 1 && (!first.chars().all(char::is_alphanumeric) || gap >= 0.4);
let block_size = if context.size == 0.0 {
size
} else {
context.size
};
out.push([
if kind == Some("bullet") { 1.0 } else { 0.0 },
if kind == Some("ordered") { 1.0 } else { 0.0 },
if kind == Some("figure") { 1.0 } else { 0.0 },
first_chars as f64,
gap,
(bxl[0] - bx[0]) / size,
indent_vs_prev,
prev_short,
prev_terminal,
prev_runs,
(bxl[2] - bxl[0]) / measure,
size / body,
bold,
at as f64,
lines.len() as f64,
gap_above,
if context.kind == "list_item" {
1.0
} else {
0.0
},
block_size / body,
if lone_symbol { 1.0 } else { 0.0 },
f64::from(
heads
.get(&(first.to_string(), round_half_even(line[0].bbox[0])))
.copied()
.unwrap_or(0),
),
if context.kind == "table_cell" {
1.0
} else {
0.0
},
line.windows(2)
.map(|pair| (pair[1].bbox[0] - pair[0].bbox[2]) / size)
.fold(
f64::NAN,
|acc, gap| if acc.is_nan() || gap > acc { gap } else { acc },
),
match context.prev_ends_colon {
None => f64::NAN,
Some(true) => 1.0,
Some(false) => 0.0,
},
]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn word(text: &str, block: usize, x0: f64, y0: f64) -> PageWord {
PageWord {
text: text.to_string(),
bbox: [x0, y0, x0 + 30.0, y0 + 10.0],
block,
span: 0,
size: 10.0,
bold: false,
italic: false,
font: None,
color: None,
}
}
fn texts(lines: &[Vec<&PageWord>]) -> Vec<Vec<String>> {
lines
.iter()
.map(|line| line.iter().map(|w| w.text.clone()).collect())
.collect()
}
fn assert_line_vectors_equal(block: usize, left: &[[f64; 23]], right: &[[f64; 23]]) {
assert_eq!(left.len(), right.len(), "block {block}: line count differs");
for (row, expected) in left.iter().zip(right.iter()) {
for (got, ask) in row.iter().zip(expected.iter()) {
assert!(
got == ask || (got.is_nan() && ask.is_nan()),
"block {block} vectors differ: {row:?} vs {expected:?}"
);
}
}
}
#[test]
fn grouped_lines_match_lines_of_block_by_block() {
let words = vec![
word("left", 0, 72.0, 700.0),
word("right", 0, 300.0, 700.0),
word("second", 0, 72.0, 686.0),
word("other", 1, 72.0, 700.0),
word("block", 1, 110.0, 700.0),
];
let grouped = grouped_lines(&words, 2);
assert_eq!(grouped.len(), 2);
for (block, lines) in grouped.iter().enumerate() {
assert_eq!(
texts(lines),
texts(&lines_of(block, &words)),
"block {block} groups differently whole-page than alone"
);
}
assert!(
grouped[0].len() >= 2,
"the fixture groups to one line and tests nothing: {grouped:?}"
);
assert_eq!(heads_on_grouped(&grouped), heads_on_page(2, &words));
let heads = heads_on_page(2, &words);
let context = LineContext {
bbox: [72.0, 680.0, 340.0, 710.0],
kind: "paragraph",
size: 10.0,
body_size: 10.0,
prev_ends_colon: None,
};
for (block, lines) in grouped.iter().enumerate() {
assert_line_vectors_equal(
block,
&line_vectors_on_lines(lines, &context, &heads),
&line_vectors(block, &context, &words, &heads),
);
assert_eq!(
line_boxes_on_lines(lines),
line_boxes(block, &words),
"block {block} boxes differ"
);
}
}
#[test]
fn a_left_edge_is_rounded_the_way_python_rounds_it() {
assert_eq!(round_half_even(72.5), 72);
assert_eq!(round_half_even(73.5), 74);
assert_eq!(round_half_even(72.4), 72);
assert_eq!(round_half_even(-0.5), 0);
}
}