use std::cmp::Ordering;
use crate::text::{HorizontalRule, TextBlock};
const SEGMENT_GAP_EM: f64 = 1.0;
const CONFIRM_GAP_EM: f64 = 3.0;
const GUTTER_EM: f64 = 1.5;
const GUTTER_ROW_FRACTION: f64 = 0.75;
const GUTTER_MIN_ROWS: usize = 2;
const MIN_ROWS: usize = 2;
const MAX_TRIMMED_ROWS: usize = 2;
const MIN_COLUMNS: usize = 2;
const MIN_FILL: f64 = 0.5;
const ROW_RULE_FRACTION: f64 = 0.5;
const COLLINEAR_EM: f64 = 0.25;
const CELL_RULE_FRACTION: f64 = 0.75;
const HEADER_BOLD_FRACTION: f64 = 0.5;
const SECTION_WHITE: f64 = 1.75;
const CROSSING_TOLERANCE: f64 = 2.0;
const CROSSING_SHORTFALL: f64 = 1.0;
pub(crate) struct Row {
pub(crate) first: usize,
pub(crate) end: usize,
pub(crate) left: f64,
pub(crate) right: f64,
pub(crate) top: f64,
pub(crate) bottom: f64,
pub(crate) font_size: f64,
pub(crate) bold: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct Cell {
pub(crate) row: u16,
pub(crate) column: u16,
pub(crate) row_span: u16,
pub(crate) column_span: u16,
pub(crate) header: bool,
pub(crate) spans: Vec<(usize, usize)>,
}
#[derive(Debug, Clone)]
pub(crate) struct Table {
pub(crate) first: usize,
pub(crate) end: usize,
pub(crate) cells: Vec<Cell>,
pub(crate) doubt: crate::blocks::GridDoubt,
pub(crate) origin: crate::table_trace::TableOrigin,
}
#[derive(Debug, Clone)]
struct Segment {
left: f64,
right: f64,
spans: Vec<(usize, usize)>,
}
pub(crate) fn find(lines: &[&TextBlock], rows: &[Row], rules: &[HorizontalRule]) -> Vec<Table> {
let cells: Vec<Vec<Segment>> = rows.iter().map(|row| segments(lines, row)).collect();
let white = row_white(rows, &cells);
let edge_left = cells
.iter()
.flatten()
.map(|s| s.left)
.fold(f64::INFINITY, f64::min);
let edge_right = cells
.iter()
.flatten()
.map(|s| s.right)
.fold(f64::NEG_INFINITY, f64::max);
let wide = |i: usize| {
cells[i].iter().any(|s| {
s.right - s.left >= PROSE_WIDTH * (edge_right - edge_left)
&& s.left - edge_left <= PROSE_INDENT * (edge_right - edge_left)
})
};
let reads_prose = |i: usize| cells[i].len() < MIN_COLUMNS && wide(i) && prose(lines, &rows[i]);
let mut tables = Vec::new();
let mut start = 0usize;
while start < rows.len() {
if cells[start].len() < MIN_COLUMNS {
start += 1;
continue;
}
let mut end = start + 1;
let mut thin_rows = 0usize;
while end < rows.len() {
if end > start
&& announces_another_table(lines, &rows[end])
&& !band_of_the_table(rows, end, white)
{
break;
}
if cells[end].len() >= MIN_COLUMNS {
end += 1;
continue;
}
if cells[end].len() == 1
&& end + 1 < rows.len()
&& cells[end + 1].len() >= MIN_COLUMNS
&& band_of_the_table(rows, end, white)
&& !reads_prose(end)
{
thin_rows += 1;
end += 2;
continue;
}
break;
}
let mut found = None;
let mut retried_from = 0usize;
for from in start..=(start + MAX_TRIMMED_ROWS).min(end.saturating_sub(MIN_ROWS)) {
found = grid(&rows[from..end], &cells[from..end], from, rules);
if found.is_some() {
retried_from = from - start;
break;
}
}
match found {
Some(table) if a_list_set_as_a_table(&table, lines) => start = end,
Some(table) if figure_fragment_veto(&table, lines, rows) => {
crate::table_trace::emit_candidate(
crate::table_trace::TableOrigin::Find,
&table,
lines,
rows,
rules,
);
start = end;
}
Some(mut table) => {
table.doubt.thin_rows = thin_rows;
table.doubt.retried_from = retried_from;
table.origin = crate::table_trace::TableOrigin::Find;
tables.push(table);
start = end;
}
None => start += 1,
}
}
one_table_that_carries_on_is_one_table(tables, rows, &cells, lines, rules)
}
pub(crate) fn find_whole(
lines: &[&TextBlock],
rows: &[Row],
rules: &[HorizontalRule],
) -> Vec<Table> {
let cells: Vec<Vec<Segment>> = rows.iter().map(|row| segments(lines, row)).collect();
let thin = |i: usize| cells[i].len() < MIN_COLUMNS;
let left = cells
.iter()
.flatten()
.map(|s| s.left)
.fold(f64::INFINITY, f64::min);
let right = cells
.iter()
.flatten()
.map(|s| s.right)
.fold(f64::NEG_INFINITY, f64::max);
let wide = |i: usize| {
cells[i].iter().any(|s| {
s.right - s.left >= PROSE_WIDTH * (right - left)
&& s.left - left <= PROSE_INDENT * (right - left)
})
};
let cuts: Vec<usize> = (0..rows.len())
.filter(|&i| {
announces_another_table(lines, &rows[i])
|| (thin(i) && wide(i) && prose(lines, &rows[i]))
})
.collect();
let mut stretches = Vec::new();
let mut from = 0usize;
for to in cuts.iter().copied().chain(std::iter::once(rows.len())) {
let first = from;
let mut end = to;
while end > first && thin(end - 1) {
end -= 1;
}
stretches.push((first, end));
from = to + 1;
}
let mut tables = Vec::new();
for (first, end) in stretches {
if end < first + MIN_ROWS {
continue;
}
match grid(&rows[first..end], &cells[first..end], first, rules) {
Some(table) if a_list_set_as_a_table(&table, lines) => {}
Some(table) if figure_fragment_veto(&table, lines, rows) => {
crate::table_trace::emit_candidate(
crate::table_trace::TableOrigin::FindWhole,
&table,
lines,
rows,
rules,
);
}
Some(mut table) => {
table.doubt.thin_rows = (first..end).filter(|&i| thin(i)).count();
table.origin = crate::table_trace::TableOrigin::FindWhole;
tables.push(table);
}
_ => {}
}
}
if !tables.is_empty() {
return tables;
}
find(lines, rows, rules)
}
fn a_list_set_as_a_table(table: &Table, lines: &[&TextBlock]) -> bool {
let text_of = |cell: &Cell| -> String {
cell.spans
.iter()
.filter_map(|&(line, span)| lines.get(line)?.spans.get(span))
.map(|s| s.text.trim())
.collect::<Vec<_>>()
.join(" ")
};
let mut columns: Vec<u16> = table
.cells
.iter()
.filter(|c| !text_of(c).is_empty())
.map(|c| c.column)
.collect();
columns.sort_unstable();
columns.dedup();
let [first, second] = columns[..] else {
return false;
};
let rows = table.cells.iter().map(|c| c.row).max().map_or(0, |r| r + 1) as usize;
let mut markers = 0usize;
let mut outlines = 0usize;
for cell in table.cells.iter().filter(|c| c.column == first) {
let t = text_of(cell);
if t.is_empty() {
continue;
}
let probe = format!("{t} X");
let Some(marker) = crate::lists::marker_at(&probe, crate::lists::Lead::default())
.filter(|m| m.len >= t.len())
else {
continue;
};
markers += 1;
if marker.is_outline() {
outlines += 1;
}
}
if rows < 2 || markers < 2 || (markers as f64) < LIST_MARKER_SHARE * rows as f64 {
return false;
}
if crate::table_trace::tracing() {
eprintln!(
"LISTVETO rows={rows} markers={markers} outlines={outlines} tab_em={:.2}",
index_tab(table, lines, first, second)
);
}
if outlines * 2 > markers && index_tab(table, lines, first, second) >= INDEX_TAB_GAP_EM {
return false;
}
let text: Vec<String> = table
.cells
.iter()
.filter(|c| c.column == second)
.map(text_of)
.filter(|t| !t.is_empty())
.collect();
if text.is_empty()
|| text.iter().any(|t| {
t.chars()
.all(|ch| ch.is_ascii_digit() || " .,%$()-".contains(ch))
})
{
return false;
}
let mean = text.iter().map(|t| t.chars().count()).sum::<usize>() as f64 / text.len() as f64;
mean >= LIST_TEXT_MIN_CHARS
}
const LIST_MARKER_SHARE: f64 = 0.6;
const INDEX_TAB_GAP_EM: f64 = 2.5;
fn index_tab(table: &Table, lines: &[&TextBlock], first: u16, second: u16) -> f64 {
let edge = |cell: &Cell| -> Option<(f64, f64, f64)> {
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
let mut em = 0.0f64;
for &(line, span) in &cell.spans {
let span = lines.get(line)?.spans.get(span)?;
if span.text.trim().is_empty() {
continue;
}
left = left.min(span.x);
right = right.max(crate::blocks::span_right(span));
em = em.max(span.font_size);
}
(left.is_finite() && right.is_finite()).then_some((left, right, em))
};
let mut gaps: Vec<f64> = Vec::new();
let rows = table.cells.iter().map(|c| c.row).max().map_or(0, |r| r + 1) as usize;
let mut firsts: Vec<Option<&Cell>> = vec![None; rows];
let mut seconds: Vec<Option<&Cell>> = vec![None; rows];
for cell in &table.cells {
let slot = if cell.column == first {
&mut firsts
} else if cell.column == second {
&mut seconds
} else {
continue;
};
slot[cell.row as usize].get_or_insert(cell);
}
for row in 0..rows {
let a = firsts[row].and_then(edge);
let b = seconds[row].and_then(edge);
if let (Some((_, right, em)), Some((left, _, _))) = (a, b) {
if em > 0.0 {
gaps.push((left - right) / em);
}
}
}
if gaps.is_empty() {
return 0.0;
}
gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
gaps[gaps.len() / 2]
}
const LIST_TEXT_MIN_CHARS: f64 = 12.0;
fn figure_fragment_veto(table: &Table, lines: &[&TextBlock], rows: &[Row]) -> bool {
(fragmentary_cells(table, lines) && near_figure_caption(table, lines, rows))
|| continued_panels(table, lines)
|| continued_panel_fragments(table, lines, rows)
}
pub(crate) const FRAGMENT_CELL_MEDIAN: usize = 3;
pub(crate) const FIGURE_CAPTION_NEAR_EM: f64 = 12.0;
fn continued_panels(table: &Table, lines: &[&TextBlock]) -> bool {
let mut hits = 0usize;
for cell in &table.cells {
let text = cell
.spans
.iter()
.filter_map(|&(line, span)| {
lines
.get(line)
.and_then(|block| block.spans.get(span))
.map(|span| span.text.trim())
})
.collect::<Vec<_>>()
.join(" ");
let hits_here = text.to_ascii_lowercase().matches("continu").count();
if hits_here > 0 && cell.row != 0 {
return false;
}
hits += hits_here;
}
hits >= 2
}
fn continued_panel_fragments(table: &Table, lines: &[&TextBlock], rows: &[Row]) -> bool {
if table.cells.is_empty() {
return false;
}
let mut lens: Vec<usize> = Vec::with_capacity(table.cells.len());
for cell in &table.cells {
let text = cell
.spans
.iter()
.filter_map(|&(line, span)| {
lines
.get(line)
.and_then(|block| block.spans.get(span))
.map(|span| span.text.trim())
})
.collect::<Vec<_>>()
.join(" ");
lens.push(text.chars().filter(|c| c.is_alphanumeric()).count());
}
lens.sort_unstable();
let mid = lens.len() / 2;
let short = if lens.len() % 2 == 1 {
lens[mid] <= FRAGMENT_CELL_MEDIAN
} else {
lens[mid - 1] + lens[mid] <= 2 * FRAGMENT_CELL_MEDIAN
};
if !short {
return false;
}
let lo = table.first.saturating_sub(2);
let hi = (table.end + 2).min(rows.len());
let nearby = |row: &Row| -> bool {
let end = row.end.max(row.first);
let text = lines
.get(row.first..end)
.map(|blocks| {
blocks
.iter()
.flat_map(|block| block.spans.iter())
.map(|span| span.text.trim())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
text.trim_start_matches(|c: char| c == '-' || c == '—' || c.is_whitespace())
.to_ascii_lowercase()
.starts_with("continu")
};
rows.get(lo..table.first.min(rows.len()))
.is_some_and(|above| above.iter().any(&nearby))
|| rows
.get(table.end.min(rows.len())..hi)
.is_some_and(|below| below.iter().any(&nearby))
}
fn fragmentary_cells(table: &Table, lines: &[&TextBlock]) -> bool {
if table.cells.is_empty() || table.end <= table.first {
return false;
}
let columns = table
.cells
.iter()
.map(|cell| cell.column as usize + cell.column_span as usize)
.max()
.unwrap_or(0);
if columns == 0 {
return false;
}
let positions = (table.end - table.first) * columns;
let filled: usize = table
.cells
.iter()
.map(|cell| cell.column_span as usize * cell.row_span.max(1) as usize)
.sum();
if positions == 0 || filled >= positions {
return false;
}
if (positions - filled) * 3 <= positions {
return false;
}
let mut lens: Vec<usize> = Vec::with_capacity(table.cells.len());
for cell in &table.cells {
let text = cell
.spans
.iter()
.filter_map(|&(line, span)| {
lines
.get(line)
.and_then(|block| block.spans.get(span))
.map(|span| span.text.trim())
})
.collect::<Vec<_>>()
.join(" ");
lens.push(text.chars().filter(|c| c.is_alphanumeric()).count());
}
lens.sort_unstable();
let mid = lens.len() / 2;
if lens.len() % 2 == 1 {
lens[mid] <= FRAGMENT_CELL_MEDIAN
} else {
lens[mid - 1] + lens[mid] <= 2 * FRAGMENT_CELL_MEDIAN
}
}
fn near_figure_caption(table: &Table, lines: &[&TextBlock], rows: &[Row]) -> bool {
let Some(span) = rows.get(table.first..table.end) else {
return false;
};
let mut grid_left = f64::INFINITY;
let mut grid_right = f64::NEG_INFINITY;
let mut grid_lo = f64::INFINITY;
let mut grid_hi = f64::NEG_INFINITY;
let mut em = f64::MIN_POSITIVE;
for row in span {
grid_left = grid_left.min(row.left);
grid_right = grid_right.max(row.right);
grid_lo = grid_lo.min(row.top.min(row.bottom));
grid_hi = grid_hi.max(row.top.max(row.bottom));
em = em.max(row.font_size);
}
if !(grid_left.is_finite() && grid_right > grid_left && grid_hi >= grid_lo) {
return false;
}
let near = FIGURE_CAPTION_NEAR_EM * em;
lines.iter().any(|line| {
if !is_figure_caption(&line.text()) {
return false;
}
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for span in &line.spans {
left = left.min(span.x);
right = right.max(crate::blocks::span_right(span));
lo = lo.min(span.y);
hi = hi.max(span.y + span.height);
}
if !(left.is_finite() && right > left && hi >= lo) {
return false;
}
if right <= grid_left || left >= grid_right {
return false;
}
let gap = if hi < grid_lo {
grid_lo - hi
} else if lo > grid_hi {
lo - grid_hi
} else {
0.0
};
gap <= near
})
}
pub(crate) fn is_figure_caption(text: &str) -> bool {
if text.to_ascii_lowercase().contains("chemical formula") {
return true;
}
let words: Vec<&str> = text.split_whitespace().collect();
words.iter().enumerate().any(|(at, word)| {
let bare = word
.trim_matches(|c: char| !c.is_alphanumeric())
.to_ascii_lowercase();
if !matches!(bare.as_str(), "fig" | "figure") {
return false;
}
words.get(at + 1).is_some_and(|next| {
next.trim_matches(|c: char| !c.is_alphanumeric())
.starts_with(|c: char| c.is_alphanumeric())
})
})
}
fn one_table_that_carries_on_is_one_table(
tables: Vec<Table>,
rows: &[Row],
cells: &[Vec<Segment>],
lines: &[&TextBlock],
rules: &[HorizontalRule],
) -> Vec<Table> {
if tables.len() < 2 {
return tables;
}
let mut out: Vec<Table> = Vec::new();
for table in tables {
let Some(previous) = out.last() else {
out.push(table);
continue;
};
let announced =
(previous.end..table.first).any(|band| announces_another_table(lines, &rows[band]));
if !announced {
let first = previous.first;
if let Some(mut joined) = grid(
&rows[first..table.end],
&cells[first..table.end],
first,
rules,
) {
let previous_doubt = out.last().map(|t| t.doubt).unwrap_or_default();
joined.doubt.thin_rows = (first..table.end)
.filter(|row| cells[*row].len() < MIN_COLUMNS)
.count();
joined.doubt.retried_from =
previous_doubt.retried_from.max(table.doubt.retried_from);
joined.origin = table.origin;
out.pop();
out.push(joined);
continue;
}
}
out.push(table);
}
out
}
fn prose(lines: &[&TextBlock], row: &Row) -> bool {
let text: String = lines[row.first..row.end]
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join(" ");
let words: Vec<&str> = text.split_whitespace().collect();
let lettered = words
.iter()
.filter(|w| w.chars().filter(|c| c.is_alphabetic()).count() >= 2)
.count();
words.len() >= PROSE_WORDS && lettered * 10 >= words.len() * 8
}
const PROSE_WORDS: usize = 8;
const PROSE_WIDTH: f64 = 0.6;
const PROSE_INDENT: f64 = 0.05;
fn announces_another_table(lines: &[&TextBlock], row: &Row) -> bool {
let text: String = lines[row.first..row.end]
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join(" ");
announces_a_table(&text)
}
pub(crate) fn table_has_ruling(rows: &[Row], table: &Table, rules: &[HorizontalRule]) -> bool {
if table.first >= table.end {
return false;
}
let Some(span) = rows.get(table.first..table.end) else {
return false;
};
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
let mut bottom = f64::INFINITY;
let mut top = f64::NEG_INFINITY;
for row in span {
left = left.min(row.left);
right = right.max(row.right);
bottom = bottom.min(row.bottom);
top = top.max(row.top);
}
rules.iter().any(|rule| {
rule.left <= left + CROSSING_SHORTFALL
&& rule.right >= right - CROSSING_SHORTFALL
&& rule.y >= bottom - CROSSING_TOLERANCE
&& rule.y <= top + CROSSING_TOLERANCE
})
}
pub(crate) fn announces_a_table(text: &str) -> bool {
let mut words = text.split_whitespace();
let Some(first) = words.next() else {
return false;
};
let word = first.trim_end_matches(':').to_ascii_lowercase();
if !matches!(word.as_str(), "table" | "figure" | "fig" | "fig.") {
return false;
}
let Some(second) = words.next() else {
return false;
};
let identifier = second.trim_end_matches(['.', ':', ',']);
identifier_of_a_table(identifier)
}
pub(crate) fn identifier_of_a_table(identifier: &str) -> bool {
if identifier.is_empty() {
return false;
}
if identifier.starts_with(|c: char| c.is_ascii_digit()) {
return true;
}
if let Some(rest) = identifier.strip_prefix(['S', 's']) {
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
identifier.chars().all(|c| c.is_ascii_uppercase())
&& crate::lists::roman_value(identifier)
.is_some_and(|value| (1..=CAPTION_ROMAN_MAX).contains(&value))
}
const CAPTION_ROMAN_MAX: u32 = 50;
fn row_white(rows: &[Row], cells: &[Vec<Segment>]) -> f64 {
let gap = |i: usize| (rows[i].bottom - rows[i + 1].top).max(0.0);
let mut between: Vec<f64> = (0..rows.len().saturating_sub(1))
.filter(|i| cells[*i].len() >= MIN_COLUMNS && cells[i + 1].len() >= MIN_COLUMNS)
.map(gap)
.collect();
if between.is_empty() {
between = (0..rows.len().saturating_sub(1)).map(gap).collect();
}
if between.is_empty() {
return 0.0;
}
between.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
between[between.len() / 2]
}
fn band_of_the_table(rows: &[Row], at: usize, white: f64) -> bool {
if at == 0 || at + 1 >= rows.len() {
return false;
}
let limit = SECTION_WHITE * white;
let above = (rows[at - 1].bottom - rows[at].top).max(0.0);
let below = (rows[at].bottom - rows[at + 1].top).max(0.0);
above <= limit && below <= limit
}
fn segments(lines: &[&TextBlock], row: &Row) -> Vec<Segment> {
let em = row.font_size.max(f64::MIN_POSITIVE);
let gap = SEGMENT_GAP_EM * em;
let mut order: Vec<(usize, usize)> = (row.first..row.end)
.flat_map(|line| {
(0..lines[line].spans.len())
.filter(move |span| !lines[line].spans[*span].text.trim().is_empty())
.map(move |span| (line, span))
})
.collect();
order.sort_by(|a, b| {
lines[a.0].spans[a.1]
.x
.partial_cmp(&lines[b.0].spans[b.1].x)
.unwrap_or(Ordering::Equal)
});
let mut out: Vec<Segment> = Vec::new();
for at in order {
let span = &lines[at.0].spans[at.1];
let left = span.x;
let right = crate::blocks::span_right(span);
match out.last_mut() {
Some(last) if left - last.right < gap => {
last.right = last.right.max(right);
last.spans.push(at);
}
_ => out.push(Segment {
left,
right,
spans: vec![at],
}),
}
}
out
}
fn gutters_agree(per_row: &[Vec<Segment>], run: &[Row]) -> bool {
let wide = |at: usize| -> Vec<(f64, f64)> {
let em = run[at].font_size.max(f64::MIN_POSITIVE);
per_row[at]
.windows(2)
.map(|pair| (pair[0].right, pair[1].left))
.filter(|(left, right)| right - left >= CONFIRM_GAP_EM * em)
.collect()
};
(0..per_row.len().saturating_sub(1)).any(|at| {
let (mine, theirs) = (wide(at), wide(at + 1));
mine.iter()
.any(|a| theirs.iter().any(|b| a.0 < b.1 && b.0 < a.1))
})
}
fn grid(
run: &[Row],
per_row: &[Vec<Segment>],
first: usize,
rules: &[HorizontalRule],
) -> Option<Table> {
let confirmed = gutters_agree(per_row, run) || ruled_rows(run, rules) >= MIN_ROWS - 1;
if !confirmed {
return None;
}
let em = run
.iter()
.map(|row| row.font_size)
.fold(0.0_f64, f64::max)
.max(f64::MIN_POSITIVE);
let left = run.iter().map(|row| row.left).fold(f64::INFINITY, f64::min);
let right = run
.iter()
.map(|row| row.right)
.fold(f64::NEG_INFINITY, f64::max);
if !(left.is_finite() && right > left) {
return None;
}
let bounds = column_bounds(per_row, left, right, em)?;
let columns = bounds.len();
if columns < MIN_COLUMNS {
return None;
}
let mut cells: Vec<Cell> = Vec::new();
for (r, segments) in per_row.iter().enumerate() {
if segments.len() == 1 {
let (from, to) = covered(&segments[0], &bounds)?;
cells.push(Cell {
row: r as u16,
column: from as u16,
row_span: 1,
column_span: (to - from + 1) as u16,
header: false,
spans: segments[0].spans.clone(),
});
continue;
}
let claims = claim_columns(segments, &bounds)?;
for (segment, (from, to)) in segments.iter().zip(claims) {
cells.push(Cell {
row: r as u16,
column: from as u16,
row_span: 1,
column_span: (to - from + 1) as u16,
header: false,
spans: segment.spans.clone(),
});
}
}
let filled: usize = cells.iter().map(|cell| cell.column_span as usize).sum();
if filled < (MIN_FILL * (run.len() * columns) as f64).ceil() as usize {
return None;
}
if cells
.iter()
.all(|cell| cell.column_span as usize == columns)
{
return None;
}
merge_rows(&mut cells, run, &bounds, rules);
mark_header(&mut cells, run, rules);
cells.sort_by_key(|cell| (cell.row, cell.column));
let mut per_row = vec![0usize; run.len()];
for cell in &cells {
if let Some(slot) = per_row.get_mut(cell.row as usize) {
*slot += cell.column_span as usize;
}
}
let doubt = crate::blocks::GridDoubt {
thin_rows: 0,
fill: filled as f64 / (run.len() * columns).max(1) as f64,
retried_from: 0,
full_rows: per_row
.iter()
.filter(|covered| **covered >= columns)
.count(),
columns,
};
Some(Table {
first,
end: first + run.len(),
cells,
doubt,
origin: crate::table_trace::TableOrigin::Other,
})
}
fn ruled_rows(run: &[Row], rules: &[HorizontalRule]) -> usize {
(0..run.len().saturating_sub(1))
.filter(|i| row_rule(&run[*i], &run[i + 1], rules).is_some())
.count()
}
fn row_rule(above: &Row, below: &Row, rules: &[HorizontalRule]) -> Option<Vec<(f64, f64)>> {
let mut ink: Vec<&HorizontalRule> = rules
.iter()
.filter(|rule| rule.y >= below.top && rule.y <= above.bottom)
.filter(|rule| rule.right > rule.left)
.collect();
if ink.is_empty() {
return None;
}
ink.sort_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(Ordering::Equal));
let left = above.left.min(below.left);
let right = above.right.max(below.right);
let width = (right - left).max(f64::MIN_POSITIVE);
let apart = COLLINEAR_EM * above.font_size.max(below.font_size).max(f64::MIN_POSITIVE);
let mut best: Option<(f64, Vec<(f64, f64)>)> = None;
let mut at = 0usize;
while at < ink.len() {
let mut end = at + 1;
while end < ink.len() && ink[end].y - ink[end - 1].y <= apart {
end += 1;
}
let mut spans: Vec<(f64, f64)> = ink[at..end].iter().map(|r| (r.left, r.right)).collect();
spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
let mut merged: Vec<(f64, f64)> = Vec::with_capacity(spans.len());
for span in spans {
match merged.last_mut() {
Some(last) if span.0 <= last.1 => last.1 = last.1.max(span.1),
_ => merged.push(span),
}
}
let covered = covered_width(&merged, left, right);
if best.as_ref().is_none_or(|(most, _)| covered > *most) {
best = Some((covered, merged));
}
at = end;
}
best.filter(|(covered, _)| covered / width >= ROW_RULE_FRACTION)
.map(|(_, merged)| merged)
}
fn covered_width(ink: &[(f64, f64)], from: f64, to: f64) -> f64 {
ink.iter()
.map(|(left, right)| (right.min(to) - left.max(from)).max(0.0))
.sum()
}
fn column_bounds(
per_row: &[Vec<Segment>],
left: f64,
right: f64,
em: f64,
) -> Option<Vec<(f64, f64)>> {
let mut edges: Vec<f64> = vec![left, right];
for row in per_row {
for segment in row {
edges.push(segment.left.max(left));
edges.push(segment.right.min(right));
}
}
edges.retain(|x| *x >= left && *x <= right);
edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
edges.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
if edges.len() < 2 {
return None;
}
let needed = ((GUTTER_ROW_FRACTION * per_row.len() as f64).ceil() as usize)
.max(GUTTER_MIN_ROWS)
.min(per_row.len());
let free: Vec<bool> = edges
.windows(2)
.map(|pair| {
let (from, to) = (pair[0], pair[1]);
let rows = per_row
.iter()
.filter(|row| {
!row.iter()
.any(|segment| segment.left < to && segment.right > from)
})
.count();
rows >= needed
})
.collect();
let mut gutters: Vec<(f64, f64)> = Vec::new();
let mut open: Option<(f64, f64)> = None;
for (i, is_free) in free.iter().enumerate() {
match (is_free, open) {
(true, Some((from, _))) => open = Some((from, edges[i + 1])),
(true, None) => open = Some((edges[i], edges[i + 1])),
(false, Some(strip)) => {
gutters.push(strip);
open = None;
}
(false, None) => {}
}
}
gutters.extend(open);
gutters.retain(|(from, to)| to - from >= GUTTER_EM * em);
let mut bounds: Vec<(f64, f64)> = Vec::new();
let mut start = left;
for (from, to) in &gutters {
if *from <= start {
start = start.max(*to);
continue;
}
if *to >= right {
break;
}
let middle = (from + to) * 0.5;
bounds.push((start, middle));
start = middle;
}
if right > start {
bounds.push((start, right));
}
Some(bounds)
}
fn covered(segment: &Segment, bounds: &[(f64, f64)]) -> Option<(usize, usize)> {
let hit: Vec<usize> = bounds
.iter()
.enumerate()
.filter(|(_, (from, to))| segment.left < *to && segment.right > *from)
.map(|(i, _)| i)
.collect();
Some((*hit.first()?, *hit.last()?))
}
fn claim_columns(segments: &[Segment], bounds: &[(f64, f64)]) -> Option<Vec<(usize, usize)>> {
let overlap = |segment: &Segment, column: &(f64, f64)| {
(segment.right.min(column.1) - segment.left.max(column.0)).max(0.0)
};
let owner: Vec<Option<usize>> = bounds
.iter()
.map(|column| {
segments
.iter()
.enumerate()
.map(|(at, segment)| (at, overlap(segment, column)))
.filter(|(_, covered)| *covered > 0.0)
.fold(
None,
|best: Option<(usize, f64)>, (at, covered)| match best {
Some((_, most)) if most >= covered => best,
_ => Some((at, covered)),
},
)
.map(|(at, _)| at)
})
.collect();
let mut claims = Vec::with_capacity(segments.len());
for at in 0..segments.len() {
let held: Vec<usize> = owner
.iter()
.enumerate()
.filter(|(_, held)| **held == Some(at))
.map(|(column, _)| column)
.collect();
let (first, last) = match (held.first(), held.last()) {
(Some(first), Some(last)) => (*first, *last),
_ => return None,
};
if last - first + 1 != held.len() {
return None;
}
claims.push((first, last));
}
Some(claims)
}
fn merge_rows(cells: &mut [Cell], run: &[Row], bounds: &[(f64, f64)], rules: &[HorizontalRule]) {
for r in 0..run.len().saturating_sub(1) {
let Some(ink) = row_rule(&run[r], &run[r + 1], rules) else {
continue;
};
for (column, (from, to)) in bounds.iter().enumerate() {
let width = (to - from).max(f64::MIN_POSITIVE);
if covered_width(&ink, *from, *to) / width >= CELL_RULE_FRACTION {
continue;
}
let below_is_empty = !cells
.iter()
.any(|cell| cell.row as usize == r + 1 && spans_column(cell, column));
if !below_is_empty {
continue;
}
if let Some(cell) = cells.iter_mut().find(|cell| {
(cell.row + cell.row_span) as usize == r + 1 && spans_column(cell, column)
}) {
cell.row_span += 1;
}
}
}
}
fn spans_column(cell: &Cell, column: usize) -> bool {
let from = cell.column as usize;
column >= from && column < from + cell.column_span as usize
}
fn mark_header(cells: &mut [Cell], run: &[Row], rules: &[HorizontalRule]) {
if run.len() < MIN_ROWS + 1 {
return;
}
let ruled_off = row_rule(&run[0], &run[1], rules).is_some()
&& (1..run.len() - 1).all(|i| row_rule(&run[i], &run[i + 1], rules).is_none());
let body_bold = run[1..].iter().filter(|row| row.bold).count();
let bold_header =
run[0].bold && (body_bold as f64) < HEADER_BOLD_FRACTION * (run.len() - 1) as f64;
if !(ruled_off || bold_header) {
return;
}
for cell in cells.iter_mut().filter(|cell| cell.row == 0) {
cell.header = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::TextSpan;
fn span(text: &str, x: f64, y: f64) -> TextSpan {
TextSpan {
text: text.to_string(),
x,
y,
width: 5.0 * text.chars().count() as f64,
height: 10.0,
font_size: 10.0,
..Default::default()
}
}
fn line(cells: &[(&str, f64)], y: f64) -> TextBlock {
TextBlock {
spans: cells
.iter()
.map(|(text, x)| span(text, *x, y))
.collect::<Vec<_>>(),
}
}
fn rows_of(lines: &[&TextBlock]) -> Vec<Row> {
lines
.iter()
.enumerate()
.map(|(i, line)| {
let y = line.spans[0].y;
Row {
first: i,
end: i + 1,
left: line.spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min),
right: line
.spans
.iter()
.map(crate::blocks::span_right)
.fold(f64::NEG_INFINITY, f64::max),
top: y + 8.0,
bottom: y,
font_size: 10.0,
bold: false,
}
})
.collect()
}
#[test]
fn a_table_that_carries_on_under_its_own_heading_is_one_table() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
line(&[("Southern hemisphere", 72.0)], 640.0),
line(&[("East", 72.0), ("60", 200.0), ("20%", 320.0)], 610.0),
line(&[("West", 72.0), ("21", 200.0), ("7%", 320.0)], 596.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let rows = rows_of(&lines);
let found = find(&lines, &rows, &[]);
assert_eq!(found.len(), 1, "the table was left in pieces: {found:?}");
}
#[test]
fn a_caption_in_capitals_announces_a_table_of_its_own() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
line(&[("TABLE 3 | The same by subtype", 72.0)], 640.0),
line(&[("East", 72.0), ("60", 200.0), ("20%", 320.0)], 610.0),
line(&[("West", 72.0), ("21", 200.0), ("7%", 320.0)], 596.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let rows = rows_of(&lines);
let found = find(&lines, &rows, &[]);
assert_eq!(
found.len(),
2,
"a caption announcing the next table was read as a heading inside this \
one: {found:?}"
);
}
#[test]
fn a_roman_or_supplementary_caption_announces_a_table_of_its_own() {
for caption in ["TABLE I Something else entirely", "Table S2 The supplement"] {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
line(&[(caption, 72.0)], 640.0),
line(&[("East", 72.0), ("60", 200.0), ("20%", 320.0)], 610.0),
line(&[("West", 72.0), ("21", 200.0), ("7%", 320.0)], 596.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let rows = rows_of(&lines);
let found = find(&lines, &rows, &[]);
assert_eq!(
found.len(),
2,
"{caption:?} was read as a heading inside the table above it: {found:?}"
);
}
}
#[test]
fn a_word_of_roman_letters_is_not_a_table_number() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
line(&[("Table CIVIL and military", 72.0)], 640.0),
line(&[("East", 72.0), ("60", 200.0), ("20%", 320.0)], 610.0),
line(&[("West", 72.0), ("21", 200.0), ("7%", 320.0)], 596.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let rows = rows_of(&lines);
let found = find(&lines, &rows, &[]);
assert_eq!(
found.len(),
1,
"an English word of Roman letters was read as a table number: {found:?}"
);
}
#[test]
fn a_numbered_section_head_is_not_a_caption() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
line(&[("7.2 Southern hemisphere", 72.0)], 640.0),
line(&[("East", 72.0), ("60", 200.0), ("20%", 320.0)], 610.0),
line(&[("West", 72.0), ("21", 200.0), ("7%", 320.0)], 596.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let rows = rows_of(&lines);
let found = find(&lines, &rows, &[]);
assert_eq!(
found.len(),
1,
"a numbered section head was read as the caption of a new table: {found:?}"
);
}
#[test]
fn a_joined_table_keeps_the_doubt_of_both_halves() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
line(&[("Southern hemisphere", 72.0)], 640.0),
line(&[("East", 72.0), ("60", 200.0), ("20%", 320.0)], 610.0),
line(&[("West", 72.0), ("21", 200.0), ("7%", 320.0)], 596.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let found = find(&lines, &rows_of(&lines), &[]);
assert_eq!(found.len(), 1, "the halves were not joined: {found:?}");
assert!(
found[0].doubt.thin_rows >= 1,
"the joined table reports no thin row, so the evidence of the halves \
was lost: {:?}",
found[0].doubt
);
}
#[test]
fn a_clean_grid_records_no_doubt() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let found = find(&lines, &rows_of(&lines), &[]);
assert_eq!(found.len(), 1, "{found:?}");
let doubt = found[0].doubt;
assert_eq!(doubt.columns, 3, "three columns: {doubt:?}");
assert_eq!(doubt.full_rows, 3, "every row is full: {doubt:?}");
assert_eq!(doubt.thin_rows, 0, "no row was admitted thin: {doubt:?}");
assert_eq!(doubt.retried_from, 0, "no row had to be trimmed: {doubt:?}");
assert!(
(doubt.fill - 1.0).abs() < 1e-9,
"every cell has ink, so the fill is one: {doubt:?}"
);
}
#[test]
fn a_head_inside_a_table_is_recorded_as_doubt() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("Southern hemisphere", 72.0)], 672.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 658.0),
line(&[("East", 72.0), ("60", 200.0), ("20%", 320.0)], 644.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let found = find(&lines, &rows_of(&lines), &[]);
assert_eq!(found.len(), 1, "{found:?}");
let doubt = found[0].doubt;
assert_eq!(
doubt.thin_rows, 1,
"the head was admitted on its neighbours' strength: {doubt:?}"
);
assert!(
doubt.full_rows < 5,
"the head's row is not full, so not every row can be: {doubt:?}"
);
assert!(
doubt.fill < 1.0,
"a row of one cell in a three-column grid lowers the fill: {doubt:?}"
);
}
#[test]
fn a_column_is_a_strip_of_white_through_every_row() {
let drawn = [
line(
&[("Region", 72.0), ("Amount", 200.0), ("Share", 320.0)],
700.0,
),
line(&[("North", 72.0), ("120", 200.0), ("41%", 320.0)], 686.0),
line(&[("South", 72.0), ("95", 200.0), ("32%", 320.0)], 672.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let found = find(&lines, &rows_of(&lines), &[]);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].cells.len(), 9);
assert_eq!(found[0].cells.iter().map(|cell| cell.column).max(), Some(2));
assert!(found[0].cells.iter().all(|cell| cell.column_span == 1));
}
#[test]
fn running_text_has_no_column_in_it() {
let drawn = [
line(&[("the committee considered", 72.0)], 700.0),
line(&[("the proposals put before", 72.0)], 686.0),
line(&[("it and resolved that each", 72.0)], 672.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
assert!(find(&lines, &rows_of(&lines), &[]).is_empty());
}
#[test]
fn marks_at_two_heights_are_not_one_rule() {
let drawn = [
line(&[("alpha", 72.0), ("one", 117.0)], 700.0),
line(&[("beta", 72.0), ("two", 117.0)], 686.0),
line(&[("gamma", 72.0), ("six", 117.0)], 672.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
let mark = |left: f64, right: f64, y: f64| HorizontalRule { left, right, y };
let apart = [
mark(70.0, 90.0, 699.0),
mark(100.0, 125.0, 695.0),
mark(70.0, 90.0, 685.0),
mark(100.0, 125.0, 681.0),
];
assert!(
find(&lines, &rows_of(&lines), &apart).is_empty(),
"two marks at two heights were read as a separator"
);
let together = [
mark(70.0, 90.0, 697.0),
mark(100.0, 125.0, 697.0),
mark(70.0, 90.0, 683.0),
mark(100.0, 125.0, 683.0),
];
assert!(!find(&lines, &rows_of(&lines), &together).is_empty());
}
#[test]
fn a_gutter_is_believed_only_when_a_second_row_shows_it() {
let drawn = vec![
line(&[("alpha", 72.0), ("one", 200.0)], 700.0),
line(&[("beta gamma delta epsilon", 72.0), ("two", 330.0)], 686.0),
];
let lines: Vec<&TextBlock> = drawn.iter().collect();
assert!(find(&lines, &rows_of(&lines), &[]).is_empty(), "{drawn:?}");
}
}