#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct PairWord {
pub text: String,
pub bbox: [f64; 4],
pub table: Option<u32>,
pub row: Option<u16>,
pub column: Option<u16>,
}
#[doc(hidden)]
pub const NAMES: [&str; 25] = [
"gap_x",
"gap_y",
"centre_dx",
"centre_dy",
"overlap_x",
"overlap_y",
"left_aligned",
"right_aligned",
"centre_aligned",
"height_ratio",
"width_ratio",
"gap_x_em",
"gap_y_em",
"same_band",
"reading_distance",
"both_numeric",
"either_numeric",
"length_ratio",
"either_ends_colon",
"either_all_caps",
"both_in_a_table",
"our_same_table",
"our_same_row",
"our_same_column",
"our_same_cell",
];
#[doc(hidden)]
pub const CLASSES: [&str; 4] = ["neither", "same_column", "same_row", "same_cell"];
fn numeric(text: &str) -> bool {
let body: String = text
.trim()
.trim_matches(|c: char| "()[]%*†‡§,.;:".contains(c))
.chars()
.filter(|c| !matches!(c, ',' | '±'))
.map(|c| if c == '−' || c == '–' { '-' } else { c })
.collect();
!body.is_empty() && body.parse::<f64>().is_ok()
}
#[doc(hidden)]
pub fn features(a: &PairWord, b: &PairWord) -> [f64; 25] {
let first_is_a = (-a.bbox[3], a.bbox[0]) <= (-b.bbox[3], b.bbox[0]);
let (first, second) = if first_is_a { (a, b) } else { (b, a) };
let [ax0, ay0, ax1, ay1] = first.bbox;
let [bx0, by0, bx1, by1] = second.bbox;
let ah = (ay1 - ay0).max(1e-6);
let aw = (ax1 - ax0).max(1e-6);
let bh = (by1 - by0).max(1e-6);
let bw = (bx1 - bx0).max(1e-6);
let em = ((ah + bh) / 2.0).max(1e-6);
let gap_x = (ax0.max(bx0) - ax1.min(bx1)).max(0.0);
let gap_y = (ay0.max(by0) - ay1.min(by1)).max(0.0);
let over_x = (ax1.min(bx1) - ax0.max(bx0)).max(0.0) / aw.min(bw);
let over_y = (ay1.min(by1) - ay0.max(by0)).max(0.0) / ah.min(bh);
let at = first.text.as_str();
let bt = second.text.as_str();
let ours = what_we_think(first, second);
[
gap_x,
gap_y,
(bx0 + bx1) / 2.0 - (ax0 + ax1) / 2.0,
(by0 + by1) / 2.0 - (ay0 + ay1) / 2.0,
over_x,
over_y,
(ax0 - bx0).abs() / em,
(ax1 - bx1).abs() / em,
((ax0 + ax1) - (bx0 + bx1)).abs() / (2.0 * em),
ah / bh,
aw / bw,
gap_x / em,
gap_y / em,
if over_y > 0.5 { 1.0 } else { 0.0 },
(gap_x + gap_y) / em,
if numeric(at) && numeric(bt) { 1.0 } else { 0.0 },
if numeric(at) || numeric(bt) { 1.0 } else { 0.0 },
at.chars().count() as f64 / (bt.chars().count().max(1)) as f64,
if at.ends_with(':') || bt.ends_with(':') {
1.0
} else {
0.0
},
if is_upper(at) || is_upper(bt) {
1.0
} else {
0.0
},
ours[0],
ours[1],
ours[2],
ours[3],
ours[4],
]
}
fn is_upper(text: &str) -> bool {
text.chars().any(|c| c.is_alphabetic()) && !text.chars().any(|c| c.is_lowercase())
}
fn what_we_think(a: &PairWord, b: &PairWord) -> [f64; 5] {
let (Some(at), Some(bt)) = (a.table, b.table) else {
return [0.0; 5];
};
if at != bt {
return [1.0, 0.0, 0.0, 0.0, 0.0];
}
let same_row = a.row.is_some() && a.row == b.row;
let same_column = a.column.is_some() && a.column == b.column;
[
1.0,
1.0,
if same_row { 1.0 } else { 0.0 },
if same_column { 1.0 } else { 0.0 },
if same_row && same_column { 1.0 } else { 0.0 },
]
}
#[doc(hidden)]
pub const NEAREST: usize = 6;
#[doc(hidden)]
pub fn candidates(words: &[PairWord], nearest: usize) -> Vec<(usize, usize)> {
use std::collections::{HashMap, HashSet};
let mut found: HashSet<(usize, usize)> = HashSet::new();
if words.len() < 2 {
return Vec::new();
}
let step = {
let mut heights: Vec<f64> = words.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));
(heights[heights.len() / 2] * 2.0).max(1.0)
};
let cell_of = |w: &PairWord| {
(
((w.bbox[0] + w.bbox[2]) / 2.0 / step).floor() as i64,
((w.bbox[1] + w.bbox[3]) / 2.0 / step).floor() as i64,
)
};
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, w) in words.iter().enumerate() {
grid.entry(cell_of(w)).or_default().push(i);
}
let overlaps = |a: &[f64; 4], b: &[f64; 4], vertical: bool| -> f64 {
let (i, j) = if vertical { (1, 3) } else { (0, 2) };
let wide = a[j].min(b[j]) - a[i].max(b[i]);
wide / (a[j] - a[i]).min(b[j] - b[i]).max(1e-6)
};
let twice_apart = |a: &PairWord, b: &PairWord| {
let dx = (b.bbox[0] + b.bbox[2]) - a.bbox[0] - a.bbox[2];
let dy = (b.bbox[1] + b.bbox[3]) - a.bbox[1] - a.bbox[3];
dx * dx + dy * dy
};
let (mut lo_x, mut hi_x, mut lo_y, mut hi_y) = (i64::MAX, i64::MIN, i64::MAX, i64::MIN);
for key in grid.keys() {
lo_x = lo_x.min(key.0);
hi_x = hi_x.max(key.0);
lo_y = lo_y.min(key.1);
hi_y = hi_y.max(key.1);
}
for (i, here) in words.iter().enumerate() {
let (cx, cy) = cell_of(here);
let span = (cx - lo_x)
.abs()
.max((hi_x - cx).abs())
.max((cy - lo_y).abs())
.max((hi_y - cy).abs());
let mut radius: i64 = 2;
let mut near: Vec<usize> = Vec::new();
let mut right: Option<(usize, f64)> = None;
let mut down: Option<(usize, f64)> = None;
for (j, other) in words.iter().enumerate() {
if i == j {
continue;
}
let there = &other.bbox;
if there[0] >= here.bbox[2] && overlaps(&here.bbox, there, true) > 0.5 {
let gap = there[0] - here.bbox[2];
if right.is_none_or(|(_, best)| gap < best) {
right = Some((j, gap));
}
}
if there[3] <= here.bbox[1] && overlaps(&here.bbox, there, false) > 0.0 {
let gap = here.bbox[1] - there[3];
if down.is_none_or(|(_, best)| gap < best) {
down = Some((j, gap));
}
}
}
let mut by_distance: Vec<(f64, usize)>;
loop {
near.clear();
for dx in -radius..=radius {
for dy in -radius..=radius {
if let Some(bucket) = grid.get(&(cx + dx, cy + dy)) {
near.extend(bucket.iter().copied());
}
}
}
by_distance = near
.iter()
.copied()
.filter(|&j| j != i)
.map(|j| (twice_apart(here, &words[j]), j))
.collect();
by_distance.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.1.cmp(&b.1))
});
if radius >= span {
break;
}
let sure = radius as f64 * step;
if by_distance.len() >= nearest && by_distance[nearest - 1].0 <= 4.0 * sure * sure {
break;
}
radius += 1;
}
if let Some((j, _)) = right {
found.insert((i.min(j), i.max(j)));
}
if let Some((j, _)) = down {
found.insert((i.min(j), i.max(j)));
}
for &(_d, j) in by_distance.iter().take(nearest) {
found.insert((i.min(j), i.max(j)));
}
}
let mut out: Vec<(usize, usize)> = found.into_iter().collect();
out.sort_unstable();
out
}
#[doc(hidden)]
pub const GROWTH: f64 = 2.0;
#[doc(hidden)]
pub fn in_table_regions(words: &[PairWord]) -> Vec<Vec<usize>> {
let mut tables: Vec<u32> = words.iter().filter_map(|w| w.table).collect();
tables.sort_unstable();
tables.dedup();
let mut out: Vec<Vec<usize>> = Vec::new();
for t in tables {
let mine: Vec<usize> = words
.iter()
.enumerate()
.filter(|(_, w)| w.table == Some(t))
.map(|(i, _)| i)
.collect();
if mine.is_empty() {
continue;
}
let mut box_ = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
let mut heights: Vec<f64> = Vec::with_capacity(mine.len());
for &i in &mine {
let b = words[i].bbox;
box_ = [
box_[0].min(b[0]),
box_[1].min(b[1]),
box_[2].max(b[2]),
box_[3].max(b[3]),
];
heights.push(b[3] - b[1]);
}
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let line = heights[heights.len() / 2].max(1.0);
let grown = [
box_[0] - line * GROWTH / 4.0,
box_[1] - line * GROWTH,
box_[2] + line * GROWTH / 4.0,
box_[3] + line * GROWTH,
];
let mut keep: Vec<usize> = Vec::new();
for (i, w) in words.iter().enumerate() {
let (cx, cy) = ((w.bbox[0] + w.bbox[2]) / 2.0, (w.bbox[1] + w.bbox[3]) / 2.0);
let claimed_elsewhere = w.table.is_some_and(|other| other != t);
if w.table == Some(t)
|| (!claimed_elsewhere
&& grown[0] <= cx
&& cx <= grown[2]
&& grown[1] <= cy
&& cy <= grown[3])
{
keep.push(i);
}
}
out.push(keep);
}
out
}