use super::models;
#[derive(Debug, Clone)]
pub struct Word {
pub bbox: [f64; 4],
pub numeric: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct Ruling {
pub y: f64,
}
#[doc(hidden)]
pub fn bands(order: Vec<usize>, boxes: &[[f64; 4]], low: usize, high: usize) -> Vec<Vec<usize>> {
let ascending_low = order
.windows(2)
.all(|w| boxes[w[0]][low] <= boxes[w[1]][low]);
let descending_high = order
.windows(2)
.all(|w| boxes[w[0]][high] >= boxes[w[1]][high]);
let finite = order
.iter()
.all(|&i| !boxes[i][low].is_nan() && !boxes[i][high].is_nan());
if finite && ascending_low {
return bands_sorted(order, boxes, low, high, true);
}
if finite && descending_high {
return bands_sorted(order, boxes, low, high, false);
}
let mut out: Vec<Vec<usize>> = Vec::new();
for i in order {
let joins = out.last().is_some_and(|band| {
band.iter()
.any(|&j| boxes[i][low] <= boxes[j][high] && boxes[i][high] >= boxes[j][low])
});
if joins {
out.last_mut().expect("checked").push(i);
} else {
out.push(vec![i]);
}
}
out
}
fn bands_sorted(
order: Vec<usize>,
boxes: &[[f64; 4]],
low: usize,
high: usize,
by_low: bool,
) -> Vec<Vec<usize>> {
let mut out: Vec<Vec<usize>> = Vec::new();
let mut keys: Vec<f64> = Vec::new();
let mut extremes: Vec<f64> = Vec::new();
for i in order {
let (lo, hi) = (boxes[i][low], boxes[i][high]);
let joins = if by_low {
let prefix = keys.partition_point(|&key| key <= hi);
prefix > 0 && extremes[prefix - 1] >= lo
} else {
let prefix = keys.partition_point(|&key| key >= lo);
prefix > 0 && extremes[prefix - 1] <= hi
};
let (key, other) = if by_low { (lo, hi) } else { (hi, lo) };
if joins {
out.last_mut()
.expect("a band exists once a box joined")
.push(i);
let last = *extremes.last().expect("a band has a member");
extremes.push(if by_low {
last.max(other)
} else {
last.min(other)
});
keys.push(key);
} else {
out.push(vec![i]);
keys.clear();
extremes.clear();
keys.push(key);
extremes.push(other);
}
}
out
}
fn middle_of(values: &mut [f64]) -> f64 {
if values.is_empty() {
return 1.0;
}
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = values.len();
let middle = if n % 2 == 1 {
values[n / 2]
} else {
(values[n / 2 - 1] + values[n / 2]) / 2.0
};
if middle == 0.0 {
1.0
} else {
middle
}
}
#[doc(hidden)]
pub fn scale(gaps: &[f64]) -> (f64, f64) {
let mut positive: Vec<f64> = gaps.iter().copied().filter(|g| *g > 0.0).collect();
if positive.is_empty() {
return (1.0, 1.0);
}
let middle = middle_of(&mut positive);
let at = (positive.len() / 10)
.saturating_sub(1)
.min(positive.len() - 1);
let tight = positive[at];
(
if middle == 0.0 { 1.0 } else { middle },
if tight == 0.0 { 1.0 } else { tight },
)
}
fn agree(values: &[f64], slack: f64) -> f64 {
if values.len() < 2 {
return 1.0;
}
let first = values[0];
values
.iter()
.filter(|v| (*v - first).abs() <= slack)
.count() as f64
/ values.len() as f64
}
fn numeric_share(band: &[usize], words: &[Word]) -> f64 {
if band.is_empty() {
return 0.0;
}
band.iter().filter(|&&i| words[i].numeric).count() as f64 / band.len() as f64
}
#[allow(clippy::too_many_arguments)]
#[doc(hidden)]
pub fn row_features(
above: &[usize],
below: &[usize],
words: &[Word],
gap: f64,
gaps: &[f64],
median_gap: f64,
tightest: f64,
text_height: f64,
_rules: &[Ruling],
) -> [f64; 18] {
let up = numeric_share(above, words);
let down = numeric_share(below, words);
let lefts: Vec<f64> = above.iter().map(|&i| words[i].bbox[0]).collect();
let rights: Vec<f64> = above.iter().map(|&i| words[i].bbox[2]).collect();
let span = |band: &[usize]| {
band.iter()
.map(|&i| words[i].bbox[3])
.fold(f64::NEG_INFINITY, f64::max)
- band
.iter()
.map(|&i| words[i].bbox[1])
.fold(f64::INFINITY, f64::min)
};
[
gap,
gap / text_height.max(1e-6),
gap / median_gap,
gap / tightest,
gaps.iter().filter(|g| **g < gap).count() as f64 / (gaps.len().max(1)) as f64,
median_gap,
tightest,
gaps.len() as f64,
up,
down,
(up - down).abs(),
above.len() as f64,
below.len() as f64,
agree(&lefts, text_height / 2.0),
agree(&rights, text_height / 2.0),
text_height,
span(above),
span(below),
]
}
#[allow(clippy::too_many_arguments)]
#[doc(hidden)]
pub fn column_features(
left: &[usize],
right: &[usize],
words: &[Word],
gap: f64,
gaps: &[f64],
median_gap: f64,
tightest: f64,
word_width: f64,
) -> [f64; 17] {
let a = numeric_share(left, words);
let b = numeric_share(right, words);
let tops: Vec<f64> = left.iter().map(|&i| words[i].bbox[3]).collect();
let span = |band: &[usize]| {
band.iter()
.map(|&i| words[i].bbox[2])
.fold(f64::NEG_INFINITY, f64::max)
- band
.iter()
.map(|&i| words[i].bbox[0])
.fold(f64::INFINITY, f64::min)
};
[
gap,
gap / word_width.max(1e-6),
gap / median_gap,
gap / tightest,
gaps.iter().filter(|g| **g < gap).count() as f64 / (gaps.len().max(1)) as f64,
median_gap,
tightest,
gaps.len() as f64,
a,
b,
(a - b).abs(),
left.len() as f64,
right.len() as f64,
agree(&tops, word_width / 2.0),
span(left),
span(right),
word_width,
]
}
fn across(mut order: Vec<usize>, boxes: &[[f64; 4]]) -> Vec<Vec<usize>> {
order.sort_by(|&a, &b| {
boxes[a][0]
.partial_cmp(&boxes[b][0])
.unwrap_or(std::cmp::Ordering::Equal)
});
bands(order, boxes, 0, 2)
}
struct CorridorCandidate {
from: f64,
to: f64,
free: usize,
total: usize,
west: f64,
east: f64,
left_spread: f64,
right_spread: f64,
east_lines: usize,
verdict: &'static str,
}
struct GuardCtx<'a> {
narrow: bool,
body: &'a [bool],
trace: Option<&'a mut Vec<CorridorCandidate>>,
}
struct StripeSplit {
original: Vec<usize>,
pieces: Vec<Vec<usize>>,
between: Vec<bool>,
width_em: f64,
added: Vec<usize>,
}
fn split_stripes_at_corridors(
stripes: &[Vec<usize>],
boxes: &[[f64; 4]],
lines: &[Vec<usize>],
text_height: f64,
config: CorridorConfig,
guard: &mut GuardCtx,
) -> Vec<StripeSplit> {
let mut line_of = vec![0usize; boxes.len()];
for (i, band) in lines.iter().enumerate() {
for &w in band {
line_of[w] = i;
}
}
stripes
.iter()
.map(|stripe| {
let mut added = Vec::new();
let (pieces, between, width_em) = split_stripe_at_corridors(
stripe,
boxes,
&line_of,
text_height,
config,
guard,
&mut added,
);
StripeSplit {
original: stripe.clone(),
pieces,
between,
width_em,
added,
}
})
.collect()
}
fn assemble_splits(
splits: &[StripeSplit],
keep: &[bool],
) -> (Vec<Vec<usize>>, Vec<bool>, Vec<usize>) {
let mut stripes: Vec<Vec<usize>> = Vec::with_capacity(splits.len());
let mut forced: Vec<bool> = Vec::new();
let mut added: Vec<usize> = Vec::new();
for (split, &keep) in splits.iter().zip(keep.iter()) {
if keep {
added.extend(split.added.iter().copied());
}
let pieces: &[Vec<usize>] = if keep {
&split.pieces
} else {
std::slice::from_ref(&split.original)
};
let between: &[bool] = if keep { &split.between } else { &[] };
for (p, piece) in pieces.iter().enumerate() {
if !stripes.is_empty() {
forced.push(p > 0 && between[p - 1]);
}
stripes.push(piece.clone());
}
}
(stripes, forced, added)
}
fn corridor_occupancy(
stripe: &[usize],
boxes: &[[f64; 4]],
line_of: &[usize],
body_lines: &[usize],
from: f64,
to: f64,
) -> (f64, f64) {
if body_lines.is_empty() {
return (0.0, 0.0);
}
let mut west = 0usize;
let mut east = 0usize;
for &line in body_lines {
let mut holds_west = false;
let mut holds_east = false;
for &w in stripe {
if line_of[w] != line {
continue;
}
let b = boxes[w];
if b[0] < to && b[2] > from {
holds_west = true;
holds_east = true;
} else if b[2] <= from {
holds_west = true;
} else {
holds_east = true;
}
}
west += usize::from(holds_west);
east += usize::from(holds_east);
}
(
west as f64 / body_lines.len() as f64,
east as f64 / body_lines.len() as f64,
)
}
fn corridor_alignment(
stripe: &[usize],
boxes: &[[f64; 4]],
line_of: &[usize],
body_lines: &[usize],
from: f64,
to: f64,
text_height: f64,
) -> (f64, f64, usize) {
let mut lefts: Vec<f64> = Vec::new();
let mut rights: Vec<f64> = Vec::new();
for &line in body_lines {
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
for &w in stripe {
if line_of[w] != line {
continue;
}
let b = boxes[w];
if b[0] < to && b[2] > from {
continue;
}
if b[2] > from {
left = left.min(b[0]);
right = right.max(b[2]);
}
}
if left.is_finite() {
lefts.push(left);
rights.push(right);
}
}
if lefts.len() < 2 {
return (f64::INFINITY, f64::INFINITY, lefts.len());
}
let spread = |edges: &[f64]| {
(edges.iter().fold(f64::NEG_INFINITY, |m, &e| m.max(e))
- edges.iter().fold(f64::INFINITY, |m, &e| m.min(e)))
/ text_height.max(f64::MIN_POSITIVE)
};
(spread(&lefts), spread(&rights), lefts.len())
}
#[allow(clippy::too_many_arguments)]
fn split_stripe_at_corridors(
stripe: &[usize],
boxes: &[[f64; 4]],
line_of: &[usize],
text_height: f64,
config: CorridorConfig,
guard: &mut GuardCtx,
added: &mut Vec<usize>,
) -> (Vec<Vec<usize>>, Vec<bool>, f64) {
let alone = |words: &[usize]| (vec![words.to_vec()], vec![], 0.0);
let mut held: Vec<usize> = stripe.iter().map(|&w| line_of[w]).collect();
held.sort_unstable();
held.dedup();
if held.len() < config.min_lines {
return alone(stripe);
}
let needed = (config.share * held.len() as f64).ceil() as usize;
let left = stripe
.iter()
.map(|&w| boxes[w][0])
.fold(f64::INFINITY, f64::min);
let right = stripe
.iter()
.map(|&w| boxes[w][2])
.fold(f64::NEG_INFINITY, f64::max);
let mut edges = vec![left, right];
for &w in stripe {
edges.push(boxes[w][0].max(left));
edges.push(boxes[w][2].min(right));
}
edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
edges.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
let ink: Vec<Vec<(f64, f64)>> = held
.iter()
.map(|&line| {
stripe
.iter()
.filter(|&&w| line_of[w] == line)
.map(|&w| (boxes[w][0], boxes[w][2]))
.collect()
})
.collect();
let free_of = |from: f64, to: f64| {
ink.iter()
.filter(|ranges| !ranges.iter().any(|&(l, r)| l < to && r > from))
.count()
};
let mut runs: Vec<(f64, f64)> = Vec::new();
let mut open: Option<(f64, f64)> = None;
for pair in edges.windows(2) {
let (from, to) = (pair[0], pair[1]);
if free_of(from, to) >= needed {
open = Some(match open {
Some((start, _)) => (start, to),
None => (from, to),
});
} else if let Some(run) = open.take() {
runs.push(run);
}
}
runs.extend(open);
runs.retain(|&(from, to)| to - from >= config.em * text_height);
runs.sort_by(|a, b| {
(b.1 - b.0)
.partial_cmp(&(a.1 - a.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
let (occ, align) = if guard.narrow && config.guard_narrow {
(
Some(config.guard_occupancy_strict),
Some(config.guard_align_em_strict),
)
} else {
(config.guard_occupancy, config.guard_align_em)
};
let body_lines: Vec<usize> = held
.iter()
.copied()
.filter(|&line| guard.body.get(line).copied().unwrap_or(true))
.collect();
for (from, to) in runs {
let mut crossing = Vec::new();
let mut west = Vec::new();
let mut east = Vec::new();
for &w in stripe {
let b = boxes[w];
if b[0] < to && b[2] > from {
crossing.push(w);
} else if b[2] <= from {
west.push(w);
} else {
east.push(w);
}
}
if west.is_empty() || east.is_empty() {
if let Some(trace) = guard.trace.as_deref_mut() {
trace.push(CorridorCandidate {
from,
to,
free: free_of(from, to),
total: held.len(),
west: -1.0,
east: -1.0,
left_spread: -1.0,
right_spread: -1.0,
east_lines: 0,
verdict: "empty",
});
}
continue;
}
let (west_share, east_share) = match occ {
Some(need) => {
let (west_share, east_share) =
corridor_occupancy(stripe, boxes, line_of, &body_lines, from, to);
if west_share < need || east_share < need {
if let Some(trace) = guard.trace.as_deref_mut() {
trace.push(CorridorCandidate {
from,
to,
free: free_of(from, to),
total: held.len(),
west: west_share,
east: east_share,
left_spread: -1.0,
right_spread: -1.0,
east_lines: 0,
verdict: "veto:g1",
});
}
continue;
}
(west_share, east_share)
}
None => (-1.0, -1.0),
};
let (left_spread, right_spread, east_lines) = match align {
Some(limit) => {
let (left_spread, right_spread, east_lines) =
corridor_alignment(stripe, boxes, line_of, &body_lines, from, to, text_height);
if left_spread > limit && right_spread > limit {
if let Some(trace) = guard.trace.as_deref_mut() {
trace.push(CorridorCandidate {
from,
to,
free: free_of(from, to),
total: held.len(),
west: west_share,
east: east_share,
left_spread,
right_spread,
east_lines,
verdict: "veto:g2",
});
}
continue;
}
(left_spread, right_spread, east_lines)
}
None => (-1.0, -1.0, 0),
};
if let Some(trace) = guard.trace.as_deref_mut() {
trace.push(CorridorCandidate {
from,
to,
free: free_of(from, to),
total: held.len(),
west: west_share,
east: east_share,
left_spread,
right_spread,
east_lines,
verdict: "split",
});
}
added.extend(crossing);
let (mut west_pieces, mut west_between, west_em) =
split_stripe_at_corridors(&west, boxes, line_of, text_height, config, guard, added);
let (east_pieces, east_between, east_em) =
split_stripe_at_corridors(&east, boxes, line_of, text_height, config, guard, added);
west_pieces.extend(east_pieces);
west_between.push(true);
west_between.extend(east_between);
let width_em = ((to - from) / text_height.max(f64::MIN_POSITIVE)).max(west_em.max(east_em));
return (west_pieces, west_between, width_em);
}
alone(stripe)
}
fn lines_of(boxes: &[[f64; 4]]) -> Vec<Vec<usize>> {
let mut down: Vec<usize> = (0..boxes.len()).collect();
down.sort_by(|&a, &b| {
boxes[b][3]
.partial_cmp(&boxes[a][3])
.unwrap_or(std::cmp::Ordering::Equal)
});
bands(down, boxes, 1, 3)
}
fn overlaps(word: &[f64; 4], band: &[usize], boxes: &[[f64; 4]]) -> bool {
band.iter()
.any(|&x| word[0] < boxes[x][2] && word[2] > boxes[x][0])
}
type LineView = (Vec<usize>, Vec<Vec<usize>>, Vec<Vec<usize>>, usize);
#[doc(hidden)]
pub fn bridge_features(words: &[Word], word_width: f64) -> Vec<[f64; 14]> {
let boxes: Vec<[f64; 4]> = words.iter().map(|w| w.bbox).collect();
let lines = lines_of(&boxes);
let mut line_of = vec![0usize; words.len()];
for (i, band) in lines.iter().enumerate() {
for &w in band {
line_of[w] = i;
}
}
let stripes = across((0..words.len()).collect(), &boxes);
let mut out = vec![[0.0f64; 14]; words.len()];
for stripe in &stripes {
let mut mine: Vec<usize> = stripe.iter().map(|&w| line_of[w]).collect();
mine.sort_unstable();
mine.dedup();
let left = stripe
.iter()
.map(|&w| boxes[w][0])
.fold(f64::INFINITY, f64::min);
let right = stripe
.iter()
.map(|&w| boxes[w][2])
.fold(f64::NEG_INFINITY, f64::max);
let mut by_line: std::collections::BTreeMap<usize, LineView> = Default::default();
for &own in &mine {
let below: Vec<usize> = stripe
.iter()
.copied()
.filter(|&x| line_of[x] > own)
.collect();
let beside: Vec<usize> = stripe
.iter()
.copied()
.filter(|&x| line_of[x] != own)
.collect();
let mut under_lines: Vec<usize> = below.iter().map(|&x| line_of[x]).collect();
under_lines.sort_unstable();
under_lines.dedup();
let under = across(below.clone(), &boxes);
let beside = across(beside, &boxes);
by_line.insert(own, (below, under, beside, under_lines.len()));
}
for &w in stripe {
let own = line_of[w];
let (below, under, beside, lines_below) = &by_line[&own];
let b = boxes[w];
let mut hit_lines: Vec<usize> = below
.iter()
.copied()
.filter(|&x| b[0] < boxes[x][2] && b[2] > boxes[x][0])
.map(|x| line_of[x])
.collect();
hit_lines.sort_unstable();
hit_lines.dedup();
let width = b[2] - b[0];
out[w] = [
width / word_width.max(1e-6),
width / (right - left).max(1e-6),
mine.len() as f64,
stripe.len() as f64,
own as f64 / (lines.len().saturating_sub(1)).max(1) as f64,
own as f64,
lines[own].len() as f64,
if words[w].numeric { 1.0 } else { 0.0 },
under
.iter()
.filter(|band| overlaps(&b, band, &boxes))
.count() as f64,
under.len() as f64,
beside
.iter()
.filter(|band| overlaps(&b, band, &boxes))
.count() as f64,
*lines_below as f64 / mine.len().max(1) as f64,
stripes.len() as f64,
hit_lines.len() as f64 / (*lines_below).max(1) as f64,
];
}
}
out
}
pub fn bridging(words: &[Word], floor: f64) -> Vec<bool> {
if words.len() < 4 {
return vec![false; words.len()];
}
let mut widths: Vec<f64> = words.iter().map(|w| w.bbox[2] - w.bbox[0]).collect();
let word_width = middle_of(&mut widths);
bridge_features(words, word_width)
.iter()
.map(|x| super::probability(models::table_column_bridge::predict_margin(x)) >= floor)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Placement {
pub row_of: Vec<usize>,
pub column_of: Vec<usize>,
pub last_column_of: Vec<usize>,
}
pub fn place(words: &[Word], rules: &[Ruling], floor: f64, bridge: &[bool]) -> Option<Placement> {
place_inner(words, rules, floor, bridge, None)
}
pub fn place_with_corridors(
words: &[Word],
rules: &[Ruling],
floor: f64,
bridge: &[bool],
) -> Option<Placement> {
place_inner(words, rules, floor, bridge, Some(CorridorConfig::SHIPPED))
}
#[cfg(any(test, feature = "corridor-grid"))]
pub(crate) fn place_with_corridors_in(
words: &[Word],
rules: &[Ruling],
floor: f64,
bridge: &[bool],
config: CorridorConfig,
) -> Option<Placement> {
place_inner(words, rules, floor, bridge, Some(config))
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CorridorConfig {
pub(crate) share: f64,
pub(crate) em: f64,
pub(crate) min_lines: usize,
pub(crate) decides: bool,
pub(crate) guard_occupancy: Option<f64>,
pub(crate) guard_align_em: Option<f64>,
pub(crate) guard_header: bool,
pub(crate) guard_narrow: bool,
pub(crate) guard_occupancy_strict: f64,
pub(crate) guard_align_em_strict: f64,
}
impl CorridorConfig {
const SHIPPED: Self = Self {
share: CORRIDOR_SHARE,
em: CORRIDOR_EM,
min_lines: CORRIDOR_MIN_LINES,
decides: CORRIDOR_DECIDES,
guard_occupancy: Some(CORRIDOR_GUARD_OCC),
guard_align_em: None,
guard_header: false,
guard_narrow: false,
guard_occupancy_strict: CORRIDOR_GUARD_OCC2,
guard_align_em_strict: CORRIDOR_GUARD_ALIGN2,
};
fn any_guard(&self) -> bool {
self.guard_occupancy.is_some()
|| self.guard_align_em.is_some()
|| self.guard_header
|| self.guard_narrow
}
}
pub const CORRIDOR_GUARD_OCC: f64 = 0.7;
pub const CORRIDOR_GUARD_OCC2: f64 = 0.9;
pub const CORRIDOR_GUARD_ALIGN2: f64 = 0.5;
pub const CORRIDOR_SHARE: f64 = 0.75;
pub const CORRIDOR_EM: f64 = 1.0;
pub const CORRIDOR_MIN_LINES: usize = 4;
pub const CORRIDOR_DECIDES: bool = false;
#[cfg(feature = "corridor-grid")]
#[derive(Debug, Clone, Copy)]
enum CorridorEnv {
Default,
Off,
Custom(CorridorConfig),
}
#[cfg(feature = "corridor-grid")]
pub(crate) fn resolve_corridor_config(switch_on: bool) -> Option<CorridorConfig> {
match corridor_env() {
CorridorEnv::Off => None,
CorridorEnv::Custom(config) => Some(config),
CorridorEnv::Default => switch_on.then_some(CorridorConfig::SHIPPED),
}
}
#[cfg(feature = "corridor-grid")]
fn corridor_env() -> CorridorEnv {
static ENV: std::sync::OnceLock<CorridorEnv> = std::sync::OnceLock::new();
*ENV.get_or_init(|| match std::env::var("PDFLUENT_CORRIDOR") {
Err(_) => CorridorEnv::Default,
Ok(value) if value.trim() == "off" => CorridorEnv::Off,
Ok(value) => CorridorEnv::Custom(parse_corridor_env(&value)),
})
}
#[cfg(feature = "corridor-grid")]
fn parse_corridor_env(value: &str) -> CorridorConfig {
let mut config = CorridorConfig::SHIPPED;
let flag = |raw: &str, key: &str| match raw {
"0" | "false" => false,
"1" | "true" => true,
_ => panic!("PDFLUENT_CORRIDOR: {key} wants 0/1, got {raw:?}"),
};
for part in value.split(',') {
let (key, raw) = part
.split_once('=')
.unwrap_or_else(|| panic!("PDFLUENT_CORRIDOR: want k=v pairs, got {part:?}"));
let (key, raw) = (key.trim(), raw.trim());
match key {
"share" => {
config.share = raw.parse().unwrap_or_else(|_| {
panic!("PDFLUENT_CORRIDOR: share wants a number, got {raw:?}")
})
}
"em" => {
config.em = raw
.parse()
.unwrap_or_else(|_| panic!("PDFLUENT_CORRIDOR: em wants a number, got {raw:?}"))
}
"min_lines" => {
config.min_lines = raw.parse().unwrap_or_else(|_| {
panic!("PDFLUENT_CORRIDOR: min_lines wants a number, got {raw:?}")
})
}
"decides" => config.decides = flag(raw, key),
"g1" => {
config.guard_occupancy = Some(raw.parse().unwrap_or_else(|_| {
panic!("PDFLUENT_CORRIDOR: g1 wants a number, got {raw:?}")
}))
}
"g2" => {
config.guard_align_em = Some(raw.parse().unwrap_or_else(|_| {
panic!("PDFLUENT_CORRIDOR: g2 wants a number, got {raw:?}")
}))
}
"g3" => config.guard_header = flag(raw, key),
"g4" => config.guard_narrow = flag(raw, key),
"occ2" => {
config.guard_occupancy_strict = raw.parse().unwrap_or_else(|_| {
panic!("PDFLUENT_CORRIDOR: occ2 wants a number, got {raw:?}")
})
}
"align2" => {
config.guard_align_em_strict = raw.parse().unwrap_or_else(|_| {
panic!("PDFLUENT_CORRIDOR: align2 wants a number, got {raw:?}")
})
}
_ => panic!("PDFLUENT_CORRIDOR: unknown key {key:?}"),
}
}
config
}
#[cfg(feature = "corridor-grid")]
fn corridor_tracing() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("PDFLUENT_CORRIDOR_TRACE").is_some())
}
#[cfg(not(feature = "corridor-grid"))]
fn corridor_tracing() -> bool {
false
}
fn place_inner(
words: &[Word],
rules: &[Ruling],
floor: f64,
bridge: &[bool],
corridors: Option<CorridorConfig>,
) -> Option<Placement> {
if words.len() < 4 {
return None;
}
let boxes: Vec<[f64; 4]> = words.iter().map(|w| w.bbox).collect();
let mut heights: Vec<f64> = boxes.iter().map(|b| b[3] - b[1]).collect();
let mut widths: Vec<f64> = boxes.iter().map(|b| b[2] - b[0]).collect();
let text_height = middle_of(&mut heights);
let word_width = middle_of(&mut widths);
let lines = lines_of(&boxes);
let mut core: Vec<usize> = (0..words.len())
.filter(|&i| !bridge.get(i).copied().unwrap_or(false))
.collect();
let mut bridged: Vec<usize> = (0..words.len())
.filter(|&i| bridge.get(i).copied().unwrap_or(false))
.collect();
if core.len() < 2 {
core = (0..words.len()).collect();
bridged.clear();
}
let stripes = across(core, &boxes);
let row_gaps: Vec<f64> = lines
.windows(2)
.map(|pair| {
pair[0]
.iter()
.map(|&i| boxes[i][1])
.fold(f64::INFINITY, f64::min)
- pair[1]
.iter()
.map(|&i| boxes[i][3])
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
let (row_median, row_tight) = scale(&row_gaps);
let mut row_of = vec![0usize; words.len()];
let mut at = 0usize;
for (n, band) in lines.iter().enumerate() {
for &i in band {
row_of[i] = at;
}
if n < row_gaps.len() {
let x = row_features(
band,
&lines[n + 1],
words,
row_gaps[n],
&row_gaps,
row_median,
row_tight,
text_height,
rules,
);
if super::probability(models::table_row_boundary::predict_margin(&x)) >= floor {
at += 1;
}
}
}
let tracing = corridor_tracing();
let mut guarded = GuardedSplit::default();
let (stripes, forced) = match corridors {
Some(config) => {
let (stripes, forced, added, split) = split_guarded(
words,
&boxes,
&lines,
&stripes,
text_height,
word_width,
&row_of,
floor,
&bridged,
config,
tracing,
);
bridged.extend(added);
guarded = split;
(stripes, forced)
}
None => {
let gaps = stripes.len().saturating_sub(1);
(stripes, vec![false; gaps])
}
};
let decides = corridors.is_some_and(|c| c.decides);
let (column_of, last_column_of, gaps) = decode_columns(
words,
&boxes,
&stripes,
&forced,
&bridged,
word_width,
floor,
decides,
corridors.is_some(),
);
if tracing {
emit_corridor_trace(
words.len(),
corridors,
&guarded,
&stripes,
&boxes,
&column_of,
&row_of,
&gaps,
);
}
Some(Placement {
row_of,
column_of,
last_column_of,
})
}
struct ColumnGap {
gap: f64,
p: f64,
kept: bool,
}
#[allow(clippy::too_many_arguments)]
fn decode_columns(
words: &[Word],
boxes: &[[f64; 4]],
stripes: &[Vec<usize>],
forced: &[bool],
bridged: &[usize],
word_width: f64,
floor: f64,
decides: bool,
neighbor: bool,
) -> (Vec<usize>, Vec<usize>, Vec<ColumnGap>) {
let col_gaps: Vec<f64> = stripes
.windows(2)
.map(|pair| {
pair[1]
.iter()
.map(|&i| boxes[i][0])
.fold(f64::INFINITY, f64::min)
- pair[0]
.iter()
.map(|&i| boxes[i][2])
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
let (col_median, col_tight) = scale(&col_gaps);
let mut column_of = vec![0usize; words.len()];
let mut extent: Vec<(f64, f64, usize)> = Vec::with_capacity(stripes.len());
let mut gaps = Vec::with_capacity(col_gaps.len());
let mut at = 0usize;
for (n, band) in stripes.iter().enumerate() {
for &i in band {
column_of[i] = at;
}
extent.push((
band.iter()
.map(|&i| boxes[i][0])
.fold(f64::INFINITY, f64::min),
band.iter()
.map(|&i| boxes[i][2])
.fold(f64::NEG_INFINITY, f64::max),
at,
));
if n < col_gaps.len() {
let forced_gap = decides && forced[n];
let x = column_features(
band,
&stripes[n + 1],
words,
col_gaps[n],
&col_gaps,
col_median,
col_tight,
word_width,
);
let p = super::probability(models::table_column_boundary::predict_margin(&x));
let kept = forced_gap || p >= floor;
gaps.push(ColumnGap {
gap: col_gaps[n],
p,
kept,
});
if kept {
at += 1;
}
}
}
let mut last_column_of = column_of.clone();
let mut leftover: Vec<usize> = Vec::new();
for &i in bridged {
let b = boxes[i];
let hit: Vec<usize> = extent
.iter()
.filter(|e| b[0] < e.1 && b[2] > e.0)
.map(|e| e.2)
.collect();
if hit.is_empty() {
leftover.push(i);
continue;
}
let (first, last) = (
*hit.iter().min().expect("non-empty"),
*hit.iter().max().expect("non-empty"),
);
column_of[i] = first;
last_column_of[i] = last;
}
for &i in &leftover {
let b = boxes[i];
let mut best: Option<(usize, f64)> = None;
if neighbor {
for (j, ob) in boxes.iter().enumerate() {
if j == i || leftover.contains(&j) {
continue;
}
let mut shorter = (b[3] - b[1]).min(ob[3] - ob[1]);
if shorter == 0.0 {
shorter = 1.0;
}
if (b[3].min(ob[3]) - b[1].max(ob[1])) / shorter <= 0.5 {
continue;
}
let gap = if ob[2] <= b[0] {
b[0] - ob[2]
} else if ob[0] >= b[2] {
ob[0] - b[2]
} else {
0.0
};
if best.is_none_or(|(_, g)| gap < g) {
best = Some((column_of[j], gap));
}
}
}
let (first, last) = match best {
Some((column, _)) => (column, column),
None => {
let middle = (b[0] + b[2]) / 2.0;
let mut best = extent[0];
for e in &extent[1..] {
if ((e.0 + e.1) / 2.0 - middle).abs() < ((best.0 + best.1) / 2.0 - middle).abs()
{
best = *e;
}
}
(best.2, best.2)
}
};
column_of[i] = first;
last_column_of[i] = last;
}
(column_of, last_column_of, gaps)
}
#[derive(Default)]
struct GuardedSplit {
added: Vec<usize>,
cols0: usize,
head0: usize,
cols1: usize,
head1: usize,
dropped: Vec<f64>,
candidates: Vec<CorridorCandidate>,
}
#[allow(clippy::too_many_arguments)]
fn split_guarded(
words: &[Word],
boxes: &[[f64; 4]],
lines: &[Vec<usize>],
stripes0: &[Vec<usize>],
text_height: f64,
word_width: f64,
row_of: &[usize],
floor: f64,
model_bridged: &[usize],
config: CorridorConfig,
tracing: bool,
) -> (Vec<Vec<usize>>, Vec<bool>, Vec<usize>, GuardedSplit) {
let decides = config.decides;
if !config.any_guard() {
let mut candidates = Vec::new();
let mut guard = GuardCtx {
narrow: false,
body: &[],
trace: tracing.then_some(&mut candidates),
};
let splits =
split_stripes_at_corridors(stripes0, boxes, lines, text_height, config, &mut guard);
let keep = vec![true; splits.len()];
let (stripes, forced, added) = assemble_splits(&splits, &keep);
let mut out = GuardedSplit {
added: added.clone(),
candidates,
..GuardedSplit::default()
};
if tracing {
let (column_of, last_column_of, _) = decode_columns(
words,
boxes,
&stripes,
&forced,
&[model_bridged, added.as_slice()].concat(),
word_width,
floor,
decides,
true,
);
let placed = Placement {
row_of: row_of.to_vec(),
column_of,
last_column_of,
};
out.cols1 = placed.column_of.iter().max().map(|m| m + 1).unwrap_or(0);
out.head1 = cells_placed(words, &placed)
.map(|g| g.header_rows)
.unwrap_or(0);
}
return (stripes, forced, added, out);
}
let forced0 = vec![false; stripes0.len().saturating_sub(1)];
let (column0, last0, _) = decode_columns(
words,
boxes,
stripes0,
&forced0,
model_bridged,
word_width,
floor,
decides,
true,
);
let placed0 = Placement {
row_of: row_of.to_vec(),
column_of: column0,
last_column_of: last0,
};
let cols0 = placed0.column_of.iter().max().map(|m| m + 1).unwrap_or(0);
let grid0 = cells_placed(words, &placed0);
let body: Vec<bool> = match &grid0 {
Some(grid) => lines
.iter()
.map(|band| band.first().is_some_and(|&w| row_of[w] >= grid.header_rows))
.collect(),
None => vec![true; lines.len()],
};
let head0 = grid0.map(|g| g.header_rows).unwrap_or(0);
let mut candidates = Vec::new();
let mut guard = GuardCtx {
narrow: cols0 <= 2,
body: &body,
trace: tracing.then_some(&mut candidates),
};
let splits =
split_stripes_at_corridors(stripes0, boxes, lines, text_height, config, &mut guard);
let mut keep = vec![true; splits.len()];
let trial = |keep: &[bool]| {
let (stripes, forced, added) = assemble_splits(&splits, keep);
let bridged = [model_bridged, added.as_slice()].concat();
let (column_of, last_column_of, _) = decode_columns(
words, boxes, &stripes, &forced, &bridged, word_width, floor, decides, true,
);
let placed = Placement {
row_of: row_of.to_vec(),
column_of,
last_column_of,
};
let cols = placed.column_of.iter().max().map(|m| m + 1).unwrap_or(0);
let head = cells_placed(words, &placed)
.map(|g| g.header_rows)
.unwrap_or(0);
(stripes, forced, added, cols, head)
};
let mut dropped = Vec::new();
if config.guard_header {
let (_, _, _, _, head_all) = trial(&keep);
if head_all < head0 {
let mut order: Vec<usize> = splits
.iter()
.enumerate()
.filter(|(_, s)| s.pieces.len() > 1)
.map(|(i, _)| i)
.collect();
order.sort_by(|&a, &b| {
splits[b]
.width_em
.partial_cmp(&splits[a].width_em)
.unwrap_or(std::cmp::Ordering::Equal)
});
for i in order {
keep[i] = false;
dropped.push(splits[i].width_em);
let (_, _, _, _, head) = trial(&keep);
if head >= head0 {
break;
}
}
}
}
let (stripes, forced, added, cols1, head1) = trial(&keep);
(
stripes,
forced,
added.clone(),
GuardedSplit {
added,
cols0,
head0,
cols1,
head1,
dropped,
candidates,
},
)
}
#[allow(clippy::too_many_arguments)]
fn emit_corridor_trace(
n_words: usize,
corridors: Option<CorridorConfig>,
guarded: &GuardedSplit,
stripes: &[Vec<usize>],
boxes: &[[f64; 4]],
column_of: &[usize],
rows_of: &[usize],
gaps: &[ColumnGap],
) {
fn num(v: f64) -> String {
if v.is_finite() {
format!("{:.3}", (v * 1000.0).round() / 1000.0)
} else {
"null".to_string()
}
}
let extent = |band: &[usize]| {
(
band.iter()
.map(|&i| boxes[i][0])
.fold(f64::INFINITY, f64::min),
band.iter()
.map(|&i| boxes[i][2])
.fold(f64::NEG_INFINITY, f64::max),
)
};
let mut out = String::from("{\"corridor\":{");
out.push_str(&format!("\"words\":{n_words},"));
match corridors {
None => out.push_str("\"mode\":\"off\","),
Some(c) => {
let g1 = c.guard_occupancy.map_or("-1".to_string(), num);
let g2 = c.guard_align_em.map_or("-1".to_string(), num);
out.push_str(&format!(
"\"mode\":\"on\",\"share\":{},\"em\":{},\"min_lines\":{},\"decides\":{},\
\"g1\":{g1},\"g2\":{g2},\"g3\":{},\"g4\":{},\"occ2\":{},\"align2\":{},",
num(c.share),
num(c.em),
c.min_lines,
c.decides,
c.guard_header,
c.guard_narrow,
num(c.guard_occupancy_strict),
num(c.guard_align_em_strict),
));
}
}
out.push_str(&format!(
"\"cols0\":{},\"head0\":{},\"cols1\":{},\"head1\":{},\"added\":{},",
guarded.cols0,
guarded.head0,
guarded.cols1,
guarded.head1,
guarded.added.len(),
));
out.push_str("\"dropped\":[");
for (i, w) in guarded.dropped.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&num(*w));
}
out.push_str("],\"candidates\":[");
for (i, cand) in guarded.candidates.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"from\":{},\"to\":{},\"free\":{},\"total\":{},\"west\":{},\"east\":{},\
\"ls\":{},\"rs\":{},\"east_lines\":{},\"verdict\":\"{}\"}}",
num(cand.from),
num(cand.to),
cand.free,
cand.total,
num(cand.west),
num(cand.east),
num(cand.left_spread),
num(cand.right_spread),
cand.east_lines,
cand.verdict,
));
}
out.push_str("],\"stripes\":[");
for (i, band) in stripes.iter().enumerate() {
if i > 0 {
out.push(',');
}
let (x0, x1) = extent(band);
out.push_str(&format!(
"{{\"n\":{},\"x0\":{},\"x1\":{}}}",
band.len(),
num(x0),
num(x1),
));
}
out.push_str("],\"gaps\":[");
for (i, gap) in gaps.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"gap\":{},\"p\":{},\"kept\":{}}}",
num(gap.gap),
num(gap.p),
gap.kept,
));
}
let cols = column_of.iter().max().map(|m| m + 1).unwrap_or(0);
let rows = rows_of.iter().max().map(|m| m + 1).unwrap_or(0);
out.push_str(&format!("],\"cols\":{cols},\"rows\":{rows}}}}}"));
eprintln!("{out}");
}
pub const BRIDGE_FLOOR: f64 = models::table_column_bridge::THRESHOLD;
pub const MOST_HEADER_ROWS: usize = 4;
pub const HEADER_SPAN_TAKES_STUB: bool = true;
pub const LONE_SPAN_MIN_COLUMNS: usize = 2;
pub const LONE_SPAN_BAND: f64 = 0.25;
#[derive(Debug, Clone)]
pub struct Grid {
pub cells: Vec<super::grid::Cell>,
pub header_rows: usize,
}
pub fn cells_of(words: &[Word], rules: &[Ruling], floor: f64) -> Option<Grid> {
let bridge = bridging(words, BRIDGE_FLOOR);
cells_placed(words, &place(words, rules, floor, &bridge)?)
}
pub fn cells_of_with_corridors(words: &[Word], rules: &[Ruling], floor: f64) -> Option<Grid> {
cells_placed(
words,
&place_with_corridors(words, rules, floor, &bridging(words, BRIDGE_FLOOR))?,
)
}
#[cfg(feature = "corridor-grid")]
pub(crate) fn cells_of_with_corridors_in(
words: &[Word],
rules: &[Ruling],
floor: f64,
config: CorridorConfig,
) -> Option<Grid> {
cells_placed(
words,
&place_with_corridors_in(words, rules, floor, &bridging(words, BRIDGE_FLOOR), config)?,
)
}
fn lone_header_span(
held_here: &[usize],
columns: usize,
centre: f64,
extent: Option<(f64, f64)>,
takes_stub: bool,
) -> Option<(usize, usize)> {
if held_here.len() != 1 || held_here[0] == 0 {
return None;
}
if columns.saturating_sub(1) < LONE_SPAN_MIN_COLUMNS {
return None;
}
let (left, right) = extent?;
let w = right - left;
if w.is_nan() || w < 0.0 {
return None;
}
if centre < left + LONE_SPAN_BAND * w || centre > right - LONE_SPAN_BAND * w {
return None;
}
Some((if takes_stub { 0 } else { 1 }, columns - 1))
}
#[doc(hidden)]
pub fn cells_placed(words: &[Word], placed: &Placement) -> Option<Grid> {
use std::collections::{BTreeMap, BTreeSet};
let mut held: BTreeMap<(usize, usize), Vec<usize>> = Default::default();
for i in 0..words.len() {
held.entry((placed.row_of[i], placed.column_of[i]))
.or_default()
.push(i);
}
if held.len() < 4 {
return None;
}
let columns = held.keys().map(|&(_, c)| c).max().unwrap_or(0) + 1;
let rows = held.keys().map(|&(r, _)| r).max().unwrap_or(0) + 1;
let full = |r: usize| (0..columns).all(|c| held.contains_key(&(r, c)));
let mut header_rows = rows;
for r in 1..rows {
if full(r) {
header_rows = r;
break;
}
}
if header_rows > MOST_HEADER_ROWS {
header_rows = 1;
}
let mut span: BTreeMap<(usize, usize), (usize, usize)> = BTreeMap::new();
for (&(row, column), mine) in &held {
let reach = mine
.iter()
.map(|&i| placed.last_column_of[i])
.max()
.unwrap_or(column);
let mut last = column;
while last < reach && !held.contains_key(&(row, last + 1)) {
last += 1;
}
span.insert((row, column), (column, last));
}
let mut taken: BTreeSet<(usize, usize)> = held.keys().copied().collect();
if columns >= 2 {
for r in header_rows..rows {
let alone =
held.contains_key(&(r, 0)) && (1..columns).all(|c| !held.contains_key(&(r, c)));
if alone {
span.insert((r, 0), (0, columns - 1));
taken.extend((0..columns).map(|k| (r, k)));
}
}
}
let extent = {
let last = columns.saturating_sub(1);
let mut left: Option<f64> = None;
let mut right: Option<f64> = None;
for (i, word) in words.iter().enumerate() {
if placed.row_of[i] < header_rows {
continue;
}
if placed.column_of[i] == 1 {
left = Some(left.map_or(word.bbox[0], |m: f64| m.min(word.bbox[0])));
}
if placed.column_of[i] == last {
right = Some(right.map_or(word.bbox[2], |m: f64| m.max(word.bbox[2])));
}
}
left.zip(right)
};
let height = {
let mut h: Vec<f64> = words.iter().map(|w| w.bbox[3] - w.bbox[1]).collect();
h.sort_by(f64::total_cmp);
h.get(h.len() / 2).copied().unwrap_or(1.0)
};
let max_gap = super::tables::WORD_SPACE * height;
for r in 0..header_rows {
let here: Vec<usize> = (0..columns)
.filter(|&c| held.contains_key(&(r, c)))
.collect();
if here.is_empty() {
continue;
}
let mut runs: Vec<Vec<usize>> = Vec::new();
for &c in &here {
let mut starts = true;
if let Some(prev) = runs.last() {
let p = *prev.last().expect("a run holds a slot");
if c == p + 1 {
let right_prev = held[&(r, p)]
.iter()
.map(|&i| words[i].bbox[2])
.fold(f64::NEG_INFINITY, f64::max);
let left_cur = held[&(r, c)]
.iter()
.map(|&i| words[i].bbox[0])
.fold(f64::INFINITY, f64::min);
if left_cur - right_prev < max_gap {
starts = false;
}
}
}
if starts {
runs.push(vec![c]);
} else {
runs.last_mut().expect("a run holds a slot").push(c);
}
}
if runs.len() != 1 || runs[0][0] == 0 {
continue;
}
let run = &runs[0];
let mut x0 = f64::INFINITY;
let mut x1 = f64::NEG_INFINITY;
for &c in run {
for &i in &held[&(r, c)] {
x0 = x0.min(words[i].bbox[0]);
x1 = x1.max(words[i].bbox[2]);
}
}
if let Some((first, last)) = lone_header_span(
&run[0..1],
columns,
(x0 + x1) / 2.0,
extent,
HEADER_SPAN_TAKES_STUB,
) {
let slot = run[0];
if run.len() > 1 {
let mut merged = Vec::new();
for &c in &run[1..] {
if let Some(v) = held.remove(&(r, c)) {
merged.extend(v);
}
span.remove(&(r, c));
}
if let Some(v) = held.get_mut(&(r, slot)) {
v.extend(merged);
}
}
span.insert((r, slot), (first, last));
taken.extend((first..columns).map(|k| (r, k)));
}
}
let mut down: BTreeMap<(usize, usize), usize> = BTreeMap::new();
for r in header_rows..rows {
if !held.contains_key(&(r, 0)) || span[&(r, 0)].1 > 0 {
continue;
}
let mut last = r;
while last + 1 < rows
&& !taken.contains(&(last + 1, 0))
&& (1..columns).any(|k| held.contains_key(&(last + 1, k)))
{
last += 1;
}
if last > r {
taken.extend((r + 1..=last).map(|q| (q, 0)));
down.insert((r, 0), last);
}
}
let boxes: Vec<[f64; 4]> = words.iter().map(|w| w.bbox).collect();
let mut out = Vec::with_capacity(held.len());
for (&(row, column), mine) in &held {
let (first, last) = span[&(row, column)];
let bottom = down.get(&(row, column)).copied().unwrap_or(row);
let order = {
let mut down: Vec<usize> = mine.clone();
down.sort_by(|&a, &b| {
words[b].bbox[3]
.partial_cmp(&words[a].bbox[3])
.unwrap_or(std::cmp::Ordering::Equal)
});
bands(down, &boxes, 1, 3)
};
let mine: Vec<usize> = order
.into_iter()
.flat_map(|mut line| {
line.sort_by(|&a, &b| {
words[a].bbox[0]
.partial_cmp(&words[b].bbox[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
line
})
.collect();
let x0 = mine
.iter()
.map(|&i| words[i].bbox[0])
.fold(f64::INFINITY, f64::min);
let y0 = mine
.iter()
.map(|&i| words[i].bbox[1])
.fold(f64::INFINITY, f64::min);
let x1 = mine
.iter()
.map(|&i| words[i].bbox[2])
.fold(f64::NEG_INFINITY, f64::max);
let y1 = mine
.iter()
.map(|&i| words[i].bbox[3])
.fold(f64::NEG_INFINITY, f64::max);
out.push(super::grid::Cell {
words: mine,
bbox: [x0, y0, x1, y1],
rows: (row..=bottom).collect(),
columns: (first..=last).collect(),
});
}
Some(Grid {
cells: out,
header_rows,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn word(x0: f64, y0: f64, x1: f64, y1: f64) -> Word {
Word {
bbox: [x0, y0, x1, y1],
numeric: false,
}
}
#[test]
fn a_bridge_spans_its_columns_and_stops_at_a_filled_one() {
let mut words = vec![word(0.0, 20.0, 50.0, 25.0), word(60.0, 20.0, 70.0, 25.0)];
for row in 0..2 {
let y = 10.0 - 10.0 * row as f64;
for column in 0..4 {
let x = 20.0 * column as f64;
words.push(word(x, y, x + 10.0, y + 5.0));
}
}
let n = words.len();
let mut row_of = vec![0usize; n];
let mut column_of = vec![0usize; n];
for i in 2..n {
row_of[i] = 1 + (i - 2) / 4;
column_of[i] = (i - 2) % 4;
}
column_of[1] = 3;
let mut last_column_of = column_of.clone();
last_column_of[0] = 3;
let cells = cells_placed(
&words,
&Placement {
row_of,
column_of,
last_column_of,
},
)
.expect("cells")
.cells;
let header = cells
.iter()
.find(|c| c.words == vec![0])
.expect("the header's cell");
assert_eq!(header.columns, vec![0, 1, 2]);
let other = cells
.iter()
.find(|c| c.words == vec![1])
.expect("the second header");
assert_eq!(other.columns, vec![3]);
assert!(cells
.iter()
.filter(|c| c.rows == vec![1] || c.rows == vec![2])
.all(|c| c.columns.len() == 1));
}
fn at(row: usize, column: usize) -> (usize, usize) {
(row, column)
}
#[test]
fn the_grid_names_its_header_its_sections_and_its_row_groups() {
let slots = [
at(0, 1),
at(0, 2),
at(1, 0),
at(1, 1),
at(1, 2),
at(2, 0),
at(3, 0),
at(3, 1),
at(3, 2),
at(4, 1),
at(4, 2),
];
let words: Vec<Word> = slots
.iter()
.map(|&(r, c)| {
let (x, y) = (20.0 * c as f64, 100.0 - 10.0 * r as f64);
word(x, y, x + 10.0, y + 5.0)
})
.collect();
let placed = Placement {
row_of: slots.iter().map(|s| s.0).collect(),
column_of: slots.iter().map(|s| s.1).collect(),
last_column_of: slots.iter().map(|s| s.1).collect(),
};
let grid = cells_placed(&words, &placed).expect("grid");
assert_eq!(grid.header_rows, 1, "row 1 is the first full row");
let cell = |w: usize| {
grid.cells
.iter()
.find(|c| c.words.contains(&w))
.expect("a cell")
};
assert_eq!(cell(5).columns, vec![0, 1, 2], "row 2 is a section row");
assert_eq!(cell(6).rows, vec![3, 4], "row 3's stub label covers row 4");
assert_eq!(cell(2).rows, vec![1], "a section row below stops a group");
}
fn placed_of(slots: &[(usize, usize)], reach: &[(usize, usize)]) -> (Vec<Word>, Placement) {
let words: Vec<Word> = slots
.iter()
.map(|&(r, c)| {
let (x, y) = (20.0 * c as f64, 100.0 - 10.0 * r as f64);
word(x, y, x + 10.0, y + 5.0)
})
.collect();
let mut last_column_of: Vec<usize> = slots.iter().map(|s| s.1).collect();
for &(i, last) in reach {
last_column_of[i] = last;
}
let placed = Placement {
row_of: slots.iter().map(|s| s.0).collect(),
column_of: slots.iter().map(|s| s.1).collect(),
last_column_of,
};
(words, placed)
}
#[test]
fn a_lone_header_label_spans_the_columns_it_heads() {
let mut slots = vec![(0, 4)];
for r in 1..3 {
for c in 0..9 {
slots.push((r, c));
}
}
let (words, placed) = placed_of(&slots, &[]);
let grid = cells_placed(&words, &placed).expect("grid");
assert_eq!(grid.header_rows, 1);
let header = grid
.cells
.iter()
.find(|c| c.words == vec![0])
.expect("the header's cell");
let first = if HEADER_SPAN_TAKES_STUB { 0 } else { 1 };
assert_eq!(header.columns, (first..9).collect::<Vec<_>>());
}
#[test]
fn each_span_variant_reaches_the_columns_its_dataset_writes() {
let centred = Some((20.0, 170.0));
assert_eq!(
lone_header_span(&[4], 9, 85.0, centred, false),
Some((1, 8))
);
assert_eq!(lone_header_span(&[4], 9, 85.0, centred, true), Some((0, 8)));
assert_eq!(lone_header_span(&[1, 5], 9, 85.0, centred, false), None);
assert_eq!(lone_header_span(&[1, 5], 9, 85.0, centred, true), None);
assert_eq!(lone_header_span(&[0], 9, 85.0, centred, false), None);
assert_eq!(lone_header_span(&[0], 9, 85.0, centred, true), None);
}
#[test]
fn a_label_over_the_last_column_keeps_one_column() {
let mut slots = vec![(0, 3)];
for r in 1..3 {
for c in 0..4 {
slots.push((r, c));
}
}
let (words, placed) = placed_of(&slots, &[]);
let grid = cells_placed(&words, &placed).expect("grid");
assert_eq!(grid.header_rows, 1);
let header = grid
.cells
.iter()
.find(|c| c.words == vec![0])
.expect("the header's cell");
assert_eq!(header.columns, vec![3]);
}
#[test]
fn a_lone_label_over_one_figure_column_does_not_span() {
let mut slots = vec![(0, 1)];
for r in 1..3 {
for c in 0..2 {
slots.push((r, c));
}
}
let (words, placed) = placed_of(&slots, &[]);
let grid = cells_placed(&words, &placed).expect("grid");
assert_eq!(grid.header_rows, 1);
let header = grid
.cells
.iter()
.find(|c| c.words == vec![0])
.expect("the header's cell");
assert_eq!(header.columns, vec![1]);
let mut slots = vec![(0, 1)];
for r in 1..3 {
for c in 0..3 {
slots.push((r, c));
}
}
let (mut words, placed) = placed_of(&slots, &[]);
words[0].bbox[0] = 25.0;
words[0].bbox[2] = 45.0;
let grid = cells_placed(&words, &placed).expect("grid");
let header = grid
.cells
.iter()
.find(|c| c.words == vec![0])
.expect("the header's cell");
let first = if HEADER_SPAN_TAKES_STUB { 0 } else { 1 };
assert_eq!(header.columns, (first..3).collect::<Vec<_>>());
}
#[test]
fn a_label_centred_at_the_band_edge_spans_and_past_it_does_not() {
let columns_of = |x0: f64, x1: f64| {
let mut slots = vec![(0, 2)];
for r in 1..3 {
for c in 0..5 {
slots.push((r, c));
}
}
let (mut words, placed) = placed_of(&slots, &[]);
words[0].bbox[0] = x0;
words[0].bbox[2] = x1;
cells_placed(&words, &placed)
.expect("grid")
.cells
.iter()
.find(|c| c.words == vec![0])
.expect("the header's cell")
.columns
.clone()
};
let first = if HEADER_SPAN_TAKES_STUB { 0 } else { 1 };
let spanned: Vec<usize> = (first..5).collect();
assert_eq!(columns_of(32.5, 42.5), spanned, "on the low edge: spans");
assert_eq!(columns_of(32.4, 42.4), vec![2], "past the low edge: stays");
assert_eq!(columns_of(67.5, 77.5), spanned, "on the high edge: spans");
assert_eq!(columns_of(67.6, 77.6), vec![2], "past the high edge: stays");
}
#[test]
fn two_header_labels_keep_their_own_spans() {
let mut slots = vec![(0, 1), (0, 5)];
for r in 1..3 {
for c in 0..9 {
slots.push((r, c));
}
}
let (words, placed) = placed_of(&slots, &[(0, 3), (1, 7)]);
let grid = cells_placed(&words, &placed).expect("grid");
assert_eq!(grid.header_rows, 1);
let cell = |w: usize| {
grid.cells
.iter()
.find(|c| c.words.contains(&w))
.expect("a cell")
};
assert_eq!(cell(0).columns, vec![1, 2, 3]);
assert_eq!(cell(1).columns, vec![5, 6, 7]);
}
#[test]
fn a_stub_header_label_stays_put() {
let mut slots = vec![(0, 0)];
for r in 1..3 {
for c in 0..9 {
slots.push((r, c));
}
}
let (words, placed) = placed_of(&slots, &[]);
let grid = cells_placed(&words, &placed).expect("grid");
assert_eq!(grid.header_rows, 1);
let corner = grid
.cells
.iter()
.find(|c| c.words == vec![0])
.expect("the stub head's cell");
assert_eq!(corner.columns, vec![0]);
}
#[test]
fn a_three_slot_label_placed_by_place_spans_the_row_it_heads() {
let header = [(60.0, 70.0), (72.0, 82.0), (84.0, 97.0), (99.0, 109.0)];
let mut words = Vec::new();
for (x0, x1) in header {
words.push(word(x0, 40.0, x1, 45.0));
}
for r in 0..4 {
let y = 30.0 - 10.0 * r as f64;
for c in 0..9 {
let x = 20.0 * c as f64;
words.push(Word {
bbox: [x, y, x + 10.0, y + 5.0],
numeric: c != 0,
});
}
}
let placed = place(&words, &[], 0.1, &bridging(&words, BRIDGE_FLOOR)).expect("placed");
{
use std::collections::BTreeSet;
let here: BTreeSet<usize> = (0..words.len())
.filter(|&i| placed.row_of[i] == 0)
.map(|i| placed.column_of[i])
.collect();
assert_eq!(
here,
BTreeSet::from([3, 4, 5]),
"place() scatters the label over its stripes"
);
}
let grid = cells_placed(&words, &placed).expect("grid");
assert_eq!(grid.header_rows, 1);
let first = if HEADER_SPAN_TAKES_STUB { 0 } else { 1 };
let header_cells: Vec<_> = grid.cells.iter().filter(|c| c.rows == vec![0]).collect();
assert_eq!(
header_cells.len(),
1,
"one run, one cell: {:?}",
header_cells
.iter()
.map(|c| (&c.words, &c.columns))
.collect::<Vec<_>>()
);
assert_eq!(header_cells[0].columns, (first..9).collect::<Vec<_>>());
assert_eq!(header_cells[0].words, vec![0, 1, 2, 3]);
}
fn joined_by_a_last_row_label() -> Vec<Word> {
let mut words = Vec::new();
for row in 0..6 {
let y = 50.0 - 9.0 * row as f64;
words.push(word(0.0, y, 12.0, y + 6.0));
words.push(word(38.0, y, 50.0, y + 6.0));
}
words.push(word(5.0, 5.0, 45.0, 11.0));
words
}
#[test]
fn a_white_corridor_splits_the_stripe_a_long_label_joined() {
let words = joined_by_a_last_row_label();
let label = words.len() - 1;
assert!(
!bridging(&words, BRIDGE_FLOOR)[label],
"the bridge model flags the label, so the corridor is not what splits"
);
let joined = place(&words, &[], 0.1, &vec![false; words.len()]).expect("placed");
assert!(
joined.column_of.iter().all(|&c| c == 0),
"without the rule the label joins the two columns"
);
let split =
place_with_corridors(&words, &[], 0.1, &vec![false; words.len()]).expect("placed");
assert_ne!(split.column_of[0], split.column_of[1]);
assert_eq!(split.column_of[label], split.column_of[0]);
assert_eq!(split.last_column_of[label], split.column_of[1]);
}
#[test]
fn a_rejected_corridor_gap_is_kept_only_when_the_variant_decides() {
let mut words = Vec::new();
for row in 0..6 {
let y = 50.0 - 9.0 * row as f64;
words.push(word(0.0, y, 12.0, y + 6.0));
words.push(word(15.0, y, 27.0, y + 6.0));
}
words.push(word(5.0, 5.0, 21.0, 11.0));
let bridge = vec![false; words.len()];
let narrow = CorridorConfig {
em: 0.4,
..CorridorConfig::SHIPPED
};
let scored = place_inner(
&words,
&[],
0.1,
&bridge,
Some(CorridorConfig {
decides: false,
..narrow
}),
)
.expect("placed");
assert!(
scored.column_of.iter().all(|&c| c == 0),
"the model accepts a 0.5 em gap, so this no longer tells V1 from V2"
);
let kept = place_inner(
&words,
&[],
0.1,
&bridge,
Some(CorridorConfig {
decides: true,
..narrow
}),
)
.expect("placed");
assert_ne!(kept.column_of[0], kept.column_of[1]);
}
#[test]
fn each_half_is_checked_again() {
let mut words = Vec::new();
for row in 0..4 {
let y = 50.0 - 9.0 * row as f64;
words.push(word(0.0, y, 12.0, y + 6.0));
words.push(word(38.0, y, 50.0, y + 6.0));
words.push(word(76.0, y, 88.0, y + 6.0));
}
words.push(word(5.0, 14.0, 45.0, 20.0));
words.push(word(76.0, 14.0, 88.0, 20.0));
words.push(word(0.0, 5.0, 12.0, 11.0));
words.push(word(43.0, 5.0, 83.0, 11.0));
let split =
place_with_corridors(&words, &[], 0.1, &vec![false; words.len()]).expect("placed");
assert_eq!(split.column_of[0], 0);
assert_eq!(split.column_of[1], 1);
assert_eq!(split.column_of[2], 2);
assert_eq!(split.last_column_of[12], 1, "row 4 crosses one gutter");
assert_eq!(split.column_of[14], 0);
assert_eq!(split.last_column_of[15], 2, "row 5 crosses the other");
}
#[test]
fn two_lines_with_aligned_gaps_do_not_split() {
let words = vec![
word(0.0, 10.0, 12.0, 16.0),
word(38.0, 10.0, 50.0, 16.0),
word(5.0, 1.0, 45.0, 7.0),
word(40.0, 1.0, 52.0, 7.0),
];
let split =
place_with_corridors(&words, &[], 0.1, &vec![false; words.len()]).expect("placed");
assert!(
split.column_of.iter().all(|&c| c == 0),
"two lines split: {split:?}"
);
}
#[test]
fn the_line_count_vetoes_a_short_stripe_on_its_own() {
let words = vec![
word(0.0, 10.0, 12.0, 16.0),
word(38.0, 10.0, 50.0, 16.0),
word(5.0, 1.0, 45.0, 7.0),
word(-10.0, 1.0, -2.0, 7.0),
];
let bridge = vec![false; words.len()];
let held = place_inner(
&words,
&[],
0.1,
&bridge,
Some(CorridorConfig {
share: 0.5,
decides: true,
..CorridorConfig::SHIPPED
}),
)
.expect("placed");
assert!(
held.column_of.iter().all(|&c| c == 0),
"the count did not veto two lines: {held:?}"
);
let split = place_inner(
&words,
&[],
0.1,
&bridge,
Some(CorridorConfig {
share: 0.5,
min_lines: 1,
decides: true,
..CorridorConfig::SHIPPED
}),
)
.expect("placed");
assert!(
split.column_of.contains(&1),
"one line did not release the corridor: {split:?}"
);
}
#[test]
fn a_ragged_column_holds_no_corridor() {
let mut words = Vec::new();
for row in 0..6 {
let y = 50.0 - 9.0 * row as f64;
let x = 4.0 * row as f64 + 2.0;
words.push(word(x, y, x + 10.0, y + 6.0));
words.push(word(x + 22.0, y, x + 32.0, y + 6.0));
}
let split =
place_with_corridors(&words, &[], 0.1, &vec![false; words.len()]).expect("placed");
assert!(
split.column_of.iter().all(|&c| c == 0),
"a ragged column split: {split:?}"
);
}
#[test]
fn a_narrow_aligned_strip_is_no_corridor() {
let mut words = Vec::new();
for row in 0..6 {
let y = 50.0 - 9.0 * row as f64;
words.push(word(0.0, y, 12.0, y + 6.0));
words.push(word(16.5, y, 28.5, y + 6.0));
}
words.push(word(5.0, 5.0, 25.0, 11.0));
let split =
place_with_corridors(&words, &[], 0.1, &vec![false; words.len()]).expect("placed");
assert!(
split.column_of.iter().all(|&c| c == 0),
"a 0.75 em strip split: {split:?}"
);
}
#[test]
fn leaving_a_bridge_out_keeps_the_columns_under_it_apart() {
let mut words = vec![word(5.0, 60.0, 45.0, 66.0)];
for row in 0..6 {
let y = 50.0 - 9.0 * row as f64;
words.push(word(0.0, y, 12.0, y + 6.0));
words.push(word(38.0, y, 50.0, y + 6.0));
}
let joined = place(&words, &[], 0.1, &vec![false; words.len()]).expect("placed");
assert!(
joined.column_of[1..].iter().all(|&c| c == 0),
"built from every word, the header joins the two columns"
);
let mut bridge = vec![false; words.len()];
bridge[0] = true;
let apart = place(&words, &[], 0.1, &bridge).expect("placed");
assert_ne!(apart.column_of[1], apart.column_of[2]);
assert_eq!(apart.column_of[0], apart.column_of[1]);
assert_eq!(apart.last_column_of[0], apart.column_of[2]);
}
fn unguarded() -> CorridorConfig {
CorridorConfig {
guard_occupancy: None,
guard_align_em: None,
guard_header: false,
guard_narrow: false,
..CorridorConfig::SHIPPED
}
}
fn wrapped_prose(east_rows: usize) -> Vec<Word> {
let mut words = Vec::new();
for row in 0..6 {
let y = 60.0 - 12.0 * row as f64;
words.push(word(0.0, y, 12.0, y + 6.0));
if row < east_rows {
words.push(word(38.0, y, 50.0, y + 6.0));
}
}
words.push(word(5.0, 5.0, 45.0, 11.0));
words
}
#[test]
fn the_occupancy_guard_vetoes_a_half_empty_side() {
let ragged = wrapped_prose(2);
let bridge = vec![false; ragged.len()];
let split =
place_with_corridors_in(&ragged, &[], 0.1, &bridge, unguarded()).expect("placed");
assert!(
split.column_of.contains(&1),
"the unguarded corridor did not split the wrapped stripe: {split:?}"
);
let config = CorridorConfig {
guard_occupancy: Some(0.8),
..unguarded()
};
let held = place_with_corridors_in(&ragged, &[], 0.1, &bridge, config).expect("placed");
assert!(
held.column_of.iter().all(|&c| c == 0),
"G1 did not veto the half-empty east side: {held:?}"
);
let full = wrapped_prose(6);
let bridge = vec![false; full.len()];
let split = place_with_corridors_in(&full, &[], 0.1, &bridge, config).expect("placed");
assert!(
split.column_of.contains(&1),
"G1 vetoed a corridor both sides fill: {split:?}"
);
}
fn ragged_edges(drift: f64) -> Vec<Word> {
let mut words = Vec::new();
for row in 0..6 {
let y = 60.0 - 12.0 * row as f64;
words.push(word(0.0, y, 12.0, y + 6.0));
let x = 38.0 + drift * row as f64;
words.push(word(x, y, x + 10.0, y + 6.0));
}
words.push(word(5.0, 5.0, 45.0, 11.0));
words
}
#[test]
fn the_alignment_guard_vetoes_ragged_edges() {
let ragged = ragged_edges(2.0);
let bridge = vec![false; ragged.len()];
let split =
place_with_corridors_in(&ragged, &[], 0.1, &bridge, unguarded()).expect("placed");
assert!(
split.column_of.contains(&1),
"the unguarded corridor did not split the ragged stripe: {split:?}"
);
let config = CorridorConfig {
guard_align_em: Some(1.0),
..unguarded()
};
let held = place_with_corridors_in(&ragged, &[], 0.1, &bridge, config).expect("placed");
assert!(
held.column_of.iter().all(|&c| c == 0),
"G2 did not veto the ragged edges: {held:?}"
);
let aligned = ragged_edges(0.0);
let bridge = vec![false; aligned.len()];
let split = place_with_corridors_in(&aligned, &[], 0.1, &bridge, config).expect("placed");
assert!(
split.column_of.contains(&1),
"G2 vetoed an aligned edge: {split:?}"
);
}
fn header_breaking_split() -> Vec<Word> {
let mut words = Vec::new();
for row in 0..8 {
let y = 100.0 - 12.0 * row as f64;
words.push(word(0.0, y, 8.0, y + 6.0));
if !(3..=4).contains(&row) {
words.push(word(24.0, y, 32.0, y + 6.0));
}
if row >= 3 {
words.push(word(60.0, y, 68.0, y + 6.0));
}
}
words.push(word(4.0, 2.0, 36.0, 8.0));
words
}
#[test]
fn the_header_guard_drops_a_split_that_costs_header_rows() {
let words = header_breaking_split();
let bridge = vec![false; words.len()];
let plain = place(&words, &[], 0.1, &bridge).expect("placed");
let grid0 = cells_placed(&words, &plain).expect("grid");
assert_eq!(grid0.header_rows, 3, "the no-split decode reads {grid0:?}");
let split =
place_with_corridors_in(&words, &[], 0.1, &bridge, unguarded()).expect("placed");
let grid1 = cells_placed(&words, &split).expect("grid");
assert_eq!(
grid1.header_rows, 1,
"the unguarded split did not cost header rows: {grid1:?}"
);
let config = CorridorConfig {
guard_header: true,
..unguarded()
};
let held = place_with_corridors_in(&words, &[], 0.1, &bridge, config).expect("placed");
let grid = cells_placed(&words, &held).expect("grid");
assert_eq!(
grid.header_rows, 3,
"G3 did not keep the header rows: {grid:?}"
);
assert!(
held.column_of.iter().all(|&c| c <= 1),
"G3 kept the splitting column: {held:?}"
);
}
#[test]
fn the_narrow_veto_holds_a_ragged_two_column_table() {
let ragged = ragged_edges(2.0);
let bridge = vec![false; ragged.len()];
let plain = place(&ragged, &[], 0.1, &bridge).expect("placed");
assert!(
plain.column_of.iter().all(|&c| c == 0),
"the no-split decode is not narrow: {plain:?}"
);
let config = CorridorConfig {
guard_narrow: true,
..unguarded()
};
let held = place_with_corridors_in(&ragged, &[], 0.1, &bridge, config).expect("placed");
assert!(
held.column_of.iter().all(|&c| c == 0),
"G4 did not veto the ragged narrow table: {held:?}"
);
let aligned = ragged_edges(0.0);
let bridge = vec![false; aligned.len()];
let split = place_with_corridors_in(&aligned, &[], 0.1, &bridge, config).expect("placed");
assert!(
split.column_of.contains(&1),
"G4 vetoed an aligned narrow table: {split:?}"
);
}
}