use crate::ir::{BBox, Block, Cell, Inline, Line, ListItem, Marker, PageLayout, Role};
use crate::output::{line_text, Output, Text};
use pdfboss_text::{Ruling, TextSpan};
const WORD_GAP: f32 = 0.15;
const LINE_OVERLAP: f32 = 0.5;
const COLUMN_MIN_SPANS: usize = 40;
const COLUMN_MIN_SIDE_SPANS: usize = 10;
const COLUMN_MIN_SIDE_LINES: usize = 6;
const COLUMN_MIN_HEIGHT: f32 = 0.4;
const COLUMN_MIN_SIDE_WIDTH: f32 = 0.25;
const TWO_UP_MIN_GUTTER: f32 = 0.05;
const GUTTER_MIN_WIDTH: f32 = 6.0;
const GUTTER_BAND: std::ops::RangeInclusive<f32> = 0.25..=0.75;
const GUTTER_BINS: usize = 128;
const GUTTER_MAX_CROSSING: f32 = 0.1;
const FLOW_STEP_UP: f32 = 2.0;
const FLOW_LINE_UP: f32 = 1.0;
const FLOW_STEP_ASIDE: f32 = 2.0;
const FLOW_FRAGMENT_FRACTION: f32 = 0.5;
const HEADING_MIN_DELTA: f32 = 1.0;
const HEADING_MAX_LEVEL: u8 = 6;
const BOLD_HEADING_MAX_CHARS: usize = 60;
const HEADING_MAX_CHARS: usize = 120;
const PARAGRAPH_GAP: f32 = 1.8;
const HEADING_MERGE_STEP: f32 = 1.8;
const BULLETS: &[char] = &['\u{2022}', '\u{25E6}', '\u{25AA}', '\u{2013}', '-', '*'];
const LIST_MIN_LINES: usize = 2;
const LIST_CONTINUATION_INDENT: f32 = 0.5;
const TABLE_MIN_LANES: usize = 2;
const TABLE_MIN_ROWS: usize = 3;
const TABLE_MIN_ROW_CELLS: usize = 2;
const TABLE_ROW_GAP: f32 = 2.0;
const RULING_SNAP_TOLERANCE: f32 = 6.0;
const RULED_GRID_MIN_VERTICALS: usize = 2;
const RULED_GRID_MIN_HORIZONTALS: usize = 3;
const BAND_INFER_MIN_LINES: usize = 4;
const RULED_BOXED_MIN_ROWS: usize = 2;
const HEADER_FOOTER_MIN_PAGES: usize = 3;
const HEADER_FOOTER_Y_TOLERANCE: f32 = 2.0;
pub fn layout(spans: &[TextSpan]) -> String {
Text.render(&[page_layout(spans)])
}
pub fn page_layout(spans: &[TextSpan]) -> PageLayout {
page_layout_with_rulings(spans, &[])
}
pub fn page_layout_with_rulings(spans: &[TextSpan], rulings: &[Ruling]) -> PageLayout {
page_layout_with_stats(spans, rulings, &size_stats(&[spans]))
}
pub fn document_layout(pages: &[Vec<TextSpan>]) -> Vec<PageLayout> {
let paired: Vec<(&[TextSpan], &[Ruling])> = pages
.iter()
.map(|spans| (spans.as_slice(), &[][..]))
.collect();
layouts_of(&paired)
}
pub fn document_layout_with_rulings(pages: &[(Vec<TextSpan>, Vec<Ruling>)]) -> Vec<PageLayout> {
let paired: Vec<(&[TextSpan], &[Ruling])> = pages
.iter()
.map(|(spans, rulings)| (spans.as_slice(), rulings.as_slice()))
.collect();
layouts_of(&paired)
}
fn layouts_of(pages: &[(&[TextSpan], &[Ruling])]) -> Vec<PageLayout> {
let borrowed: Vec<&[TextSpan]> = pages.iter().map(|(spans, _)| *spans).collect();
let stats = size_stats(&borrowed);
let mut layouts: Vec<PageLayout> = pages
.iter()
.map(|(spans, rulings)| page_layout_with_stats(spans, rulings, &stats))
.collect();
tag_page_roles(&mut layouts);
layouts
}
fn tag_page_roles(layouts: &mut [PageLayout]) {
if layouts.len() < HEADER_FOOTER_MIN_PAGES {
return;
}
let top: Vec<Option<(String, f32)>> = layouts
.iter()
.map(|layout| edge_line(layout, true))
.collect();
let bottom: Vec<Option<(String, f32)>> = layouts
.iter()
.map(|layout| edge_line(layout, false))
.collect();
let headers = header_footer_pages(&top);
let footers = header_footer_pages(&bottom);
for (index, layout) in layouts.iter_mut().enumerate() {
if footers[index] {
split_edge(layout, false, Role::PageFooter);
}
if headers[index] {
split_edge(layout, true, Role::PageHeader);
}
}
}
fn edge_line(layout: &PageLayout, top: bool) -> Option<(String, f32)> {
let block = if top {
layout.blocks.first()
} else {
layout.blocks.last()
}?;
let Block::Paragraph { lines, role, .. } = block else {
return None;
};
if !matches!(role, Role::Body) {
return None;
}
let line = if top { lines.first() } else { lines.last() }?;
let normalized = normalize_candidate(&line_text(line));
(!normalized.is_empty()).then_some((normalized, line.y))
}
fn header_footer_pages(candidates: &[Option<(String, f32)>]) -> Vec<bool> {
let mut tagged = repeated_lines(candidates);
for (index, candidate) in candidates.iter().enumerate() {
let Some((text, _)) = candidate else { continue };
tagged[index] |= looks_like_page_number(text);
}
tagged
}
fn repeated_lines(candidates: &[Option<(String, f32)>]) -> Vec<bool> {
let threshold = (candidates.len() / 2).max(HEADER_FOOTER_MIN_PAGES);
let mut groups: std::collections::BTreeMap<&str, Vec<(usize, f32)>> =
std::collections::BTreeMap::new();
for (index, candidate) in candidates.iter().enumerate() {
let Some((text, y)) = candidate else { continue };
groups.entry(text.as_str()).or_default().push((index, *y));
}
let mut tagged = vec![false; candidates.len()];
for occurrences in groups.values() {
if occurrences.len() < threshold {
continue;
}
let (min_y, max_y) = occurrences
.iter()
.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), &(_, y)| {
(lo.min(y), hi.max(y))
});
if max_y - min_y > HEADER_FOOTER_Y_TOLERANCE {
continue;
}
for &(index, _) in occurrences {
tagged[index] = true;
}
}
tagged
}
fn normalize_candidate(text: &str) -> String {
let digits_marked: String = text
.chars()
.map(|ch| if ch.is_ascii_digit() { '#' } else { ch })
.collect();
digits_marked
.to_lowercase()
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
fn looks_like_page_number(normalized: &str) -> bool {
let body = normalized.strip_prefix("page ").unwrap_or(normalized);
if is_hash_run(body) {
return true;
}
if let Some((left, right)) = body.split_once(" of ") {
return is_hash_run(left) && is_hash_run(right);
}
let Some(inner) = normalized
.strip_prefix('-')
.and_then(|s| s.strip_suffix('-'))
else {
return false;
};
is_hash_run(inner.trim())
}
fn is_hash_run(text: &str) -> bool {
!text.is_empty() && text.chars().all(|ch| ch == '#')
}
fn split_edge(layout: &mut PageLayout, top: bool, role: Role) {
if layout.blocks.is_empty() {
return;
}
let index = if top { 0 } else { layout.blocks.len() - 1 };
let Block::Paragraph {
lines,
role: current,
..
} = &layout.blocks[index]
else {
return;
};
if !matches!(current, Role::Body) {
return;
}
let mut lines = lines.clone();
let edge_line = if top {
lines.remove(0)
} else {
let Some(line) = lines.pop() else { return };
line
};
let edge_block = Block::Paragraph {
bbox: bbox(std::slice::from_ref(&edge_line)),
lines: vec![edge_line],
role,
};
if lines.is_empty() {
layout.blocks[index] = edge_block;
return;
}
let rest_block = Block::Paragraph {
bbox: bbox(&lines),
lines,
role: Role::Body,
};
if top {
layout.blocks[index] = rest_block;
layout.blocks.insert(index, edge_block);
} else {
layout.blocks[index] = rest_block;
layout.blocks.push(edge_block);
}
}
fn page_layout_with_stats(spans: &[TextSpan], rulings: &[Ruling], stats: &SizeStats) -> PageLayout {
let grids = ruled_grids(rulings);
let mut blocks = Vec::new();
for segment in segments(spans) {
push_segment_blocks(segment, &grids, stats, &mut blocks);
}
PageLayout { blocks }
}
fn push_segment_blocks(
segment: Segment<'_>,
grids: &[RuledGrid],
stats: &SizeStats,
out: &mut Vec<Block>,
) {
let groups = segment.into_groups();
if grids.is_empty() {
push_lane_blocks(&groups, stats, out);
return;
}
let claims = grid_claims(&groups, grids);
if claims.is_empty() {
push_lane_blocks(&groups, stats, out);
return;
}
let mut next = 0usize;
for claim in claims {
push_stretch(&groups[next..claim.range.start], stats, out);
out.push(Block::Table {
bbox: claim.bbox,
rows: claim.rows,
});
next = claim.range.end;
}
push_stretch(&groups[next..], stats, out);
}
fn push_lane_blocks(groups: &[Group], stats: &SizeStats, out: &mut Vec<Block>) {
let Some(band) = table_band(groups) else {
push_blocks(groups.iter().map(assembled).collect(), stats, out);
return;
};
push_blocks(band.above, stats, out);
out.push(Block::Table {
bbox: table_bbox(&band.rows),
rows: band.rows,
});
push_blocks(band.below, stats, out);
}
fn push_stretch(groups: &[Group], stats: &SizeStats, out: &mut Vec<Block>) {
if groups.is_empty() {
return;
}
let spans: Vec<&TextSpan> = groups
.iter()
.flat_map(|group| group.spans.iter().copied())
.collect();
push_lane_blocks(&line_groups(&spans), stats, out);
}
struct SizeStats {
body: f32,
ladder: Vec<f32>,
}
impl SizeStats {
fn level(&self, size: f32) -> Option<u8> {
let rank = self
.ladder
.iter()
.position(|bucket| half_points(*bucket) == half_points(size))?;
Some(clamped_level(rank + 1))
}
fn bold_level(&self) -> u8 {
clamped_level(self.ladder.len() + 1)
}
fn is_body(&self, size: f32) -> bool {
half_points(size) == half_points(self.body)
}
}
fn clamped_level(rank: usize) -> u8 {
rank.min(HEADING_MAX_LEVEL as usize) as u8
}
fn half_points(size: f32) -> i32 {
(size * 2.0).round() as i32
}
fn size_stats(pages: &[&[TextSpan]]) -> SizeStats {
let mut weights: Vec<(i32, usize)> = Vec::new();
for span in pages.iter().flat_map(|page| page.iter()) {
let bucket = half_points(span.size);
let chars = span.text.bytes().filter(|b| (b & 0xC0) != 0x80).count();
match weights.binary_search_by_key(&bucket, |(b, _)| *b) {
Ok(index) => weights[index].1 += chars,
Err(index) => weights.insert(index, (bucket, chars)),
}
}
let body = weights
.iter()
.min_by_key(|(bucket, weight)| (std::cmp::Reverse(*weight), *bucket))
.map(|(bucket, _)| *bucket as f32 / 2.0);
let Some(body) = body else {
return SizeStats {
body: 0.0,
ladder: Vec::new(),
};
};
let ladder: Vec<f32> = weights
.iter()
.rev()
.map(|(bucket, _)| *bucket as f32 / 2.0)
.filter(|size| *size >= body + HEADING_MIN_DELTA)
.collect();
SizeStats { body, ladder }
}
struct Assembled {
line: Line,
min_size: f32,
}
fn push_blocks(lines: Vec<Assembled>, stats: &SizeStats, out: &mut Vec<Block>) {
let mut stretches: Vec<(usize, Option<u8>)> = Vec::new();
let mut index = 0;
while index < lines.len() {
let heading = heading_level(&lines[index], stats).map(|level| {
let mut end = index + 1;
while end < lines.len() && continues_heading(&lines[end - 1], &lines[end], stats, level)
{
end += 1;
}
(end, level)
});
let run_length = match heading {
None => 1,
Some((end, level)) => {
let candidate = lines[index..end].iter().map(|a| &a.line);
if heading_chars(candidate) <= HEADING_MAX_CHARS {
stretches.push((end - index, Some(level)));
index = end;
continue;
}
end - index
}
};
match stretches.last_mut() {
Some((count, None)) => *count += run_length,
_ => stretches.push((run_length, None)),
}
index += run_length;
}
let mut moved = lines.into_iter();
let mut run: Vec<Line> = Vec::new();
for (count, level) in stretches {
let Some(level) = level else {
run.extend(moved.by_ref().take(count).map(|a| a.line));
push_run(&mut run, out);
continue;
};
let heading: Vec<Line> = moved.by_ref().take(count).map(|a| a.line).collect();
let bbox = bbox(&heading);
out.push(Block::Heading {
level,
lines: heading,
bbox,
});
}
}
fn heading_chars<'l>(lines: impl Iterator<Item = &'l Line>) -> usize {
lines
.map(line_text)
.collect::<Vec<String>>()
.join(" ")
.trim()
.chars()
.count()
}
fn heading_level(line: &Assembled, stats: &SizeStats) -> Option<u8> {
if let Some(level) = stats.level(line.min_size) {
return Some(level);
}
if !stats.is_body(line.min_size) {
return None;
}
if !is_bold_title(&line.line) {
return None;
}
Some(stats.bold_level())
}
fn continues_heading(prev: &Assembled, next: &Assembled, stats: &SizeStats, level: u8) -> bool {
if heading_level(next, stats) != Some(level) {
return false;
}
if half_points(prev.min_size) != half_points(next.min_size) {
return false;
}
prev.line.y - next.line.y <= HEADING_MERGE_STEP * next.line.size
}
fn is_bold_title(line: &Line) -> bool {
if line.inlines.is_empty() || !line.inlines.iter().all(|inline| inline.bold) {
return false;
}
let text = line_text(line);
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.chars().count() > BOLD_HEADING_MAX_CHARS {
return false;
}
!trimmed.ends_with(['.', ',', ';'])
}
fn push_run(run: &mut Vec<Line>, out: &mut Vec<Block>) {
let lines = std::mem::take(run);
if lines.is_empty() {
return;
}
let markers: Vec<Option<(Marker, usize)>> = lines
.iter()
.map(|line| list_marker(&line_text(line)))
.collect();
let mut lists: Vec<(usize, Vec<ListRunItem>)> = Vec::new();
let mut prose_count = 0usize;
let mut index = 0;
while index < lines.len() {
let Some(items) = list_run(&lines[index..], &markers[index..]) else {
prose_count += 1;
index += 1;
continue;
};
index += items.iter().map(|(_, _, count)| count).sum::<usize>();
lists.push((prose_count, items));
prose_count = 0;
}
let mut moved = lines.into_iter();
let mut prose: Vec<Line> = Vec::new();
for (count, items) in lists {
prose.extend(moved.by_ref().take(count));
push_paragraphs(&mut prose, out);
let items: Vec<ListItem> = items
.into_iter()
.map(|(marker, marker_len, count)| ListItem {
marker,
marker_len,
lines: moved.by_ref().take(count).collect(),
})
.collect();
out.push(Block::List {
bbox: bbox(items.iter().flat_map(|item| &item.lines)),
items,
});
}
prose.extend(moved);
push_paragraphs(&mut prose, out);
}
type ListRunItem = (Marker, usize, usize);
fn list_run(lines: &[Line], markers: &[Option<(Marker, usize)>]) -> Option<Vec<ListRunItem>> {
let mut items = Vec::new();
let mut consumed = 0usize;
let mut index = 0;
while index < lines.len() {
let Some((marker, marker_len)) = markers[index].clone() else {
break;
};
let item_x = lines[index].x;
let item_size = lines[index].size;
let opened = index;
index += 1;
while index < lines.len()
&& markers[index].is_none()
&& lines[index].x > item_x + LIST_CONTINUATION_INDENT * item_size
{
index += 1;
}
consumed += index - opened;
items.push((marker, marker_len, index - opened));
}
(consumed >= LIST_MIN_LINES).then_some(items)
}
fn list_marker(text: &str) -> Option<(Marker, usize)> {
let trimmed = text.trim_start();
let indent = text.chars().count() - trimmed.chars().count();
let first = trimmed.chars().next()?;
if BULLETS.contains(&first) {
let rest = &trimmed[first.len_utf8()..];
let whitespace = rest.chars().take_while(|c| c.is_whitespace()).count();
if whitespace == 0 {
return None;
}
return Some((Marker::Bullet, indent + 1 + whitespace));
}
if !first.is_ascii_digit() {
return None;
}
let digit_count = trimmed
.chars()
.take(3)
.take_while(char::is_ascii_digit)
.count();
let rest = &trimmed[digit_count..];
let mut rest_chars = rest.chars();
let separator = rest_chars.next()?;
if separator != '.' && separator != ')' {
return None;
}
let whitespace = rest_chars
.as_str()
.chars()
.take_while(|c| c.is_whitespace())
.count();
if whitespace == 0 {
return None;
}
let number: u32 = trimmed[..digit_count].parse().ok()?;
Some((
Marker::Number(number),
indent + digit_count + 1 + whitespace,
))
}
fn push_paragraphs(run: &mut Vec<Line>, out: &mut Vec<Block>) {
let lines = std::mem::take(run);
if lines.is_empty() {
return;
}
let limit = PARAGRAPH_GAP * median_step(&lines);
let mut counts: Vec<usize> = Vec::new();
let mut start = 0;
for index in 1..lines.len() {
if limit <= 0.0 || lines[index - 1].y - lines[index].y <= limit {
continue;
}
counts.push(index - start);
start = index;
}
counts.push(lines.len() - start);
let mut moved = lines.into_iter();
for count in counts {
let paragraph: Vec<Line> = moved.by_ref().take(count).collect();
out.push(Block::Paragraph {
bbox: bbox(¶graph),
lines: paragraph,
role: Role::Body,
});
}
}
fn median_step(lines: &[Line]) -> f32 {
median(lines.windows(2).map(|pair| pair[0].y - pair[1].y).collect())
}
fn median(mut values: Vec<f32>) -> f32 {
if values.is_empty() {
return 0.0;
}
values.sort_by(f32::total_cmp);
values[values.len() / 2]
}
struct Group<'s> {
y: f32,
size: f32,
spans: Vec<&'s TextSpan>,
}
fn same_line(y: f32, size: f32, span: &TextSpan) -> bool {
if (y - span.y).abs() <= 0.5 * size.max(span.size) {
return true;
}
let line_extent = (y - 0.25 * size, y + 0.75 * size);
let span_extent = (span.y - 0.25 * span.size, span.y + 0.75 * span.size);
let overlap = line_extent.1.min(span_extent.1) - line_extent.0.max(span_extent.0);
overlap >= LINE_OVERLAP * size.min(span.size)
}
fn line_groups<'s>(spans: &[&'s TextSpan]) -> Vec<Group<'s>> {
let mut groups: Vec<Group> = Vec::new();
let mut counts: Vec<usize> = Vec::new();
let mut homes: Vec<usize> = Vec::with_capacity(spans.len());
let mut last: Option<(&TextSpan, usize)> = None;
for &span in spans {
let repeat = last.filter(|(prev, home)| {
prev.y == span.y
&& prev.size == span.size
&& (groups[*home].y - span.y).abs() <= 0.5 * groups[*home].size.max(span.size)
});
let found = match repeat {
Some((_, home)) => Some(home),
None => groups
.iter()
.position(|group| same_line(group.y, group.size, span)),
};
last = Some((span, found.unwrap_or(groups.len())));
match found {
Some(index) => {
groups[index].size = groups[index].size.max(span.size);
counts[index] += 1;
homes.push(index);
}
None => {
homes.push(groups.len());
groups.push(Group {
y: span.y,
size: span.size,
spans: Vec::new(),
});
counts.push(1);
}
}
}
for (group, count) in groups.iter_mut().zip(&counts) {
group.spans.reserve_exact(*count);
}
for (&span, &home) in spans.iter().zip(&homes) {
groups[home].spans.push(span);
}
groups.sort_by(|a, b| b.y.total_cmp(&a.y)); for group in &mut groups {
group.spans.sort_by(|a, b| a.x.total_cmp(&b.x));
}
groups
}
struct TableBand {
above: Vec<Assembled>,
rows: Vec<Vec<Cell>>,
below: Vec<Assembled>,
}
struct RuledGrid {
xs: Vec<f32>,
ys: Vec<f32>,
boxed: bool,
}
impl RuledGrid {
fn columns(&self) -> Vec<std::ops::Range<f32>> {
self.xs.windows(2).map(|pair| pair[0]..pair[1]).collect()
}
fn holds(&self, y: f32) -> bool {
self.ys[0] <= y && y < self.ys[self.ys.len() - 1]
}
fn band_of(&self, y: f32) -> usize {
self.ys.partition_point(|ruling_y| *ruling_y <= y) - 1
}
fn bbox(&self) -> BBox {
BBox {
x0: self.xs[0],
y0: self.ys[0],
x1: self.xs[self.xs.len() - 1],
y1: self.ys[self.ys.len() - 1],
}
}
}
struct GridLine {
position: f32,
extent: std::ops::Range<f32>,
}
fn ruled_grids(rulings: &[Ruling]) -> Vec<RuledGrid> {
if rulings.is_empty() {
return Vec::new();
}
let vertical = |r: &&Ruling| r.end.y - r.start.y > r.end.x - r.start.x;
let verticals = grid_lines(
rulings
.iter()
.filter(vertical)
.map(|r| (r.start.x, r.start.y..r.end.y)),
);
let horizontals = grid_lines(
rulings
.iter()
.filter(|r| !vertical(r))
.map(|r| (r.start.y, r.start.x..r.end.x)),
);
let mut parent: Vec<usize> = (0..verticals.len() + horizontals.len()).collect();
for (v, vertical) in verticals.iter().enumerate() {
for (h, horizontal) in horizontals.iter().enumerate() {
if crosses(vertical, horizontal) {
union(&mut parent, v, verticals.len() + h);
}
}
}
let mut components: std::collections::BTreeMap<usize, (Vec<usize>, Vec<usize>)> =
std::collections::BTreeMap::new();
for v in 0..verticals.len() {
let root = find(&mut parent, v);
components.entry(root).or_default().0.push(v);
}
for h in 0..horizontals.len() {
let root = find(&mut parent, verticals.len() + h);
components.entry(root).or_default().1.push(h);
}
let mut grids: Vec<RuledGrid> = components
.values()
.filter_map(|(v_indices, h_indices)| {
let component_verticals: Vec<&GridLine> =
v_indices.iter().map(|&i| &verticals[i]).collect();
let component_horizontals: Vec<&GridLine> =
h_indices.iter().map(|&i| &horizontals[i]).collect();
lattice(&component_verticals, &component_horizontals)
})
.collect();
grids.sort_by(|a, b| b.ys[b.ys.len() - 1].total_cmp(&a.ys[a.ys.len() - 1]));
grids
}
fn grid_lines(rulings: impl Iterator<Item = (f32, std::ops::Range<f32>)>) -> Vec<GridLine> {
let mut all: Vec<(f32, std::ops::Range<f32>)> = rulings.collect();
all.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut lines: Vec<GridLine> = Vec::new();
let mut cluster: Vec<(f32, std::ops::Range<f32>)> = Vec::new();
for line in all {
if let Some(last) = cluster.last() {
if line.0 - last.0 > RULING_SNAP_TOLERANCE {
lines.append(&mut merged_cluster(std::mem::take(&mut cluster)));
}
}
cluster.push(line);
}
lines.append(&mut merged_cluster(cluster));
lines
}
fn merged_cluster(mut cluster: Vec<(f32, std::ops::Range<f32>)>) -> Vec<GridLine> {
if cluster.is_empty() {
return Vec::new();
}
let position = cluster.iter().map(|(p, _)| *p).sum::<f32>() / cluster.len() as f32;
cluster.sort_by(|a, b| a.1.start.total_cmp(&b.1.start));
let mut lines: Vec<GridLine> = Vec::new();
for (_, extent) in cluster {
match lines.last_mut() {
Some(last) if extent.start <= last.extent.end + RULING_SNAP_TOLERANCE => {
last.extent.end = last.extent.end.max(extent.end);
}
_ => lines.push(GridLine { position, extent }),
}
}
lines
}
fn crosses(vertical: &GridLine, horizontal: &GridLine) -> bool {
vertical.position >= horizontal.extent.start - RULING_SNAP_TOLERANCE
&& vertical.position <= horizontal.extent.end + RULING_SNAP_TOLERANCE
&& horizontal.position >= vertical.extent.start - RULING_SNAP_TOLERANCE
&& horizontal.position <= vertical.extent.end + RULING_SNAP_TOLERANCE
}
fn find(parent: &mut [usize], mut node: usize) -> usize {
while parent[node] != node {
parent[node] = parent[parent[node]];
node = parent[node];
}
node
}
fn union(parent: &mut [usize], a: usize, b: usize) {
let root_a = find(parent, a);
let root_b = find(parent, b);
parent[root_a] = root_b;
}
fn lattice(verticals: &[&GridLine], horizontals: &[&GridLine]) -> Option<RuledGrid> {
let xs = distinct_positions(verticals);
let mut ys = distinct_positions(horizontals);
if xs.len() < RULED_GRID_MIN_VERTICALS || ys.is_empty() {
return None;
}
let (x_lo, x_hi) = (xs[0], xs[xs.len() - 1]);
let (y_lo, y_hi) = (ys[0], ys[ys.len() - 1]);
let boxed = covers(verticals, x_lo, y_lo, y_hi)
&& covers(verticals, x_hi, y_lo, y_hi)
&& covers(horizontals, y_lo, x_lo, x_hi)
&& covers(horizontals, y_hi, x_lo, x_hi);
let reach_lo = verticals
.iter()
.map(|line| line.extent.start)
.fold(f32::INFINITY, f32::min);
let reach_hi = verticals
.iter()
.map(|line| line.extent.end)
.fold(f32::NEG_INFINITY, f32::max);
if reach_lo < y_lo - RULING_SNAP_TOLERANCE {
ys.insert(0, reach_lo);
}
if reach_hi > y_hi + RULING_SNAP_TOLERANCE {
ys.push(reach_hi);
}
if ys.len() < RULED_GRID_MIN_HORIZONTALS {
return None;
}
Some(RuledGrid { xs, ys, boxed })
}
fn distinct_positions(lines: &[&GridLine]) -> Vec<f32> {
let mut positions: Vec<f32> = lines.iter().map(|line| line.position).collect();
positions.sort_by(f32::total_cmp);
positions.dedup_by(|next, kept| *next - *kept <= RULING_SNAP_TOLERANCE);
positions
}
fn covers(lines: &[&GridLine], position: f32, lo: f32, hi: f32) -> bool {
let mut extents: Vec<&std::ops::Range<f32>> = lines
.iter()
.filter(|line| (line.position - position).abs() <= RULING_SNAP_TOLERANCE)
.map(|line| &line.extent)
.collect();
extents.sort_by(|a, b| a.start.total_cmp(&b.start));
let mut reached = lo;
for extent in extents {
if extent.start > reached + RULING_SNAP_TOLERANCE {
return false;
}
reached = reached.max(extent.end);
}
reached >= hi - RULING_SNAP_TOLERANCE
}
struct GridClaim {
range: std::ops::Range<usize>,
rows: Vec<Vec<Cell>>,
bbox: BBox,
}
fn grid_claims(groups: &[Group], grids: &[RuledGrid]) -> Vec<GridClaim> {
let mut claims: Vec<GridClaim> = Vec::new();
for grid in grids {
let Some(claim) = grid_claim(groups, grid) else {
continue;
};
let taken = claims
.iter()
.any(|held| held.range.start < claim.range.end && claim.range.start < held.range.end);
if taken {
continue;
}
claims.push(claim);
}
claims.sort_by_key(|claim| claim.range.start);
claims
}
fn grid_claim(groups: &[Group], grid: &RuledGrid) -> Option<GridClaim> {
let lo = groups.iter().position(|group| grid.holds(group.y))?;
let inside = groups[lo..]
.iter()
.take_while(|group| grid.holds(group.y))
.count();
let hi = lo + inside;
let columns = open_columns(&groups[lo..hi], grid);
let mut rows = Vec::new();
for band in groups[lo..hi].chunk_by(|a, b| grid.band_of(a.y) == grid.band_of(b.y)) {
let mut lines = Vec::with_capacity(band.len());
for group in band {
lines.push(table_row(group, &columns)?);
}
if lines.len() >= BAND_INFER_MIN_LINES && 2 * lines.len() > hi - lo {
rows.append(&mut anchored_rows(lines, columns.len()));
continue;
}
rows.push(logical_row(lines, columns.len()));
}
if rows.len() < TABLE_MIN_ROWS && !(grid.boxed && rows.len() >= RULED_BOXED_MIN_ROWS) {
return None;
}
Some(GridClaim {
range: lo..hi,
rows,
bbox: grid.bbox(),
})
}
fn open_columns(groups: &[Group], grid: &RuledGrid) -> Vec<std::ops::Range<f32>> {
let spans: Vec<&TextSpan> = groups
.iter()
.flat_map(|group| group.spans.iter().copied())
.collect();
let (x_lo, x_hi) = x_bounds(&spans);
let mut columns = grid.columns();
let widest = columns
.iter()
.map(|column| column.end - column.start)
.fold(0.0f32, f32::max);
let first = grid.xs[0];
let last = grid.xs[grid.xs.len() - 1];
if x_lo < first - RULING_SNAP_TOLERANCE && first - x_lo <= widest {
columns.insert(0, x_lo..first);
}
if x_hi > last + RULING_SNAP_TOLERANCE && x_hi - last <= widest {
columns.push(last..x_hi);
}
columns
}
fn anchored_rows(lines: Vec<Vec<Cell>>, columns: usize) -> Vec<Vec<Cell>> {
let Some(anchor) =
(0..columns).find(|column| lines.iter().any(|line| populates(line, *column)))
else {
return vec![logical_row(lines, columns)];
};
let opens = |line: &Vec<Cell>| {
populates(line, anchor)
&& line.iter().filter(|cell| cell.line.is_some()).count() >= TABLE_MIN_ROW_CELLS
};
if !lines.first().is_some_and(opens) {
return vec![logical_row(lines, columns)];
}
let mut rows = Vec::new();
let mut group: Vec<Vec<Cell>> = Vec::new();
for line in lines {
if opens(&line) && !group.is_empty() {
rows.push(logical_row(std::mem::take(&mut group), columns));
}
group.push(line);
}
if !group.is_empty() {
rows.push(logical_row(group, columns));
}
rows
}
fn populates(row: &[Cell], column: usize) -> bool {
let mut at = 0usize;
for cell in row {
let width = cell.colspan as usize;
if at <= column && column < at + width {
return cell.line.is_some();
}
at += width;
}
false
}
fn logical_row(lines: Vec<Vec<Cell>>, columns: usize) -> Vec<Cell> {
let mut fragments: Vec<(std::ops::Range<usize>, Line)> = Vec::new();
for cells in lines {
let mut column = 0usize;
for cell in cells {
let width = cell.colspan as usize;
if let Some(line) = cell.line {
fragments.push((column..column + width, line));
}
column += width;
}
}
let mut intervals: Vec<std::ops::Range<usize>> = fragments
.iter()
.map(|(interval, _)| interval.clone())
.collect();
intervals.sort_by_key(|interval| interval.start);
let mut merged: Vec<std::ops::Range<usize>> = Vec::new();
for interval in intervals {
match merged.last_mut() {
Some(last) if interval.start < last.end => last.end = last.end.max(interval.end),
_ => merged.push(interval),
}
}
let mut row = Vec::with_capacity(columns);
let mut next = 0usize;
for interval in merged {
for _ in next..interval.start {
row.push(empty_cell());
}
let cell_lines: Vec<&Line> = fragments
.iter()
.filter(|(held, _)| interval.start <= held.start && held.end <= interval.end)
.map(|(_, line)| line)
.collect();
row.push(Cell {
line: Some(merged_line(&cell_lines)),
colspan: (interval.end - interval.start) as u8,
rowspan: 1,
});
next = interval.end;
}
for _ in next..columns {
row.push(empty_cell());
}
row
}
fn merged_line(fragments: &[&Line]) -> Line {
let mut inlines: Vec<Inline> = Vec::new();
let mut x = f32::INFINITY;
let mut end_x = f32::NEG_INFINITY;
let mut size = 0.0f32;
for (index, fragment) in fragments.iter().enumerate() {
x = x.min(fragment.x);
end_x = end_x.max(fragment.end_x);
size = size.max(fragment.size);
for (position, inline) in fragment.inlines.iter().enumerate() {
let Some(last) = inlines.last_mut() else {
inlines.push(inline.clone());
continue;
};
if index > 0 && position == 0 {
last.text.push(' ');
}
if last.bold == inline.bold && last.italic == inline.italic {
last.text.push_str(&inline.text);
continue;
}
inlines.push(inline.clone());
}
}
Line {
inlines,
y: fragments.first().map_or(0.0, |line| line.y),
x,
end_x,
size,
}
}
fn table_band(groups: &[Group]) -> Option<TableBand> {
for start in 0..groups.len() {
let (end, lanes) = lane_run(groups, start);
if end - start < TABLE_MIN_ROWS {
continue;
}
if let Some(band) = grid(groups, start, end, &lanes) {
return Some(band);
}
}
None
}
fn lane_run(groups: &[Group], start: usize) -> (usize, Vec<std::ops::Range<f32>>) {
let mut occupied: Vec<std::ops::Range<f32>> = Vec::new();
let mut lanes = Vec::new();
for (offset, group) in groups[start..].iter().enumerate() {
let mut next = occupied.clone();
for span in &group.spans {
add_ink(&mut next, span.x.min(span.end_x)..span.x.max(span.end_x));
}
let gaps = ink_gaps(&next);
if gaps.len() < TABLE_MIN_LANES {
return (start + offset, lanes);
}
occupied = next;
lanes = gaps;
}
(groups.len(), lanes)
}
fn add_ink(occupied: &mut Vec<std::ops::Range<f32>>, ink: std::ops::Range<f32>) {
let at = occupied.partition_point(|held| held.end < ink.start);
let mut merged = ink;
while at < occupied.len() && occupied[at].start <= merged.end {
let held = occupied.remove(at);
merged.start = merged.start.min(held.start);
merged.end = merged.end.max(held.end);
}
occupied.insert(at, merged);
}
fn ink_gaps(occupied: &[std::ops::Range<f32>]) -> Vec<std::ops::Range<f32>> {
occupied
.windows(2)
.filter(|pair| pair[1].start - pair[0].end >= GUTTER_MIN_WIDTH)
.map(|pair| pair[0].end..pair[1].start)
.collect()
}
fn grid(
groups: &[Group],
start: usize,
end: usize,
lanes: &[std::ops::Range<f32>],
) -> Option<TableBand> {
let spans: Vec<&TextSpan> = groups[start..end]
.iter()
.flat_map(|group| group.spans.iter().copied())
.collect();
let columns = cell_columns(&spans, lanes);
let (lo, hi) = merged_edges(groups, start, end, &columns);
let inside = &groups[lo..hi];
let mut rows = Vec::with_capacity(inside.len());
let mut populated = Vec::with_capacity(inside.len());
for group in inside {
let Some(row) = table_row(group, &columns) else {
break;
};
let cells = row.iter().filter(|cell| cell.line.is_some()).count();
populated.push(cells >= TABLE_MIN_ROW_CELLS);
rows.push(row);
}
let first = populated.iter().position(|filled| *filled)?;
let last = populated.iter().rposition(|filled| *filled)?;
let baselines: Vec<f32> = inside[first..=last]
.iter()
.zip(&populated[first..=last])
.filter(|(_, filled)| **filled)
.map(|(group, _)| group.y)
.collect();
if baselines.len() < TABLE_MIN_ROWS {
return None;
}
if !even_rows(&baselines) {
return None;
}
if populated_columns(&rows[first..=last], columns.len()) < TABLE_MIN_LANES + 1 {
return None;
}
let above = groups[..lo + first].iter().map(assembled).collect();
let below = groups[lo + last + 1..].iter().map(assembled).collect();
rows.truncate(last + 1);
rows.drain(..first);
Some(TableBand { above, rows, below })
}
fn merged_edges(
groups: &[Group],
start: usize,
end: usize,
columns: &[std::ops::Range<f32>],
) -> (usize, usize) {
let pitch = median(
groups[start..end]
.windows(2)
.map(|pair| pair[0].y - pair[1].y)
.collect(),
);
let limit = TABLE_ROW_GAP * pitch;
let holds = |index: usize, neighbour: usize| {
(groups[neighbour].y - groups[index].y).abs() <= limit
&& table_row(&groups[index], columns).is_some()
};
let mut lo = start;
while lo > 0 && holds(lo - 1, lo) {
lo -= 1;
}
let mut hi = end;
while hi < groups.len() && holds(hi, hi - 1) {
hi += 1;
}
(lo, hi)
}
fn populated_columns(rows: &[Vec<Cell>], columns: usize) -> usize {
let mut filled = vec![false; columns];
for row in rows {
let mut column = 0usize;
for cell in row {
let width = cell.colspan as usize;
if cell.line.is_some() {
for slot in filled.iter_mut().skip(column).take(width) {
*slot = true;
}
}
column += width;
}
}
filled.iter().filter(|slot| **slot).count()
}
fn assembled(group: &Group) -> Assembled {
assemble_line(group.y, group.size, &group.spans)
}
fn cell_columns(spans: &[&TextSpan], lanes: &[std::ops::Range<f32>]) -> Vec<std::ops::Range<f32>> {
let (lo, hi) = x_bounds(spans);
let mut columns = Vec::with_capacity(lanes.len() + 1);
let mut start = lo;
for lane in lanes {
columns.push(start..lane.start);
start = lane.end;
}
columns.push(start..hi);
columns
}
fn even_rows(baselines: &[f32]) -> bool {
let steps: Vec<f32> = baselines.windows(2).map(|pair| pair[0] - pair[1]).collect();
if steps.is_empty() {
return true;
}
let limit = TABLE_ROW_GAP * median(steps.clone());
if limit <= 0.0 {
return false;
}
steps.iter().all(|step| *step <= limit)
}
fn table_row(group: &Group, columns: &[std::ops::Range<f32>]) -> Option<Vec<Cell>> {
let mut claimed: Vec<(usize, usize, std::ops::Range<usize>)> = Vec::new();
for (position, &span) in group.spans.iter().enumerate() {
let lo = span.x.min(span.end_x);
let hi = span.x.max(span.end_x);
let start = columns.iter().rposition(|column| column.start <= lo)?;
if lo >= columns[start].end {
return None;
}
let end = columns.iter().rposition(|column| column.start <= hi)?;
match claimed.last_mut() {
Some(last) if start <= last.1 => {
last.1 = last.1.max(end);
last.2.end = position + 1;
}
_ => claimed.push((start, end, position..position + 1)),
}
}
let mut row = Vec::with_capacity(columns.len());
let mut next = 0usize;
for (start, end, spans) in &claimed {
for _ in next..*start {
row.push(empty_cell());
}
row.push(Cell {
line: Some(assemble_line(group.y, group.size, &group.spans[spans.clone()]).line),
colspan: (end - start + 1) as u8,
rowspan: 1,
});
next = end + 1;
}
for _ in next..columns.len() {
row.push(empty_cell());
}
spaced_cells(&row, group.size).then_some(row)
}
fn empty_cell() -> Cell {
Cell {
line: None,
colspan: 1,
rowspan: 1,
}
}
fn spaced_cells(row: &[Cell], size: f32) -> bool {
let lines: Vec<&Line> = row.iter().filter_map(|cell| cell.line.as_ref()).collect();
lines
.windows(2)
.all(|pair| pair[1].x - pair[0].end_x > WORD_GAP * size)
}
fn table_bbox(rows: &[Vec<Cell>]) -> BBox {
bbox(rows.iter().flatten().filter_map(|cell| cell.line.as_ref()))
}
fn assemble_line(y: f32, size: f32, spans: &[&TextSpan]) -> Assembled {
let capacity = spans.iter().map(|span| span.text.len() + 1).sum();
let mut inlines: Vec<Inline> = Vec::with_capacity(1);
let mut prev_end: Option<f32> = None;
let mut prev_size = 0.0f32;
let mut min_size = f32::INFINITY;
for span in spans {
let spaced = prev_end.is_some_and(|end| span.x - end > WORD_GAP * prev_size.max(span.size));
push_span(&mut inlines, span, spaced, capacity);
prev_end = Some(span.end_x);
prev_size = span.size;
min_size = min_size.min(span.size);
}
Assembled {
line: Line {
inlines,
y,
x: spans.first().map_or(0.0, |span| span.x),
end_x: spans.last().map_or(0.0, |span| span.end_x),
size,
},
min_size: if min_size.is_finite() { min_size } else { size },
}
}
fn push_span(inlines: &mut Vec<Inline>, span: &TextSpan, spaced: bool, capacity: usize) {
if let Some(last) = inlines.last_mut() {
let already_spaced =
last.text.ends_with(char::is_whitespace) || span.text.starts_with(char::is_whitespace);
if spaced && !already_spaced {
last.text.push(' ');
}
if last.bold == span.bold && last.italic == span.italic {
last.text.push_str(&span.text);
return;
}
}
let mut text = String::with_capacity(capacity);
text.push_str(&span.text);
inlines.push(Inline {
text,
bold: span.bold,
italic: span.italic,
});
}
fn bbox<'l>(lines: impl IntoIterator<Item = &'l Line>) -> BBox {
let mut x0 = f32::INFINITY;
let mut y0 = f32::INFINITY;
let mut x1 = f32::NEG_INFINITY;
let mut y1 = f32::NEG_INFINITY;
let mut size = 0.0f32;
for line in lines {
x0 = x0.min(line.x);
x1 = x1.max(line.end_x);
y0 = y0.min(line.y);
y1 = y1.max(line.y);
size = size.max(line.size);
}
BBox {
x0,
y0,
x1,
y1: y1 + size,
}
}
struct Segment<'s> {
spans: Vec<&'s TextSpan>,
groups: Option<Vec<Group<'s>>>,
}
impl<'s> Segment<'s> {
fn ungrouped(spans: Vec<&'s TextSpan>) -> Segment<'s> {
Segment {
spans,
groups: None,
}
}
fn into_groups(self) -> Vec<Group<'s>> {
match self.groups {
Some(groups) => groups,
None => line_groups(&self.spans),
}
}
}
fn segments(spans: &[TextSpan]) -> Vec<Segment<'_>> {
flows(spans)
.into_iter()
.flat_map(gutter_split)
.filter(|segment| !segment.spans.is_empty())
.collect()
}
fn flows(spans: &[TextSpan]) -> Vec<Vec<&TextSpan>> {
let mut flows: Vec<Vec<&TextSpan>> = Vec::new();
for span in spans {
match flows.last_mut() {
Some(flow) if flow.last().is_some_and(|prev| !steps_up(prev, span)) => flow.push(span),
_ => flows.push(vec![span]),
}
}
let chars = |flow: &[&TextSpan]| -> usize { flow.iter().map(|s| s.text.chars().count()).sum() };
let fragmented: usize = flows
.iter()
.filter(|flow| baseline_count(flow) == 1)
.map(|flow| chars(flow))
.sum();
let total: usize = flows.iter().map(|flow| chars(flow)).sum();
if flows.len() > 1 && fragmented as f32 > FLOW_FRAGMENT_FRACTION * total as f32 {
return vec![spans.iter().collect()];
}
merge_sparse_neighbours(flows)
}
fn steps_up(prev: &TextSpan, next: &TextSpan) -> bool {
let size = prev.size.max(next.size);
let rise = next.y - prev.y;
if rise > FLOW_STEP_UP * size {
return true;
}
let (prev_lo, prev_hi) = (prev.x.min(prev.end_x), prev.x.max(prev.end_x));
let aside =
next.x > prev_hi + FLOW_STEP_ASIDE * size || next.x < prev_lo - FLOW_STEP_ASIDE * size;
rise > FLOW_LINE_UP * size && aside
}
fn merge_sparse_neighbours(flows: Vec<Vec<&TextSpan>>) -> Vec<Vec<&TextSpan>> {
let table_column =
|flow: &[&TextSpan]| !column_shaped(flow) && baseline_count(flow) >= TABLE_MIN_ROWS;
let mut merged: Vec<Vec<&TextSpan>> = Vec::new();
for flow in flows {
let Some(prev) = merged.last_mut() else {
merged.push(flow);
continue;
};
if !table_column(prev) || !table_column(&flow) || !y_overlaps(prev, &flow) {
merged.push(flow);
continue;
}
prev.extend(flow);
}
merged
}
fn y_overlaps(a: &[&TextSpan], b: &[&TextSpan]) -> bool {
let (a_lo, a_hi) = y_extent(a);
let (b_lo, b_hi) = y_extent(b);
a_lo <= b_hi && b_lo <= a_hi
}
fn gutter_split(spans: Vec<&TextSpan>) -> Vec<Segment<'_>> {
if spans.len() < COLUMN_MIN_SPANS {
return vec![Segment::ungrouped(spans)];
}
let (x_min, x_max) = x_bounds(&spans);
let width = x_max - x_min;
if !width.is_finite() || width <= 0.0 {
return vec![Segment::ungrouped(spans)];
}
let lines = line_groups(&spans);
match split_at_gutter(&lines, x_min, width) {
Some(bands) => bands,
None => vec![Segment {
spans,
groups: Some(lines),
}],
}
}
fn split_at_gutter<'s>(lines: &[Group<'s>], x_min: f32, width: f32) -> Option<Vec<Segment<'s>>> {
let scale = GUTTER_BINS as f32 / width;
let mut coverage = [0usize; GUTTER_BINS];
for line in lines {
let mut covered = [false; GUTTER_BINS];
fill_bins(&mut covered, &line.spans, x_min, scale);
for (count, hit) in coverage.iter_mut().zip(covered) {
*count += usize::from(hit);
}
}
let allowed = (GUTTER_MAX_CROSSING * lines.len() as f32) as usize;
let occupied: [bool; GUTTER_BINS] = std::array::from_fn(|bin| coverage[bin] > allowed);
let gaps = wide_gaps(&occupied, scale);
let [gutter] = gaps.as_slice() else {
return None;
};
let center = (gutter.start + gutter.end) as f32 / 2.0 / GUTTER_BINS as f32;
if !GUTTER_BAND.contains(¢er) {
return None;
}
let cut = x_min + (gutter.start + gutter.end) as f32 / 2.0 / scale;
let (crossing, columns): (Vec<&Group>, Vec<&Group>) = lines.iter().partition(|line| {
line.spans
.iter()
.any(|s| s.x.min(s.end_x) < cut && s.x.max(s.end_x) > cut)
});
let body: Vec<&TextSpan> = columns
.iter()
.flat_map(|line| line.spans.iter().copied())
.collect();
let (left, right): (Vec<&TextSpan>, Vec<&TextSpan>) =
body.iter().partition(|s| s.x.max(s.end_x) <= cut);
let (body_lo, body_hi) = y_extent(&body);
if body_hi - body_lo <= width {
let gutter_width = (gutter.end - gutter.start) as f32 / scale;
if gutter_width < TWO_UP_MIN_GUTTER * width || !portrait(&left) || !portrait(&right) {
return None;
}
}
if !column_shaped(&left) || !column_shaped(&right) {
return None;
}
if x_span(&left) < COLUMN_MIN_SIDE_WIDTH * width
|| x_span(&right) < COLUMN_MIN_SIDE_WIDTH * width
{
return None;
}
let (left_lo, left_hi) = y_extent(&left);
let (right_lo, right_hi) = y_extent(&right);
let height = left_hi.max(right_hi) - left_lo.min(right_lo);
if height <= 0.0
|| left_hi - left_lo < COLUMN_MIN_HEIGHT * height
|| right_hi - right_lo < COLUMN_MIN_HEIGHT * height
{
return None;
}
let mut cuts: Vec<f32> = crossing.iter().map(|line| line.y).collect();
cuts.sort_by(|a, b| b.total_cmp(a));
cuts.dedup();
let mut out: Vec<Segment<'s>> = Vec::new();
let mut top = f32::INFINITY;
for &sep_y in &cuts {
push_band(&left, &right, top, sep_y, &mut out);
out.push(Segment::ungrouped(
crossing
.iter()
.filter(|line| line.y == sep_y)
.flat_map(|line| line.spans.iter().copied())
.collect(),
));
top = sep_y;
}
push_band(&left, &right, top, f32::NEG_INFINITY, &mut out);
Some(out)
}
fn push_band<'s>(
left: &[&'s TextSpan],
right: &[&'s TextSpan],
top: f32,
bottom: f32,
out: &mut Vec<Segment<'s>>,
) {
for side in [left, right] {
out.push(Segment::ungrouped(
side.iter()
.filter(|s| s.y <= top && s.y > bottom)
.copied()
.collect(),
));
}
}
fn fill_bins(occupied: &mut [bool; GUTTER_BINS], spans: &[&TextSpan], x_min: f32, scale: f32) {
for span in spans {
let lo = ((span.x.min(span.end_x) - x_min) * scale).floor().max(0.0) as usize;
let hi = ((span.x.max(span.end_x) - x_min) * scale).ceil() as usize;
for bin in occupied.iter_mut().take(hi.min(GUTTER_BINS)).skip(lo) {
*bin = true;
}
}
}
fn wide_gaps(occupied: &[bool; GUTTER_BINS], scale: f32) -> Vec<std::ops::Range<usize>> {
let mut gaps = Vec::new();
let mut run_start: Option<usize> = None;
let bins = occupied.iter().copied().chain(std::iter::once(true));
for (i, filled) in bins.enumerate() {
match (filled, run_start.take()) {
(false, None) => run_start = Some(i),
(false, Some(start)) => run_start = Some(start),
(true, Some(start)) => {
let interior = start > 0 && i < GUTTER_BINS;
if interior && (i - start) as f32 / scale >= GUTTER_MIN_WIDTH {
gaps.push(start..i);
}
}
(true, None) => {}
}
}
gaps
}
fn baseline_count(spans: &[&TextSpan]) -> usize {
let mut baselines: Vec<i32> = spans.iter().map(|s| s.y.round() as i32).collect();
baselines.sort_unstable();
baselines.dedup();
baselines.len()
}
fn portrait(spans: &[&TextSpan]) -> bool {
let (lo, hi) = y_extent(spans);
hi - lo > x_span(spans)
}
fn column_shaped(spans: &[&TextSpan]) -> bool {
spans.len() >= COLUMN_MIN_SIDE_SPANS && baseline_count(spans) >= COLUMN_MIN_SIDE_LINES
}
fn y_extent(spans: &[&TextSpan]) -> (f32, f32) {
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for span in spans {
lo = lo.min(span.y);
hi = hi.max(span.y);
}
(lo, hi)
}
fn x_bounds(spans: &[&TextSpan]) -> (f32, f32) {
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for span in spans {
lo = lo.min(span.x.min(span.end_x));
hi = hi.max(span.x.max(span.end_x));
}
(lo, hi)
}
fn x_span(spans: &[&TextSpan]) -> f32 {
let (lo, hi) = x_bounds(spans);
hi - lo
}
#[cfg(test)]
pub(crate) fn layout_reference(spans: &[TextSpan]) -> String {
let mut out = String::new();
for segment in segments(spans) {
if !out.is_empty() {
out.push('\n');
}
flow(&segment.spans, &mut out);
}
out
}
#[cfg(test)]
fn flow(spans: &[&TextSpan], out: &mut String) {
struct Group<'s> {
y: f32,
size: f32,
spans: Vec<&'s TextSpan>,
}
let mut lines: Vec<Group> = Vec::new();
for &span in spans {
let found = lines
.iter_mut()
.find(|line| same_line(line.y, line.size, span));
match found {
Some(line) => {
line.size = line.size.max(span.size);
line.spans.push(span);
}
None => lines.push(Group {
y: span.y,
size: span.size,
spans: vec![span],
}),
}
}
lines.sort_by(|a, b| b.y.total_cmp(&a.y)); for (i, line) in lines.iter_mut().enumerate() {
if i > 0 {
out.push('\n');
}
line.spans.sort_by(|a, b| a.x.total_cmp(&b.x));
let mut prev_end: Option<f32> = None;
let mut prev_size = 0.0f32;
for span in &line.spans {
if let Some(end) = prev_end {
let gap = span.x - end;
if gap > WORD_GAP * prev_size.max(span.size) {
out.push(' ');
}
}
out.push_str(&span.text);
prev_end = Some(span.end_x);
prev_size = span.size;
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use pdfboss_core::Document;
use pdfboss_testkit::doc_with_graphics;
pub(crate) fn fixture_contents() -> Vec<String> {
let mut contents: Vec<String> = [
"BT ET",
"BT /F1 12 Tf 72 720 Td (Line one) Tj 0 -20 Td (Line two) Tj ET",
"BT /F1 12 Tf 72 720 Td [(A) -300 (B)] TJ ET",
"BT /F1 12 Tf 72 720 Td [(A) -50 (B)] TJ ET",
"BT /F1 12 Tf 0.993 0 0 1 72 720 Tm [(We) -251 (would)] TJ ET",
"BT /F1 12 Tf 14 TL 72 720 Td (a) Tj T* (b) Tj (c) ' ET",
"BT /F1 12 Tf 200 720 Td (world) Tj ET BT /F1 12 Tf 72 720 Td (hello) Tj ET",
"BT /F1 24 Tf 72 740 Td (Chapter title) Tj \
/F1 12 Tf 0 -40 Td (Body line one is long enough to look like body.) Tj \
0 -14 Td (Body line two keeps twelve the dominant size.) Tj \
0 -14 Td (And a third line for good measure.) Tj \
0 -60 Td (A far-below line starts a second paragraph.) Tj ET",
]
.iter()
.map(|s| s.to_string())
.collect();
contents.push(two_column_content(25));
contents.push(two_column_content(3));
contents.push(two_up_content(25));
contents.push(format!(
"BT /F1 12 Tf 72 760 Td (A quite wide heading spanning both text columns here) Tj ET {}",
two_column_content(25)
));
contents.push(lane_grid_content());
contents.push(narrow_gap_lane_grid_content());
contents.push(grid_with_edge_lines_content());
contents.push(margin_number_grid_content());
contents.push(ruled_grid_content());
contents.push(ruled_boxed_list_content());
contents.push(ruled_sub_word_gap_content());
contents.push(ruled_wrapped_band_content());
contents.push(ruled_grid_above_lane_grid_content());
contents.push(ruled_open_grid_content());
contents.push(ruled_wrapped_records_content());
contents.push(ruled_centered_record_content());
contents
}
pub(crate) fn ruled_grid_content() -> String {
String::from(
"70 670 360 40 re S 250 670 m 250 710 l S 70 690 m 430 690 l S \
BT /F1 10 Tf 1 0 0 1 80 695 Tm (a1) Tj 1 0 0 1 260 695 Tm (b1) Tj \
1 0 0 1 80 675 Tm (a2) Tj 1 0 0 1 260 675 Tm (b2) Tj ET",
)
}
pub(crate) fn ruled_boxed_list_content() -> String {
String::from(
"70 630 360 80 re S 70 690 m 430 690 l S 70 670 m 430 670 l S 70 650 m 430 650 l S \
BT /F1 10 Tf 1 0 0 1 80 695 Tm (first item) Tj \
1 0 0 1 80 675 Tm (second item) Tj \
1 0 0 1 80 655 Tm (third item) Tj \
1 0 0 1 80 635 Tm (fourth item) Tj ET",
)
}
pub(crate) fn ruled_sub_word_gap_content() -> String {
String::from(
"70 670 360 40 re S 250 670 m 250 710 l S 70 690 m 430 690 l S \
BT /F1 10 Tf 1 0 0 1 80 695 Tm (a1) Tj 1 0 0 1 260 695 Tm (b1) Tj \
1 0 0 1 229.4 675 Tm (worl) Tj 1 0 0 1 250.5 675 Tm (d) Tj ET",
)
}
pub(crate) fn ruled_wrapped_band_content() -> String {
String::from(
"70 600 390 100 re S 200 600 m 200 700 l S 330 600 m 330 700 l S \
70 660 m 460 660 l S 70 680 m 460 680 l S \
BT /F1 10 Tf 1 0 0 1 80 685 Tm (h1) Tj 1 0 0 1 210 685 Tm (h2) Tj \
1 0 0 1 340 685 Tm (h3) Tj \
1 0 0 1 80 665 Tm (m1) Tj 1 0 0 1 210 665 Tm (m2) Tj \
1 0 0 1 340 665 Tm (m3) Tj \
1 0 0 1 80 645 Tm (wrap one) Tj 1 0 0 1 210 645 Tm (solo) Tj \
1 0 0 1 340 645 Tm (tail) Tj \
1 0 0 1 80 625 Tm (wrap two) Tj 1 0 0 1 80 605 Tm (wrap three) Tj ET",
)
}
pub(crate) fn ruled_open_grid_content() -> String {
String::from(
"150 600 m 150 712 l S 250 600 m 250 712 l S \
70 600 m 330 600 l S 70 700 m 330 700 l S \
BT /F1 10 Tf 1 0 0 1 80 703 Tm (name) Tj 1 0 0 1 160 703 Tm (count) Tj \
1 0 0 1 260 703 Tm (note) Tj \
1 0 0 1 80 685 Tm (alpha) Tj 1 0 0 1 160 685 Tm (one) Tj \
1 0 0 1 260 685 Tm (xx) Tj \
1 0 0 1 80 665 Tm (beta) Tj 1 0 0 1 160 665 Tm (two) Tj \
1 0 0 1 260 665 Tm (yy) Tj \
1 0 0 1 80 645 Tm (gamma) Tj 1 0 0 1 160 645 Tm (three) Tj \
1 0 0 1 260 645 Tm (zz) Tj \
1 0 0 1 80 625 Tm (delta) Tj 1 0 0 1 160 625 Tm (four) Tj \
1 0 0 1 260 625 Tm (ww) Tj ET",
)
}
pub(crate) fn ruled_wrapped_records_content() -> String {
String::from(
"150 600 m 150 712 l S 250 600 m 250 712 l S \
70 600 m 330 600 l S 70 700 m 330 700 l S \
BT /F1 10 Tf 1 0 0 1 80 703 Tm (name) Tj 1 0 0 1 160 703 Tm (org) Tj \
1 0 0 1 260 703 Tm (count) Tj \
1 0 0 1 80 685 Tm (one) Tj 1 0 0 1 160 685 Tm (recordaa) Tj \
1 0 0 1 260 685 Tm (c1) Tj \
1 0 0 1 160 670 Tm (wrapa) Tj \
1 0 0 1 80 650 Tm (two) Tj 1 0 0 1 160 650 Tm (recordbb) Tj \
1 0 0 1 260 650 Tm (c2) Tj \
1 0 0 1 160 635 Tm (wrapb) Tj ET",
)
}
pub(crate) fn ruled_centered_record_content() -> String {
String::from(
"150 600 m 150 712 l S 250 600 m 250 712 l S \
70 600 m 330 600 l S 70 700 m 330 700 l S \
BT /F1 10 Tf 1 0 0 1 80 703 Tm (name) Tj 1 0 0 1 160 703 Tm (org) Tj \
1 0 0 1 260 703 Tm (count) Tj \
1 0 0 1 80 685 Tm (actlinea) Tj \
1 0 0 1 80 665 Tm (actlineb) Tj 1 0 0 1 160 665 Tm (union) Tj \
1 0 0 1 260 665 Tm (c9) Tj \
1 0 0 1 80 645 Tm (actlinec) Tj \
1 0 0 1 80 625 Tm (actlined) Tj ET",
)
}
pub(crate) fn ruled_grid_above_lane_grid_content() -> String {
let mut content = format!("{} BT /F1 10 Tf ", ruled_grid_content());
for (row, y) in [(0, 560.0), (1, 540.0), (2, 520.0), (3, 500.0)] {
for (col, x) in [(0, 72.0), (1, 250.0), (2, 430.0)] {
content += &format!("1 0 0 1 {x} {y} Tm (r{row}c{col}) Tj ");
}
}
content += "ET";
content
}
pub(crate) fn lane_grid_content() -> String {
let mut content = String::from("BT /F1 10 Tf ");
for (row, y) in [(0, 700.0), (1, 680.0), (2, 660.0), (3, 640.0)] {
for (col, x) in [(0, 72.0), (1, 250.0), (2, 430.0)] {
content += &format!("1 0 0 1 {x} {y} Tm (r{row}c{col}) Tj ");
}
}
content += "ET";
content
}
pub(crate) fn narrow_gap_lane_grid_content() -> String {
let mut content = String::from("BT /F1 10 Tf ");
for (row, y) in [(0, 700.0), (1, 680.0), (2, 660.0), (3, 640.0)] {
for (col, x) in [(0, 72.0), (1, 500.0), (2, 528.0)] {
content += &format!("1 0 0 1 {x} {y} Tm (r{row}c{col}) Tj ");
}
}
content += "ET";
content
}
pub(crate) const RUNNING_HEADER: &str =
"ANFREL Pre-Election Assessment Mission Report to the Union Election Commission";
pub(crate) fn margin_number_grid_content() -> String {
let mut content = String::from("BT /F1 10 Tf ");
for (row, y) in [(0, 700.0), (1, 680.0), (2, 660.0), (3, 640.0)] {
for (col, x) in [(0, 72.0), (1, 250.0)] {
content += &format!("1 0 0 1 {x} {y} Tm (r{row}c{col}) Tj ");
}
}
content += "1 0 0 1 500 600 Tm (3) Tj ET";
content
}
pub(crate) fn grid_with_edge_lines_content() -> String {
let mut content = format!("BT /F1 10 Tf 1 0 0 1 72 760 Tm ({RUNNING_HEADER}) Tj ");
for (row, y) in [(0, 700.0), (1, 680.0), (2, 660.0), (3, 640.0)] {
for (col, x) in [(0, 72.0), (1, 250.0), (2, 430.0)] {
content += &format!("1 0 0 1 {x} {y} Tm (r{row}c{col}) Tj ");
}
if row == 1 {
content += "1 0 0 1 72 670 Tm (wrapped cell) Tj ";
}
}
content += "1 0 0 1 72 600 Tm (24) Tj ET";
content
}
fn page_spans(doc: &Document, page: &pdfboss_core::Page) -> Vec<TextSpan> {
let (spans, report) = pdfboss_text::extract_spans_reporting(doc, page).unwrap();
assert!(report.is_complete(), "unexpected skips: {report:?}");
spans
}
fn text_of(content: &str) -> String {
let doc = Document::load(doc_with_graphics(content)).unwrap();
let page = doc.page(0).unwrap();
layout(&page_spans(&doc, &page))
}
#[test]
fn two_td_lines_become_newline() {
let text = text_of("BT /F1 12 Tf 72 720 Td (Line one) Tj 0 -20 Td (Line two) Tj ET");
assert_eq!(text, "Line one\nLine two");
}
#[test]
fn tj_offset_space_thresholds() {
assert_eq!(
text_of("BT /F1 12 Tf 72 720 Td [(A) -300 (B)] TJ ET"),
"A B"
);
assert_eq!(text_of("BT /F1 12 Tf 72 720 Td [(A) -50 (B)] TJ ET"), "AB");
}
#[test]
fn shrunk_justified_word_gaps_still_become_spaces() {
let text = text_of("BT /F1 12 Tf 0.993 0 0 1 72 720 Tm [(We) -251 (would)] TJ ET");
assert_eq!(text, "We would");
}
#[test]
fn invisible_render_mode_still_extracted() {
assert_eq!(
text_of("BT /F1 12 Tf 3 Tr 72 720 Td (ghost) Tj ET"),
"ghost"
);
}
#[test]
fn leading_and_t_star_and_quote() {
let text = text_of("BT /F1 12 Tf 14 TL 72 720 Td (a) Tj T* (b) Tj (c) ' ET");
assert_eq!(text, "a\nb\nc");
}
#[test]
fn layout_orders_spans_left_to_right() {
let text = text_of(
"BT /F1 12 Tf 200 720 Td (world) Tj ET \
BT /F1 12 Tf 72 720 Td (hello) Tj ET",
);
assert_eq!(text, "hello world");
}
#[test]
fn empty_content_yields_no_spans() {
assert_eq!(text_of("BT ET"), "");
}
fn column_line(x: u32, y: u32, tag: &str) -> String {
format!(
"BT /F1 12 Tf {x} {y} Td [({tag}a) -400 ({tag}b) -400 ({tag}c) -400 ({tag}d)] TJ ET "
)
}
fn two_column_content(lines: u32) -> String {
(0..lines)
.flat_map(|i| {
let y = 720 - i * 14;
[
column_line(72, y, &format!("L{i}")),
column_line(240, y, &format!("R{i}")),
]
})
.collect()
}
pub(crate) fn two_up_content(lines: u32) -> String {
(0..lines)
.flat_map(|i| {
let y = 720 - i * 14;
[
column_line(72, y, &format!("Left{i}")),
column_line(500, y, &format!("Right{i}")),
]
})
.collect()
}
#[test]
fn two_up_sheet_reads_page_by_page() {
let text = text_of(&two_up_content(25));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 50);
assert_eq!(lines[0], "Left0a Left0b Left0c Left0d");
assert_eq!(lines[24], "Left24a Left24b Left24c Left24d");
assert_eq!(lines[25], "Right0a Right0b Right0c Right0d");
assert_eq!(lines[49], "Right24a Right24b Right24c Right24d");
}
#[test]
fn two_column_page_reads_column_major() {
let text = text_of(&two_column_content(25));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 50);
assert_eq!(lines[0], "L0a L0b L0c L0d");
assert_eq!(lines[24], "L24a L24b L24c L24d");
assert_eq!(lines[25], "R0a R0b R0c R0d");
assert_eq!(lines[49], "R24a R24b R24c R24d");
}
#[test]
fn full_width_heading_reads_before_both_columns() {
let content = format!(
"BT /F1 12 Tf 72 760 Td (A quite wide heading spanning both text columns here) Tj ET {}",
two_column_content(25)
);
let text = text_of(&content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines[0],
"A quite wide heading spanning both text columns here"
);
assert_eq!(lines[1], "L0a L0b L0c L0d");
assert_eq!(lines[26], "R0a R0b R0c R0d");
}
#[test]
fn sparse_clusters_do_not_split_into_columns() {
let text = text_of(&two_column_content(3));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "L0a L0b L0c L0d R0a R0b R0c R0d");
}
#[test]
fn a_space_glyph_before_a_word_gap_is_one_space() {
let text = text_of("BT /F1 12 Tf 72 700 Td [(Hello ) -300 (world) -300 ( again)] TJ ET");
assert_eq!(text, "Hello world again");
}
fn running_header() -> String {
"BT /F1 9 Tf 80 790 Td (Journal of) Tj 100 0 Td (manuscript) Tj 80 0 Td (no. 12345) Tj ET "
.to_string()
}
fn page_number() -> String {
"BT /F1 10 Tf 214 40 Td (7) Tj ET ".to_string()
}
fn column_by_column_content(lines: u32) -> String {
let left: String = (0..lines)
.map(|i| column_line(72, 720 - i * 14, &format!("L{i}")))
.collect();
let right: String = (0..lines)
.map(|i| column_line(240, 720 - i * 14, &format!("R{i}")))
.collect();
left + &right
}
#[test]
fn column_by_column_emission_reads_in_content_order() {
let text = text_of(&(running_header() + &column_by_column_content(25) + &page_number()));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 52);
assert_eq!(lines[0], "Journal of manuscript no. 12345");
assert_eq!(lines[1], "L0a L0b L0c L0d");
assert_eq!(lines[25], "L24a L24b L24c L24d");
assert_eq!(lines[26], "R0a R0b R0c R0d");
assert_eq!(lines[50], "R24a R24b R24c R24d");
assert_eq!(lines[51], "7");
}
#[test]
fn a_header_crossing_the_gutter_does_not_break_the_columns() {
let text = text_of(&(running_header() + &two_column_content(25) + &page_number()));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 52);
assert_eq!(lines[0], "Journal of manuscript no. 12345");
assert_eq!(lines[1], "L0a L0b L0c L0d");
assert_eq!(lines[26], "R0a R0b R0c R0d");
assert_eq!(lines[51], "7");
}
#[test]
fn a_bottom_up_stream_still_reads_top_to_bottom() {
let content: String = (0..12)
.rev()
.map(|i| format!("BT /F1 12 Tf 72 {} Td (Line{i}) Tj ET ", 720 - i * 14))
.collect();
let text = text_of(&content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "Line0");
assert_eq!(lines[11], "Line11");
}
#[test]
fn scattered_figure_labels_do_not_force_geometric_order() {
let body: String = (0..12)
.map(|i| {
format!(
"BT /F1 12 Tf 72 {} Td (Body line number {i} of the column) Tj ET ",
720 - i * 14
)
})
.collect();
let labels: String = [600, 700, 580, 690, 566, 650, 720, 610, 640, 680, 590, 630]
.iter()
.enumerate()
.map(|(i, y)| format!("BT /F1 8 Tf 420 {y} Td (t{i}) Tj ET "))
.collect();
let text = text_of(&(body + &labels));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "Body line number 0 of the column");
assert_eq!(lines[11], "Body line number 11 of the column");
assert_eq!(lines[12], "t0");
assert_eq!(lines.len(), 24);
}
#[test]
fn side_by_side_captions_read_one_after_the_other() {
let content = "BT /F1 10 Tf 72 300 Td (\\(a\\) The marked triangles meet) Tj ET \
BT /F1 10 Tf 72 288 Td (at the center and on a side.) Tj ET \
BT /F1 10 Tf 300 300 Td (\\(b\\) The marked triangles meet) Tj ET \
BT /F1 10 Tf 300 288 Td (at the center and outside.) Tj ET ";
let text = text_of(content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines,
[
"(a) The marked triangles meet",
"at the center and on a side.",
"(b) The marked triangles meet",
"at the center and outside.",
]
);
}
#[test]
fn a_raised_superscript_stays_on_its_line() {
let content = "BT /F1 10 Tf 72 700 Td (10) Tj ET \
BT /F1 7 Tf 83.5 705.5 Td (9) Tj ET \
BT /F1 10 Tf 92 700 Td (stars) Tj ET";
assert_eq!(text_of(content), "109 stars");
}
#[test]
fn a_fraction_numerator_stays_in_its_flow() {
let content = "BT /F1 12 Tf 72 700 Td (Before the fraction) Tj ET \
BT /F1 12 Tf 200 708 Td (numerator) Tj ET \
BT /F1 12 Tf 200 692 Td (denominator) Tj ET \
BT /F1 12 Tf 300 700 Td (after it) Tj ET \
BT /F1 12 Tf 72 680 Td (Next line of prose) Tj ET ";
let text = text_of(content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines,
[
"numerator",
"Before the fraction after it",
"denominator",
"Next line of prose"
]
);
}
#[test]
fn sparse_side_by_side_flows_read_as_table_rows() {
let content: String = [72, 240, 400]
.iter()
.enumerate()
.flat_map(|(column, &x)| {
(0..4).map(move |row| {
format!(
"BT /F1 12 Tf {x} {} Td (C{column}R{row}) Tj ET ",
720 - row * 14
)
})
})
.collect();
let text = text_of(&content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "C0R0 C1R0 C2R0");
assert_eq!(lines[3], "C0R3 C1R3 C2R3");
}
#[test]
fn wide_flat_block_does_not_split() {
let content: String = (0..12)
.flat_map(|i| {
let y = 720 - i * 14;
[
format!("BT /F1 12 Tf 72 {y} Td [(Stagename{i}) -400 (functionaa) -400 (listing)] TJ ET "),
format!("BT /F1 12 Tf 400 {y} Td [(Explanation{i}) -400 (of) -400 (the) -400 (feature)] TJ ET "),
]
})
.collect();
let text = text_of(&content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 12);
assert!(lines[0].starts_with("Stagename0 functionaa listing Explanation0"));
}
#[test]
fn multi_lane_table_does_not_split() {
let content: String = (0..30)
.map(|i| {
let y = 720 - i * 14;
format!(
"BT /F1 12 Tf 72 {y} Td (Rowname{i}) Tj ET \
BT /F1 12 Tf 200 {y} Td (12345) Tj ET \
BT /F1 12 Tf 330 {y} Td (678) Tj ET \
BT /F1 12 Tf 430 {y} Td (90) Tj ET "
)
})
.collect();
let text = text_of(&content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 30);
assert_eq!(lines[0], "Rowname0 12345 678 90");
}
fn ruling(x0: f32, y0: f32, x1: f32, y1: f32) -> Ruling {
Ruling {
start: pdfboss_text::Point { x: x0, y: y0 },
end: pdfboss_text::Point { x: x1, y: y1 },
width: 1.0,
}
}
fn boxed_grid_rulings(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<Ruling> {
let mid = (y0 + y1) / 2.0;
vec![
ruling(x0, y0, x0, y1),
ruling(x1, y0, x1, y1),
ruling(x0, y0, x1, y0),
ruling(x0, y1, x1, y1),
ruling(x0, mid, x1, mid),
]
}
#[test]
fn a_boxed_lattice_clusters_into_one_grid() {
let grids = ruled_grids(&boxed_grid_rulings(70.0, 630.0, 430.0, 710.0));
assert_eq!(grids.len(), 1);
assert_eq!(grids[0].xs, vec![70.0, 430.0]);
assert_eq!(grids[0].ys, vec![630.0, 670.0, 710.0]);
assert!(grids[0].boxed);
}
#[test]
fn stacked_boxes_sharing_their_x_stay_two_grids() {
let mut rulings = boxed_grid_rulings(70.0, 600.0, 430.0, 680.0);
rulings.extend(boxed_grid_rulings(70.0, 300.0, 430.0, 380.0));
let grids = ruled_grids(&rulings);
assert_eq!(grids.len(), 2);
assert_eq!(grids[0].ys, vec![600.0, 640.0, 680.0], "topmost first");
assert_eq!(grids[1].ys, vec![300.0, 340.0, 380.0]);
}
#[test]
fn vertical_reach_beyond_the_horizontals_adds_bands() {
let rulings = vec![
ruling(150.0, 590.0, 150.0, 712.0),
ruling(250.0, 590.0, 250.0, 712.0),
ruling(70.0, 600.0, 330.0, 600.0),
ruling(70.0, 640.0, 330.0, 640.0),
ruling(70.0, 700.0, 330.0, 700.0),
];
let grids = ruled_grids(&rulings);
assert_eq!(grids.len(), 1);
assert_eq!(grids[0].ys, vec![590.0, 600.0, 640.0, 700.0, 712.0]);
}
#[test]
fn a_plain_box_is_not_a_grid() {
let rulings = vec![
ruling(70.0, 630.0, 70.0, 710.0),
ruling(430.0, 630.0, 430.0, 710.0),
ruling(70.0, 630.0, 430.0, 630.0),
ruling(70.0, 710.0, 430.0, 710.0),
];
assert!(ruled_grids(&rulings).is_empty());
assert!(ruled_grids(&[ruling(70.0, 400.0, 430.0, 400.0)]).is_empty());
}
#[test]
fn an_unconnected_ruling_stays_out_of_the_lattice() {
let mut rulings = boxed_grid_rulings(70.0, 600.0, 430.0, 680.0);
rulings.push(ruling(70.0, 100.0, 200.0, 100.0));
let grids = ruled_grids(&rulings);
assert_eq!(grids.len(), 1);
assert_eq!(grids[0].ys, vec![600.0, 640.0, 680.0]);
}
#[test]
fn ruled_fixtures_keep_the_flat_flow() {
assert_eq!(
text_of(&ruled_boxed_list_content()),
"first item\nsecond item\nthird item\nfourth item"
);
assert_eq!(text_of(&ruled_sub_word_gap_content()), "a1 b1\nworld");
}
#[test]
fn narrow_table_column_does_not_split() {
let content: String = (0..30)
.map(|i| {
let y = 720 - i * 14;
format!(
"BT /F1 12 Tf 72 {y} Td (1{i}) Tj ET \
BT /F1 12 Tf 300 {y} Td [(Partyaa) -300 (Nameebb) -300 (Row{i})] TJ ET "
)
})
.collect();
let text = text_of(&content);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 30);
assert_eq!(lines[0], "10 Partyaa Nameebb Row0");
}
}