use crate::blocks::{Block, BlockKind};
use crate::text::{Ruling, TextSpan};
const EDGE_GAP: f64 = 12.0;
const WIDTH_OVERLAP: f64 = 0.4;
const MIN_RUN_CELLS: usize = 3;
const MARKER_LEN: usize = 3;
const COLUMN_SLACK: f64 = 4.0;
const AMOUNT_WIDTH: f64 = 40.0;
const LINE_SPLIT: f64 = 0.75;
const CHECKBOX_MIN: f64 = 3.0;
const CHECKBOX_SIDE: f64 = 16.0;
fn span_box(span: &TextSpan) -> [f64; 4] {
[span.x, span.y, span.x + span.width, span.y + span.height]
}
fn overlaps(a0: f64, a1: f64, b0: f64, b1: f64, need: f64) -> bool {
let lo = a0.max(b0);
let hi = a1.min(b1);
hi - lo >= need
}
fn is_marker(text: &str) -> bool {
let trimmed = text.trim();
!trimmed.is_empty()
&& trimmed.len() <= MARKER_LEN
&& trimmed.chars().all(|c| c.is_alphanumeric())
}
fn union_box(spans: &[&TextSpan]) -> [f64; 4] {
let first = span_box(spans[0]);
spans[1..].iter().fold(first, |seen, span| {
let box_ = span_box(span);
[
seen[0].min(box_[0]),
seen[1].min(box_[1]),
seen[2].max(box_[2]),
seen[3].max(box_[3]),
]
})
}
fn visual_lines(spans: &[TextSpan]) -> Vec<Vec<&TextSpan>> {
let mut ordered: Vec<&TextSpan> = spans.iter().collect();
ordered.sort_by(|a, b| {
let center_a = a.y + a.height / 2.0;
let center_b = b.y + b.height / 2.0;
center_b
.partial_cmp(¢er_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut heights: Vec<f64> = ordered.iter().map(|span| span.height).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = heights[heights.len() / 2];
let mut lines: Vec<Vec<&TextSpan>> = Vec::new();
for span in ordered {
let center = span.y + span.height / 2.0;
let continues = lines.last().is_some_and(|line: &Vec<&TextSpan>| {
let top = line[0].y + line[0].height / 2.0;
(top - center).abs() <= LINE_SPLIT * median
});
match lines.last_mut() {
Some(line) if continues => line.push(span),
_ => lines.push(vec![span]),
}
}
for line in &mut lines {
line.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
}
lines
}
fn joined_text(spans: &[TextSpan]) -> String {
spans
.iter()
.flat_map(|span| span.text.split_whitespace())
.collect::<Vec<_>>()
.join(" ")
}
struct ReclaimedCell {
column: u16,
column_span: u16,
spans: Vec<TextSpan>,
}
struct TableRun {
start: usize,
end: usize,
first_row: u16,
last_row: u16,
first_col: u16,
last_col: u16,
marker: [f64; 4],
amount: [f64; 4],
top: f64,
bottom: f64,
extent: [f64; 4],
}
fn table_run(blocks: &[Block], anchor: usize) -> Option<TableRun> {
if !matches!(blocks.get(anchor)?.kind, BlockKind::TableCell { .. }) {
return None;
}
let page = blocks[anchor].page;
let mut start = anchor;
while start > 0
&& matches!(blocks[start - 1].kind, BlockKind::TableCell { .. })
&& blocks[start - 1].page == page
{
start -= 1;
}
let mut end = anchor;
while end + 1 < blocks.len()
&& matches!(blocks[end + 1].kind, BlockKind::TableCell { .. })
&& blocks[end + 1].page == page
{
end += 1;
}
if end + 1 - start < MIN_RUN_CELLS {
return None;
}
let mut rows = Vec::new();
for block in &blocks[start..=end] {
if let BlockKind::TableCell { row, column, .. } = block.kind {
rows.push((row, column, block.bbox));
}
}
let first_row = rows.iter().map(|cell| cell.0).min()?;
let last_row = rows.iter().map(|cell| cell.0).max()?;
let first_col = rows.iter().map(|cell| cell.1).min()?;
let last_col = rows.iter().map(|cell| cell.1).max()?;
let column_box = |column: u16| {
rows.iter()
.filter(|cell| cell.1 == column)
.map(|cell| cell.2)
.reduce(|seen, bbox| {
[
seen[0].min(bbox[0]),
seen[1].min(bbox[1]),
seen[2].max(bbox[2]),
seen[3].max(bbox[3]),
]
})
};
let top = rows
.iter()
.filter(|cell| cell.0 == first_row)
.map(|cell| cell.2[3])
.reduce(f64::max)?;
let bottom = rows
.iter()
.filter(|cell| cell.0 == last_row)
.map(|cell| cell.2[1])
.reduce(f64::min)?;
let extent = rows.iter().map(|cell| cell.2).reduce(|seen, bbox| {
[
seen[0].min(bbox[0]),
seen[1].min(bbox[1]),
seen[2].max(bbox[2]),
seen[3].max(bbox[3]),
]
})?;
Some(TableRun {
start,
end,
first_row,
last_row,
first_col,
last_col,
marker: column_box(first_col)?,
amount: column_box(last_col)?,
top,
bottom,
extent,
})
}
fn has_entry_box(rulings: &[Ruling], amount: [f64; 4]) -> bool {
let grown = [
amount[0] - 3.0,
amount[1] - 3.0,
amount[2] + 3.0,
amount[3] + 3.0,
];
rulings
.iter()
.filter(|ruling| {
let width = (ruling.x1 - ruling.x0).abs();
let height = (ruling.y1 - ruling.y0).abs();
width.max(height) <= 44.0
&& overlaps(
ruling.x0.min(ruling.x1),
ruling.x0.max(ruling.x1),
grown[0],
grown[2],
0.0,
)
&& overlaps(
ruling.y0.min(ruling.y1),
ruling.y0.max(ruling.y1),
grown[1],
grown[3],
0.0,
)
})
.count()
>= 2
}
fn has_checkbox(rulings: &[Ruling], line_box: [f64; 4], extent: [f64; 4]) -> bool {
rulings.iter().any(|ruling| {
let left = ruling.x0.min(ruling.x1);
let right = ruling.x0.max(ruling.x1);
let bottom = ruling.y0.min(ruling.y1);
let top = ruling.y0.max(ruling.y1);
let side = (right - left).max(top - bottom);
(CHECKBOX_MIN..=CHECKBOX_SIDE).contains(&side)
&& overlaps(bottom, top, line_box[1], line_box[3], 0.0)
&& overlaps(left, right, extent[0], extent[2], 0.0)
})
}
fn amount_index(line: &[&TextSpan], run: &TableRun) -> Option<usize> {
line.iter()
.enumerate()
.skip(1)
.find(|(_, span)| {
let center = span.x + span.width / 2.0;
center >= run.amount[0] - COLUMN_SLACK
&& center <= run.amount[2] + COLUMN_SLACK
&& span.width <= AMOUNT_WIDTH
})
.map(|(index, _)| index)
}
fn marker_at(line: &[&TextSpan], run: &TableRun) -> Option<usize> {
line.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal))
.and_then(|(index, span)| {
let center = span.x + span.width / 2.0;
(is_marker(&span.text)
&& center >= run.marker[0] - COLUMN_SLACK
&& center <= run.marker[2] + COLUMN_SLACK)
.then_some(index)
})
}
fn plan_rows(
paragraph: &Block,
run: &TableRun,
rulings: &[Ruling],
) -> Option<Vec<Vec<ReclaimedCell>>> {
let lines = visual_lines(¶graph.spans);
if lines.iter().any(Vec::is_empty) {
return None;
}
if lines.iter().any(|line| marker_at(line, run).is_none()) {
return None;
}
let widths = run.last_col - run.first_col;
if lines.len() == 1 && amount_index(&lines[0], run).is_none() {
let line_box = union_box(&lines[0]);
if !has_checkbox(rulings, line_box, run.extent) {
return None;
}
return Some(vec![vec![ReclaimedCell {
column: run.first_col,
column_span: widths + 1,
spans: lines[0].iter().map(|span| (*span).clone()).collect(),
}]]);
}
if lines.iter().all(|line| amount_index(line, run).is_none()) {
return None;
}
let mut rows = Vec::new();
for line in &lines {
let marker = marker_at(line, run)?;
let mut cells = Vec::new();
match amount_index(line, run) {
Some(amount) => {
if !has_entry_box(rulings, span_box(line[amount])) {
return None;
}
let mut head: Vec<TextSpan> = vec![line[marker].clone()];
let stub: Vec<TextSpan> = line
.iter()
.enumerate()
.filter(|(index, _)| *index != marker && *index != amount)
.map(|(_, span)| (*span).clone())
.collect();
if widths >= 2 {
cells.push(ReclaimedCell {
column: run.first_col,
column_span: 1,
spans: std::mem::take(&mut head),
});
cells.push(ReclaimedCell {
column: run.first_col + 1,
column_span: widths - 1,
spans: stub,
});
} else {
head.extend(stub);
cells.push(ReclaimedCell {
column: run.first_col,
column_span: widths + 1,
spans: head,
});
}
cells.push(ReclaimedCell {
column: run.last_col,
column_span: 1,
spans: vec![line[amount].clone()],
});
}
None => {
let stub: Vec<TextSpan> = line
.iter()
.enumerate()
.filter(|(index, _)| *index != marker)
.map(|(_, span)| (*span).clone())
.collect();
if widths >= 1 {
cells.push(ReclaimedCell {
column: run.first_col,
column_span: 1,
spans: vec![line[marker].clone()],
});
cells.push(ReclaimedCell {
column: run.first_col + 1,
column_span: widths,
spans: stub,
});
} else {
let mut spans = vec![line[marker].clone()];
spans.extend(stub);
cells.push(ReclaimedCell {
column: run.first_col,
column_span: 1,
spans,
});
}
}
}
rows.push(cells);
}
Some(rows)
}
fn adjacent(paragraph: &Block, run: &TableRun, blocks: &[Block], index: usize) -> bool {
if paragraph.page != blocks[run.start].page {
return false;
}
let gap = if index + 1 == run.start {
paragraph.bbox[1] - run.top
} else if index == run.end + 1 {
run.bottom - paragraph.bbox[3]
} else {
return false;
};
if gap < 0.0 {
return false;
}
if gap > EDGE_GAP {
return false;
}
let width = paragraph.bbox[2] - paragraph.bbox[0];
overlaps(
paragraph.bbox[0],
paragraph.bbox[2],
run.extent[0],
run.extent[2],
WIDTH_OVERLAP * width,
)
}
pub(crate) fn reclaim_table_edge_rows(blocks: &mut Vec<Block>, lattices: &[Vec<Ruling>]) {
loop {
let mut at = None;
for (index, block) in blocks.iter().enumerate() {
if !matches!(block.kind, BlockKind::Paragraph) {
continue;
}
let anchor = if index + 1 < blocks.len()
&& matches!(blocks[index + 1].kind, BlockKind::TableCell { .. })
{
index + 1
} else if index > 0 && matches!(blocks[index - 1].kind, BlockKind::TableCell { .. }) {
index - 1
} else {
continue;
};
let Some(run) = table_run(blocks, anchor) else {
continue;
};
if !adjacent(block, &run, blocks, index) {
continue;
}
let rulings: &[Ruling] = lattices.get(block.page).map(Vec::as_slice).unwrap_or(&[]);
let Some(rows) = plan_rows(block, &run, rulings) else {
continue;
};
if rows
.iter()
.flatten()
.any(|cell| joined_text(&cell.spans).is_empty())
{
continue;
}
at = Some((index, run, rows));
break;
}
let Some((index, run, rows)) = at else {
return;
};
let paragraph = blocks.remove(index);
let prepend = index + 1 == run.start;
let start = if prepend { run.start - 1 } else { run.start };
let end = if prepend { run.end - 1 } else { run.end };
let shift = (rows.len() as u16).saturating_sub(run.first_row);
let shift = if prepend { shift } else { 0 };
if shift > 0 {
for block in &mut blocks[start..=end] {
if let BlockKind::TableCell { row, .. } = &mut block.kind {
*row += shift;
}
}
}
let base = if prepend {
run.first_row + shift - rows.len() as u16
} else {
run.last_row + 1
};
let mut at = if prepend { start } else { end + 1 };
for (offset, cells) in rows.iter().enumerate() {
for cell in cells {
let text = joined_text(&cell.spans);
let bbox = union_box(&cell.spans.iter().collect::<Vec<_>>());
let mut block = Block::new(
BlockKind::TableCell {
row: base + offset as u16,
column: cell.column,
row_span: 1,
column_span: cell.column_span,
header: false,
},
paragraph.page,
bbox,
text,
at,
);
block.spans = cell.spans.clone();
blocks.insert(at, block);
at += 1;
}
}
for (position, block) in blocks.iter_mut().enumerate() {
block.index = position;
}
}
}