use super::features::{page_features, PageWord, SpanWord};
use super::{judge, lattice, lines, models, pairs, region, separations};
use crate::blocks::{TatrOptions, TatrTestCell, TatrTestHook};
use crate::table_trace::{ForcedReason, LearnedTrace, TableOrigin};
use crate::text::{Ruling as TextRuling, TextSpan};
use crate::{Block, BlockKind, PdfDocument};
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableSplit {
Intact,
TitleRowsRemoved,
KeyValueSplit,
TitleAndKeyValue,
}
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IsTableModel {
V1,
V2,
}
impl IsTableModel {
pub fn threshold(&self) -> f64 {
match self {
IsTableModel::V1 => models::table_is::THRESHOLD,
IsTableModel::V2 => TABLE_IS_V2_THRESHOLD,
}
}
}
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct TableSignal {
pub p_judge: f64,
pub verifier_score: f64,
pub verifier_margin: Option<f64>,
pub p_is_table: Option<f64>,
pub p_is_table_model: Option<IsTableModel>,
pub split: TableSplit,
pub bbox: [f64; 4],
pub block_range: std::ops::Range<usize>,
}
#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct PageTableSignals {
pub tables: Vec<TableSignal>,
pub p_best_rejected: Option<f64>,
pub n_candidates: usize,
pub refused: Vec<f64>,
pub drawn: bool,
}
impl PageTableSignals {
#[doc(hidden)]
pub fn to_json(&self) -> String {
fn number(value: f64) -> String {
if value.is_finite() {
format!("{value}")
} else {
"null".to_string()
}
}
fn split_name(split: TableSplit) -> &'static str {
match split {
TableSplit::Intact => "intact",
TableSplit::TitleRowsRemoved => "title_rows_removed",
TableSplit::KeyValueSplit => "key_value_split",
TableSplit::TitleAndKeyValue => "title_and_key_value",
}
}
let tables: Vec<String> = self
.tables
.iter()
.map(|t| {
let mut fields = vec![
format!("\"p_judge\": {}", number(t.p_judge)),
format!("\"verifier_score\": {}", number(t.verifier_score)),
];
if let Some(margin) = t.verifier_margin {
fields.push(format!("\"verifier_margin\": {}", number(margin)));
}
if let Some(p) = t.p_is_table {
fields.push(format!("\"p_is_table\": {}", number(p)));
}
if let Some(model) = t.p_is_table_model {
let name = match model {
IsTableModel::V1 => "v1",
IsTableModel::V2 => "v2",
};
fields.push(format!("\"p_is_table_model\": \"{name}\""));
}
fields.push(format!("\"split\": \"{}\"", split_name(t.split)));
fields.push(format!(
"\"bbox\": [{}, {}, {}, {}]",
number(t.bbox[0]),
number(t.bbox[1]),
number(t.bbox[2]),
number(t.bbox[3])
));
fields.push(format!(
"\"block_range\": [{}, {}]",
t.block_range.start, t.block_range.end
));
format!("{{{}}}", fields.join(", "))
})
.collect();
let mut fields = vec![format!("\"tables\": [{}]", tables.join(", "))];
if let Some(p) = self.p_best_rejected {
fields.push(format!("\"p_best_rejected\": {}", number(p)));
}
fields.push(format!("\"n_candidates\": {}", self.n_candidates));
let refused: Vec<String> = self.refused.iter().map(|p| number(*p)).collect();
fields.push(format!("\"refused\": [{}]", refused.join(", ")));
fields.push(format!("\"drawn\": {}", self.drawn));
format!("{{{}}}", fields.join(", "))
}
}
#[doc(hidden)]
pub fn model_stamp_json() -> String {
format!(
"\"models\": {{\"table_judge\": \"{}\", \"table_verifier\": \"{}\", \"table_is\": \"{}\", \"table_is_v2\": \"{}\"}}",
super::models::table_judge::MODEL_SHA256,
super::models::table_verifier::MODEL_SHA256,
super::models::table_is::MODEL_SHA256,
super::models::table_is_v2::MODEL_SHA256,
)
}
#[doc(hidden)]
pub fn table_signals_jsonl(signals: &[Option<PageTableSignals>]) -> String {
let stamp = model_stamp_json();
let mut out = String::new();
for (page, signals) in signals.iter().enumerate() {
match signals {
Some(signals) => out.push_str(&format!(
"{{\"page\": {page}, {stamp}, \"signals\": {}}}\n",
signals.to_json()
)),
None => out.push_str(&format!("{{\"page\": {page}, {stamp}}}\n")),
}
}
out
}
const REGION_THRESHOLD: f64 = models::table_region::THRESHOLD;
const GAP_LINES: f64 = 3.0;
fn runs(page: &[&Block], inside: &[bool], sizes: &[f64]) -> Vec<Vec<usize>> {
let mut out: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = Vec::new();
for (i, block) in page.iter().enumerate() {
if !inside[i] {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
continue;
}
if let Some(&last) = current.last() {
let line = sizes[last].max(1.0);
let jumped_up = block.bbox[3] - page[last].bbox[3] > line;
if jumped_up || page[last].bbox[1] - block.bbox[3] > GAP_LINES * line {
out.push(std::mem::take(&mut current));
}
}
current.push(i);
}
if !current.is_empty() {
out.push(current);
}
out
}
fn words_of(page: &[&Block], run: &[usize]) -> (Vec<pairs::PairWord>, Vec<usize>) {
let mut words = Vec::new();
let mut from = Vec::new();
for &i in run {
let block = page[i];
let (table, row, column) = match block.kind {
BlockKind::TableCell { row, column, .. } => (Some(0u32), Some(row), Some(column)),
_ => (None, None, None),
};
for span in &block.spans {
for w in super::features::words_of(span) {
let SpanWord { text, bbox, .. } = w;
words.push(pairs::PairWord {
text,
bbox,
table,
row,
column,
});
from.push(i);
}
}
}
(words, from)
}
fn grid_of(
words: &[pairs::PairWord],
rules: &[separations::Ruling],
corridors: bool,
) -> Option<separations::Grid> {
if words.len() < 4 {
return None;
}
let mine: Vec<separations::Word> = words
.iter()
.map(|w| separations::Word {
bbox: w.bbox,
numeric: w.text.chars().any(|c| c.is_ascii_digit()),
})
.collect();
corridor_cells(&mine, rules, corridors)
}
#[cfg(not(feature = "corridor-grid"))]
fn corridor_cells(
words: &[separations::Word],
rules: &[separations::Ruling],
corridors: bool,
) -> Option<separations::Grid> {
if corridors {
separations::cells_of_with_corridors(words, rules, SEPARATION_FLOOR)
} else {
separations::cells_of(words, rules, SEPARATION_FLOOR)
}
}
#[cfg(feature = "corridor-grid")]
fn corridor_cells(
words: &[separations::Word],
rules: &[separations::Ruling],
corridors: bool,
) -> Option<separations::Grid> {
match separations::resolve_corridor_config(corridors) {
Some(config) => {
separations::cells_of_with_corridors_in(words, rules, SEPARATION_FLOOR, config)
}
None => separations::cells_of(words, rules, SEPARATION_FLOOR),
}
}
fn header_cells_join(a: &[usize], b: &[usize], words: &[pairs::PairWord], limit: f64) -> bool {
struct HeadWord {
x0: f64,
x1: f64,
y0: f64,
y1: f64,
right: bool,
}
let mut lines: Vec<Vec<HeadWord>> = Vec::new();
let mut all: Vec<HeadWord> = Vec::new();
for &i in a {
let Some(word) = words.get(i) else {
continue;
};
all.push(HeadWord {
x0: word.bbox[0],
x1: word.bbox[2],
y0: word.bbox[1],
y1: word.bbox[3],
right: false,
});
}
for &i in b {
let Some(word) = words.get(i) else {
continue;
};
all.push(HeadWord {
x0: word.bbox[0],
x1: word.bbox[2],
y0: word.bbox[1],
y1: word.bbox[3],
right: true,
});
}
for word in &all {
let mut placed = false;
for line in lines.iter_mut() {
let low = line
.iter()
.map(|w: &HeadWord| w.y0)
.fold(f64::INFINITY, f64::min);
let high = line
.iter()
.map(|w: &HeadWord| w.y1)
.fold(f64::NEG_INFINITY, f64::max);
let mut shorter = (word.y1 - word.y0).min(high - low);
if shorter == 0.0 {
shorter = 1.0;
}
if (word.y1.min(high) - word.y0.max(low)) / shorter > 0.5 {
line.push(HeadWord {
x0: word.x0,
x1: word.x1,
y0: word.y0,
y1: word.y1,
right: word.right,
});
placed = true;
break;
}
}
if !placed {
lines.push(vec![HeadWord {
x0: word.x0,
x1: word.x1,
y0: word.y0,
y1: word.y1,
right: word.right,
}]);
}
}
let mut shared = 0usize;
for line in &lines {
let left = line
.iter()
.filter(|w| !w.right)
.map(|w| w.x1)
.fold(f64::NEG_INFINITY, f64::max);
let has_left = line.iter().any(|w| !w.right);
let has_right = line.iter().any(|w| w.right);
if !(has_left && has_right) {
continue;
}
let gap = line
.iter()
.filter(|w| w.right)
.map(|w| w.x0)
.fold(f64::INFINITY, f64::min)
- left;
if gap >= limit {
return false;
}
shared += 1;
}
if shared > 0 {
return true;
}
let right = all
.iter()
.filter(|w| !w.right)
.map(|w| w.x1)
.fold(f64::NEG_INFINITY, f64::max);
let left = all
.iter()
.filter(|w| w.right)
.map(|w| w.x0)
.fold(f64::INFINITY, f64::min);
left - right < limit
}
fn spaced_header_joined(
mut grid: separations::Grid,
words: &[pairs::PairWord],
) -> separations::Grid {
let heights: Vec<f64> = words.iter().map(|w| w.bbox[3] - w.bbox[1]).collect();
let height = {
let mut h = heights;
h.sort_by(f64::total_cmp);
if h.is_empty() {
return grid;
}
h[h.len() / 2]
};
for row in 0..grid.header_rows {
loop {
let mut mine: Vec<usize> = (0..grid.cells.len())
.filter(|&k| grid.cells[k].rows == [row] && !grid.cells[k].words.is_empty())
.collect();
mine.sort_by_key(|&k| grid.cells[k].columns[0]);
let Some(pair) = mine.windows(2).find(|p| {
let (a, b) = (p[0], p[1]);
*grid.cells[b].columns.first().unwrap_or(&0)
== grid.cells[a].columns.last().copied().unwrap_or(0) + 1
&& header_cells_join(
&grid.cells[a].words,
&grid.cells[b].words,
words,
WORD_SPACE * height,
)
}) else {
break;
};
let (a, b) = (pair[0], pair[1]);
let taken = grid.cells.remove(b);
let a = if b < a { a - 1 } else { a };
let cell = &mut grid.cells[a];
cell.words.extend(taken.words);
cell.columns =
(cell.columns[0]..=*taken.columns.last().unwrap_or(&cell.columns[0])).collect();
for k in 0..4 {
cell.bbox[k] = if k < 2 {
cell.bbox[k].min(taken.bbox[k])
} else {
cell.bbox[k].max(taken.bbox[k])
};
}
}
}
grid
}
pub(crate) const WORD_SPACE: f64 = 0.6;
const FIGURE_COLUMN_SPLIT_GAP: f64 = 2.0;
const FIGURE_COLUMN_SPLIT_ROWS: usize = 3;
fn is_bare_currency_prefix(text: &str) -> bool {
!text.is_empty() && text.chars().all(|c| "$€£¥¢₹".contains(c))
}
fn split_twin_figure_columns(
mut grid: separations::Grid,
words: &[pairs::PairWord],
) -> separations::Grid {
let width = grid
.cells
.iter()
.flat_map(|cell| cell.columns.iter().copied())
.max()
.map_or(0, |last| last + 1);
let mut splits: Vec<(usize, f64)> = Vec::new();
for column in 0..width {
if let Some(x) = twin_column_split(&grid, words, column) {
splits.push((column, x));
}
}
splits.sort_by_key(|&(column, _)| std::cmp::Reverse(column));
for (column, x) in splits {
apply_column_split(&mut grid, words, column, x);
}
grid
}
struct SplitUnit {
x0: f64,
x1: f64,
row: usize,
header: bool,
digit: bool,
alpha: bool,
}
fn twin_column_split(
grid: &separations::Grid,
words: &[pairs::PairWord],
column: usize,
) -> Option<f64> {
let mut units: Vec<SplitUnit> = Vec::new();
for cell in &grid.cells {
if cell.columns != [column] {
continue;
}
if cell.rows.len() != 1 {
return None;
}
let row = cell.rows[0];
let header = row < grid.header_rows;
let mut mine: Vec<usize> = cell.words.clone();
mine.sort_by(|&a, &b| words[a].bbox[0].total_cmp(&words[b].bbox[0]));
let mut i = 0;
while i < mine.len() {
let w = mine[i];
let next = mine.get(i + 1).copied();
let grouped = next.filter(|&n| {
is_bare_currency_prefix(&words[w].text)
&& !is_bare_currency_prefix(&words[n].text)
&& super::cell_merge::is_complete_figure_line(&words[n].text)
});
let (x0, x1, digit, alpha) = match grouped {
Some(n) => (
words[w].bbox[0].min(words[n].bbox[0]),
words[w].bbox[2].max(words[n].bbox[2]),
words[w].text.chars().any(|c| c.is_ascii_digit())
|| words[n].text.chars().any(|c| c.is_ascii_digit()),
words[w].text.chars().any(|c| c.is_ascii_alphabetic())
|| words[n].text.chars().any(|c| c.is_ascii_alphabetic()),
),
None => (
words[w].bbox[0],
words[w].bbox[2],
words[w].text.chars().any(|c| c.is_ascii_digit()),
words[w].text.chars().any(|c| c.is_ascii_alphabetic()),
),
};
units.push(SplitUnit {
x0,
x1,
row,
header,
digit,
alpha,
});
i += if grouped.is_some() { 2 } else { 1 };
}
}
if units.iter().any(|unit| !unit.header && unit.alpha) {
return None;
}
units.sort_by(|a, b| a.x0.total_cmp(&b.x0));
let mut windows: Vec<(f64, f64)> = Vec::new();
let mut reach = f64::NEG_INFINITY;
for unit in &units {
if unit.x0 > reach {
if reach > f64::NEG_INFINITY {
windows.push((reach, unit.x0));
}
reach = unit.x1;
} else {
reach = reach.max(unit.x1);
}
}
windows.sort_by(|a, b| (b.1 - b.0).total_cmp(&(a.1 - a.0)));
for (low, high) in windows {
if high - low < FIGURE_COLUMN_SPLIT_GAP {
continue;
}
let mut left_rows = std::collections::BTreeSet::new();
let mut right_rows = std::collections::BTreeSet::new();
let (mut left_digit, mut right_digit) = (false, false);
for unit in &units {
if unit.header {
continue;
}
if unit.x1 <= low {
left_rows.insert(unit.row);
left_digit |= unit.digit;
} else if unit.x0 >= high {
right_rows.insert(unit.row);
right_digit |= unit.digit;
}
}
if left_rows.len() >= FIGURE_COLUMN_SPLIT_ROWS
&& right_rows.len() >= FIGURE_COLUMN_SPLIT_ROWS
&& left_digit
&& right_digit
{
return Some((low + high) / 2.0);
}
}
None
}
fn apply_column_split(
grid: &mut separations::Grid,
words: &[pairs::PairWord],
column: usize,
x: f64,
) {
let mut made: Vec<super::grid::Cell> = Vec::with_capacity(grid.cells.len() + 1);
for cell in std::mem::take(&mut grid.cells) {
if cell.columns == [column] && cell.rows.len() == 1 {
let mut left: Vec<usize> = Vec::new();
let mut right: Vec<usize> = Vec::new();
for &w in &cell.words {
if (words[w].bbox[0] + words[w].bbox[2]) / 2.0 < x {
left.push(w);
} else {
right.push(w);
}
}
if left.is_empty() || right.is_empty() {
let mut kept = cell;
kept.columns = vec![if left.is_empty() { column + 1 } else { column }];
made.push(kept);
continue;
}
let side = |mine: Vec<usize>, at: usize| {
let ordered = in_reading_order(mine, words);
let bbox = [
ordered
.iter()
.map(|&w| words[w].bbox[0])
.fold(f64::INFINITY, f64::min),
cell.bbox[1],
ordered
.iter()
.map(|&w| words[w].bbox[2])
.fold(f64::NEG_INFINITY, f64::max),
cell.bbox[3],
];
super::grid::Cell {
words: ordered,
bbox,
rows: cell.rows.clone(),
columns: vec![at],
}
};
made.push(side(left, column));
made.push(side(right, column + 1));
} else if cell.columns.contains(&column) {
let mut spanned = cell;
let mut columns = Vec::with_capacity(spanned.columns.len() + 1);
for c in std::mem::take(&mut spanned.columns) {
columns.push(if c > column { c + 1 } else { c });
if c == column {
columns.push(column + 1);
}
}
spanned.columns = columns;
made.push(spanned);
} else {
let mut kept = cell;
kept.columns = kept
.columns
.iter()
.map(|&c| if c > column { c + 1 } else { c })
.collect();
made.push(kept);
}
}
grid.cells = made;
}
const KEY_VALUE_ROWS: usize = 3;
const KEY_VALUE_SHARE: f64 = 0.5;
#[derive(Debug)]
enum SplitPiece {
Table {
cells: Vec<super::grid::Cell>,
header_rows: usize,
},
Section { row: usize },
}
fn key_value_sections_split(
grid: &separations::Grid,
words: &[pairs::PairWord],
) -> Option<Vec<SplitPiece>> {
let width = |cell: &super::grid::Cell| cell.columns.iter().max().copied().unwrap_or(0) + 1;
if !(2..=4).contains(&grid.cells.iter().map(width).max().unwrap_or(0)) {
return None;
}
let depth = |cell: &super::grid::Cell| cell.rows.iter().max().copied().unwrap_or(0) + 1;
let n_rows = grid.cells.iter().map(depth).max().unwrap_or(0);
if n_rows == 0 {
return None;
}
let text_of = |cell: &super::grid::Cell| join_decoded_words(words, &cell.words);
let holding = |row: usize| {
grid.cells
.iter()
.filter(|cell| cell.rows.contains(&row))
.collect::<Vec<_>>()
};
let is_key_value = |row: usize| {
holding(row).iter().any(|cell| {
let text = text_of(cell);
text.contains(" = ") || text.starts_with("= ") || text.ends_with(" =") || text == "="
})
};
let is_section = |row: usize| {
let mine = holding(row);
if mine.len() != 1 {
return false;
}
let text = text_of(mine[0]);
text.split_whitespace().count() <= 4
&& !text.contains('=')
&& !text.chars().any(|c| c.is_ascii_digit())
};
let candidates: Vec<usize> = (0..n_rows).filter(|&r| is_section(r)).collect();
if candidates.is_empty() {
return None;
}
let mut splits: Vec<usize> = Vec::new();
for (k, &at) in candidates.iter().enumerate() {
let start = splits.last().map(|last| last + 1).unwrap_or(0);
let end = candidates.get(k + 1).copied().unwrap_or(n_rows);
let block = |rows: &[usize]| {
rows.len() >= KEY_VALUE_ROWS
&& rows.iter().filter(|&&r| is_key_value(r)).count() as f64 / rows.len() as f64
>= KEY_VALUE_SHARE
};
let above: Vec<usize> = (start..at).collect();
let below: Vec<usize> = (at + 1..end).collect();
if block(&above) && block(&below) {
splits.push(at);
}
}
if splits.is_empty() {
return None;
}
let torn = grid.cells.iter().any(|cell| {
if cell.rows.iter().any(|row| splits.contains(row)) {
cell.rows.len() != 1
} else {
splits
.iter()
.any(|&at| cell.rows.iter().any(|&r| r < at) && cell.rows.iter().any(|&r| r > at))
}
});
if torn {
return None;
}
let mut pieces = Vec::with_capacity(2 * splits.len() + 1);
let mut start = 0;
for &at in &splits {
pieces.push(table_piece(&grid.cells, start, at, grid.header_rows));
pieces.push(SplitPiece::Section { row: at });
start = at + 1;
}
pieces.push(table_piece(&grid.cells, start, n_rows, grid.header_rows));
Some(pieces)
}
fn table_piece(
cells: &[super::grid::Cell],
start: usize,
end: usize,
header_rows: usize,
) -> SplitPiece {
let mine: Vec<super::grid::Cell> = cells
.iter()
.filter(|cell| cell.rows.iter().all(|&r| r >= start && r < end))
.map(|cell| {
let mut renumbered = cell.clone();
renumbered.rows = cell.rows.iter().map(|&r| r - start).collect();
renumbered
})
.collect();
SplitPiece::Table {
cells: mine,
header_rows: header_rows.saturating_sub(start).min(end - start),
}
}
fn table_has_header(
cells: &[super::grid::Cell],
header_rows: usize,
from: &[usize],
refs: &[&Block],
) -> bool {
let spanned_top = header_rows >= 2
&& cells
.iter()
.any(|cell| cell.rows.first() == Some(&0) && cell.columns.len() >= 2);
spanned_top
|| cells.iter().any(|cell| {
cell.words.iter().any(|&w| {
matches!(
refs[from[w]].kind,
BlockKind::TableCell { header: true, .. }
)
})
})
}
fn cell_spans(
cell: &super::grid::Cell,
words: &[pairs::PairWord],
from: &[usize],
refs: &[&Block],
) -> Vec<TextSpan> {
let mut spans: Vec<TextSpan> = Vec::new();
let mut seen: Vec<usize> = Vec::new();
for &w in &cell.words {
let block = from[w];
if seen.contains(&block) {
continue;
}
seen.push(block);
for span in &refs[block].spans {
let x = span.x + span.width / 2.0;
let y = span.y + span.height / 2.0;
if x >= cell.bbox[0] && x <= cell.bbox[2] && y >= cell.bbox[1] && y <= cell.bbox[3] {
spans.push(span.clone());
}
}
}
if spans.is_empty() {
for &w in &cell.words {
let word = &words[w];
let cx = (word.bbox[0] + word.bbox[2]) / 2.0;
let cy = (word.bbox[1] + word.bbox[3]) / 2.0;
let cover = seen
.iter()
.flat_map(|&block| refs[block].spans.iter())
.find(|span| {
cx >= span.x
&& cx <= span.x + span.width
&& cy >= span.y
&& cy <= span.y + span.height
});
let Some(cover) = cover else {
continue;
};
let mut clipped = cover.clone();
clipped.text.clone_from(&word.text);
clipped.x = word.bbox[0];
clipped.y = word.bbox[1];
clipped.width = word.bbox[2] - word.bbox[0];
clipped.height = word.bbox[3] - word.bbox[1];
clipped.char_bounds.clear();
clipped.tight_char_bounds.clear();
clipped.glyph_advances.clear();
clipped.glyph_bounds_sources.clear();
let dupe = spans.iter().any(|span| {
span.text == clipped.text
&& span.x == clipped.x
&& span.y == clipped.y
&& span.width == clipped.width
&& span.height == clipped.height
});
if !dupe {
spans.push(clipped);
}
}
}
spans
}
fn cell_word_span<'a>(
word: &pairs::PairWord,
from: usize,
refs: &[&'a Block],
) -> Option<(usize, &'a TextSpan)> {
let cx = (word.bbox[0] + word.bbox[2]) / 2.0;
let cy = (word.bbox[1] + word.bbox[3]) / 2.0;
refs[from].spans.iter().enumerate().find(|(_, span)| {
cx >= span.x && cx <= span.x + span.width && cy >= span.y && cy <= span.y + span.height
})
}
fn span_ws_is_drawn(span: &TextSpan) -> bool {
span.text.chars().count() == span.char_bounds.len()
}
fn cell_word_size(word: &pairs::PairWord, from: usize, refs: &[&Block]) -> f64 {
cell_word_span(word, from, refs)
.map(|(_, span)| span.font_size)
.or_else(|| refs[from].spans.first().map(|span| span.font_size))
.unwrap_or(10.0)
}
fn cell_text(
cell: &super::grid::Cell,
words: &[pairs::PairWord],
from: &[usize],
refs: &[&Block],
cell_path_words: bool,
) -> String {
let mut text = String::new();
for (i, &w) in cell.words.iter().enumerate() {
if i > 0 {
let prev = &words[cell.words[i - 1]];
let curr = &words[w];
let prev_span = cell_word_span(prev, from[cell.words[i - 1]], refs);
let curr_span = cell_word_span(curr, from[w], refs);
let same_span = match (&prev_span, &curr_span) {
(Some((p, _)), Some((c, _))) => from[cell.words[i - 1]] == from[w] && p == c,
_ => false,
};
let join = if cell_path_words {
let drawn_ws = match (&prev_span, &curr_span) {
(Some((_, p)), Some((_, c))) => {
(same_span && span_ws_is_drawn(p))
|| (p.text.chars().last().is_some_and(char::is_whitespace)
&& span_ws_is_drawn(p))
|| (c.text.chars().next().is_some_and(char::is_whitespace)
&& span_ws_is_drawn(c))
}
_ => false,
};
if drawn_ws {
false
} else {
let (gap, drift) = match (&prev_span, &curr_span) {
(Some((_, p)), Some((_, c))) if !same_span => {
(c.x - (p.x + p.width), (c.y - p.y).abs())
}
_ => (
curr.bbox[0] - prev.bbox[2],
(curr.bbox[1] - prev.bbox[1]).abs(),
),
};
let em = cell_word_size(prev, from[cell.words[i - 1]], refs)
.max(cell_word_size(curr, from[w], refs))
.max(f64::MIN_POSITIVE);
gap > -0.1 * em && crate::words_join::pieces_join(gap, drift, em)
}
} else {
let boundary_space = match (&prev_span, &curr_span) {
(Some((_, p)), Some((_, c))) => {
p.text.chars().last().is_some_and(char::is_whitespace)
|| c.text.chars().next().is_some_and(char::is_whitespace)
}
_ => false,
};
if same_span || boundary_space {
false
} else {
let (gap, drift) = match (&prev_span, &curr_span) {
(Some((_, p)), Some((_, c))) => (c.x - (p.x + p.width), (c.y - p.y).abs()),
_ => (
curr.bbox[0] - prev.bbox[2],
(curr.bbox[1] - prev.bbox[1]).abs(),
),
};
let em = cell_word_size(prev, from[cell.words[i - 1]], refs)
.max(cell_word_size(curr, from[w], refs));
crate::words_join::pieces_join(gap, drift, em)
}
};
if !join {
text.push(' ');
}
}
text.push_str(&words[w].text);
}
text
}
fn join_decoded_words(words: &[pairs::PairWord], order: &[usize]) -> String {
let mut text = String::new();
let mut line_top = f64::NAN;
let mut prev: Option<&pairs::PairWord> = None;
for &w in order {
let Some(word) = words.get(w) else {
continue;
};
let height = (word.bbox[3] - word.bbox[1]).max(f64::MIN_POSITIVE);
let parted = match prev {
None => {
line_top = word.bbox[3];
false
}
Some(last) => {
if (line_top - word.bbox[3]).abs() > 0.5 * height {
line_top = word.bbox[3];
true
} else {
let em = height.max((last.bbox[3] - last.bbox[1]).max(f64::MIN_POSITIVE));
let gap = word.bbox[0] - last.bbox[2];
let drift = (word.bbox[1] - last.bbox[1]).abs();
gap <= -0.1 * em || !crate::words_join::pieces_join(gap, drift, em)
}
}
};
if parted {
text.push(' ');
}
text.push_str(&word.text);
prev = Some(word);
}
text
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn cell_block(
cell: &super::grid::Cell,
words: &[pairs::PairWord],
from: &[usize],
refs: &[&Block],
weak: Option<Weakness>,
page_number: usize,
header: bool,
confidence: Option<f64>,
by_line: bool,
) -> Block {
cell_block_with_cell_path_words(
cell,
words,
from,
refs,
weak,
page_number,
header,
confidence,
by_line,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn cell_block_with_cell_path_words(
cell: &super::grid::Cell,
words: &[pairs::PairWord],
from: &[usize],
refs: &[&Block],
weak: Option<Weakness>,
page_number: usize,
header: bool,
confidence: Option<f64>,
by_line: bool,
cell_path_words: bool,
) -> Block {
let reordered;
let cell = if by_line && cell.words.len() > 1 {
reordered = super::grid::Cell {
words: text_reading_order(cell.words.clone(), words),
..cell.clone()
};
&reordered
} else {
cell
};
let text = cell_text(cell, words, from, refs, cell_path_words);
Block {
kind: BlockKind::TableCell {
row: *cell.rows.first().unwrap_or(&0) as u16,
column: *cell.columns.first().unwrap_or(&0) as u16,
row_span: cell.rows.len().max(1) as u16,
column_span: cell.columns.len().max(1) as u16,
header,
},
page: page_number,
bbox: cell.bbox,
text,
spans: cell_spans(cell, words, from, refs),
index: 0,
continues_previous: false,
confidence,
grid: weak,
table_origin: TableOrigin::Other,
trace_learned: None,
}
}
fn section_kind(cell: &super::grid::Cell, from: &[usize], refs: &[&Block]) -> BlockKind {
cell.words
.iter()
.find_map(|&w| match refs[from[w]].kind {
BlockKind::Heading { level } => Some(level),
_ => None,
})
.map_or(BlockKind::Paragraph, |level| BlockKind::Heading { level })
}
fn section_block(
cell: &super::grid::Cell,
words: &[pairs::PairWord],
from: &[usize],
refs: &[&Block],
page_number: usize,
) -> Block {
let text = join_decoded_words(words, &cell.words);
Block {
kind: section_kind(cell, from, refs),
page: page_number,
bbox: cell.bbox,
text,
spans: cell_spans(cell, words, from, refs),
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn refine_traced(
page: Vec<Block>,
size: (f64, f64),
facts: &super::DocumentFacts,
switches: super::Switches,
judged: Option<&[Option<usize>]>,
judge_trace: Option<&JudgeTrace>,
lattices: &[super::lattice::Lattice],
cells: &[CellHint],
drawn: bool,
tatr: Option<&TatrContext>,
cell_path_words: bool,
) -> (Vec<Block>, Vec<usize>, Option<PageTableSignals>) {
refine_judged_with_tatr(
page,
size,
facts,
switches,
&weakness_of,
judged,
judge_trace,
lattices,
cells,
drawn,
tatr,
cell_path_words,
)
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[doc(hidden)]
pub struct CellHint {
pub heading: bool,
pub p_heading: Option<f64>,
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct JudgeTrace {
pub details: Vec<super::verifier::Choice>,
pub p_best_rejected: Option<f64>,
pub n_candidates: usize,
pub builder_p: Vec<Option<f64>>,
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct JudgedRegions {
pub regions: Vec<Option<usize>>,
pub trace: Option<JudgeTrace>,
}
#[doc(hidden)]
pub fn judge_regions(
page: &[Block],
size: (f64, f64),
rules: &[crate::text::HorizontalRule],
facts: &super::DocumentFacts,
switches: super::Switches,
) -> Vec<Option<usize>> {
judge_regions_traced(page, size, rules, facts, switches).regions
}
#[doc(hidden)]
pub fn judge_regions_traced(
page: &[Block],
size: (f64, f64),
rules: &[crate::text::HorizontalRule],
facts: &super::DocumentFacts,
switches: super::Switches,
) -> JudgedRegions {
let refs: Vec<&Block> = page.iter().collect();
let features = page_features(&refs, size.0, size.1);
let signal = super::list_signal(&refs, size, facts);
let lines = judge::lines_of_page(&refs, &features.words, &signal);
judge_regions_with_lines(page, size, rules, switches, &lines, &features.words)
}
pub(crate) fn judge_regions_with_lines(
page: &[Block],
size: (f64, f64),
rules: &[crate::text::HorizontalRule],
switches: super::Switches,
lines: &[judge::Line],
words: &[PageWord],
) -> JudgedRegions {
if lines.is_empty() {
return JudgedRegions {
regions: vec![None; page.len()],
trace: None,
};
}
let spans: Vec<crate::text::TextSpan> =
page.iter().flat_map(|b| b.spans.iter().cloned()).collect();
let mut drawn: Vec<(&'static str, Vec<usize>, bool)> =
crate::text::table_proposals(&spans, rules)
.into_iter()
.map(|p| (p.detector, judge::held_by(p.hull, lines), p.accepted))
.collect();
let boxes: Vec<[f64; 4]> = lines.iter().map(|l| l.bbox).collect();
drawn.extend(
super::alignment::proposals(&boxes)
.into_iter()
.map(|s| ("alignment_lines", s, true)),
);
let sets: Vec<Vec<usize>> = drawn.iter().map(|(_, s, _)| s.clone()).collect();
drawn.extend(
super::splits::pieces(&sets, &boxes)
.into_iter()
.map(|s| ("split", s, true)),
);
let all = judge::proposals(lines, &drawn);
judge_regions_from_proposals(page.len(), lines, words, &all, size, switches)
}
pub(crate) fn judge_regions_from_proposals(
n_blocks: usize,
lines: &[judge::Line],
words: &[PageWord],
all: &[judge::Proposal],
size: (f64, f64),
switches: super::Switches,
) -> JudgedRegions {
if all.is_empty() {
return JudgedRegions {
regions: vec![None; n_blocks],
trace: Some(JudgeTrace {
details: Vec::new(),
p_best_rejected: None,
n_candidates: 0,
builder_p: vec![None; n_blocks],
}),
};
}
judge_regions_scored(n_blocks, lines, words, all, size, switches)
}
fn judge_regions_scored(
n_blocks: usize,
lines: &[judge::Line],
words: &[PageWord],
all: &[judge::Proposal],
size: (f64, f64),
switches: super::Switches,
) -> JudgedRegions {
let mut traced = judge::verdict_on_proposals(size, lines, words, all);
traced.chosen = super::stitch::stitch_sets(
lines,
words,
n_blocks,
&traced.chosen,
switches.table_stitch_header,
switches.table_stitch_rows,
switches.table_stitch_merge,
switches.table_split_stacked,
);
let mut total = vec![0usize; n_blocks];
for line in lines {
total[line.block] += 1;
}
let mut out = vec![None; n_blocks];
for (k, set) in traced.chosen.iter().enumerate() {
let mut held = vec![0usize; n_blocks];
for &i in set {
held[lines[i].block] += 1;
}
for b in 0..n_blocks {
if out[b].is_none() && held[b] > 0 && 2 * held[b] >= total[b] {
out[b] = Some(k);
}
}
}
for set in &traced.rejected {
for &i in set {
if out[lines[i].block].is_none() {
out[lines[i].block] = Some(REJECTED);
}
}
}
let mut builder_p = vec![None; n_blocks];
for (i, line) in lines.iter().enumerate() {
if builder_p[line.block].is_none() {
builder_p[line.block] = traced.builder_p.get(i).copied().flatten();
}
}
JudgedRegions {
regions: out,
trace: Some(JudgeTrace {
details: traced.details,
p_best_rejected: traced.p_best_rejected,
n_candidates: traced.n_candidates,
builder_p,
}),
}
}
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct JudgeRegionMeta {
pub judge_p: f64,
pub kind: &'static str,
pub from_builder: bool,
}
impl JudgeRegionMeta {
#[doc(hidden)]
pub fn of_trace(trace: &JudgeTrace) -> Vec<JudgeRegionMeta> {
trace
.details
.iter()
.map(|d| JudgeRegionMeta {
judge_p: d.p,
kind: d.kind,
from_builder: d.kind == "builder",
})
.collect()
}
}
#[doc(hidden)]
pub const REJECTED: usize = usize::MAX;
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TableIsTrace {
pub refused: Vec<f64>,
pub probs: std::collections::BTreeMap<usize, f64>,
}
#[cfg(test)]
thread_local! {
static TABLE_IS_THRESHOLD_OVERRIDE: std::cell::Cell<Option<u64>> =
const { std::cell::Cell::new(None) };
}
fn table_is_threshold() -> f64 {
#[cfg(test)]
if let Some(bits) = TABLE_IS_THRESHOLD_OVERRIDE.with(|o| o.get()) {
return f64::from_bits(bits);
}
models::table_is::THRESHOLD
}
#[cfg(test)]
struct TableIsThresholdGuard;
#[cfg(test)]
impl TableIsThresholdGuard {
fn set(threshold: f64) -> Self {
TABLE_IS_THRESHOLD_OVERRIDE.with(|o| o.set(Some(f64::to_bits(threshold))));
TableIsThresholdGuard
}
}
#[cfg(test)]
impl Drop for TableIsThresholdGuard {
fn drop(&mut self) {
TABLE_IS_THRESHOLD_OVERRIDE.with(|o| o.set(None));
}
}
fn refused_by_the_table_model_traced(
refs: &[&Block],
judged: &[Option<usize>],
cells: &[CellHint],
height: f64,
) -> (Vec<Option<usize>>, TableIsTrace) {
let mut out = judged.to_vec();
let mut trace = TableIsTrace::default();
if cells.len() == refs.len() {
let tables = heuristic_tables(refs);
let mut by: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
for (i, t) in tables.iter().enumerate() {
if let Some(t) = t {
by.entry(*t).or_default().push(i);
}
}
for (id, blocks) in &by {
let p = table_is_probability(&table_is_features(refs, blocks, cells, height));
trace.probs.insert(*id, p);
if p < table_is_threshold() {
trace.refused.push(p);
}
}
let refused: Vec<bool> = {
let mut r = vec![false; refs.len()];
for (id, blocks) in &by {
let kept = trace
.probs
.get(id)
.is_some_and(|p| *p >= table_is_threshold());
if !kept {
for &i in blocks {
r[i] = true;
}
}
}
r
};
let whole = |k: usize| {
let mine: Vec<usize> = (0..refs.len()).filter(|&i| judged[i] == Some(k)).collect();
let gone = mine.iter().filter(|&&i| refused[i]).count();
gone as f64 >= REFUSED_SHARE * mine.len() as f64
};
for i in 0..refs.len() {
if refused[i] && judged[i].is_none_or(|k| k == REJECTED || whole(k)) {
out[i] = Some(REJECTED);
}
}
}
(out, trace)
}
const REFUSED_SHARE: f64 = 1.0;
pub const TABLE_IS_FEATURES: usize = 15;
const WORDY: usize = 8;
fn is_figure(text: &str) -> bool {
!text.is_empty()
&& text
.chars()
.all(|c| c.is_whitespace() || c.is_ascii_digit() || "$€£%()+-–−.,".contains(c))
}
#[doc(hidden)]
pub fn table_is_features(
page: &[&Block],
blocks: &[usize],
hints: &[CellHint],
height: f64,
) -> [f64; TABLE_IS_FEATURES] {
let place = |i: usize| match page[i].kind {
BlockKind::TableCell { row, column, .. } => (row as usize, column as usize),
_ => (0, 0),
};
let n = blocks.len().max(1) as f64;
let rows = blocks
.iter()
.map(|&i| place(i).0)
.max()
.map_or(0, |r| r + 1);
let cols = blocks
.iter()
.map(|&i| place(i).1)
.max()
.map_or(0, |c| c + 1);
let words: Vec<usize> = blocks
.iter()
.map(|&i| page[i].text.split_whitespace().count())
.collect();
let heading: Vec<f64> = blocks
.iter()
.map(|&i| hints[i].p_heading.unwrap_or(0.0))
.collect();
let mut by_column: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
for (k, &i) in blocks.iter().enumerate() {
by_column.entry(place(i).1).or_default().push(words[k]);
}
let means: Vec<f64> = by_column
.values()
.map(|v| v.iter().sum::<usize>() as f64 / v.len() as f64)
.collect();
let first: Vec<usize> = (0..blocks.len())
.filter(|&k| place(blocks[k]).1 == 0)
.collect();
let mean = |xs: &[f64]| {
if xs.is_empty() {
0.0
} else {
xs.iter().sum::<f64>() / xs.len() as f64
}
};
let y0 = blocks
.iter()
.map(|&i| page[i].bbox[1])
.fold(f64::INFINITY, f64::min);
let y1 = blocks
.iter()
.map(|&i| page[i].bbox[3])
.fold(f64::NEG_INFINITY, f64::max);
[
rows as f64,
cols as f64,
blocks.len() as f64,
blocks.iter().filter(|&&i| is_figure(&page[i].text)).count() as f64 / n,
words.iter().sum::<usize>() as f64 / n,
means.iter().copied().fold(f64::NEG_INFINITY, f64::max),
means.iter().copied().fold(f64::INFINITY, f64::min),
heading.iter().filter(|&&p| p >= 0.5).count() as f64 / n,
heading.iter().copied().fold(f64::NEG_INFINITY, f64::max),
mean(&heading),
mean(&first.iter().map(|&k| heading[k]).collect::<Vec<f64>>()),
1.0 - blocks.len() as f64 / (rows * cols).max(1) as f64,
(y1 - y0) / height.max(1.0),
words.iter().filter(|&&w| w >= WORDY).count() as f64 / n,
mean(&first.iter().map(|&k| words[k] as f64).collect::<Vec<f64>>()),
]
}
pub fn is_a_table(page: &[&Block], blocks: &[usize], hints: &[CellHint], height: f64) -> bool {
if blocks.is_empty() {
return true;
}
table_is_probability(&table_is_features(page, blocks, hints, height)) >= table_is_threshold()
}
pub fn table_is_probability(x: &[f64; TABLE_IS_FEATURES]) -> f64 {
super::probability(models::table_is::predict_margin(x))
}
fn heuristic_tables(page: &[&Block]) -> Vec<Option<usize>> {
let mut out = Vec::with_capacity(page.len());
let mut id = 0usize;
let mut last: Option<(usize, u16, u16)> = None;
for block in page {
match block.kind {
BlockKind::TableCell { row, column, .. } => {
match last {
Some((page_no, r, c)) if page_no == block.page && (row, column) > (r, c) => {}
Some(_) => id += 1,
None => {}
}
last = Some((block.page, row, column));
out.push(Some(id));
}
_ => {
if last.take().is_some() {
id += 1;
}
out.push(None);
}
}
}
out
}
#[derive(Debug, Clone)]
pub(crate) struct DrawnGrid {
grid: separations::Grid,
words: Vec<pairs::PairWord>,
from: Vec<usize>,
}
fn lattice_runs(
refs: &[&Block],
lattices: &[super::lattice::Lattice],
) -> Vec<(Vec<usize>, Option<DrawnGrid>)> {
let mut out: Vec<(Vec<usize>, Option<DrawnGrid>)> = Vec::new();
for lattice in lattices {
let [left, bottom, right, top] = lattice.bbox();
let run: Vec<usize> = (0..refs.len())
.filter(|&i| {
let b = refs[i].bbox;
let (x, y) = ((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5);
x >= left && x <= right && y >= bottom && y <= top
})
.filter(|i| !out.iter().any(|(r, _)| r.contains(i)))
.collect();
if run.is_empty() {
continue;
}
let (words, from) = words_of(refs, &run);
if words.len() < 4 {
continue;
}
let atoms = lattice.cells();
let mut held: Vec<Vec<usize>> = vec![Vec::new(); atoms.len()];
let mut placed = 0usize;
for (w, word) in words.iter().enumerate() {
let (x, y) = (
(word.bbox[0] + word.bbox[2]) * 0.5,
(word.bbox[1] + word.bbox[3]) * 0.5,
);
if let Some(c) = lattice.cell_at(&atoms, x, y) {
held[c].push(w);
placed += 1;
}
}
if (placed as f64) < LATTICE_MIN_PLACED * words.len() as f64 {
continue;
}
let mut cells: Vec<super::grid::Cell> = Vec::new();
for (c, mut mine) in held.into_iter().enumerate() {
if mine.is_empty() {
continue;
}
mine = in_reading_order(mine, &words);
let bbox = [
mine.iter()
.map(|&w| words[w].bbox[0])
.fold(f64::INFINITY, f64::min),
mine.iter()
.map(|&w| words[w].bbox[1])
.fold(f64::INFINITY, f64::min),
mine.iter()
.map(|&w| words[w].bbox[2])
.fold(f64::NEG_INFINITY, f64::max),
mine.iter()
.map(|&w| words[w].bbox[3])
.fold(f64::NEG_INFINITY, f64::max),
];
let (rows, columns) = atoms[c].clone();
cells.push(super::grid::Cell {
words: mine,
bbox,
rows: rows.collect(),
columns: columns.collect(),
});
}
let rows: std::collections::BTreeSet<usize> = cells
.iter()
.filter_map(|c| c.rows.first().copied())
.collect();
let columns: std::collections::BTreeSet<usize> = cells
.iter()
.filter_map(|c| c.columns.first().copied())
.collect();
if rows.len() < 2 || columns.len() < 2 {
continue;
}
let undrawn = cells.iter().any(|cell| {
(cell.rows.len() > 1 || cell.columns.len() > 1) && lines_in(&cell.words, &words) > 1
});
let grid = (!undrawn).then_some(DrawnGrid {
grid: separations::Grid {
cells,
header_rows: 1,
},
words,
from,
});
out.push((run, grid));
}
out
}
fn lines_in(mine: &[usize], words: &[pairs::PairWord]) -> usize {
let mut spans: Vec<(f64, f64)> = mine
.iter()
.map(|&w| (words[w].bbox[1], words[w].bbox[3]))
.collect();
spans.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut count = 0usize;
let mut floor = f64::INFINITY;
for (bottom, top) in spans {
if top < floor {
count += 1;
floor = bottom;
} else {
floor = floor.min(bottom);
}
}
count
}
const LATTICE_MIN_PLACED: f64 = 0.9;
fn rebuilt_reads_like_a_table(made: &[Block]) -> bool {
let mut columns = std::collections::BTreeSet::new();
for block in made {
if let BlockKind::TableCell { column, .. } = block.kind {
columns.insert(column);
}
}
let like_table = columns.len() >= 2;
if !like_table && crate::table_trace::tracing() {
eprintln!(
"LATTICE refused after rebuild: columns={} cells={}",
columns.len(),
made.iter()
.filter(|b| matches!(b.kind, BlockKind::TableCell { .. }))
.count()
);
}
like_table
}
fn judged_runs(judged: &[Option<usize>], refs: &[&Block]) -> Vec<Vec<usize>> {
let mut by: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
for (i, k) in judged.iter().enumerate() {
if matches!(refs[i].kind, BlockKind::ListItem { .. }) {
continue;
}
if let Some(k) = k.filter(|&k| k != REJECTED) {
by.entry(k).or_default().push(i);
}
}
let regions: Vec<Vec<usize>> = by.into_values().collect();
grown(regions, &heuristic_tables(refs))
}
fn grown(regions: Vec<Vec<usize>>, tables: &[Option<usize>]) -> Vec<Vec<usize>> {
let mut sets: Vec<std::collections::BTreeSet<usize>> = regions
.into_iter()
.map(|r| {
let mut all: std::collections::BTreeSet<usize> = r.iter().copied().collect();
let touched: Vec<usize> = r.iter().filter_map(|&i| tables[i]).collect();
for (i, t) in tables.iter().enumerate() {
if t.is_some_and(|t| touched.contains(&t)) {
all.insert(i);
}
}
all
})
.collect();
let mut merged = true;
while merged {
merged = false;
'outer: for a in 0..sets.len() {
for b in a + 1..sets.len() {
if !sets[a].is_disjoint(&sets[b]) {
let other = sets.remove(b);
sets[a].extend(other);
merged = true;
break 'outer;
}
}
}
}
let mut out: Vec<Vec<usize>> = sets.into_iter().map(|s| s.into_iter().collect()).collect();
out.sort_by_key(|r| r[0]);
out
}
fn is_one_table(run: &[usize], tables: &[Option<usize>]) -> bool {
let Some(table) = tables.get(run[0]).copied().flatten() else {
return false;
};
let whole: Vec<usize> = (0..tables.len())
.filter(|&i| tables[i] == Some(table))
.collect();
whole == run
}
fn demote_unchosen(
page: &[&Block],
tables: &[Option<usize>],
judged: &[Option<usize>],
weakness: &Builder<'_>,
hints: &[CellHint],
per_cell: bool,
replacement: &mut [Option<Vec<Block>>],
) {
let doubted = |t: usize| {
let whole: Vec<usize> = (0..tables.len())
.filter(|&i| tables[i] == Some(t))
.collect();
let cells: Vec<&Block> = whole.iter().filter_map(|&i| page.get(i).copied()).collect();
(whole.iter().all(|&i| judged[i] == Some(REJECTED))
|| matches!(weakness(&whole, page), Some(w) if may_overrule(&w)))
&& !table_has_clean_columns(&cells)
};
let mut rows: std::collections::BTreeMap<(usize, u16), Vec<usize>> = Default::default();
for (i, block) in page.iter().enumerate() {
if judged[i].is_some_and(|k| k != REJECTED) || replacement[i].is_some() {
continue;
}
if let (Some(table), BlockKind::TableCell { row, .. }) = (tables[i], block.kind) {
if doubted(table) {
rows.entry((table, row)).or_default().push(i);
}
}
}
if per_cell && hints.len() == page.len() {
demote_by_column(page, rows, hints, replacement);
return;
}
for cells in rows.into_values() {
let first = cells[0];
let mut made = page[first].clone();
made.kind = BlockKind::Paragraph;
made.text = cells
.iter()
.map(|&i| page[i].text.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<&str>>()
.join(" ");
made.spans = cells
.iter()
.flat_map(|&i| page[i].spans.iter().cloned())
.collect();
made.bbox = [
cells
.iter()
.map(|&i| page[i].bbox[0])
.fold(f64::INFINITY, f64::min),
cells
.iter()
.map(|&i| page[i].bbox[1])
.fold(f64::INFINITY, f64::min),
cells
.iter()
.map(|&i| page[i].bbox[2])
.fold(f64::NEG_INFINITY, f64::max),
cells
.iter()
.map(|&i| page[i].bbox[3])
.fold(f64::NEG_INFINITY, f64::max),
];
made.grid = None;
made.confidence = None;
replacement[first] = Some(vec![made]);
for &i in &cells[1..] {
replacement[i] = Some(Vec::new());
}
}
}
fn demote_by_column(
page: &[&Block],
rows: std::collections::BTreeMap<(usize, u16), Vec<usize>>,
hints: &[CellHint],
replacement: &mut [Option<Vec<Block>>],
) {
let mut by_table: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
for ((table, _row), cells) in rows {
by_table.entry(table).or_default().extend(cells);
}
for cells in by_table.into_values() {
let first = *cells.iter().min().expect("a table has a cell");
let at = |i: usize| match page[i].kind {
BlockKind::TableCell { row, column, .. } => (column, row),
_ => (0, 0),
};
let mut ordered = cells.clone();
ordered.sort_by_key(|&i| (at(i), i));
let mut parts: Vec<Vec<usize>> = Vec::new();
for &i in &ordered {
let opens = match parts.last().and_then(|p| p.last()) {
None => true,
Some(&prev) => at(prev).0 != at(i).0 || hints[prev].heading != hints[i].heading,
};
if opens {
parts.push(vec![i]);
} else if let Some(part) = parts.last_mut() {
part.push(i);
}
}
let made: Vec<Block> = parts
.iter()
.map(|part| {
let mut block = joined(page, part);
if hints[part[0]].heading {
block.kind = BlockKind::Heading { level: 1 };
let ps: Vec<f64> = part.iter().filter_map(|&i| hints[i].p_heading).collect();
block.confidence =
(!ps.is_empty()).then(|| ps.iter().sum::<f64>() / ps.len() as f64);
}
block
})
.collect();
for &i in &cells {
replacement[i] = Some(Vec::new());
}
replacement[first] = Some(made);
}
}
fn joined(page: &[&Block], part: &[usize]) -> Block {
let mut made = page[part[0]].clone();
made.kind = BlockKind::Paragraph;
made.text = part
.iter()
.map(|&i| page[i].text.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<&str>>()
.join(" ");
made.spans = part
.iter()
.flat_map(|&i| page[i].spans.iter().cloned())
.collect();
made.bbox = [
part.iter()
.map(|&i| page[i].bbox[0])
.fold(f64::INFINITY, f64::min),
part.iter()
.map(|&i| page[i].bbox[1])
.fold(f64::INFINITY, f64::min),
part.iter()
.map(|&i| page[i].bbox[2])
.fold(f64::NEG_INFINITY, f64::max),
part.iter()
.map(|&i| page[i].bbox[3])
.fold(f64::NEG_INFINITY, f64::max),
];
made.grid = None;
made.confidence = None;
made
}
fn cuts_a_table(run: &[usize], tables: &[Option<usize>]) -> bool {
let mut touched: Vec<usize> = run.iter().filter_map(|&i| tables[i]).collect();
touched.sort_unstable();
touched.dedup();
touched.iter().any(|table| {
tables
.iter()
.enumerate()
.any(|(i, t)| *t == Some(*table) && !run.contains(&i))
})
}
#[doc(hidden)]
pub type Builder<'a> = dyn Fn(&[usize], &[&Block]) -> Option<Weakness> + 'a;
#[doc(hidden)]
pub fn refine_gated(
page: Vec<Block>,
size: (f64, f64),
facts: &super::DocumentFacts,
switches: super::Switches,
weakness: &Builder<'_>,
) -> (Vec<Block>, Vec<usize>) {
refine_judged(page, size, facts, switches, weakness, None, &[], &[])
}
fn primary_choice(run: &[usize], judged: &[Option<usize>]) -> Option<usize> {
let mut counts: std::collections::BTreeMap<usize, usize> = Default::default();
for &i in run {
if let Some(k) = judged.get(i).copied().flatten().filter(|&k| k != REJECTED) {
*counts.entry(k).or_default() += 1;
}
}
counts
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0)))
.map(|(k, _)| k)
}
fn builder_prob(
run: &[usize],
tables: &[Option<usize>],
probs: &std::collections::BTreeMap<usize, f64>,
) -> Option<f64> {
let mut ids: Vec<usize> = run
.iter()
.filter_map(|&i| tables.get(i).copied().flatten())
.collect();
ids.sort_unstable();
ids.dedup();
(ids.len() == 1)
.then(|| probs.get(&ids[0]).copied())
.flatten()
}
fn split_of(title_removed: bool, key_split: bool) -> TableSplit {
match (title_removed, key_split) {
(false, false) => TableSplit::Intact,
(true, false) => TableSplit::TitleRowsRemoved,
(false, true) => TableSplit::KeyValueSplit,
(true, true) => TableSplit::TitleAndKeyValue,
}
}
fn union_of(page: &[Block], range: std::ops::Range<usize>) -> [f64; 4] {
let mut bbox = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for block in page.get(range).unwrap_or(&[]) {
bbox[0] = bbox[0].min(block.bbox[0]);
bbox[1] = bbox[1].min(block.bbox[1]);
bbox[2] = bbox[2].max(block.bbox[2]);
bbox[3] = bbox[3].max(block.bbox[3]);
}
if bbox[0] > bbox[2] || bbox[1] > bbox[3] {
[0.0; 4]
} else {
bbox
}
}
pub(crate) const REGION_PAD_LINES: f64 = 0.5;
#[doc(hidden)]
#[derive(Clone)]
#[non_exhaustive]
pub struct TatrContext<'a> {
document: Option<&'a PdfDocument>,
options: TatrOptions,
}
impl std::fmt::Debug for TatrContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TatrContext")
.field("has_document", &self.document.is_some())
.field("options", &self.options)
.finish_non_exhaustive()
}
}
impl<'a> TatrContext<'a> {
pub(crate) fn new(document: &'a PdfDocument, options: TatrOptions) -> Self {
Self {
document: Some(document),
options,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct TatrRegion<'a> {
context: &'a TatrContext<'a>,
from_detector: bool,
}
impl<'a> TatrRegion<'a> {
pub(crate) fn of(context: &'a TatrContext<'a>, from_detector: bool) -> Self {
Self {
context,
from_detector,
}
}
}
fn tatr_tracing() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("PDFLUENT_TATR_TRACE").is_some())
}
fn tatr_trace(page: usize, region: [f64; 4], outcome: TatrOutcome, started: std::time::Instant) {
if !tatr_tracing() {
return;
}
let outcome = match outcome {
TatrOutcome::Fired => "tatr".to_string(),
TatrOutcome::Fallback(reason) => format!("fallback({reason})"),
};
eprintln!(
"TATR page={page} region=[{:.1} {:.1} {:.1} {:.1}] {outcome} ms={:.1}",
region[0],
region[1],
region[2],
region[3],
started.elapsed().as_secs_f64() * 1000.0,
);
}
#[derive(Debug, Clone, Copy)]
enum TatrOutcome {
Fired,
Fallback(&'static str),
}
pub(crate) enum TatrVerdict {
Cells(Vec<Block>),
Fallback(&'static str),
}
fn region_bbox(words: &[pairs::PairWord]) -> [f64; 4] {
let mut bbox = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for word in words {
bbox[0] = bbox[0].min(word.bbox[0]);
bbox[1] = bbox[1].min(word.bbox[1]);
bbox[2] = bbox[2].max(word.bbox[2]);
bbox[3] = bbox[3].max(word.bbox[3]);
}
if bbox[0] > bbox[2] || bbox[1] > bbox[3] {
[0.0; 4]
} else {
bbox
}
}
pub(crate) const TATR_MIN_COVERAGE: f64 = 0.8;
pub(crate) fn tatr_table_extent(cell_boxes: &[[f64; 4]]) -> Option<[f64; 4]> {
let mut extent: Option<[f64; 4]> = None;
for cbox in cell_boxes {
if cbox[0] >= cbox[2] || cbox[1] >= cbox[3] {
continue;
}
extent = Some(match extent {
None => *cbox,
Some(had) => [
had[0].min(cbox[0]),
had[1].min(cbox[1]),
had[2].max(cbox[2]),
had[3].max(cbox[3]),
],
});
}
extent
}
pub(crate) fn tatr_coverage_counts(
word_boxes: &[[f64; 4]],
extent: [f64; 4],
claims: &[Vec<usize>],
) -> (usize, usize) {
let mut claimed = vec![false; word_boxes.len()];
for cell in claims {
for &w in cell {
if let Some(slot) = claimed.get_mut(w) {
*slot = true;
}
}
}
let mut inside = 0usize;
let mut claimed_inside = 0usize;
for (w, bbox) in word_boxes.iter().enumerate() {
let cx = (bbox[0] + bbox[2]) / 2.0;
let cy = (bbox[1] + bbox[3]) / 2.0;
if extent[0] <= cx && cx <= extent[2] && extent[1] <= cy && cy <= extent[3] {
inside += 1;
if claimed[w] {
claimed_inside += 1;
}
}
}
(claimed_inside, inside)
}
pub(crate) fn tatr_fill_gaps(
word_boxes: &[[f64; 4]],
cell_boxes: &[[f64; 4]],
extent: [f64; 4],
claims: &mut [Vec<usize>],
) {
assert_eq!(
claims.len(),
cell_boxes.len(),
"one claim list per cell box"
);
let mut claimed = vec![false; word_boxes.len()];
for cell in claims.iter() {
for &w in cell {
if let Some(slot) = claimed.get_mut(w) {
*slot = true;
}
}
}
for (w, bbox) in word_boxes.iter().enumerate() {
if claimed[w] {
continue;
}
let cx = (bbox[0] + bbox[2]) / 2.0;
let cy = (bbox[1] + bbox[3]) / 2.0;
if !(extent[0] <= cx && cx <= extent[2] && extent[1] <= cy && cy <= extent[3]) {
continue;
}
let gap = |lo: f64, hi: f64, at: f64| {
if at < lo {
lo - at
} else if at > hi {
at - hi
} else {
0.0
}
};
let mut best: Option<(usize, u8, f64)> = None;
for (cell, cbox) in cell_boxes.iter().enumerate() {
let in_row = cbox[1] <= cy && cy <= cbox[3];
let in_column = cbox[0] <= cx && cx <= cbox[2];
let (rank, distance) = if in_row {
(0u8, gap(cbox[0], cbox[2], cx))
} else if in_column {
(1u8, gap(cbox[1], cbox[3], cy))
} else {
let dx = (cbox[0] + cbox[2]) / 2.0 - cx;
let dy = (cbox[1] + cbox[3]) / 2.0 - cy;
(2u8, dx * dx + dy * dy)
};
let better = match best {
None => true,
Some((_, best_rank, best_distance)) => {
(rank, distance) < (best_rank, best_distance)
}
};
if better {
best = Some((cell, rank, distance));
}
}
if let Some((cell, _, _)) = best {
claims[cell].push(w);
claimed[w] = true;
}
}
for cell in claims.iter_mut() {
cell.sort_unstable();
}
}
pub(crate) fn conserved_table_blocks(
placed: &[(super::grid::Cell, bool)],
pair_words: &[pairs::PairWord],
from: &[usize],
page_blocks: &[&Block],
page_number: usize,
cell_path_words: bool,
) -> Vec<Block> {
let mut claimed = vec![false; pair_words.len()];
for (cell, _) in placed {
for &w in &cell.words {
if let Some(slot) = claimed.get_mut(w) {
*slot = true;
}
}
}
let anchor = claimed
.iter()
.enumerate()
.filter(|(_, c)| **c)
.filter_map(|(w, _)| from.get(w).copied())
.min();
let mut groups: std::collections::BTreeMap<usize, Vec<usize>> =
std::collections::BTreeMap::new();
for (w, c) in claimed.iter().enumerate() {
if !c {
groups.entry(from[w]).or_default().push(w);
}
}
let mut before = Vec::new();
let mut after = Vec::new();
for (block, words) in &groups {
let prose = prose_block_for(*block, words, pair_words, page_blocks, page_number);
match anchor {
Some(first) if *block < first => before.push(prose),
_ => after.push(prose),
}
}
let mut out = before;
out.extend(placed.iter().map(|(cell, header)| {
cell_block_with_cell_path_words(
cell,
pair_words,
from,
page_blocks,
None,
page_number,
*header,
None,
false,
cell_path_words,
)
}));
out.extend(after);
out
}
fn prose_block_for(
block: usize,
words: &[usize],
pair_words: &[pairs::PairWord],
page_blocks: &[&Block],
page_number: usize,
) -> Block {
let source = page_blocks[block];
let kind = match source.kind {
BlockKind::TableCell { .. } => BlockKind::Paragraph,
kind => kind,
};
let text = join_decoded_words(pair_words, words);
let mut bbox = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for &w in words {
let held = pair_words[w].bbox;
bbox[0] = bbox[0].min(held[0]);
bbox[1] = bbox[1].min(held[1]);
bbox[2] = bbox[2].max(held[2]);
bbox[3] = bbox[3].max(held[3]);
}
let mut spans: Vec<TextSpan> = source
.spans
.iter()
.filter(|span| {
let x = span.x + span.width / 2.0;
let y = span.y + span.height / 2.0;
x >= bbox[0] && x <= bbox[2] && y >= bbox[1] && y <= bbox[3]
})
.cloned()
.collect();
if spans.is_empty() {
spans.extend(source.spans.iter().cloned());
}
Block {
kind,
page: page_number,
bbox,
text,
spans,
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
}
fn fake_conserved_cells(
cells: &[TatrTestCell],
pair_words: &[pairs::PairWord],
from: &[usize],
page_blocks: &[&Block],
page_number: usize,
cell_path_words: bool,
) -> Option<Vec<Block>> {
if cells.is_empty() {
return None;
}
let mut boxes = Vec::with_capacity(cells.len());
let mut claims = Vec::with_capacity(cells.len());
for cell in cells {
let claimed: Vec<usize> = cell
.words
.iter()
.copied()
.filter(|&w| w < pair_words.len())
.collect();
let mut bbox = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for &w in &claimed {
let held = pair_words[w].bbox;
bbox[0] = bbox[0].min(held[0]);
bbox[1] = bbox[1].min(held[1]);
bbox[2] = bbox[2].max(held[2]);
bbox[3] = bbox[3].max(held[3]);
}
if claimed.is_empty() {
bbox = [0.0; 4];
}
boxes.push(bbox);
claims.push(claimed);
}
let extent = tatr_table_extent(&boxes)?;
let word_boxes: Vec<[f64; 4]> = pair_words.iter().map(|w| w.bbox).collect();
let (claimed_inside, inside) = tatr_coverage_counts(&word_boxes, extent, &claims);
if inside == 0 || claimed_inside as f64 / (inside as f64) < TATR_MIN_COVERAGE {
return None;
}
tatr_fill_gaps(&word_boxes, &boxes, extent, &mut claims);
let placed: Vec<(super::grid::Cell, bool)> = cells
.iter()
.zip(claims)
.zip(boxes)
.map(|((cell, words), bbox)| {
(
super::grid::Cell {
words,
bbox,
rows: cell.rows.clone(),
columns: cell.columns.clone(),
},
cell.header,
)
})
.collect();
Some(conserved_table_blocks(
&placed,
pair_words,
from,
page_blocks,
page_number,
cell_path_words,
))
}
const TATR_UPRIGHT_TOLERANCE_RAD: f64 = 0.174_533;
fn tatr_span_is_upright(span: &TextSpan) -> bool {
let Some([a, b, ..]) = span.transform else {
return true;
};
if (a * a + b * b).sqrt() <= f64::EPSILON {
return true;
}
b.atan2(a).abs() <= TATR_UPRIGHT_TOLERANCE_RAD
}
fn tatr_region_is_rotated(
page_blocks: &[&Block],
lines: &[judge::Line],
words: &[PageWord],
region_lines: &[usize],
) -> bool {
let mut in_region = vec![false; lines.len()];
for &i in region_lines {
if let Some(slot) = in_region.get_mut(i) {
*slot = true;
}
}
let mut total = 0usize;
let mut rotated = 0usize;
let mut count = |word: &PageWord| {
let turned = page_blocks
.get(word.block)
.and_then(|block| block.spans.get(word.span))
.is_some_and(|span| !tatr_span_is_upright(span));
total += 1;
rotated += usize::from(turned);
};
if let Some(line_of) = line_of_each_word(page_blocks.len(), lines, words) {
for (index, word) in words.iter().enumerate() {
let held = line_of
.get(index)
.copied()
.flatten()
.is_some_and(|line| in_region[line]);
if held && word.block < page_blocks.len() {
count(word);
}
}
} else {
let mut blocks: Vec<usize> = region_lines
.iter()
.filter_map(|&i| lines.get(i).map(|line| line.block))
.filter(|&b| b < page_blocks.len())
.collect();
blocks.sort_unstable();
blocks.dedup();
for word in words.iter() {
if blocks.contains(&word.block) {
count(word);
}
}
}
rotated * 2 > total
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn tatr_for_region(
tatr: Option<&TatrRegion>,
page_blocks: &[&Block],
lines: &[judge::Line],
words: &[PageWord],
region_lines: &[usize],
pair_words: &[pairs::PairWord],
page_number: usize,
) -> TatrVerdict {
tatr_for_region_with_cell_path_words(
tatr,
page_blocks,
lines,
words,
region_lines,
pair_words,
page_number,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn tatr_for_region_with_cell_path_words(
tatr: Option<&TatrRegion>,
page_blocks: &[&Block],
lines: &[judge::Line],
words: &[PageWord],
region_lines: &[usize],
pair_words: &[pairs::PairWord],
page_number: usize,
cell_path_words: bool,
) -> TatrVerdict {
let started = std::time::Instant::now();
let region = region_bbox(pair_words);
let Some(request) = tatr else {
tatr_trace(
page_number,
region,
TatrOutcome::Fallback("no-model"),
started,
);
return TatrVerdict::Fallback("no-model");
};
let options = &request.context.options;
if options.detector_regions_only && !request.from_detector {
tatr_trace(
page_number,
region,
TatrOutcome::Fallback("not-a-detector-region"),
started,
);
return TatrVerdict::Fallback("not-a-detector-region");
}
if tatr_region_is_rotated(page_blocks, lines, words, region_lines) {
tatr_trace(
page_number,
region,
TatrOutcome::Fallback("rotated"),
started,
);
return TatrVerdict::Fallback("rotated");
}
if let Some(hook) = &options.test_hook {
let fill_words = tatr_fill_words_with_cell_path_words(words, page_blocks, cell_path_words);
let (fill_pair_words, fill_from) =
words_of_region(page_blocks, lines, &fill_words, region_lines);
let verdict = match hook {
TatrTestHook::Cells(cells) => {
match fake_conserved_cells(
cells,
&fill_pair_words,
&fill_from,
page_blocks,
page_number,
cell_path_words,
) {
Some(blocks) => TatrVerdict::Cells(blocks),
None => TatrVerdict::Fallback("low-coverage"),
}
}
TatrTestHook::Declined => TatrVerdict::Fallback("declined"),
TatrTestHook::Failed => TatrVerdict::Fallback("inference-failed"),
};
let outcome = match &verdict {
TatrVerdict::Cells(_) => TatrOutcome::Fired,
TatrVerdict::Fallback(reason) => TatrOutcome::Fallback(reason),
};
tatr_trace(page_number, region, outcome, started);
return verdict;
}
#[cfg(feature = "ort-tables")]
{
real_tatr_for_region(
request,
page_blocks,
lines,
words,
region_lines,
region,
page_number,
started,
cell_path_words,
)
}
#[cfg(not(feature = "ort-tables"))]
{
let _ = (page_blocks, lines, words, region_lines);
tatr_trace(
page_number,
region,
TatrOutcome::Fallback("no-model"),
started,
);
TatrVerdict::Fallback("no-model")
}
}
#[cfg(feature = "ort-tables")]
#[allow(clippy::too_many_arguments)]
fn real_tatr_for_region(
request: &TatrRegion,
page_blocks: &[&Block],
lines: &[judge::Line],
words: &[PageWord],
region_lines: &[usize],
region: [f64; 4],
page_number: usize,
started: std::time::Instant,
cell_path_words: bool,
) -> TatrVerdict {
let Some(document) = request.context.document else {
tatr_trace(
page_number,
region,
TatrOutcome::Fallback("no-model"),
started,
);
return TatrVerdict::Fallback("no-model");
};
let config = request.context.options.runtime_config();
let attempt = super::tables_tatr::tatr_attempt(
document,
page_number,
region,
page_blocks,
lines,
words,
region_lines,
&config,
cell_path_words,
);
let verdict = match attempt {
Ok(super::tables_tatr::TatrAttempt::Cells(cells)) => TatrVerdict::Cells(cells),
Ok(super::tables_tatr::TatrAttempt::Declined(declined)) => {
TatrVerdict::Fallback(match declined {
super::tables_tatr::TatrDecline::FewRowsOrColumns { .. } => "few-rows-or-columns",
super::tables_tatr::TatrDecline::LowConfidence { .. } => "low-confidence",
super::tables_tatr::TatrDecline::LowCoverage { .. } => "low-coverage",
})
}
Err(failed) => TatrVerdict::Fallback(match failed.stage {
super::tables_tatr::TatrFailure::Render => "render-failed",
super::tables_tatr::TatrFailure::Inference => "inference-failed",
super::tables_tatr::TatrFailure::ModelMissing => "model-missing",
super::tables_tatr::TatrFailure::ModelInvalid => "model-invalid",
}),
};
let outcome = match &verdict {
TatrVerdict::Cells(_) => TatrOutcome::Fired,
TatrVerdict::Fallback(reason) => TatrOutcome::Fallback(reason),
};
tatr_trace(page_number, region, outcome, started);
verdict
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn rebuild_region(
page_blocks: &[&Block],
lines: &[judge::Line],
words: &[PageWord],
rules: &[TextRuling],
region_lines: &[usize],
drawn: Option<&DrawnGrid>,
switches: super::Switches,
tatr: Option<&TatrRegion>,
cell_path_words: bool,
) -> Vec<Block> {
let valid: Vec<usize> = region_lines
.iter()
.copied()
.filter(|&i| i < lines.len())
.collect();
if valid.is_empty() {
return Vec::new();
}
let (pair_words, from) = words_of_region(page_blocks, lines, words, &valid);
if pair_words.is_empty() {
return Vec::new();
}
let page_number = valid
.iter()
.filter_map(|&i| lines.get(i))
.filter_map(|line| page_blocks.get(line.block))
.map(|block| block.page)
.next()
.unwrap_or(0);
if let TatrVerdict::Cells(mut cells) = tatr_for_region_with_cell_path_words(
tatr,
page_blocks,
lines,
words,
region_lines,
&pair_words,
page_number,
cell_path_words,
) {
for cell in cells.iter_mut() {
if matches!(cell.kind, BlockKind::TableCell { .. }) {
cell.table_origin = TableOrigin::Tatr;
}
}
return cells;
}
let mut region_blocks: Vec<usize> = valid
.iter()
.filter_map(|&i| lines.get(i).map(|line| line.block))
.filter(|&b| b < page_blocks.len())
.collect();
region_blocks.sort_unstable();
region_blocks.dedup();
let weak = region_blocks
.iter()
.find_map(|&b| match page_blocks[b].kind {
BlockKind::TableCell { .. } => page_blocks[b].grid,
_ => None,
});
let sep_rules: Vec<separations::Ruling> = rules
.iter()
.filter(|r| (r.y1 - r.y0).abs() < 1e-6)
.map(|r| separations::Ruling { y: r.y0 })
.collect();
let (grid, is_drawn, pair_words, from) = match drawn {
Some(drawn) => (
drawn.grid.clone(),
true,
drawn.words.clone(),
drawn.from.clone(),
),
None => match lattice_grid_of(rules, &pair_words) {
Some(grid) => (grid, true, pair_words, from),
None => match grid_of(&pair_words, &sep_rules, switches.table_corridor_split) {
Some(grid) => (
split_twin_figure_columns(spaced_header_joined(grid, &pair_words), &pair_words),
false,
pair_words,
from,
),
None => return Vec::new(),
},
},
};
let split_grid = (!is_drawn).then(|| key_value_sections_split(&grid, &pair_words));
let split_grid = split_grid.flatten();
let pieces = split_grid.unwrap_or_else(|| {
vec![SplitPiece::Table {
cells: grid.cells.clone(),
header_rows: grid.header_rows,
}]
});
let mut made: Vec<Block> = Vec::new();
for piece in &pieces {
match piece {
SplitPiece::Table { cells, header_rows } => {
let has_header = table_has_header(cells, *header_rows, &from, page_blocks);
let mut table: Vec<Block> = cells
.iter()
.map(|cell| {
cell_block_with_cell_path_words(
cell,
&pair_words,
&from,
page_blocks,
weak,
page_number,
has_header && cell.rows.first().is_some_and(|&r| r < *header_rows),
None,
switches.table_cell_text_by_line,
cell_path_words,
)
})
.collect();
super::cell_merge::tidy(&mut table, is_drawn, switches.table_runs_on_figures);
made.extend(table);
}
SplitPiece::Section { row } => {
let Some(cell) = grid.cells.iter().find(|cell| cell.rows == [*row]) else {
continue;
};
made.push(section_block(
cell,
&pair_words,
&from,
page_blocks,
page_number,
));
}
}
}
made
}
#[cfg(test)]
pub(crate) fn tatr_fill_words(words: &[PageWord], page_blocks: &[&Block]) -> Vec<PageWord> {
tatr_fill_words_with_cell_path_words(words, page_blocks, false)
}
pub(crate) fn tatr_fill_words_with_cell_path_words(
words: &[PageWord],
page_blocks: &[&Block],
cell_path_words: bool,
) -> Vec<PageWord> {
let mut out: Vec<PageWord> = Vec::with_capacity(words.len());
let mut start = 0usize;
while start < words.len() {
let block = words[start].block;
let mut end = start + 1;
while end < words.len() && words[end].block == block {
end += 1;
}
if !join_block_words(&words[start..end], page_blocks, &mut out, cell_path_words) {
out.extend(words[start..end].iter().cloned());
}
start = end;
}
out
}
fn join_block_words(
fragments: &[PageWord],
page_blocks: &[&Block],
out: &mut Vec<PageWord>,
cell_path_words: bool,
) -> bool {
let Some(block) = fragments
.first()
.and_then(|first| page_blocks.get(first.block))
else {
return false;
};
let tokens: Vec<&str> = block.text.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
let mut built: Vec<PageWord> = Vec::with_capacity(tokens.len());
let mut taken = 0usize;
let emit = |built: &mut Vec<PageWord>, token: String, span: &[PageWord]| -> bool {
if !span
.windows(2)
.all(|pair| fragments_touch(&pair[0], &pair[1]))
{
built.extend(span.iter().cloned());
return true;
}
let Some(first) = span.first() else {
return false;
};
let mut bbox = first.bbox;
for fragment in &span[1..] {
bbox = [
bbox[0].min(fragment.bbox[0]),
bbox[1].min(fragment.bbox[1]),
bbox[2].max(fragment.bbox[2]),
bbox[3].max(fragment.bbox[3]),
];
}
built.push(PageWord {
text: token,
bbox,
block: first.block,
span: first.span,
size: first.size,
bold: first.bold,
italic: first.italic,
font: first.font.clone(),
color: first.color,
});
true
};
if cell_path_words {
let mut ti = 0usize;
while ti < tokens.len() {
let from = taken;
let mut want: Vec<char> = tokens[ti].chars().collect();
ti += 1;
let mut got: Vec<char> = Vec::new();
loop {
while got.len() < want.len() {
let Some(head) = fragments.get(taken) else {
return false;
};
got.extend(head.text.chars());
taken += 1;
}
if got.len() == want.len() {
break;
}
let Some(tok) = tokens.get(ti) else {
return false;
};
want.extend(tok.chars());
ti += 1;
}
if got != want {
return false;
}
let token: String = want.iter().collect();
if !emit(&mut built, token, &fragments[from..taken]) {
return false;
}
}
} else {
for token in tokens {
let want: Vec<char> = token.chars().collect();
let mut got: Vec<char> = Vec::with_capacity(want.len());
let from = taken;
while got.len() < want.len() {
let Some(head) = fragments.get(taken) else {
return false;
};
got.extend(head.text.chars());
taken += 1;
}
if got != want {
return false;
}
if !emit(&mut built, token.to_string(), &fragments[from..taken]) {
return false;
}
}
}
if taken != fragments.len() {
return false;
}
out.extend(built);
true
}
fn fragments_touch(prev: &PageWord, next: &PageWord) -> bool {
let share = |a0: f64, a1: f64, b0: f64, b1: f64| {
let shared = a1.min(b1) - a0.max(b0);
if shared <= 0.0 {
return 0.0;
}
let smallest = (a1 - a0).min(b1 - b0);
if smallest <= 0.0 {
return 0.0;
}
shared / smallest
};
const TOUCH_EPS: f64 = 1e-6;
if share(prev.bbox[1], prev.bbox[3], next.bbox[1], next.bbox[3]) > 0.5 {
return next.bbox[0] - prev.bbox[2] <= TOUCH_EPS;
}
if share(prev.bbox[0], prev.bbox[2], next.bbox[0], next.bbox[2]) > 0.5 {
return next.bbox[1] - prev.bbox[3] <= TOUCH_EPS;
}
false
}
pub(crate) fn words_of_region(
page_blocks: &[&Block],
lines: &[judge::Line],
words: &[PageWord],
region_lines: &[usize],
) -> (Vec<pairs::PairWord>, Vec<usize>) {
let mut in_region = vec![false; lines.len()];
for &i in region_lines {
if let Some(slot) = in_region.get_mut(i) {
*slot = true;
}
}
if let Some(line_of) = line_of_each_word(page_blocks.len(), lines, words) {
let mut pair_words = Vec::new();
let mut from = Vec::new();
for (index, word) in words.iter().enumerate() {
let held = line_of
.get(index)
.copied()
.flatten()
.is_some_and(|line| in_region[line]);
if !held || word.block >= page_blocks.len() {
continue;
}
let (table, row, column) = match page_blocks[word.block].kind {
BlockKind::TableCell { row, column, .. } => (Some(0u32), Some(row), Some(column)),
_ => (None, None, None),
};
pair_words.push(pairs::PairWord {
text: word.text.clone(),
bbox: word.bbox,
table,
row,
column,
});
from.push(word.block);
}
return (pair_words, from);
}
let mut blocks: Vec<usize> = region_lines
.iter()
.filter_map(|&i| lines.get(i).map(|line| line.block))
.filter(|&b| b < page_blocks.len())
.collect();
blocks.sort_unstable();
blocks.dedup();
let mut pair_words = Vec::new();
let mut from = Vec::new();
for word in words.iter() {
if !blocks.contains(&word.block) {
continue;
}
let (table, row, column) = match page_blocks[word.block].kind {
BlockKind::TableCell { row, column, .. } => (Some(0u32), Some(row), Some(column)),
_ => (None, None, None),
};
pair_words.push(pairs::PairWord {
text: word.text.clone(),
bbox: word.bbox,
table,
row,
column,
});
from.push(word.block);
}
(pair_words, from)
}
fn line_of_each_word(
n_blocks: usize,
lines: &[judge::Line],
words: &[PageWord],
) -> Option<Vec<Option<usize>>> {
let mut out: Vec<Option<usize>> = vec![None; words.len()];
let base = words.as_ptr() as usize;
let width = std::mem::size_of::<PageWord>().max(1);
let mut at = 0usize;
for block in 0..n_blocks {
for piece in lines::lines_of(block, words) {
if piece.is_empty() {
continue;
}
let line = lines.get(at)?;
if line.block != block {
return None;
}
for word in piece {
let offset = (word as *const PageWord as usize).wrapping_sub(base) / width;
if offset >= words.len() || !std::ptr::eq(&words[offset], word) {
return None;
}
out[offset] = Some(at);
}
at += 1;
}
}
(at == lines.len()).then_some(out)
}
fn lattice_grid_of(rules: &[TextRuling], words: &[pairs::PairWord]) -> Option<separations::Grid> {
if rules.is_empty() || words.len() < 4 {
return None;
}
for lattice in lattice::lattices(rules) {
let atoms = lattice.cells();
let mut held: Vec<Vec<usize>> = vec![Vec::new(); atoms.len()];
let mut placed = 0usize;
for (w, word) in words.iter().enumerate() {
let (x, y) = (
(word.bbox[0] + word.bbox[2]) * 0.5,
(word.bbox[1] + word.bbox[3]) * 0.5,
);
if let Some(c) = lattice.cell_at(&atoms, x, y) {
held[c].push(w);
placed += 1;
}
}
if (placed as f64) < LATTICE_MIN_PLACED * words.len() as f64 {
continue;
}
let mut cells: Vec<super::grid::Cell> = Vec::new();
for (c, mut mine) in held.into_iter().enumerate() {
if mine.is_empty() {
continue;
}
mine = in_reading_order(mine, words);
let bbox = [
mine.iter()
.map(|&w| words[w].bbox[0])
.fold(f64::INFINITY, f64::min),
mine.iter()
.map(|&w| words[w].bbox[1])
.fold(f64::INFINITY, f64::min),
mine.iter()
.map(|&w| words[w].bbox[2])
.fold(f64::NEG_INFINITY, f64::max),
mine.iter()
.map(|&w| words[w].bbox[3])
.fold(f64::NEG_INFINITY, f64::max),
];
let (rows, columns) = atoms[c].clone();
cells.push(super::grid::Cell {
words: mine,
bbox,
rows: rows.collect(),
columns: columns.collect(),
});
}
let rows: std::collections::BTreeSet<usize> = cells
.iter()
.filter_map(|c| c.rows.first().copied())
.collect();
let columns: std::collections::BTreeSet<usize> = cells
.iter()
.filter_map(|c| c.columns.first().copied())
.collect();
if rows.len() < 2 || columns.len() < 2 {
continue;
}
let undrawn = cells.iter().any(|cell| {
(cell.rows.len() > 1 || cell.columns.len() > 1) && lines_in(&cell.words, words) > 1
});
if undrawn {
continue;
}
return Some(separations::Grid {
cells,
header_rows: 1,
});
}
None
}
fn table_pieces_of(made: &[Block]) -> (bool, Vec<std::ops::Range<usize>>) {
let mut spans: Vec<std::ops::Range<usize>> = Vec::new();
let mut at = 0usize;
while at < made.len() {
let position = |block: &Block| match block.kind {
BlockKind::TableCell { row, column, .. } => Some((row, column)),
_ => None,
};
let Some(first) = position(&made[at]) else {
at += 1;
continue;
};
let start = at;
let mut previous = first;
at += 1;
while at < made.len() {
let Some(here) = position(&made[at]) else {
break;
};
if here <= previous {
break;
}
previous = here;
at += 1;
}
spans.push(start..at);
}
(spans.len() > 1, spans)
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn refine_judged(
page: Vec<Block>,
size: (f64, f64),
facts: &super::DocumentFacts,
switches: super::Switches,
weakness: &Builder<'_>,
judged: Option<&[Option<usize>]>,
lattices: &[super::lattice::Lattice],
cells: &[CellHint],
) -> (Vec<Block>, Vec<usize>) {
let (blocks, origin, _) = refine_judged_traced(
page, size, facts, switches, weakness, judged, None, lattices, cells, false, true,
);
(blocks, origin)
}
fn drop_empty_columns_from_tables(
out: &mut Vec<Block>,
origin: &mut Vec<usize>,
) -> Vec<Option<usize>> {
let saved_blocks = out.clone();
let saved_origin = origin.clone();
let mut map: Vec<Option<usize>> = vec![None; out.len()];
let mut run_of: Vec<usize> = vec![usize::MAX; out.len()];
let mut runs = 0usize;
let mut old = 0usize;
let mut i = 0usize;
while i < out.len() {
let Some(end) = crate::markdown::table_run(out, i) else {
map[old] = Some(i);
old += 1;
i += 1;
continue;
};
let run = end - i;
for slot in run_of.iter_mut().skip(old).take(run) {
*slot = runs;
}
runs += 1;
let mut pairs: Vec<(Block, (usize, usize))> = out
.drain(i..end)
.zip(origin.drain(i..end))
.zip(old..old + run)
.map(|((block, from), o)| (block, (from, o)))
.collect();
old += run;
super::cell_merge::drop_empty_columns(&mut pairs);
let mut back: Vec<Block> = Vec::with_capacity(pairs.len());
let mut back_from: Vec<usize> = Vec::with_capacity(pairs.len());
for (block, (from, o)) in pairs {
map[o] = Some(i + back.len());
back.push(block);
back_from.push(from);
}
let n = back.len();
out.splice(i..i, back);
origin.splice(i..i, back_from);
i += n;
}
let mut new_to_old = vec![0usize; out.len()];
for (o, n) in map.iter().enumerate() {
if let Some(n) = n {
new_to_old[*n] = o;
}
}
let mut i = 0usize;
while i < out.len() {
let Some(end) = crate::markdown::table_run(out, i) else {
i += 1;
continue;
};
let mut stood = std::collections::BTreeSet::new();
for n in i..end {
stood.insert(run_of[new_to_old[n]]);
}
if stood.len() > 1 {
*out = saved_blocks;
*origin = saved_origin;
return (0..map.len()).map(Some).collect();
}
i = end;
}
map
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn refine_judged_traced(
page: Vec<Block>,
size: (f64, f64),
facts: &super::DocumentFacts,
switches: super::Switches,
weakness: &Builder<'_>,
judged: Option<&[Option<usize>]>,
judge_trace: Option<&JudgeTrace>,
lattices: &[super::lattice::Lattice],
cells: &[CellHint],
drawn_flag: bool,
cell_path_words: bool,
) -> (Vec<Block>, Vec<usize>, Option<PageTableSignals>) {
refine_judged_with_tatr(
page,
size,
facts,
switches,
weakness,
judged,
judge_trace,
lattices,
cells,
drawn_flag,
None,
cell_path_words,
)
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn refine_judged_with_tatr(
page: Vec<Block>,
size: (f64, f64),
facts: &super::DocumentFacts,
switches: super::Switches,
weakness: &Builder<'_>,
judged: Option<&[Option<usize>]>,
judge_trace: Option<&JudgeTrace>,
lattices: &[super::lattice::Lattice],
cells: &[CellHint],
drawn_flag: bool,
tatr: Option<&TatrContext>,
cell_path_words: bool,
) -> (Vec<Block>, Vec<usize>, Option<PageTableSignals>) {
let identity = |n: usize| (0..n).collect::<Vec<usize>>();
if !switches.table_region
&& !switches.table_structure
&& judged.is_none()
&& lattices.is_empty()
{
let n = page.len();
return (page, identity(n), None);
}
let refs: Vec<&Block> = page.iter().collect();
let (refused, is_trace): (Option<Vec<Option<usize>>>, TableIsTrace) = match judged {
Some(judged) if !switches.table_is_v2 => {
let (regions, trace) = refused_by_the_table_model_traced(&refs, judged, cells, size.1);
(Some(regions), trace)
}
Some(judged) => (Some(judged.to_vec()), TableIsTrace::default()),
None => (None, TableIsTrace::default()),
};
let judged = refused.as_deref();
let mut features = page_features(&refs, size.0, size.1);
let texts: Vec<String> = refs.iter().map(|b| b.text.clone()).collect();
facts.fill(&mut features, &texts);
let sizes: Vec<f64> = features
.blocks
.iter()
.map(|b| if b.size > 0.0 { b.size } else { 10.0 })
.collect();
let inside: Vec<bool> = if judged.is_some() {
vec![false; refs.len()]
} else if switches.table_region {
(0..refs.len())
.map(|i| {
let x = region::vector(&features.blocks, &refs, i);
super::probability(models::table_region::predict_margin(&x)) >= REGION_THRESHOLD
})
.collect()
} else {
refs.iter()
.map(|b| matches!(b.kind, BlockKind::TableCell { .. }))
.collect()
};
let regions = match judged {
Some(judged) => judged_runs(judged, &refs),
None => runs(&refs, &inside, &sizes),
};
let drawn = lattice_runs(&refs, lattices);
let taken: Vec<usize> = drawn
.iter()
.flat_map(|(run, _)| run.iter().copied())
.collect();
let regions: Vec<(Vec<usize>, bool, Option<DrawnGrid>)> = drawn
.into_iter()
.map(|(run, grid)| (run, true, grid))
.chain(regions.into_iter().filter_map(|run| {
let run: Vec<usize> = run.into_iter().filter(|i| !taken.contains(i)).collect();
(!run.is_empty()).then_some((run, false, None))
}))
.collect();
if judged.is_none() && taken.is_empty() && (regions.is_empty() || !switches.table_structure) {
let n = page.len();
return (page, identity(n), None);
}
let mut replacement: Vec<Option<Vec<Block>>> = (0..page.len()).map(|_| None).collect();
let tables = heuristic_tables(&refs);
type PendingSignal = (
usize,
(
f64,
f64,
Option<f64>,
Option<f64>,
Option<IsTableModel>,
TableSplit,
),
std::ops::Range<usize>,
);
let mut signals: Vec<PendingSignal> = Vec::new();
let page_lines = judge::lines_of_page(&refs, &features.words, &[]);
for (run, lattice, drawn) in ®ions {
let forced = *lattice || (judged.is_some() && !is_one_table(run, &tables));
if !forced && cuts_a_table(run, &tables) {
continue;
}
let weak = match weakness(run, &refs) {
Some(weak) if may_overrule(&weak) => Some(weak),
recorded if forced => recorded,
_ => continue,
};
let mut in_run = vec![false; refs.len()];
for &i in run {
if let Some(slot) = in_run.get_mut(i) {
*slot = true;
}
}
let region_lines: Vec<usize> = page_lines
.iter()
.enumerate()
.filter(|(_, line)| in_run.get(line.block).copied().unwrap_or(false))
.map(|(i, _)| i)
.collect();
let region_tatr = tatr.map(|context| TatrRegion::of(context, false));
let mut made = rebuild_region(
&refs,
&page_lines,
&features.words,
&[],
®ion_lines,
drawn.as_ref(),
switches,
region_tatr.as_ref(),
cell_path_words,
);
if made.is_empty() {
continue;
}
made = super::table_split::split_tables(made, switches.table_split_side_by_side, false);
if *lattice && !rebuilt_reads_like_a_table(&made) {
continue;
}
let choice: Option<&super::verifier::Choice> = match (judged, judge_trace, *lattice) {
(Some(judged), Some(trace), false) => {
primary_choice(run, judged).and_then(|k| trace.details.get(k))
}
_ => None,
};
let confidence = choice.map(|c| c.p);
for block in made.iter_mut() {
if matches!(block.kind, BlockKind::TableCell { .. }) {
block.grid = weak;
block.confidence = confidence;
block.trace_learned = Some(LearnedTrace {
judge_p: confidence,
forced: if forced {
Some(if *lattice {
ForcedReason::Lattice
} else {
ForcedReason::JudgedNotOneTable
})
} else {
None
},
});
if block.table_origin != TableOrigin::Tatr {
block.table_origin = if forced {
TableOrigin::LearnedForced
} else {
TableOrigin::LearnedRefine
};
}
}
}
let (key_split, table_spans) = table_pieces_of(&made);
let first = run[0];
if let Some(detail) = choice {
let p_is_table = builder_prob(run, &tables, &is_trace.probs);
let p_is_table_model = p_is_table.map(|_| IsTableModel::V1);
let split = split_of(detail.title_removed, key_split);
for span in table_spans {
if span.is_empty() {
continue;
}
signals.push((
first,
(
detail.p,
detail.score,
detail.margin,
p_is_table,
p_is_table_model,
split,
),
span,
));
}
}
replacement[first] = Some(made);
for &i in &run[1..] {
replacement[i] = Some(Vec::new());
}
}
if let Some(judged) = judged {
demote_unchosen(
&refs,
&tables,
judged,
weakness,
cells,
switches.demote_per_cell,
&mut replacement,
);
}
if switches.table_split_side_by_side {
let mut at = 0usize;
while at < page.len() {
let untouched =
replacement[at].is_none() && matches!(page[at].kind, BlockKind::TableCell { .. });
if !untouched {
at += 1;
continue;
}
let start = at;
while at < page.len()
&& replacement[at].is_none()
&& matches!(page[at].kind, BlockKind::TableCell { .. })
{
at += 1;
}
let run: Vec<Block> = page[start..at].to_vec();
replacement[start] = Some(super::table_split::split_tables(
run,
switches.table_split_side_by_side,
false,
));
for slot in replacement.iter_mut().take(at).skip(start + 1) {
*slot = Some(Vec::new());
}
}
}
let mut out_start = vec![0usize; replacement.len()];
let mut total = 0usize;
for (i, slot) in replacement.iter().enumerate() {
out_start[i] = total;
total += slot.as_ref().map_or(1, Vec::len);
}
let mut out: Vec<Block> = Vec::with_capacity(page.len());
let mut origin: Vec<usize> = Vec::with_capacity(page.len());
for (i, block) in page.into_iter().enumerate() {
match replacement[i].take() {
Some(made) => {
origin.extend(std::iter::repeat_n(i, made.len()));
out.extend(made);
}
None => {
origin.push(i);
out.push(block);
}
}
}
let remap: Option<Vec<Option<usize>>> = if switches.table_drop_empty_columns {
Some(drop_empty_columns_from_tables(&mut out, &mut origin))
} else {
None
};
for (i, block) in out.iter_mut().enumerate() {
block.index = i;
}
signals.sort_by_key(|(first, _, _)| *first);
let tables: Vec<TableSignal> = signals
.into_iter()
.map(
|(
first,
(p_judge, verifier_score, verifier_margin, p_is_table, p_is_table_model, split),
span,
)| {
let base = out_start.get(first).copied().unwrap_or(0);
let block_range = match &remap {
None => base + span.start..base + span.end,
Some(map) => {
let new: Vec<usize> = (base + span.start..base + span.end)
.filter_map(|old| map.get(old).copied().flatten())
.collect();
match (new.first(), new.last()) {
(Some(lo), Some(hi)) => *lo..hi + 1,
_ => out.len()..out.len(),
}
}
};
let bbox = union_of(&out, block_range.clone());
TableSignal {
p_judge,
verifier_score,
verifier_margin,
p_is_table,
p_is_table_model,
split,
bbox,
block_range,
}
},
)
.collect();
let page_signals = match (judged, judge_trace) {
(Some(_), Some(trace)) => Some(PageTableSignals {
tables,
p_best_rejected: trace.p_best_rejected,
n_candidates: trace.n_candidates,
refused: is_trace.refused,
drawn: drawn_flag,
}),
_ => None,
};
(out, origin, page_signals)
}
const SEPARATION_FLOOR: f64 = 0.1;
pub use crate::blocks::GridDoubt as Weakness;
pub fn may_overrule(weak: &Weakness) -> bool {
weak.fill < 0.8 && weak.full_rows <= 2
}
fn weakness_of(run: &[usize], page: &[&Block]) -> Option<Weakness> {
run.iter()
.filter_map(|&i| page.get(i))
.find_map(|block| block.grid)
}
fn text_reading_order(mut mine: Vec<usize>, words: &[pairs::PairWord]) -> Vec<usize> {
mine.sort_by(|&a, &b| words[b].bbox[3].total_cmp(&words[a].bbox[3]));
let mut lines: Vec<Vec<usize>> = Vec::new();
let mut line: Vec<usize> = Vec::new();
let mut line_top = f64::NAN;
for w in mine {
let top = words[w].bbox[3];
let height = words[w].bbox[3] - words[w].bbox[1];
if !line.is_empty() && (line_top - top).abs() > 0.5 * height {
lines.push(std::mem::take(&mut line));
}
if line.is_empty() {
line_top = top;
}
line.push(w);
}
if !line.is_empty() {
lines.push(line);
}
join_disputed_singletons(&mut lines, words);
let mut out = Vec::with_capacity(lines.iter().map(Vec::len).sum());
for line in &mut lines {
line.sort_by(|&a, &b| words[a].bbox[0].total_cmp(&words[b].bbox[0]));
let mut text: Vec<usize> = Vec::with_capacity(line.len());
let mut leaders: Vec<usize> = Vec::new();
for (i, &w) in line.iter().enumerate() {
let prev = i.checked_sub(1).map(|k| &words[line[k]]);
let next = line.get(i + 1).map(|&k| &words[k]);
if is_dot_fill(&words[w]) && !is_sentence_period(&words[w], prev, next) {
leaders.push(w);
} else {
text.push(w);
}
}
text.append(&mut leaders);
out.append(&mut text);
}
out
}
fn join_disputed_singletons(lines: &mut Vec<Vec<usize>>, words: &[pairs::PairWord]) {
if lines.len() < 2 {
return;
}
let extent = |line: &[usize]| {
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
for &w in line {
low = low.min(words[w].bbox[1]);
high = high.max(words[w].bbox[3]);
}
(low, high)
};
let mut target: Vec<Option<usize>> = vec![None; lines.len()];
for (i, line) in lines.iter().enumerate() {
if line.len() != 1 {
continue;
}
let w = line[0];
let height = words[w].bbox[3] - words[w].bbox[1];
if height <= 0.0 {
continue;
}
let overlap = |j: usize| {
let (low, high) = extent(&lines[j]);
(words[w].bbox[3].min(high) - words[w].bbox[1].max(low)).max(0.0)
};
let prev = if i > 0 && lines[i - 1].len() > 1 {
overlap(i - 1)
} else {
0.0
};
let next = if i + 1 < lines.len() && lines[i + 1].len() > 1 {
overlap(i + 1)
} else {
0.0
};
if prev > next && prev > 0.5 * height {
target[i] = Some(i - 1);
} else if next > prev && next > 0.5 * height {
target[i] = Some(i + 1);
}
}
for (i, choices) in target.iter().enumerate() {
if let Some(j) = choices {
let w = lines[i].pop().expect("a singleton line holds one word");
lines[*j].push(w);
}
}
lines.retain(|line| !line.is_empty());
}
fn is_sentence_period(
word: &pairs::PairWord,
prev: Option<&pairs::PairWord>,
next: Option<&pairs::PairWord>,
) -> bool {
if word.text != "." {
return false;
}
let tall = (word.bbox[3] - word.bbox[1]).max(f64::MIN_POSITIVE);
for neighbour in [prev, next].into_iter().flatten() {
if is_dot_fill(neighbour) {
let em = tall.max((neighbour.bbox[3] - neighbour.bbox[1]).max(f64::MIN_POSITIVE));
let gap = if neighbour.bbox[0] < word.bbox[0] {
word.bbox[0] - neighbour.bbox[2]
} else {
neighbour.bbox[0] - word.bbox[2]
};
if gap <= 1.5 * em {
return false;
}
}
}
if let Some(before) = prev {
let em = tall.max((before.bbox[3] - before.bbox[1]).max(f64::MIN_POSITIVE));
let gap = word.bbox[0] - before.bbox[2];
let drift = (word.bbox[1] - before.bbox[1]).abs();
if gap >= -0.02 * em && gap < 0.15 * em && drift < 0.3 * em {
return true;
}
}
if let Some(after) = next {
if after.text.chars().next().is_some_and(|c| c.is_uppercase()) {
let em = tall.max((after.bbox[3] - after.bbox[1]).max(f64::MIN_POSITIVE));
let gap = after.bbox[0] - word.bbox[2];
let drift = (after.bbox[1] - word.bbox[1]).abs();
if gap >= 0.15 * em && gap <= 1.0 * em && drift < 0.3 * em {
return true;
}
}
}
false
}
fn is_dot_fill(word: &pairs::PairWord) -> bool {
!word.text.is_empty() && word.text.chars().all(|c| matches!(c, '.' | '·' | '…'))
}
fn in_reading_order(mut mine: Vec<usize>, words: &[pairs::PairWord]) -> Vec<usize> {
mine.sort_by(|&a, &b| words[b].bbox[3].total_cmp(&words[a].bbox[3]));
let mut out = Vec::with_capacity(mine.len());
let mut line: Vec<usize> = Vec::new();
let mut line_top = f64::NAN;
for w in mine {
let top = words[w].bbox[3];
let height = words[w].bbox[3] - words[w].bbox[1];
if !line.is_empty() && (line_top - top).abs() > 0.5 * height {
line.sort_by(|&a, &b| words[a].bbox[0].total_cmp(&words[b].bbox[0]));
out.append(&mut line);
}
if line.is_empty() {
line_top = top;
}
line.push(w);
}
line.sort_by(|&a, &b| words[a].bbox[0].total_cmp(&words[b].bbox[0]));
out.append(&mut line);
out
}
pub const TABLE_IS_V2_FEATURES: usize = 29;
pub const TABLE_IS_V2_NAMES: [&str; TABLE_IS_V2_FEATURES] = [
"rows",
"cols",
"cells",
"numeric",
"words_cell",
"col_words_max",
"col_words_min",
"heading_share",
"p_head_max",
"p_head_mean",
"first_col_p_head",
"empty",
"height_share",
"wordy_share",
"first_col_words",
"words_filled_median",
"words_filled_mean",
"figure_share_filled",
"sentence_share",
"lines_median",
"run_on_share",
"row_overlap",
"column_start_spread",
"judge_p",
"from_builder",
"grid_rows",
"grid_columns",
"filled_share",
"has_rulings",
];
fn v2_is_filled(block: &Block) -> bool {
!block.text.trim().is_empty()
}
fn v2_median(mut values: Vec<f64>) -> f64 {
if values.is_empty() {
return 0.0;
}
values.sort_by(f64::total_cmp);
let mid = values.len() / 2;
if values.len().is_multiple_of(2) {
(values[mid - 1] + values[mid]) * 0.5
} else {
values[mid]
}
}
fn v2_stddev(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
var.sqrt()
}
fn v2_ends_sentence(text: &str) -> bool {
let trimmed = text.trim_end();
let mut chars = trimmed.chars().rev();
match (chars.next(), chars.next()) {
(Some(last), Some(before)) => {
matches!(last, '.' | '!' | '?') && before.is_ascii_lowercase()
}
_ => false,
}
}
fn v2_starts_lower(text: &str) -> bool {
text.trim_start()
.chars()
.next()
.is_some_and(|c| c.is_lowercase())
}
fn v2_lines_of_cell(spans: &[TextSpan]) -> usize {
let mut boxes: Vec<[f64; 4]> = spans
.iter()
.map(|s| [s.x, s.y, s.x + s.width, s.y + s.height])
.collect();
if boxes.is_empty() {
return 1;
}
boxes.sort_by(|a, b| b[3].total_cmp(&a[3]));
let mut lines: Vec<(f64, f64)> = Vec::new();
for b in boxes {
let mut placed = false;
for line in lines.iter_mut() {
let shorter = ((b[3] - b[1]).min(line.1 - line.0)).max(f64::MIN_POSITIVE);
let shared = (b[3].min(line.1) - b[1].max(line.0)).max(0.0);
if shared / shorter > 0.5 {
line.0 = line.0.min(b[1]);
line.1 = line.1.max(b[3]);
placed = true;
break;
}
}
if !placed {
lines.push((b[1], b[3]));
}
}
lines.len().max(1)
}
fn v2_row_overlap(cells: &[&Block]) -> f64 {
use std::collections::BTreeMap;
let mut by_row: BTreeMap<u16, Vec<&Block>> = BTreeMap::new();
for cell in cells.iter().filter(|c| v2_is_filled(c)) {
if let BlockKind::TableCell { row, .. } = cell.kind {
by_row.entry(row).or_default().push(cell);
}
}
let mut ratios = Vec::new();
for row in by_row.values() {
if row.len() < 2 {
continue;
}
let bottom = row
.iter()
.map(|c| c.bbox[1])
.fold(f64::NEG_INFINITY, f64::max);
let top = row.iter().map(|c| c.bbox[3]).fold(f64::INFINITY, f64::min);
let shared = (top - bottom).max(0.0);
let mean_height = row
.iter()
.map(|c| (c.bbox[3] - c.bbox[1]).max(0.0))
.sum::<f64>()
/ row.len() as f64;
if mean_height > 0.0 {
ratios.push(shared / mean_height);
}
}
if ratios.is_empty() {
1.0
} else {
ratios.iter().sum::<f64>() / ratios.len() as f64
}
}
fn v2_column_start_spread(cells: &[&Block]) -> f64 {
use std::collections::BTreeMap;
let mut by_column: BTreeMap<u16, Vec<&Block>> = BTreeMap::new();
for cell in cells.iter().filter(|c| v2_is_filled(c)) {
if let BlockKind::TableCell { column, .. } = cell.kind {
by_column.entry(column).or_default().push(cell);
}
}
let mut tops = Vec::new();
for column in by_column.values() {
let mut sorted = column.clone();
sorted.sort_by_key(|c| match c.kind {
BlockKind::TableCell { row, .. } => row,
_ => u16::MAX,
});
if let Some(first) = sorted.first() {
tops.push(first.bbox[3]);
}
}
let heights: Vec<f64> = cells
.iter()
.filter(|c| v2_is_filled(c))
.map(|c| (c.bbox[3] - c.bbox[1]).max(0.0))
.collect();
let median_height = {
let mut h = heights;
if h.is_empty() {
f64::NAN
} else {
h.sort_by(f64::total_cmp);
let mid = h.len() / 2;
if h.len().is_multiple_of(2) {
(h[mid - 1] + h[mid]) * 0.5
} else {
h[mid]
}
}
};
if !median_height.is_finite() || median_height <= 0.0 {
0.0
} else {
v2_stddev(&tops) / median_height
}
}
fn v2_reads_on(upper: &str, lower: &str) -> bool {
let upper = upper.trim_end();
let lower = lower.trim_start();
if upper.is_empty() || lower.is_empty() {
return false;
}
!v2_ends_sentence(upper) && v2_starts_lower(lower)
}
fn v2_run_on_share(cells: &[&Block]) -> f64 {
use std::collections::BTreeMap;
let mut by_column: BTreeMap<u16, Vec<&Block>> = BTreeMap::new();
for cell in cells.iter().filter(|c| v2_is_filled(c)) {
if let BlockKind::TableCell { column, .. } = cell.kind {
by_column.entry(column).or_default().push(cell);
}
}
let mut pairs = 0usize;
let mut on = 0usize;
for column in by_column.values() {
let mut sorted = column.clone();
sorted.sort_by_key(|c| match c.kind {
BlockKind::TableCell { row, .. } => row,
_ => u16::MAX,
});
for window in sorted.windows(2) {
pairs += 1;
if v2_reads_on(&window[0].text, &window[1].text) {
on += 1;
}
}
}
if pairs == 0 {
0.0
} else {
on as f64 / pairs as f64
}
}
fn v2_grid_size(cells: &[Block]) -> (usize, usize) {
let mut rows = 0usize;
let mut columns = 0usize;
for cell in cells {
if let BlockKind::TableCell {
row,
column,
row_span,
column_span,
..
} = cell.kind
{
rows = rows.max(usize::from(row) + usize::from(row_span.max(1)));
columns = columns.max(usize::from(column) + usize::from(column_span.max(1)));
}
}
(rows, columns)
}
#[doc(hidden)]
pub fn table_is_v2_features(
table: &[Block],
hints: &[CellHint],
height: f64,
judge_p: f64,
from_builder: bool,
has_rulings: bool,
) -> [f64; TABLE_IS_V2_FEATURES] {
let refs: Vec<&Block> = table.iter().collect();
let all: Vec<usize> = (0..table.len()).collect();
let padded: Vec<CellHint> = (0..table.len())
.map(|i| hints.get(i).copied().unwrap_or_default())
.collect();
let v1 = table_is_features(&refs, &all, &padded, height);
let filled: Vec<&&Block> = refs.iter().filter(|c| v2_is_filled(c)).collect();
let n_filled = filled.len().max(1) as f64;
let words: Vec<f64> = filled
.iter()
.map(|c| c.text.split_whitespace().count() as f64)
.collect();
let figure = filled.iter().filter(|c| is_figure(&c.text)).count() as f64 / n_filled;
let sentence = filled.iter().filter(|c| v2_ends_sentence(&c.text)).count() as f64 / n_filled;
let lines = v2_median(
filled
.iter()
.map(|c| v2_lines_of_cell(&c.spans) as f64)
.collect(),
);
let (grid_rows, grid_columns) = v2_grid_size(table);
let filled_share = if grid_rows == 0 || grid_columns == 0 {
0.0
} else {
filled.len() as f64 / (grid_rows * grid_columns) as f64
};
let mut x = [0.0f64; TABLE_IS_V2_FEATURES];
x[..TABLE_IS_FEATURES].copy_from_slice(&v1);
x[TABLE_IS_FEATURES] = v2_median(words.clone());
x[TABLE_IS_FEATURES + 1] = if words.is_empty() {
0.0
} else {
words.iter().sum::<f64>() / words.len() as f64
};
x[TABLE_IS_FEATURES + 2] = if filled.is_empty() { 0.0 } else { figure };
x[TABLE_IS_FEATURES + 3] = if filled.is_empty() { 0.0 } else { sentence };
x[TABLE_IS_FEATURES + 4] = lines;
x[TABLE_IS_FEATURES + 5] = v2_run_on_share(&refs);
x[TABLE_IS_FEATURES + 6] = v2_row_overlap(&refs);
x[TABLE_IS_FEATURES + 7] = v2_column_start_spread(&refs);
x[TABLE_IS_FEATURES + 8] = judge_p;
x[TABLE_IS_FEATURES + 9] = if from_builder { 1.0 } else { 0.0 };
x[TABLE_IS_FEATURES + 10] = grid_rows as f64;
x[TABLE_IS_FEATURES + 11] = grid_columns as f64;
x[TABLE_IS_FEATURES + 12] = filled_share;
x[TABLE_IS_FEATURES + 13] = if has_rulings { 1.0 } else { 0.0 };
x
}
pub const TABLE_IS_V2_THRESHOLD: f64 = models::table_is_v2::THRESHOLD;
pub fn table_is_v2_probability(x: &[f64; TABLE_IS_V2_FEATURES]) -> f64 {
super::probability(models::table_is_v2::predict_margin(x))
}
pub fn is_a_table_v2(x: &[f64; TABLE_IS_V2_FEATURES]) -> bool {
table_is_v2_probability(x) >= TABLE_IS_V2_THRESHOLD
}
#[derive(Debug, Clone)]
#[doc(hidden)]
pub struct FinalTableV2 {
pub bbox: [f64; 4],
pub kind: &'static str,
pub features: [f64; TABLE_IS_V2_FEATURES],
pub cells: Vec<usize>,
}
#[doc(hidden)]
pub fn has_rulings_under(
bbox: &[f64; 4],
rules: &[crate::text::HorizontalRule],
rulings: &[crate::text::Ruling],
) -> bool {
rules
.iter()
.any(|r| r.y >= bbox[1] && r.y <= bbox[3] && r.left <= bbox[2] && r.right >= bbox[0])
|| rulings.iter().any(|r| {
let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1));
let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1));
x0 <= bbox[2] && x1 >= bbox[0] && y0 <= bbox[3] && y1 >= bbox[1]
})
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn final_tables_for_is_table_v2(
out: &[Block],
origin: &[usize],
judged: Option<&[Option<usize>]>,
meta: &[JudgeRegionMeta],
builder_p: &[Option<f64>],
hints: &[CellHint],
rules: &[crate::text::HorizontalRule],
rulings: &[crate::text::Ruling],
height: f64,
) -> Vec<FinalTableV2> {
let refs: Vec<&Block> = out.iter().collect();
let tables = heuristic_tables(&refs);
let mut by: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
for (i, t) in tables.iter().enumerate() {
if let Some(t) = t {
by.entry(*t).or_default().push(i);
}
}
let grouped: Vec<Vec<Block>> = by
.values()
.map(|cells| cells.iter().map(|&i| out[i].clone()).collect())
.collect();
let order: Vec<Vec<usize>> = by.into_values().collect();
let mut finals = Vec::with_capacity(grouped.len());
for (table, cells) in grouped.into_iter().zip(order) {
let first = cells.first().copied().unwrap_or(0);
let from = origin.get(first).copied().unwrap_or(first);
let region = judged.and_then(|j| j.get(from).copied().flatten());
let (judge_p, kind, from_builder): (f64, &'static str, bool) = match region {
Some(k) if k != REJECTED => match meta.get(k) {
Some(m) => (m.judge_p, m.kind, m.from_builder),
None => (f64::NAN, "builder", true),
},
_ => (
builder_p.get(from).copied().flatten().unwrap_or(f64::NAN),
"builder",
true,
),
};
let bbox = table.iter().fold(
[
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
],
|mut acc, b| {
acc[0] = acc[0].min(b.bbox[0]);
acc[1] = acc[1].min(b.bbox[1]);
acc[2] = acc[2].max(b.bbox[2]);
acc[3] = acc[3].max(b.bbox[3]);
acc
},
);
let bbox = if bbox[0].is_infinite() {
[0.0, 0.0, 0.0, 0.0]
} else {
bbox
};
let cell_hints: Vec<CellHint> = cells
.iter()
.map(|&i| {
origin
.get(i)
.and_then(|&o| hints.get(o))
.copied()
.unwrap_or_default()
})
.collect();
let features = table_is_v2_features(
&table,
&cell_hints,
height,
judge_p,
from_builder,
has_rulings_under(&bbox, rules, rulings),
);
finals.push(FinalTableV2 {
bbox,
kind,
features,
cells,
});
}
finals
}
struct ReleasedBlock {
block: Block,
from: usize,
members: Vec<usize>,
}
fn join_line_of_cell(cell: &Block) -> crate::blocks::JoinLine<'_> {
let left = cell.spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
let left = if left.is_finite() { left } else { cell.bbox[0] };
let right = cell
.spans
.iter()
.map(crate::blocks::span_right)
.fold(f64::NEG_INFINITY, f64::max);
let right = if right.is_finite() {
right
} else {
cell.bbox[2]
};
crate::blocks::JoinLine {
left,
indent_left: left,
right,
baseline: crate::blocks::anchor_span(cell.spans.iter())
.map(|s| s.y)
.unwrap_or(cell.bbox[1]),
top: cell.bbox[3],
bottom: cell.bbox[1],
font_size: crate::blocks::dominant_font_size(cell.spans.iter()),
bold: crate::blocks::spans_read_bold(cell.spans.iter()),
face: crate::blocks::dominant_face_of_spans(&cell.spans),
text: &cell.text,
marker_or_item: crate::lists::marker_at(&cell.text, crate::lists::Lead::default())
.is_some(),
tabular: false,
}
}
fn release_cell(cell: &mut Block, hint: CellHint) {
if hint.heading {
cell.kind = BlockKind::Heading { level: 1 };
cell.confidence = hint.p_heading;
} else {
cell.kind = BlockKind::Paragraph;
cell.confidence = None;
}
cell.grid = None;
}
fn release_runs(
page: &[&Block],
origin: &[usize],
cells: &[usize],
hints: &[CellHint],
rules: &[crate::text::HorizontalRule],
column_order: bool,
) -> Vec<(usize, ReleasedBlock)> {
let mut columns: std::collections::BTreeMap<u16, Vec<usize>> = Default::default();
let mut spanning: std::collections::HashSet<usize> = Default::default();
for &i in cells {
let (column, span) = match page[i].kind {
BlockKind::TableCell {
column,
row_span,
column_span,
..
} => (column, column_span > 1 || row_span > 1),
_ => (u16::MAX, true),
};
columns.entry(column).or_default().push(i);
if span {
spanning.insert(i);
}
}
for column in columns.values_mut() {
column.sort_by(|&a, &b| {
let row = |i: usize| match page[i].kind {
BlockKind::TableCell { row, .. } => row,
_ => 0,
};
row(a).cmp(&row(b)).then_with(|| {
page[b].bbox[3]
.total_cmp(&page[a].bbox[3])
.then_with(|| a.cmp(&b))
})
});
}
let lines: std::collections::HashMap<usize, crate::blocks::JoinLine<'_>> = cells
.iter()
.map(|&i| (i, join_line_of_cell(page[i])))
.collect();
let mut runs: Vec<(u16, Vec<usize>)> = Vec::new();
for (column, members) in &columns {
let em = members
.iter()
.map(|&i| lines.get(&i).map(|l| l.font_size).unwrap_or(0.0))
.fold(0.0f64, f64::max)
.max(1.0);
let mut raw: Vec<f64> = members
.windows(2)
.map(|w| {
lines.get(&w[0]).map(|l| l.baseline).unwrap_or(0.0)
- lines.get(&w[1]).map(|l| l.baseline).unwrap_or(0.0)
})
.filter(|s| *s > 0.0 && *s < 4.0 * em)
.collect();
let column_pitch = crate::blocks::median_step(&mut raw);
let measure = members
.iter()
.filter(|i| !spanning.contains(i))
.map(|&i| lines.get(&i).map(|l| l.right).unwrap_or(0.0))
.fold(f64::NEG_INFINITY, f64::max);
let mut segment: Vec<usize> = Vec::new();
let flush = |segment: &mut Vec<usize>, runs: &mut Vec<(u16, Vec<usize>)>| {
let mut open: Vec<usize> = Vec::new();
for &i in segment.iter() {
let Some(&above) = open.last() else {
open.push(i);
continue;
};
let pitch = if open.len() >= 2 {
let mut steps: Vec<f64> = open
.windows(2)
.map(|w| {
lines.get(&w[0]).map(|l| l.baseline).unwrap_or(0.0)
- lines.get(&w[1]).map(|l| l.baseline).unwrap_or(0.0)
})
.collect();
crate::blocks::median_step(&mut steps)
} else {
let em = lines
.get(&above)
.map(|l| l.font_size)
.unwrap_or(0.0)
.max(lines.get(&i).map(|l| l.font_size).unwrap_or(0.0));
column_pitch.max(crate::blocks::SOLID_PITCH_EM * em)
};
let joins = match (lines.get(&above), lines.get(&i)) {
(Some(upper), Some(lower)) => crate::blocks::narrow_column_pair_joins(
upper, false, lower, pitch, measure, rules,
),
_ => false,
};
if joins {
open.push(i);
} else {
runs.push((*column, std::mem::take(&mut open)));
open.push(i);
}
}
if !open.is_empty() {
runs.push((*column, open));
}
segment.clear();
};
for &i in members {
if spanning.contains(&i) {
flush(&mut segment, &mut runs);
runs.push((*column, vec![i]));
} else {
segment.push(i);
}
}
flush(&mut segment, &mut runs);
}
let table_at = cells.iter().min().copied().unwrap_or(0);
if column_order && columns.len() >= 2 && runs.iter().any(|(_, r)| r.len() >= 2) {
runs.into_iter()
.map(|(_, members)| {
let from = origin.get(members[0]).copied().unwrap_or(members[0]);
let block = released_block(page, origin, &members, hints);
(
table_at,
ReleasedBlock {
block,
from,
members,
},
)
})
.collect()
} else {
let mut runs = runs;
runs.sort_by_key(|(_, members)| members.iter().min().copied().unwrap_or(0));
runs.into_iter()
.map(|(_, members)| {
let at = members.iter().min().copied().unwrap_or(table_at);
let from = origin.get(members[0]).copied().unwrap_or(members[0]);
let block = released_block(page, origin, &members, hints);
(
at,
ReleasedBlock {
block,
from,
members,
},
)
})
.collect()
}
}
fn released_block(
page: &[&Block],
origin: &[usize],
members: &[usize],
hints: &[CellHint],
) -> Block {
if members.len() >= 2 {
return joined(page, members);
}
let i = members[0];
let hint = origin
.get(i)
.and_then(|&o| hints.get(o))
.copied()
.unwrap_or_default();
let mut block = page[i].clone();
release_cell(&mut block, hint);
block
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn apply_table_is_v2(
out: Vec<Block>,
origin: Vec<usize>,
judged: Option<&[Option<usize>]>,
meta: &[JudgeRegionMeta],
builder_p: &[Option<f64>],
hints: &[CellHint],
rules: &[crate::text::HorizontalRule],
rulings: &[crate::text::Ruling],
height: f64,
switches: super::Switches,
) -> (Vec<Block>, Vec<usize>) {
let (out, origin, _, _) = apply_table_is_v2_traced(
out, origin, judged, meta, builder_p, hints, rules, rulings, height, switches,
);
(out, origin)
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct TableIsV2Verdict {
pub cells: Vec<usize>,
pub probability: f64,
pub kept: bool,
}
const CLEAN_COLUMN_ALIGN_TOLERANCE: f64 = 1.0;
const CLEAN_COLUMN_MAX_WORDS: usize = 2;
const CLEAN_COLUMN_MIN_CHARS: usize = 2;
fn table_has_clean_columns(cells: &[&Block]) -> bool {
use std::collections::{BTreeMap, BTreeSet};
if cells.is_empty() {
return false;
}
let mut rows: BTreeSet<u16> = BTreeSet::new();
let mut by_column: BTreeMap<u16, Vec<&Block>> = BTreeMap::new();
for &cell in cells {
match cell.kind {
BlockKind::TableCell { row, column, .. } => {
rows.insert(row);
by_column.entry(column).or_default().push(cell);
}
_ => return false,
}
}
if rows.len() < 2 || by_column.len() < 2 {
return false;
}
for column in by_column.values() {
let mut edges = Vec::new();
for cell in column.iter().filter(|c| v2_is_filled(c)) {
let edge = cell.spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
edges.push(if edge.is_finite() { edge } else { cell.bbox[0] });
}
if edges.len() >= 2 {
let lo = edges.iter().copied().fold(f64::INFINITY, f64::min);
let hi = edges.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if hi - lo > CLEAN_COLUMN_ALIGN_TOLERANCE {
return false;
}
}
}
for &cell in cells {
if !v2_is_filled(cell) {
continue;
}
if v2_ends_sentence(&cell.text) {
return false;
}
if cell.text.split_whitespace().count() > CLEAN_COLUMN_MAX_WORDS {
return false;
}
if cell.text.chars().filter(|c| !c.is_whitespace()).count() < CLEAN_COLUMN_MIN_CHARS {
return false;
}
}
true
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn apply_table_is_v2_traced(
out: Vec<Block>,
origin: Vec<usize>,
judged: Option<&[Option<usize>]>,
meta: &[JudgeRegionMeta],
builder_p: &[Option<f64>],
hints: &[CellHint],
rules: &[crate::text::HorizontalRule],
rulings: &[crate::text::Ruling],
height: f64,
switches: super::Switches,
) -> (
Vec<Block>,
Vec<usize>,
Vec<TableIsV2Verdict>,
Vec<Option<usize>>,
) {
let finals = final_tables_for_is_table_v2(
&out, &origin, judged, meta, builder_p, hints, rules, rulings, height,
);
let mut verdicts = Vec::with_capacity(finals.len());
for table in &finals {
let probability = table_is_v2_probability(&table.features);
let cells: Vec<&Block> = table.cells.iter().filter_map(|&i| out.get(i)).collect();
let kept = probability >= TABLE_IS_V2_THRESHOLD || table_has_clean_columns(&cells);
verdicts.push(TableIsV2Verdict {
cells: table.cells.clone(),
probability,
kept,
});
}
release_v2_verdicts(out, origin, verdicts, hints, rules, switches)
}
pub(crate) fn release_v2_verdicts(
mut out: Vec<Block>,
origin: Vec<usize>,
mut verdicts: Vec<TableIsV2Verdict>,
hints: &[CellHint],
rules: &[crate::text::HorizontalRule],
switches: super::Switches,
) -> (
Vec<Block>,
Vec<usize>,
Vec<TableIsV2Verdict>,
Vec<Option<usize>>,
) {
if !switches.table_v2_release_rejoin {
for verdict in &verdicts {
if verdict.kept {
continue;
}
for &i in &verdict.cells {
let hint = origin
.get(i)
.and_then(|&o| hints.get(o))
.copied()
.unwrap_or_default();
release_cell(&mut out[i], hint);
}
}
for (i, block) in out.iter_mut().enumerate() {
block.index = i;
}
let map = (0..out.len()).map(Some).collect();
return (out, origin, verdicts, map);
}
let mut queued: std::collections::BTreeMap<usize, Vec<ReleasedBlock>> = Default::default();
let mut consumed: std::collections::HashSet<usize> = Default::default();
{
let page: Vec<&Block> = out.iter().collect();
for verdict in &verdicts {
if verdict.kept {
continue;
}
for (at, released) in release_runs(
&page,
&origin,
&verdict.cells,
hints,
rules,
switches.table_v2_release_column_order,
) {
consumed.extend(released.members.iter().copied());
queued.entry(at).or_default().push(released);
}
}
}
let mut new_out: Vec<Block> = Vec::with_capacity(out.len());
let mut new_origin: Vec<usize> = Vec::with_capacity(origin.len());
let mut map: Vec<Option<usize>> = vec![None; out.len()];
for (i, block) in out.into_iter().enumerate() {
if let Some(released) = queued.remove(&i) {
for r in released {
map[r.members[0]] = Some(new_out.len());
new_origin.push(r.from);
new_out.push(r.block);
}
}
if consumed.contains(&i) {
continue;
}
map[i] = Some(new_out.len());
new_origin.push(origin[i]);
new_out.push(block);
}
for (i, block) in new_out.iter_mut().enumerate() {
block.index = i;
}
for verdict in &mut verdicts {
let mut cells: Vec<usize> = verdict
.cells
.iter()
.filter_map(|o| map.get(*o).copied().flatten())
.collect();
cells.sort_unstable();
verdict.cells = cells;
}
(new_out, new_origin, verdicts, map)
}
#[doc(hidden)]
pub fn remap_table_signal_ranges(
signals: &mut PageTableSignals,
map: &[Option<usize>],
out: &[Block],
) {
for table in &mut signals.tables {
let new: Vec<usize> = table
.block_range
.clone()
.filter_map(|old| map.get(old).copied().flatten())
.collect();
table.block_range = match (new.first(), new.last()) {
(Some(lo), Some(hi)) => *lo..hi + 1,
_ => out.len()..out.len(),
};
table.bbox = union_of(out, table.block_range.clone());
}
}
#[doc(hidden)]
pub fn fill_is_table_v2_signals(signals: &mut PageTableSignals, verdicts: &[TableIsV2Verdict]) {
for table in &mut signals.tables {
let range: Vec<usize> = table.block_range.clone().collect();
if let Some(verdict) = verdicts.iter().find(|v| v.cells == range) {
table.p_is_table = Some(verdict.probability);
table.p_is_table_model = Some(IsTableModel::V2);
}
}
}
fn split_out_stacked(mut out: Vec<Block>) -> (Vec<Block>, Vec<Option<usize>>) {
for (i, block) in out.iter_mut().enumerate() {
block.index = i;
}
let old_len = out.len();
let split = super::table_split::split_tables(out, false, true);
let mut remap: Vec<Option<usize>> = vec![None; old_len];
let mut next: Vec<Block> = Vec::with_capacity(split.len());
for mut block in split {
let old = block.index.min(old_len.saturating_sub(1));
remap[old] = Some(next.len());
block.index = next.len();
next.push(block);
}
(next, remap)
}
fn split_parts_of(out: &[Block], cells: &[usize], remap: &[Option<usize>]) -> Vec<Vec<usize>> {
let mut mapped: Vec<usize> = cells
.iter()
.filter_map(|&old| remap.get(old).copied().flatten())
.collect();
mapped.sort_unstable();
let mut parts: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = Vec::new();
let mut previous: Option<(usize, Option<(u16, u16)>)> = None;
for pos in mapped {
let coord = match out.get(pos).map(|block| &block.kind) {
Some(BlockKind::TableCell { row, column, .. }) => Some((*row, *column)),
_ => None,
};
let restart = match previous {
None => false,
Some((before, had)) => {
pos != before + 1 || matches!((had, coord), (Some(p), Some(c)) if c <= p)
}
};
if restart {
parts.push(std::mem::take(&mut current));
}
current.push(pos);
previous = Some((pos, coord));
}
if !current.is_empty() {
parts.push(current);
}
parts
}
#[doc(hidden)]
pub fn split_stacked_after_v2(
out: Vec<Block>,
origin: Vec<usize>,
verdicts: Option<Vec<TableIsV2Verdict>>,
signals: Option<PageTableSignals>,
) -> (
Vec<Block>,
Vec<usize>,
Option<Vec<TableIsV2Verdict>>,
Option<PageTableSignals>,
) {
let (out, remap) = split_out_stacked(out);
let mut next_origin: Vec<usize> = vec![0; out.len()];
for (old, new) in remap.iter().enumerate() {
if let Some(new) = new {
next_origin[*new] = origin.get(old).copied().unwrap_or(old);
}
}
let verdicts = verdicts.map(|verdicts| {
let mut remapped = Vec::with_capacity(verdicts.len());
for verdict in &verdicts {
let parts = split_parts_of(&out, &verdict.cells, &remap);
if parts.is_empty() {
remapped.push(TableIsV2Verdict {
cells: Vec::new(),
probability: verdict.probability,
kept: verdict.kept,
});
}
for cells in parts {
remapped.push(TableIsV2Verdict {
cells,
probability: verdict.probability,
kept: verdict.kept,
});
}
}
remapped
});
let signals = signals.map(|mut signals| {
let mut remapped = Vec::with_capacity(signals.tables.len());
for signal in &signals.tables {
let cells: Vec<usize> = signal.block_range.clone().collect();
let parts = split_parts_of(&out, &cells, &remap);
if parts.is_empty() {
remapped.push(TableSignal {
bbox: [0.0; 4],
block_range: out.len()..out.len(),
..signal.clone()
});
}
for part in parts {
let block_range = match (part.first(), part.last()) {
(Some(lo), Some(hi)) => *lo..hi + 1,
_ => out.len()..out.len(),
};
let bbox = union_of(&out, block_range.clone());
remapped.push(TableSignal {
bbox,
block_range,
..signal.clone()
});
}
}
signals.tables = remapped;
signals
});
(out, next_origin, verdicts, signals)
}
#[doc(hidden)]
pub fn split_final_tables_stacked(
out: Vec<Block>,
finals: Vec<FinalTableV2>,
) -> (Vec<Block>, Vec<FinalTableV2>) {
let (out, remap) = split_out_stacked(out);
let mut remapped = Vec::with_capacity(finals.len());
for table in &finals {
let parts = split_parts_of(&out, &table.cells, &remap);
if parts.is_empty() {
remapped.push(FinalTableV2 {
bbox: [0.0; 4],
kind: table.kind,
features: table.features,
cells: Vec::new(),
});
}
for cells in parts {
let bbox = cells.iter().fold(
[
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
],
|mut acc, &i| {
if let Some(block) = out.get(i) {
acc[0] = acc[0].min(block.bbox[0]);
acc[1] = acc[1].min(block.bbox[1]);
acc[2] = acc[2].max(block.bbox[2]);
acc[3] = acc[3].max(block.bbox[3]);
}
acc
},
);
let bbox = if bbox[0].is_infinite() {
[0.0; 4]
} else {
bbox
};
remapped.push(FinalTableV2 {
bbox,
kind: table.kind,
features: table.features,
cells,
});
}
}
(out, remapped)
}
#[cfg(test)]
mod tests {
use super::*;
fn header_word(text: &str, x0: f64, x1: f64, y: f64) -> pairs::PairWord {
pairs::PairWord {
text: text.to_string(),
bbox: [x0, y, x1, y + 10.0],
table: None,
row: None,
column: None,
}
}
fn header_cell(
words: Vec<usize>,
row: usize,
column: usize,
all: &[pairs::PairWord],
) -> super::super::grid::Cell {
let b = |f: fn(&[f64; 4]) -> f64, min: bool| {
words.iter().map(|&i| f(&all[i].bbox)).fold(
if min {
f64::INFINITY
} else {
f64::NEG_INFINITY
},
|a, v| if min { a.min(v) } else { a.max(v) },
)
};
super::super::grid::Cell {
bbox: [
b(|x| x[0], true),
b(|x| x[1], true),
b(|x| x[2], false),
b(|x| x[3], false),
],
words,
rows: vec![row],
columns: vec![column],
}
}
#[test]
fn no_candidate_regions_judge_as_the_full_judging_judges() {
let lines = vec![judge_line(0), judge_line(0)];
let words: Vec<PageWord> = Vec::new();
let all: Vec<judge::Proposal> = Vec::new();
let size = (612.0, 792.0);
assert_eq!(
judge_regions_from_proposals(1, &lines, &words, &all, size, super::super::SWITCHES),
judge_regions_scored(1, &lines, &words, &all, size, super::super::SWITCHES),
"the skip returns what the full judging computes"
);
let skipped =
judge_regions_from_proposals(1, &lines, &words, &all, size, super::super::SWITCHES);
assert_eq!(skipped.regions, vec![None]);
let trace = skipped.trace.expect("the skip keeps a trace");
assert!(trace.details.is_empty());
assert_eq!(trace.p_best_rejected, None);
assert_eq!(trace.n_candidates, 0);
assert_eq!(trace.builder_p, vec![None]);
}
#[test]
fn a_header_parted_only_by_word_spaces_is_one_cell() {
let words = vec![
header_word("Three", 100.0, 125.0, 100.0),
header_word("months", 128.0, 158.0, 100.0),
header_word("ended", 161.0, 185.0, 100.0),
header_word("2025", 100.0, 120.0, 80.0),
header_word("2024", 160.0, 180.0, 80.0),
header_word("1", 100.0, 105.0, 60.0),
header_word("2", 160.0, 165.0, 60.0),
];
let grid = separations::Grid {
cells: vec![
header_cell(vec![0], 0, 0, &words),
header_cell(vec![1], 0, 1, &words),
header_cell(vec![2], 0, 2, &words),
header_cell(vec![3], 1, 0, &words),
header_cell(vec![4], 1, 2, &words),
header_cell(vec![5], 2, 0, &words),
header_cell(vec![6], 2, 2, &words),
],
header_rows: 2,
};
let joined = spaced_header_joined(grid, &words);
let top: Vec<_> = joined.cells.iter().filter(|c| c.rows == [0]).collect();
assert_eq!(top.len(), 1, "the header came apart: {top:?}");
assert_eq!(top[0].columns, vec![0, 1, 2]);
assert_eq!(top[0].words, vec![0, 1, 2]);
assert_eq!(
joined.cells.iter().filter(|c| c.rows == [1]).count(),
2,
"two header cells a column gap apart were joined"
);
}
#[test]
fn a_table_the_model_refuses_is_rejected_and_one_it_keeps_is_not() {
let text = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/learned/table_is_parity.json"
))
.expect("fixture");
let fixture: serde_json::Value = serde_json::from_str(&text).expect("json");
let number = |v: &serde_json::Value| v.as_f64().unwrap_or(f64::NAN);
let (mut refused, mut kept) = (0, 0);
for table in fixture["tables"].as_array().expect("tables") {
let cells = table["cells"].as_array().expect("cells");
let page: Vec<Block> = cells
.iter()
.enumerate()
.map(|(i, c)| {
let b = c["bbox"].as_array().expect("bbox");
Block {
kind: BlockKind::TableCell {
row: c["row"].as_u64().expect("row") as u16,
column: c["column"].as_u64().expect("column") as u16,
row_span: 1,
column_span: 1,
header: false,
},
page: 0,
bbox: [number(&b[0]), number(&b[1]), number(&b[2]), number(&b[3])],
text: c["text"].as_str().unwrap_or_default().to_string(),
spans: Vec::new(),
index: i,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
})
.collect();
let hints: Vec<CellHint> = cells
.iter()
.map(|c| CellHint {
heading: false,
p_heading: c["p"].as_f64(),
})
.collect();
let refs: Vec<&Block> = page.iter().collect();
let tables = heuristic_tables(&refs);
if tables
.iter()
.flatten()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= 1
{
continue;
}
let (out, _) = refused_by_the_table_model_traced(
&refs,
&vec![None; page.len()],
&hints,
number(&table["height"]),
);
if number(&table["probability"]) < models::table_is::THRESHOLD {
assert!(
out.iter().all(|j| *j == Some(REJECTED)),
"a refused table stayed a table"
);
refused += 1;
} else {
assert!(out.iter().all(|j| j.is_none()), "a kept table was refused");
kept += 1;
}
}
assert!(
refused >= 3 && kept >= 3,
"the fixture holds {refused} refused and {kept} kept tables"
);
}
#[test]
fn the_table_or_not_model_reads_a_table_as_the_python_does() {
let text = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/learned/table_is_parity.json"
))
.expect("fixture");
let fixture: serde_json::Value = serde_json::from_str(&text).expect("json");
let number = |v: &serde_json::Value| v.as_f64().unwrap_or(f64::NAN);
assert_eq!(
fixture["names"].as_array().expect("names").len(),
TABLE_IS_FEATURES
);
let tables = fixture["tables"].as_array().expect("tables");
assert!(tables.len() >= 16, "too few tables to hold the model");
for (n, table) in tables.iter().enumerate() {
let cells = table["cells"].as_array().expect("cells");
let blocks: Vec<Block> = cells
.iter()
.enumerate()
.map(|(i, c)| {
let b = c["bbox"].as_array().expect("bbox");
Block {
kind: BlockKind::TableCell {
row: c["row"].as_u64().expect("row") as u16,
column: c["column"].as_u64().expect("column") as u16,
row_span: 1,
column_span: 1,
header: false,
},
page: 0,
bbox: [number(&b[0]), number(&b[1]), number(&b[2]), number(&b[3])],
text: c["text"].as_str().unwrap_or_default().to_string(),
spans: Vec::new(),
index: i,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
})
.collect();
let refs: Vec<&Block> = blocks.iter().collect();
let hints: Vec<CellHint> = cells
.iter()
.map(|c| CellHint {
heading: false,
p_heading: c["p"].as_f64(),
})
.collect();
let all: Vec<usize> = (0..blocks.len()).collect();
let x = table_is_features(&refs, &all, &hints, number(&table["height"]));
for (f, v) in x.iter().enumerate() {
let python = number(&table["features"][f]);
assert!(
(v - python).abs() <= 1e-9 * python.abs().max(1.0),
"table {n}: feature {f} ({}) is {v} here and {python} in Python",
fixture["names"][f]
);
}
let p = table_is_probability(&x);
let python = number(&table["probability"]);
assert!(
(p - python).abs() <= 1e-6,
"table {n}: {p} against LightGBM's {python}"
);
}
}
#[test]
fn the_dump_s_verdicts_do_not_read_the_table_model() {
fn span(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> TextSpan {
let words: Vec<&str> = text.split_whitespace().collect();
let n = words.len().max(1) as f64;
let char_bounds = words
.iter()
.enumerate()
.map(|(k, _)| {
let left = x0 + (x1 - x0) * k as f64 / n;
let right = x0 + (x1 - x0) * (k as f64 + 0.9) / n;
[left, y0, right, y1]
})
.collect();
TextSpan {
text: text.to_string(),
x: x0,
y: y0,
width: x1 - x0,
height: y1 - y0,
font_size: 10.0,
char_bounds,
..Default::default()
}
}
let mut blocks = Vec::new();
let mut head = Block::new(
BlockKind::Paragraph,
0,
[72.0, 700.0, 400.0, 712.0],
"Quarterly revenue by region".to_string(),
0,
);
head.spans = vec![span(
"Quarterly revenue by region",
72.0,
700.0,
250.0,
712.0,
)];
blocks.push(head);
for row in 0..2u16 {
for column in 0..2u16 {
let x0 = 72.0 + f64::from(column) * 90.0;
let y0 = 640.0 - f64::from(row) * 20.0;
let text = format!("value {row} {column}");
let mut b = Block::new(
BlockKind::TableCell {
row,
column,
row_span: 1,
column_span: 1,
header: row == 0,
},
0,
[x0, y0, x0 + 80.0, y0 + 12.0],
text.clone(),
blocks.len(),
);
b.spans = vec![span(&text, x0, y0, x0 + 80.0, y0 + 12.0)];
blocks.push(b);
}
}
let mut tail = Block::new(
BlockKind::Paragraph,
0,
[72.0, 560.0, 400.0, 572.0],
"Totals exclude tax".to_string(),
blocks.len(),
);
tail.spans = vec![span("Totals exclude tax", 72.0, 560.0, 200.0, 572.0)];
blocks.push(tail);
let sizes = [(612.0, 792.0)];
let images: [Vec<[f64; 4]>; 1] = [Vec::new()];
let facts = super::super::document_facts(&blocks);
let no_rules: Vec<crate::text::HorizontalRule> = Vec::new();
let verdicts = || {
(
super::super::cell_hints(&blocks, &sizes, &images),
judge_regions(&blocks, sizes[0], &no_rules, &facts, super::super::SWITCHES),
super::super::page_confidence(&blocks, &sizes, &images),
)
};
let keep_everything = {
let _force = TableIsThresholdGuard::set(0.0);
verdicts()
};
assert!(
keep_everything.0.iter().any(|h| h.p_heading.is_some()),
"the fixture asked the heading model of nothing"
);
let refuse_everything = {
let _force = TableIsThresholdGuard::set(1.0);
verdicts()
};
assert_eq!(
keep_everything, refuse_everything,
"a dumped verdict moved with the table-or-not bar: the is-table \
model reaches a refine-off record, and the exclusion has to go"
);
let refs: Vec<&Block> = blocks.iter().collect();
let cells: Vec<usize> = (1..5).collect();
let hints = vec![
CellHint {
heading: false,
p_heading: Some(0.5),
};
blocks.len()
];
let p = table_is_probability(&table_is_features(&refs, &cells, &hints, sizes[0].1));
assert!(
(0.0..1.0).contains(&p),
"the fixture's table reads p {p}: the forced bars prove nothing on it"
);
let kept = {
let _force = TableIsThresholdGuard::set(0.0);
is_a_table(&refs, &cells, &hints, sizes[0].1)
};
let dropped = {
let _force = TableIsThresholdGuard::set(1.0);
is_a_table(&refs, &cells, &hints, sizes[0].1)
};
assert!(
kept && !dropped,
"the forced bar moved no table-or-not verdict: the hook is dead, \
and the equality above pins nothing"
);
}
fn cell_word(x: f64, top: f64, height: f64) -> pairs::PairWord {
pairs::PairWord {
text: "w".into(),
bbox: [x, top - height, x + 5.0, top],
table: None,
row: None,
column: None,
}
}
#[test]
fn words_of_a_cell_read_line_by_line_whatever_their_drift() {
let words: Vec<pairs::PairWord> = vec![
cell_word(30.0, 10.0, 1.0),
cell_word(10.0, 9.8, 1.0),
cell_word(20.0, 9.4, 1.0),
cell_word(10.0, 7.0, 1.0),
cell_word(5.0, 8.8, 1.0),
];
assert_eq!(
in_reading_order((0..5).collect(), &words),
vec![1, 0, 2, 4, 3]
);
let many: Vec<pairs::PairWord> = (0..500)
.map(|i| cell_word(((i * 37) % 101) as f64, 100.0 - (i % 17) as f64 * 0.3, 1.0))
.collect();
assert_eq!(in_reading_order((0..500).collect(), &many).len(), 500);
}
#[test]
fn a_cell_of_two_stacked_lines_joins_top_line_first() {
let words = vec![
header_word("March", 410.0, 433.0, 648.87),
header_word("2025", 425.0, 445.0, 640.87),
header_word("31,", 435.0, 445.0, 648.87),
];
let made = header_cell(vec![0, 1, 2], 0, 2, &words);
let source = cell(made.bbox, "", true, Vec::new());
let refs = vec![&source];
let from = vec![0, 0, 0];
let lined = cell_block(&made, &words, &from, &refs, None, 0, true, None, true);
assert_eq!(lined.text, "March 31, 2025");
assert_eq!(lined.bbox, made.bbox);
let stored = cell_block(&made, &words, &from, &refs, None, 0, true, None, false);
assert_eq!(stored.text, "March 2025 31,");
}
#[test]
fn a_single_line_cell_joins_as_stored() {
let words = vec![
header_word("March", 410.0, 433.0, 648.87),
header_word("31,", 435.0, 445.0, 648.87),
];
let made = header_cell(vec![0, 1], 0, 2, &words);
let source = cell(made.bbox, "", true, Vec::new());
let refs = vec![&source];
let from = vec![0, 0];
let lined = cell_block(&made, &words, &from, &refs, None, 0, true, None, true);
assert_eq!(lined.text, "March 31,");
}
#[test]
fn a_fallback_cell_takes_only_its_own_words_spans() {
fn span(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> TextSpan {
TextSpan {
text: text.to_string(),
x: x0,
y: y0,
width: x1 - x0,
height: y1 - y0,
font_size: 10.0,
..Default::default()
}
}
let words = vec![
header_word("Concentric", 64.0, 94.0, 240.0),
header_word("knee", 99.0, 110.0, 240.0),
header_word("flexor", 115.0, 132.0, 240.0),
header_word("PLA", 64.0, 77.0, 226.0),
];
let left = header_cell(vec![0], 1, 0, &words);
let right = header_cell(vec![1, 2], 1, 1, &words);
let source = cell(
[56.0, 226.0, 140.0, 250.0],
"Concentric knee flexor PLA",
false,
vec![
span("Concentric knee flexor", 64.0, 240.0, 132.0, 248.0),
span("PLA", 64.0, 226.0, 77.0, 234.0),
],
);
let refs = vec![&source];
let from = vec![0, 0, 0, 0];
let held = cell_spans(&left, &words, &from, &refs);
assert_eq!(
held.iter().map(|s| s.text.clone()).collect::<Vec<_>>(),
vec!["Concentric"],
"the left cell takes its word, not the block's spans: {held:?}"
);
assert_eq!(
[held[0].x, held[0].y, held[0].width, held[0].height],
[64.0, 240.0, 30.0, 10.0],
"the clip is the word box, not the line span: {held:?}"
);
let held = cell_spans(&right, &words, &from, &refs);
assert_eq!(
held.iter().map(|s| s.text.clone()).collect::<Vec<_>>(),
vec!["knee", "flexor"],
"the right cell takes its words, not the block's spans: {held:?}"
);
let made = cell_block(&left, &words, &from, &refs, None, 0, false, None, true);
assert_eq!(made.text, "Concentric");
assert_eq!(made.spans.len(), 1);
let made = cell_block(&right, &words, &from, &refs, None, 0, false, None, true);
assert_eq!(made.text, "knee flexor");
assert_eq!(made.spans.len(), 2);
}
#[test]
fn a_judged_region_grows_to_the_tables_it_touches_and_never_cuts_one() {
let tables = [Some(0), Some(0), Some(0), Some(0), None, Some(1), Some(1)];
let got = grown(vec![vec![1, 2, 3], vec![3, 4]], &tables);
assert_eq!(got, vec![vec![0, 1, 2, 3, 4]]);
assert_eq!(grown(vec![vec![4]], &tables), vec![vec![4]]);
assert_eq!(grown(vec![vec![6]], &tables), vec![vec![5, 6]]);
}
#[test]
fn a_list_item_stays_out_of_a_judged_region() {
let block =
|kind: BlockKind| Block::new(kind, 0, [0.0, 0.0, 10.0, 10.0], "x".to_string(), 0);
let cell = BlockKind::TableCell {
row: 0,
column: 0,
row_span: 1,
column_span: 1,
header: false,
};
let page = [
block(BlockKind::Paragraph),
block(BlockKind::ListItem {
level: 0,
ordinal: None,
}),
block(cell),
];
let refs: Vec<&Block> = page.iter().collect();
let got = judged_runs(&[Some(0), Some(0), Some(0)], &refs);
assert_eq!(
got,
vec![vec![0, 2]],
"the list item was taken into the table"
);
}
#[test]
fn a_region_is_one_table_only_when_it_is_that_table_exactly() {
let tables = [Some(0), Some(0), None, Some(1)];
assert!(is_one_table(&[0, 1], &tables));
assert!(!is_one_table(&[0], &tables));
assert!(!is_one_table(&[0, 1, 2], &tables));
assert!(!is_one_table(&[2], &tables));
}
fn cell(bbox: [f64; 4], text: &str, header: bool, spans: Vec<TextSpan>) -> Block {
Block {
kind: BlockKind::TableCell {
row: 0,
column: 0,
row_span: 1,
column_span: 1,
header,
},
page: 0,
bbox,
text: text.to_string(),
spans,
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
}
#[test]
fn a_rejected_table_goes_back_column_by_column_with_its_heading() {
let at = |row: u16, column: u16, text: &str| {
let mut b = cell([0.0; 4], text, false, Vec::new());
b.kind = BlockKind::TableCell {
row,
column,
row_span: 1,
column_span: 1,
header: false,
};
b
};
let blocks = [
at(0, 0, "Basic EPS is calculated by dividing"),
at(0, 1, "Operating leases"),
at(1, 0, "the net profit by the weighted average."),
at(1, 1, "The minimum lease payments are expensed"),
at(2, 0, "Diluted EPS adjusts the shares."),
at(2, 1, "on a straight-line basis."),
];
let page: Vec<&Block> = blocks.iter().collect();
let tables = vec![Some(0); page.len()];
let judged = vec![Some(REJECTED); page.len()];
let mut hints = vec![CellHint::default(); page.len()];
hints[1] = CellHint {
heading: true,
p_heading: Some(0.9),
};
let never: &Builder<'_> = &|_, _| None;
let run = |per_cell: bool| {
let mut replacement: Vec<Option<Vec<Block>>> = vec![None; page.len()];
demote_unchosen(
&page,
&tables,
&judged,
never,
&hints,
per_cell,
&mut replacement,
);
replacement
.into_iter()
.flatten()
.flatten()
.map(|b| (matches!(b.kind, BlockKind::Heading { .. }), b.text))
.collect::<Vec<_>>()
};
assert_eq!(
run(true),
vec![
(
false,
"Basic EPS is calculated by dividing the net profit by the weighted \
average. Diluted EPS adjusts the shares."
.to_string()
),
(true, "Operating leases".to_string()),
(
false,
"The minimum lease payments are expensed on a straight-line basis.".to_string()
),
],
"the left column, then the heading, then the right column's text"
);
assert_eq!(
run(false)[0].1,
"Basic EPS is calculated by dividing Operating leases"
);
}
#[test]
fn a_run_breaks_where_reading_order_jumps_to_the_next_column() {
let blocks = [
cell([10.0, 100.0, 90.0, 110.0], "left a", false, vec![]),
cell([10.0, 88.0, 90.0, 98.0], "left b", false, vec![]),
cell([110.0, 700.0, 190.0, 710.0], "right a", false, vec![]),
cell([110.0, 688.0, 190.0, 698.0], "right b", false, vec![]),
];
let page: Vec<&Block> = blocks.iter().collect();
let inside = [true; 4];
let sizes = [10.0; 4];
let got = runs(&page, &inside, &sizes);
assert_eq!(
got,
vec![vec![0, 1], vec![2, 3]],
"the two columns' tables were run together, so one decoder saw \
both and the second was judged on the first's record"
);
}
#[test]
fn a_run_survives_the_next_row_of_the_same_table() {
let blocks = [
cell([10.0, 100.0, 90.0, 110.0], "row one", false, vec![]),
cell([10.0, 88.0, 90.0, 98.0], "row two", false, vec![]),
cell([10.0, 76.0, 90.0, 86.0], "row three", false, vec![]),
];
let page: Vec<&Block> = blocks.iter().collect();
let got = runs(&page, &[true; 3], &[10.0; 3]);
assert_eq!(got, vec![vec![0, 1, 2]], "one table was cut into pieces");
}
#[test]
fn a_wobbling_row_is_not_a_column_jump() {
let blocks = [
cell([10.0, 100.0, 40.0, 110.0], "a", false, vec![]),
cell([50.0, 105.0, 90.0, 115.0], "b", false, vec![]),
];
let page: Vec<&Block> = blocks.iter().collect();
let got = runs(&page, &[true; 2], &[10.0; 2]);
assert_eq!(
got,
vec![vec![0, 1]],
"a row whose cells do not share an exact top was cut in two"
);
}
fn kv_word(text: &str, x0: f64, x1: f64, y: f64) -> pairs::PairWord {
pairs::PairWord {
text: text.to_string(),
bbox: [x0, y, x1, y + 10.0],
table: None,
row: None,
column: None,
}
}
fn kv_cell(
words: Vec<usize>,
rows: Vec<usize>,
columns: Vec<usize>,
all: &[pairs::PairWord],
) -> super::super::grid::Cell {
let edge = |f: fn(&[f64; 4]) -> f64, min: bool| {
words.iter().map(|&i| f(&all[i].bbox)).fold(
if min {
f64::INFINITY
} else {
f64::NEG_INFINITY
},
|a, v| if min { a.min(v) } else { a.max(v) },
)
};
super::super::grid::Cell {
bbox: [
edge(|x| x[0], true),
edge(|x| x[1], true),
edge(|x| x[2], false),
edge(|x| x[3], false),
],
words,
rows,
columns,
}
}
fn section_grid(
rows: &[(&str, &str)],
section: usize,
span_section: bool,
) -> (Vec<pairs::PairWord>, separations::Grid) {
let mut words = Vec::new();
let mut cells = Vec::new();
for (r, (left, right)) in rows.iter().enumerate() {
let y = 200.0 - 20.0 * r as f64;
if r == section {
let from = words.len();
for (k, word) in left.split_whitespace().enumerate() {
let x = 100.0 + 30.0 * k as f64;
words.push(kv_word(word, x, x + 25.0, y));
}
let mine: Vec<usize> = (from..words.len()).collect();
let span = if span_section {
vec![r, r + 1]
} else {
vec![r]
};
cells.push(kv_cell(mine, span, vec![0, 1], &words));
} else {
let a = words.len();
words.push(kv_word(left, 100.0, 150.0, y));
words.push(kv_word(right, 160.0, 210.0, y));
cells.push(kv_cell(vec![a], vec![r], vec![0], &words));
cells.push(kv_cell(vec![a + 1], vec![r], vec![1], &words));
}
}
(
words,
separations::Grid {
cells,
header_rows: 1,
},
)
}
fn section_grid_n(
rows: &[&[&str]],
section: usize,
) -> (Vec<pairs::PairWord>, separations::Grid) {
let n_cols = rows
.iter()
.enumerate()
.filter(|(r, _)| *r != section)
.map(|(_, row)| row.len())
.max()
.unwrap_or(0);
let mut words = Vec::new();
let mut cells = Vec::new();
for (r, row) in rows.iter().enumerate() {
let y = 200.0 - 20.0 * r as f64;
if r == section {
let from = words.len();
for (k, word) in row[0].split_whitespace().enumerate() {
let x = 100.0 + 30.0 * k as f64;
words.push(kv_word(word, x, x + 25.0, y));
}
let mine: Vec<usize> = (from..words.len()).collect();
cells.push(kv_cell(mine, vec![r], (0..n_cols).collect(), &words));
} else {
for (c, text) in row.iter().enumerate() {
let a = words.len();
let x0 = 100.0 + 60.0 * c as f64;
words.push(kv_word(text, x0, x0 + 50.0, y));
cells.push(kv_cell(vec![a], vec![r], vec![c], &words));
}
}
}
(
words,
separations::Grid {
cells,
header_rows: 1,
},
)
}
#[test]
fn a_section_row_between_key_value_blocks_splits_the_table() {
let rows = [
("Mr = 623.79", "cubic"),
("a = 22.3794 (8) Å", "= 2.065"),
("Z = 4", "red"),
("V = 2006.85", "prism"),
("Data collection", ""),
("T min = 0.219", "graphite"),
("h = 0→26", "sealed tube"),
("k = 0→14", "none"),
("R int = 0.042", "decay: none"),
];
let (words, grid) = section_grid(&rows, 4, false);
let pieces = key_value_sections_split(&grid, &words).expect("the grid did not split");
let (first, second) = match &pieces[..] {
[SplitPiece::Table {
cells: a,
header_rows: before,
}, SplitPiece::Section { row }, SplitPiece::Table {
cells: b,
header_rows: after,
}] => {
assert_eq!(*row, 4, "the split is at the wrong row");
assert_eq!((*before, *after), (1, 0), "the header rows did not follow");
(a, b)
}
other => panic!("no split: {other:?}"),
};
for (n, table) in [first, second].into_iter().enumerate() {
assert!(
table.iter().all(|c| c.rows.iter().all(|&r| r < 4)),
"table {n} rows were not renumbered from 0"
);
assert_eq!(
table.iter().map(|c| c.rows[0]).max(),
Some(3),
"table {n} lost a row in the split"
);
for cell in table {
let text = cell
.words
.iter()
.map(|&w| words[w].text.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(
!text.contains("Data collection"),
"table {n} kept the section: {text:?}"
);
}
}
assert_eq!(
first.len() + second.len(),
16,
"cells went missing in the split"
);
let wide: &[&[&str]] = &[
&["a", "=", "22.3794 (8) Å"],
&["b =", "13.1", "cubic"],
&["colour", "red", "prism"],
&["habit", "plate", "small"],
&["Data collection"],
&["Tmin", "=", "0.219"],
&["h", "=", "0→26"],
&["= 14", "k", "none"],
&["Rint", "=", "0.042"],
];
let (words, grid) = section_grid_n(wide, 4);
let pieces =
key_value_sections_split(&grid, &words).expect("the three-column grid did not split");
match &pieces[..] {
[SplitPiece::Table {
cells: a,
header_rows: before,
}, SplitPiece::Section { row }, SplitPiece::Table {
cells: b,
header_rows: after,
}] => {
assert_eq!(*row, 4, "the split is at the wrong row");
assert_eq!((*before, *after), (1, 0), "the header rows did not follow");
assert_eq!(
a.len() + b.len(),
24,
"cells went missing in the three-column split"
);
for (n, table) in [a, b].into_iter().enumerate() {
assert!(
table.iter().all(|c| c.rows.iter().all(|&r| r < 4)),
"table {n} rows were not renumbered from 0"
);
}
}
other => panic!("no split: {other:?}"),
}
}
#[test]
fn a_section_row_in_a_table_of_figures_does_not_split_it() {
let rows = [
("Revenues", "1,234"),
("Costs", "987"),
("Margin", "12%"),
("Tax", "345"),
("Geographic locations", ""),
("North", "654"),
("South", "321"),
("East", "111"),
("West", "222"),
];
let (words, grid) = section_grid(&rows, 4, false);
assert!(
key_value_sections_split(&grid, &words).is_none(),
"a financial table split at its section row"
);
let wide: &[&[&str]] = &[
&["Revenues", "Q1", "Q2", "1,234"],
&["Costs", "Q1", "Q2", "987"],
&["Margin", "Q1", "Q2", "12%"],
&["Tax", "Q1", "Q2", "345"],
&["Geographic locations"],
&["North", "Q1", "Q2", "654"],
&["South", "Q1", "Q2", "321"],
&["East", "Q1", "Q2", "111"],
&["West", "Q1", "Q2", "222"],
];
let (words, grid) = section_grid_n(wide, 4);
assert!(
key_value_sections_split(&grid, &words).is_none(),
"a four-column financial table split at its section row"
);
}
#[test]
fn a_section_cell_spanning_two_rows_vetoes_the_split() {
let rows = [
("Mr = 623.79", "cubic"),
("Z = 4", "red"),
("V = 2006.85", "prism"),
("Data collection", ""),
("T min = 0.219", "graphite"),
("h = 0→26", "sealed tube"),
("k = 0→14", "none"),
("R int = 0.042", "decay: none"),
("F 000 = 1176", "observed"),
];
let (words, grid) = section_grid(&rows, 3, true);
assert!(
key_value_sections_split(&grid, &words).is_none(),
"the split tore through a spanning section cell"
);
}
#[test]
fn a_kept_table_behind_a_joined_refusal_keeps_its_own_signal() {
let col0 = [
"Anna Berg joined",
"the board in late",
"spring and now",
"chairs the audit",
"group each year",
];
let col1 = [
"she previously served as chief",
"financial officer for a decade",
"across three continents with",
"patience through every season",
"and retired last spring.",
];
let mut page: Vec<Block> = Vec::new();
for r in 0..5u16 {
let y = 700.0 - 14.0 * f64::from(r);
for (c, text) in [col0[usize::from(r)], col1[usize::from(r)]]
.iter()
.enumerate()
{
let x = 70.0 + 150.0 * c as f64;
let mut b = Block::new(
BlockKind::TableCell {
row: r,
column: c as u16,
row_span: 1,
column_span: 1,
header: false,
},
0,
[x, y, x + 140.0, y + 12.0],
text.to_string(),
page.len(),
);
b.spans = vec![TextSpan {
x,
y: y + 2.0,
width: 100.0,
height: 10.0,
font_size: 10.0,
font_name: Some("Helvetica".to_string()),
text: text.to_string(),
..Default::default()
}];
page.push(b);
}
}
let head = ["", "2025", "2024", "2023"];
for (c, t) in head.iter().enumerate() {
let x = 70.0 + 90.0 * c as f64;
page.push(Block::new(
BlockKind::TableCell {
row: 0,
column: c as u16,
row_span: 1,
column_span: 1,
header: false,
},
0,
[x, 560.0, x + 80.0, 572.0],
t.to_string(),
page.len(),
));
}
let labels = ["Revenue", "Costs", "Profit"];
for r in 1..=3u16 {
let y = 560.0 - 20.0 * f64::from(r);
page.push(Block::new(
BlockKind::TableCell {
row: r,
column: 0,
row_span: 1,
column_span: 1,
header: false,
},
0,
[70.0, y, 150.0, y + 12.0],
labels[usize::from(r) - 1].to_string(),
page.len(),
));
for c in 1..4u16 {
let x = 70.0 + 90.0 * f64::from(c);
page.push(Block::new(
BlockKind::TableCell {
row: r,
column: c,
row_span: 1,
column_span: 1,
header: false,
},
0,
[x, y, x + 80.0, y + 12.0],
format!("{}", 10_000 + usize::from(r) * 100 + usize::from(c)),
page.len(),
));
}
}
assert_eq!(page.len(), 26, "ten refused cells, sixteen kept");
let origin: Vec<usize> = (0..page.len()).collect();
let hints = vec![
CellHint {
heading: false,
p_heading: Some(0.1),
};
page.len()
];
let switches = super::super::Switches {
table_v2_release_rejoin: true,
table_v2_release_column_order: false,
..super::super::SWITCHES
};
let (out, out_origin, verdicts, map) = apply_table_is_v2_traced(
page.clone(),
origin,
None,
&[],
&vec![None; page.len()],
&hints,
&[],
&[],
792.0,
switches,
);
assert_eq!(verdicts.len(), 2, "two tables, two verdicts");
assert!(
!verdicts[0].kept && verdicts[1].kept,
"the prose refuses and the statement keeps: {verdicts:?}"
);
assert_eq!(out.len(), 18, "ten cells joined into two");
assert_eq!(verdicts[0].cells, vec![0, 1]);
assert_eq!(
verdicts[1].cells,
(2..18).collect::<Vec<usize>>(),
"the kept verdict names its cells where the join left them"
);
assert_eq!(out_origin.len(), out.len(), "the origin spliced along");
assert_eq!(
(out_origin[0], out_origin[1]),
(0, 1),
"a joined block's origin is its first cell's"
);
let signal = |range: std::ops::Range<usize>| TableSignal {
p_judge: 0.9,
verifier_score: 1.0,
verifier_margin: None,
p_is_table: None,
p_is_table_model: None,
split: TableSplit::Intact,
bbox: union_of(&page, range.clone()),
block_range: range,
};
let mut signals = PageTableSignals {
tables: vec![signal(0..10), signal(10..26)],
p_best_rejected: None,
n_candidates: 2,
refused: Vec::new(),
drawn: false,
};
let mut stale = signals.clone();
remap_table_signal_ranges(&mut signals, &map, &out);
fill_is_table_v2_signals(&mut signals, &verdicts);
assert_eq!(signals.tables[0].block_range, 0..2);
assert_eq!(signals.tables[1].block_range, 2..18);
for (table, verdict) in signals.tables.iter().zip(&verdicts) {
assert_eq!(
table.p_is_table,
Some(verdict.probability),
"range {:?} does not carry its own verdict's number",
table.block_range
);
assert_eq!(table.p_is_table_model, Some(IsTableModel::V2));
}
fill_is_table_v2_signals(&mut stale, &verdicts);
assert_eq!(
stale.tables[1].p_is_table, None,
"the stale range still matched, so this test says nothing"
);
}
#[test]
fn a_section_row_keeps_its_heading_level_and_is_otherwise_prose() {
let named = Block::new(
BlockKind::Heading { level: 2 },
0,
[0.0, 0.0, 10.0, 10.0],
"Data collection".to_string(),
0,
);
let plain = Block::new(
BlockKind::Paragraph,
0,
[0.0, 0.0, 10.0, 10.0],
"Data collection".to_string(),
0,
);
let words = vec![kv_word("Data", 0.0, 5.0, 0.0)];
let cell = kv_cell(vec![0], vec![4], vec![0, 1], &words);
let refs = [&named];
assert_eq!(
section_kind(&cell, &[0], &refs),
BlockKind::Heading { level: 2 },
"a named heading was demoted"
);
let refs = [&plain];
assert_eq!(
section_kind(&cell, &[0], &refs),
BlockKind::Paragraph,
"an unnamed section row became a heading"
);
}
fn drop_cell(row: u16, column: u16, text: &str) -> Block {
Block::new(
BlockKind::TableCell {
row,
column,
row_span: 1,
column_span: 1,
header: false,
},
0,
[0.0, 0.0, 10.0, 10.0],
text.to_string(),
0,
)
}
#[test]
fn the_drop_carries_the_origin_map_and_the_positions() {
let mut out = vec![
drop_cell(0, 0, "a"),
drop_cell(0, 1, ""),
drop_cell(0, 2, "b"),
drop_cell(1, 0, "c"),
drop_cell(1, 1, ""),
drop_cell(1, 2, "d"),
Block::new(
BlockKind::Paragraph,
0,
[0.0, 0.0, 10.0, 10.0],
"note".to_string(),
0,
),
];
let mut origin = vec![0, 1, 2, 3, 4, 5, 6];
let map = drop_empty_columns_from_tables(&mut out, &mut origin);
let coords: Vec<(u16, u16, String)> = out
.iter()
.filter_map(|b| match &b.kind {
BlockKind::TableCell { row, column, .. } => Some((*row, *column, b.text.clone())),
_ => None,
})
.collect();
assert_eq!(
coords,
vec![
(0, 0, "a".into()),
(0, 1, "b".into()),
(1, 0, "c".into()),
(1, 1, "d".into()),
],
"the column did not go"
);
assert_eq!(origin, vec![0, 2, 3, 5, 6], "the origins did not follow");
assert_eq!(
map,
vec![Some(0), None, Some(1), Some(2), None, Some(3), Some(4)],
"the positions did not mark the cells that went"
);
}
#[test]
fn a_refused_grid_stays_whole_and_a_kept_one_splits_in_two() {
fn glued() -> Vec<Block> {
let rows = [
("For the year ended", ["2018", "2017"]),
("Revenue", ["79,591", "79,139"]),
("Net income", ["8,728", "5,753"]),
("At December 31:", ["2018", "2017"]),
("Total assets", ["123,382", "125,356"]),
("Total debt", ["45,812", "46,824"]),
];
let mut out = Vec::new();
for (r, &(stub, figs)) in rows.iter().enumerate() {
for (c, text) in [stub, figs[0], figs[1]].iter().enumerate() {
let x = 70.0 + 150.0 * c as f64;
let y = 700.0 - 20.0 * r as f64;
out.push(Block::new(
BlockKind::TableCell {
row: r as u16,
column: c as u16,
row_span: 1,
column_span: 1,
header: false,
},
0,
[x, y, x + 140.0, y + 12.0],
text.to_string(),
out.len(),
));
}
}
out
}
fn tables_in(blocks: &[Block]) -> usize {
let mut tables = 0;
let mut last: Option<(u16, u16)> = None;
for block in blocks {
match block.kind {
BlockKind::TableCell { row, column, .. } => {
if last.is_none_or(|seen| (row, column) <= seen) {
tables += 1;
}
last = Some((row, column));
}
_ => last = None,
}
}
tables
}
fn judge(kept: bool, n: usize) -> Vec<TableIsV2Verdict> {
vec![TableIsV2Verdict {
cells: (0..n).collect(),
probability: if kept { 1.0 } else { 0.0 },
kept,
}]
}
let switches = super::super::Switches {
table_v2_release_rejoin: false,
..super::super::SWITCHES
};
let page = glued();
let origin: Vec<usize> = (0..page.len()).collect();
let hints = vec![CellHint::default(); page.len()];
let (released, released_origin, verdicts, _) =
release_v2_verdicts(page, origin, judge(false, 18), &hints, &[], switches);
assert_eq!(tables_in(&released), 0, "the refusal kept cells");
let (split, _, verdicts, _) =
split_stacked_after_v2(released, released_origin, Some(verdicts), None);
assert_eq!(tables_in(&split), 0, "a refused grid split into a table");
let verdicts = verdicts.expect("the verdicts follow the page");
assert_eq!(verdicts.len(), 1, "the refusal divided: {verdicts:?}");
assert_eq!(verdicts[0].probability, 0.0, "not the hand-made refusal");
assert!(!verdicts[0].kept, "not the hand-made refusal");
let page = glued();
let origin: Vec<usize> = (0..page.len()).collect();
let (released, released_origin, verdicts, _) =
release_v2_verdicts(page, origin, judge(true, 18), &hints, &[], switches);
assert_eq!(tables_in(&released), 1, "the kept whole is gone");
let (split, _, verdicts, _) =
split_stacked_after_v2(released, released_origin, Some(verdicts), None);
assert_eq!(tables_in(&split), 2, "the kept grid did not split in two");
let verdicts = verdicts.expect("the verdicts follow the page");
assert_eq!(verdicts.len(), 2, "one verdict per part: {verdicts:?}");
for (n, verdict) in verdicts.iter().enumerate() {
assert_eq!(
verdict.probability, 1.0,
"part {n} lost the hand-made verdict: {verdicts:?}"
);
assert!(verdict.kept, "part {n} lost the hand-made verdict");
}
assert_eq!(
verdicts[0].cells.len() + verdicts[1].cells.len(),
18,
"the parts do not tile the whole: {verdicts:?}"
);
}
#[test]
fn runs_that_would_read_as_one_keep_the_page() {
let mut out = vec![
drop_cell(0, 0, ""),
drop_cell(0, 1, ""),
drop_cell(0, 2, "x"),
drop_cell(0, 1, "y"),
drop_cell(1, 0, "w"),
drop_cell(1, 1, "v"),
];
let mut origin = vec![0, 1, 2, 3, 4, 5];
let map = drop_empty_columns_from_tables(&mut out, &mut origin);
assert_eq!(out.len(), 6, "cells went where two runs would join");
assert_eq!(origin, vec![0, 1, 2, 3, 4, 5], "the origins moved");
assert_eq!(
map,
vec![Some(0), Some(1), Some(2), Some(3), Some(4), Some(5)],
"the positions moved"
);
assert_eq!(out[2].text, "x", "the page was not put back as it was");
assert!(
matches!(
out[2].kind,
BlockKind::TableCell {
row: 0,
column: 2,
..
}
),
"the page was not put back as it was"
);
}
#[test]
fn tatr_extent_unions_cells_and_skips_degenerate_boxes() {
assert_eq!(
tatr_table_extent(&[
[0.0, 0.0, 10.0, 10.0],
[20.0, 5.0, 30.0, 15.0],
[0.0, 0.0, 0.0, 0.0],
]),
Some([0.0, 0.0, 30.0, 15.0])
);
assert_eq!(tatr_table_extent(&[]), None);
assert_eq!(tatr_table_extent(&[[0.0, 0.0, 0.0, 0.0]]), None);
}
#[test]
fn tatr_coverage_counts_direct_claims_inside_the_extent() {
let cells = [[0.0, 0.0, 10.0, 20.0], [10.0, 0.0, 20.0, 20.0]];
let words = [
[1.0, 15.0, 2.0, 16.0],
[11.0, 15.0, 12.0, 16.0],
[5.0, 5.0, 6.0, 6.0],
[100.0, 100.0, 101.0, 101.0],
];
let extent = tatr_table_extent(&cells).expect("two placed cells");
assert_eq!(
tatr_coverage_counts(&words, extent, &[vec![0], vec![1, 99]]),
(2, 3)
);
assert_eq!(
tatr_coverage_counts(&words, extent, &[vec![], vec![]]),
(0, 3)
);
}
#[test]
fn tatr_gap_fill_prefers_the_row_band_then_the_column_band() {
let cells = [
[0.0, 10.0, 10.0, 20.0],
[20.0, 10.0, 30.0, 20.0],
[0.0, 0.0, 30.0, 8.0],
];
let extent = tatr_table_extent(&cells).expect("three placed cells");
let words = [
[1.0, 15.0, 2.0, 16.0],
[21.0, 15.0, 22.0, 16.0],
[16.0, 15.0, 17.0, 16.0],
[5.0, 4.0, 6.0, 5.0],
[100.0, 100.0, 101.0, 101.0],
];
let mut claims = vec![vec![0], vec![1], vec![3]];
tatr_fill_gaps(&words, &cells, extent, &mut claims);
assert_eq!(claims, vec![vec![0], vec![1, 2], vec![3]]);
}
#[test]
fn tatr_gap_fill_falls_back_to_the_nearest_cell() {
let cells = [[0.0, 0.0, 10.0, 10.0], [20.0, 20.0, 30.0, 30.0]];
let extent = tatr_table_extent(&cells).expect("two placed cells");
let words = [
[1.0, 1.0, 2.0, 2.0],
[15.0, 15.0, 16.0, 16.0],
[100.0, 100.0, 101.0, 101.0],
];
let mut claims = vec![vec![0], vec![]];
tatr_fill_gaps(&words, &cells, extent, &mut claims);
assert_eq!(claims, vec![vec![0], vec![1]]);
}
#[test]
fn tatr_gap_fill_keeps_word_order_inside_a_cell() {
let cells = [[0.0, 0.0, 30.0, 10.0]];
let extent = tatr_table_extent(&cells).expect("one placed cell");
let words = [
[1.0, 1.0, 2.0, 2.0],
[25.0, 1.0, 26.0, 2.0],
[100.0, 100.0, 101.0, 101.0],
];
let mut claims = vec![vec![1]];
tatr_fill_gaps(&words, &cells, extent, &mut claims);
assert_eq!(claims, vec![vec![0, 1]]);
}
fn fill_word(text: &str, bbox: [f64; 4], block: usize, span: usize) -> PageWord {
PageWord {
text: text.to_string(),
bbox,
block,
span,
size: 9.0,
bold: false,
italic: false,
font: None,
color: None,
}
}
fn text_block(text: &str) -> Block {
Block {
kind: BlockKind::Paragraph,
page: 0,
bbox: [0.0; 4],
text: text.to_string(),
spans: Vec::new(),
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
}
fn fill_texts(words: &[PageWord], blocks: &[Block]) -> Vec<String> {
let refs: Vec<&Block> = blocks.iter().collect();
tatr_fill_words(words, &refs)
.iter()
.map(|w| w.text.clone())
.collect()
}
#[test]
fn tatr_fill_words_reads_the_block_s_joined_text() {
let blocks = vec![text_block("Conc Total")];
let words = vec![
fill_word("C", [0.0, 0.0, 5.0, 9.0], 0, 0),
fill_word("o", [5.0, 0.0, 10.0, 9.0], 0, 1),
fill_word("n", [10.0, 0.0, 15.0, 9.0], 0, 2),
fill_word("c", [15.0, 0.0, 20.0, 9.0], 0, 3),
fill_word("Total", [26.0, 0.0, 51.0, 9.0], 0, 4),
];
let refs: Vec<&Block> = blocks.iter().collect();
let joined = tatr_fill_words(&words, &refs);
assert_eq!(fill_texts(&words, &blocks), vec!["Conc", "Total"]);
assert_eq!(joined[0].bbox, [0.0, 0.0, 20.0, 9.0]);
assert_eq!((joined[0].block, joined[0].span), (0, 0));
assert_eq!(joined[1].bbox, [26.0, 0.0, 51.0, 9.0]);
}
#[test]
fn tatr_fill_words_joins_overlapping_stacked_fragments() {
let blocks = vec![text_block("MS")];
let words = vec![
fill_word("M", [0.0, 0.0, 9.0, 9.0], 0, 0),
fill_word("S", [0.0, 5.0, 9.0, 14.0], 0, 1),
];
assert_eq!(fill_texts(&words, &blocks), vec!["MS"]);
}
#[test]
fn tatr_fill_words_keeps_daylight_fragments_raw() {
let blocks = vec![text_block("20(76.9%) [363]")];
let words = vec![
fill_word("20", [0.0, 0.0, 8.6, 9.0], 0, 0),
fill_word("(76.9%)", [10.7, 0.0, 40.0, 9.0], 0, 1),
fill_word("[", [50.0, 0.0, 52.7, 9.0], 0, 2),
fill_word("363", [52.7, 0.0, 64.2, 9.0], 0, 3),
fill_word("]", [64.2, 0.0, 66.9, 9.0], 0, 4),
];
assert_eq!(fill_texts(&words, &blocks), vec!["20", "(76.9%)", "[363]"]);
}
#[test]
fn tatr_fill_words_never_joins_across_blocks() {
let blocks = vec![text_block("ab"), text_block("cd")];
let words = vec![
fill_word("a", [0.0, 0.0, 5.0, 9.0], 0, 0),
fill_word("b", [5.0, 0.0, 10.0, 9.0], 0, 1),
fill_word("c", [0.0, 0.0, 5.0, 9.0], 1, 0),
fill_word("d", [5.0, 0.0, 10.0, 9.0], 1, 1),
];
assert_eq!(fill_texts(&words, &blocks), vec!["ab", "cd"]);
}
#[test]
fn tatr_fill_words_keeps_raw_fragments_where_they_do_not_spell_the_text() {
let blocks = vec![text_block("ac")];
let words = vec![
fill_word("c", [6.0, 0.0, 11.0, 9.0], 0, 0),
fill_word("a", [0.0, 0.0, 5.0, 9.0], 0, 1),
];
assert_eq!(fill_texts(&words, &blocks), vec!["c", "a"]);
let blocks = vec![text_block("Conc")];
let words = vec![
fill_word("Conc", [0.0, 0.0, 20.0, 9.0], 0, 0),
fill_word("Conc", [0.0, 0.0, 20.0, 9.0], 0, 1),
];
assert_eq!(fill_texts(&words, &blocks), vec!["Conc", "Conc"]);
let blocks = vec![text_block("progressive")];
let words = vec![
fill_word("pro-", [0.0, 0.0, 15.0, 9.0], 0, 0),
fill_word("gressive", [0.0, 0.0, 30.0, 9.0], 0, 1),
];
assert_eq!(fill_texts(&words, &blocks), vec!["pro-", "gressive"]);
let blocks = vec![text_block("ab")];
let words = vec![
fill_word("a", [0.0, 0.0, 5.0, 9.0], 7, 0),
fill_word("b", [6.0, 0.0, 11.0, 9.0], 7, 1),
];
assert_eq!(fill_texts(&words, &blocks), vec!["a", "b"]);
}
fn turned_span(transform: Option<[f64; 6]>) -> TextSpan {
TextSpan {
text: "w".to_string(),
x: 0.0,
y: 0.0,
width: 5.0,
height: 9.0,
font_size: 9.0,
transform,
..Default::default()
}
}
fn judge_line(block: usize) -> judge::Line {
judge::Line {
bbox: [0.0; 4],
text: String::new(),
words: 0,
digits: 0,
kind: "paragraph",
table: None,
grid_fill: f64::NAN,
grid_columns: f64::NAN,
block,
list: f64::NAN,
}
}
#[test]
fn tatr_span_upright_reads_the_baseline_against_ten_degrees() {
assert!(tatr_span_is_upright(&turned_span(Some([
10.0, 0.0, 0.0, 10.0, 0.0, 0.0
]))));
let lean = 3.0f64.to_radians();
assert!(tatr_span_is_upright(&turned_span(Some([
10.0 * lean.cos(),
10.0 * lean.sin(),
0.0,
10.0,
0.0,
0.0
]))));
for coeffs in [
[0.0, 10.0, -10.0, 0.0, 0.0, 0.0],
[-10.0, 0.0, 0.0, -10.0, 0.0, 0.0],
[0.0, -10.0, 10.0, 0.0, 0.0, 0.0],
[8.66, 5.0, -5.0, 8.66, 0.0, 0.0],
] {
assert!(
!tatr_span_is_upright(&turned_span(Some(coeffs))),
"a turned baseline read upright: {coeffs:?}"
);
}
assert!(tatr_span_is_upright(&turned_span(None)));
assert!(tatr_span_is_upright(&turned_span(Some([
0.0, 0.0, 0.0, 0.0, 0.0, 0.0
]))));
}
#[test]
fn tatr_region_rotated_reads_the_majority_of_its_words() {
let mut block = text_block("a b c d");
block.spans = vec![
turned_span(Some([10.0, 0.0, 0.0, 10.0, 0.0, 0.0])),
turned_span(Some([10.0, 0.0, 0.0, 10.0, 0.0, 0.0])),
turned_span(Some([0.0, 10.0, -10.0, 0.0, 0.0, 0.0])),
turned_span(Some([0.0, 10.0, -10.0, 0.0, 0.0, 0.0])),
];
let refs = vec![&block];
let words = vec![
fill_word("a", [0.0, 20.0, 5.0, 29.0], 0, 0),
fill_word("b", [6.0, 20.0, 11.0, 29.0], 0, 1),
fill_word("c", [0.0, 0.0, 5.0, 9.0], 0, 2),
fill_word("d", [6.0, 0.0, 11.0, 9.0], 0, 3),
];
let lines = vec![judge_line(0), judge_line(0)];
assert!(!tatr_region_is_rotated(&refs, &lines, &words, &[0, 1]));
assert!(tatr_region_is_rotated(&refs, &lines, &words, &[1]));
assert!(!tatr_region_is_rotated(&refs, &lines, &words, &[0]));
assert!(!tatr_region_is_rotated(&refs, &[], &[], &[]));
}
#[test]
fn tatr_for_region_declines_a_rotated_region_before_the_fake() {
for turned in [true, false] {
let mut block = text_block("a b c");
let coeffs = if turned {
[0.0, 10.0, -10.0, 0.0, 0.0, 0.0]
} else {
[10.0, 0.0, 0.0, 10.0, 0.0, 0.0]
};
block.spans = vec![
turned_span(Some(coeffs)),
turned_span(Some(coeffs)),
turned_span(Some(coeffs)),
];
let refs = vec![&block];
let words = vec![
fill_word("a", [0.0, 0.0, 5.0, 9.0], 0, 0),
fill_word("b", [6.0, 0.0, 11.0, 9.0], 0, 1),
fill_word("c", [12.0, 0.0, 17.0, 9.0], 0, 2),
];
let lines = vec![judge_line(0)];
let pair_words = vec![pairs::PairWord {
text: "a b c".to_string(),
bbox: [0.0, 0.0, 17.0, 9.0],
table: None,
row: None,
column: None,
}];
let options = TatrOptions {
test_hook: Some(TatrTestHook::Cells(vec![TatrTestCell::new(
vec![0],
vec![0],
false,
vec![0, 1, 2],
)])),
..Default::default()
};
let context = TatrContext {
document: None,
options,
};
let region = TatrRegion::of(&context, false);
match tatr_for_region(Some(®ion), &refs, &lines, &words, &[0], &pair_words, 0) {
TatrVerdict::Fallback(reason) => {
assert!(turned, "the upright twin declined: {reason}");
assert_eq!(reason, "rotated", "the trace's reason drifted");
}
TatrVerdict::Cells(_) => {
assert!(!turned, "the rotated region fired past the decline")
}
}
}
}
}