use kurbo::{Affine, BezPath, PathEl, Point, Shape};
use pdf_render::pdf_interpret::cmap::BfString;
use pdf_render::pdf_interpret::font::{Glyph, GlyphPlace, OutlineGlyph};
use pdf_render::pdf_interpret::{
BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
};
use std::borrow::Cow;
use std::cell::Cell;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use crate::blocks::{Block, BlockKind, TableStrategy};
#[cfg(test)]
use crate::table_trace::TableOrigin;
const BAND_Y_TOLERANCE: f64 = 5.0;
const BAND_Y_FRACTION: f64 = 0.30;
const PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER: f64 = 1.8;
const MARKER_HANG_MIN_EM: f64 = 0.3;
const MARKER_HANG_MAX_EM: f64 = 6.0;
const MARKER_TEXT_LEFT_TOL_EM: f64 = 0.5;
const MARKER_WRAP_SLACK_EM: f64 = 1.5;
const MARKER_GAP_MAX_EM: f64 = 0.5;
const TJ_SPACE_THRESHOLD_UNITS: f32 = 100.0;
const TJ_SIGNAL_WEIGHT: f64 = 0.95;
const GAP_SIGNAL_WEIGHT: f64 = 0.80;
const HEURISTIC_SIGNAL_WEIGHT: f64 = 0.60;
const SPACE_CONSENSUS_THRESHOLD: f64 = 0.75;
const TRACKING_MIN_PAIRS: usize = 4;
const TRACKING_TOLERANCE: f64 = 0.25;
const TRACKED_GAP_MIN_EM: f64 = 0.04;
const GAP_TO_MEDIAN_CHAR_FRACTION: f64 = 0.30;
const GAP_TO_FONT_SIZE_FALLBACK_FRACTION: f64 = 0.15;
const COLUMN_GAP_THRESHOLD_MIN: f64 = 10.0;
const COLUMN_GAP_THRESHOLD_MAX: f64 = 40.0;
const COLUMN_GAP_MEDIAN_MULTIPLIER: f64 = 3.0;
const COLUMN_GAP_THRESHOLD_FALLBACK: f64 = 20.0;
const COLUMN_GAP_MATCH_TOLERANCE: f64 = 12.0;
const MIN_COLUMN_GAPPED_BANDS: usize = 3;
const MIN_COLUMN_GAP_SUPPORT: f64 = 0.80;
const MIN_DENSE_SLICE_RATIO: f64 = 0.35;
const GRID_SLICE_FILL: f64 = 0.6;
const GRID_COLUMN_SUPPORT: f64 = 0.75;
const GRID_MIN_ROWS: usize = 3;
const LABEL_MIN_ROWS: usize = 3;
const BANK_MIN_ROWS: usize = 3;
const BANK_BREAK_SHARE: f64 = 0.5;
const LABEL_PITCH_JUMP: f64 = 1.4;
const CELL_EDGE_TOLERANCE: f64 = 2.0;
const WRAPPED_CELL_LINE_GAP_FACTOR: f64 = 1.8;
const TABLE_GRID_MAX_LOOKBACK: usize = 4;
const WRAPPED_CELL_MAX_PITCH_FRACTION: f64 = 0.9;
const TABLE_GRID_MIN_COLUMNS: usize = 2;
const RULE_MAX_THICKNESS: f64 = 3.0;
const RULE_MIN_LENGTH: f64 = 24.0;
const MAX_RECORDED_PATH_BOXES: usize = 4000;
const GRAPHIC_GAP: f64 = 2.0;
fn touches(a: &[f64; 4], b: &[f64; 4], gap: f64) -> bool {
a[0] <= b[2] + gap && b[0] <= a[2] + gap && a[1] <= b[3] + gap && b[1] <= a[3] + gap
}
fn union(a: &[f64; 4], b: &[f64; 4]) -> [f64; 4] {
[
a[0].min(b[0]),
a[1].min(b[1]),
a[2].max(b[2]),
a[3].max(b[3]),
]
}
const MAX_RECORDED_IMAGES: usize = 256;
const MAX_RECORDED_RULES: usize = 512;
const MAX_RECORDED_RULINGS: usize = 8192;
const FILL_WHITE_ABOVE: u32 = 247;
const FILL_MIN_WIDTH: f64 = 10.0;
const FILL_MIN_HEIGHT: f64 = 4.0;
const RULING_MIN_LENGTH: f64 = 3.0;
const RULE_SPAN_FRACTION: f64 = 0.9;
const MIN_RULED_ROW_SEPARATORS: usize = 2;
const MIN_RULED_GAP_SUPPORT: f64 = 0.5;
const TABLE_ROW_MIN_CELLS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum WidthSource {
Metric,
#[default]
Estimate,
}
impl WidthSource {
pub fn as_str(&self) -> &'static str {
match self {
WidthSource::Metric => "Metric",
WidthSource::Estimate => "Estimate",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GeometryMode {
#[default]
Basic,
RichGeometry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BoundsSource {
#[default]
Advance,
Tight,
Estimate,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct FontMetrics {
pub ascent: f64,
pub descent: f64,
#[cfg_attr(
feature = "serde",
serde(rename = "capHeight", skip_serializing_if = "Option::is_none")
)]
pub cap_height: Option<f64>,
#[cfg_attr(
feature = "serde",
serde(rename = "xHeight", skip_serializing_if = "Option::is_none")
)]
pub x_height: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub struct TextSpan {
pub text: String,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub font_size: f64,
pub complete_run: bool,
pub font_name: Option<String>,
pub is_bold: bool,
pub is_italic: bool,
pub color: Option<[u8; 4]>,
pub width_source: WidthSource,
pub char_bounds: Vec<[f64; 4]>,
#[doc(hidden)]
pub geometry_mode: GeometryMode,
#[doc(hidden)]
pub bounds_source: BoundsSource,
#[doc(hidden)]
pub tight_char_bounds: Vec<[f64; 4]>,
#[doc(hidden)]
pub glyph_advances: Vec<f64>,
#[doc(hidden)]
pub glyph_bounds_sources: Vec<BoundsSource>,
pub transform: Option<[f64; 6]>,
pub font_weight: Option<u16>,
pub is_serif: Option<bool>,
pub is_monospace: Option<bool>,
pub render_mode: Option<u8>,
pub is_ocr_layer: bool,
pub font_metrics: Option<FontMetrics>,
}
impl TextSpan {
pub fn is_hidden(&self) -> bool {
if self.render_mode == Some(3) {
return true;
}
if let Some(color) = self.color {
if color[3] == 0 {
return true;
}
}
false
}
fn right(&self) -> f64 {
if span_is_quarter_turned(self) {
return self.x + self.width;
}
self.x + self.width.max(self.estimated_width())
}
fn measured_right(&self) -> f64 {
self.x + self.width
}
fn estimated_width(&self) -> f64 {
let char_count = self.text.chars().count() as f64;
if char_count <= 0.0 {
self.font_size * 0.5
} else {
self.font_size * 0.5 * char_count
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutBlock {
pub text: String,
pub bbox: [f64; 4],
pub page: usize,
}
#[derive(Debug, Clone)]
pub struct TextBlock {
pub spans: Vec<TextSpan>,
}
impl TextBlock {
pub fn bbox(&self) -> [f64; 4] {
let mut b = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for span in &self.spans {
b[0] = b[0].min(span.x);
b[1] = b[1].min(span.y);
b[2] = b[2].max(span.x + span.width);
b[3] = b[3].max(span.y + span.height);
}
if b[0].is_finite() {
b
} else {
[0.0; 4]
}
}
pub fn text(&self) -> String {
crate::bidi::logical_order(&self.visual_text()).into_owned()
}
pub(crate) fn text_with_cell_path_words(&self, cell_path_words: bool) -> String {
crate::bidi::logical_order(&self.visual_text_with(cell_path_words)).into_owned()
}
fn visual_text(&self) -> String {
self.visual_text_with(false)
}
fn visual_text_with(&self, cell_path_words: bool) -> String {
if self.spans.is_empty() {
return String::new();
}
let spans: &[TextSpan] = &self.spans;
enum Join {
Overlap(String),
Gap(f64, bool),
}
let mut joins: Vec<Join> = Vec::with_capacity(spans.len());
let mut anchor = &spans[0];
for pair in spans.windows(2) {
let prev = &pair[0];
let curr = &pair[1];
if !crate::bidi::is_only_marks(&prev.text) {
anchor = prev;
}
let direction = span_direction(anchor);
let (curr_along, _) = baseline_coordinates(curr, direction);
let gap = curr_along - baseline_end(anchor, direction);
debug_assert!(
!crate::words_join::pieces_join(
gap,
(baseline_coordinates(curr, direction).1
- baseline_coordinates(anchor, direction).1)
.abs(),
prev.font_size
) || gap <= prev.font_size * 0.25,
"the shared join implies the text join"
);
if gap <= prev.font_size * 0.12 && second_run_carries_on_from_the_first(prev, curr) {
if let Some(trimmed) = trim_overlapping_word_prefix(&prev.text, &curr.text) {
joins.push(Join::Overlap(trimmed));
continue;
}
}
if space_drawn_over_previous_run(prev, curr, gap) {
joins.push(Join::Overlap(curr.text.trim_start_matches(' ').to_string()));
continue;
}
let space = if cell_path_words {
gap >= prev.font_size * 0.25
} else {
gap > prev.font_size * 0.25
};
joins.push(Join::Gap(gap, space));
}
untrack_spaced_spans(spans, &mut joins, |j| match j {
Join::Gap(gap, space) => Some((*gap, space)),
Join::Overlap(_) => None,
});
let mut result = spans[0].text.clone();
for (curr, join) in spans[1..].iter().zip(joins) {
match join {
Join::Overlap(trimmed) => result.push_str(&trimmed),
Join::Gap(_, space) => {
if space {
result.push(' ');
}
result.push_str(&curr.text);
}
}
}
result
}
}
fn untrack_spaced_spans<J>(
spans: &[TextSpan],
joins: &mut [J],
mut gap_of: impl FnMut(&mut J) -> Option<(f64, &mut bool)>,
) {
let blank = |span: &TextSpan| span.text.trim().is_empty();
let small = |span: &TextSpan| {
let t = span.text.trim();
let n = t.chars().count();
(n == 1 || n == 2)
&& t.chars()
.all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
&& (n == 1 || t.chars().any(char::is_uppercase))
};
let mut start = 0;
while start < spans.len() {
if !small(&spans[start]) {
start += 1;
continue;
}
let mut end = start + 1;
while end < spans.len()
&& (small(&spans[end]) || blank(&spans[end]))
&& gap_of(&mut joins[end - 1]).is_some()
{
end += 1;
}
loop {
while end > start + 1 && blank(&spans[end - 1]) {
end -= 1;
}
let tail = spans[start..end].iter().rposition(blank);
match tail {
Some(at)
if spans[start + at + 1..end]
.iter()
.filter(|s| s.text.trim().chars().any(char::is_uppercase))
.count()
< 2 =>
{
end = start + at;
}
_ => break,
}
}
let singles = spans[start..end]
.iter()
.filter(|s| {
let t = s.text.trim();
t.chars().count() == 1 && t.chars().all(char::is_uppercase)
})
.count();
if singles >= 4 && spans[start..end].iter().any(blank) {
let mut tracking: Vec<f64> = (start..end - 1)
.filter(|&k| !blank(&spans[k]) && !blank(&spans[k + 1]))
.filter_map(|k| gap_of(&mut joins[k]).map(|(g, _)| g))
.collect();
tracking.sort_by(|a, b| a.total_cmp(b));
let median = tracking.get(tracking.len() / 2).copied().unwrap_or(0.0);
for k in start..end - 1 {
let beside_a_drawn_space = blank(&spans[k]) || blank(&spans[k + 1]);
if let Some((gap, space)) = gap_of(&mut joins[k]) {
if beside_a_drawn_space || gap <= 1.5 * median.max(0.0) {
*space = false;
}
}
}
}
start = end.max(start + 1);
}
}
#[derive(Debug, Clone)]
struct TextBand {
y: f64,
spans: Vec<TextSpan>,
}
impl TextBand {
fn new(span: TextSpan) -> Self {
Self {
y: span.y,
spans: vec![span],
}
}
fn sort_spans(&mut self) {
self.spans.sort_by(|a, b| {
a.x.partial_cmp(&b.x)
.unwrap_or(Ordering::Equal)
.then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
});
split_spans_covering_siblings(&mut self.spans);
self.spans.sort_by(|a, b| {
a.x.partial_cmp(&b.x)
.unwrap_or(Ordering::Equal)
.then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
});
collapse_overprinted_spans(&mut self.spans);
}
fn row_block(&self) -> TextBlock {
let mut spans = self.spans.clone();
spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
TextBlock { spans }
}
fn left(&self) -> f64 {
self.spans
.iter()
.map(|span| span.x)
.fold(f64::INFINITY, f64::min)
}
fn right(&self) -> f64 {
self.spans
.iter()
.map(TextSpan::right)
.fold(f64::NEG_INFINITY, f64::max)
}
fn width(&self) -> f64 {
(self.right() - self.left()).max(0.0)
}
fn gap_midpoints(&self, column_gap_threshold: f64) -> Vec<f64> {
self.gaps(column_gap_threshold)
.into_iter()
.map(|gap| (gap.start + gap.end) * 0.5)
.collect()
}
fn gaps(&self, column_gap_threshold: f64) -> Vec<BandGap> {
if self.spans.len() < 2 {
return Vec::new();
}
let mut spans = self.spans.clone();
spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
let mut gaps = Vec::new();
let mut prev_right = spans[0].right();
for span in spans.iter().skip(1) {
let gap = span.x - prev_right;
if gap >= column_gap_threshold {
gaps.push(BandGap {
start: prev_right,
end: span.x,
});
}
prev_right = prev_right.max(span.right());
}
gaps
}
fn split_by_boundaries(&self, boundaries: &[f64]) -> Vec<Vec<TextSpan>> {
let mut columns = vec![Vec::new(); boundaries.len() + 1];
for span in &self.spans {
let center_x = (span.x + span.right()) * 0.5;
let column_idx = boundaries
.iter()
.position(|boundary| center_x < *boundary)
.unwrap_or(boundaries.len());
columns[column_idx].push(span.clone());
}
for spans in &mut columns {
spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
}
columns
}
fn fits_single_column(
&self,
boundaries: &[f64],
region_left: f64,
region_right: f64,
) -> Option<usize> {
let mut column_idx: Option<usize> = None;
for span in &self.spans {
let left = span.x;
let right = span.right();
if boundaries
.iter()
.any(|boundary| left < *boundary && right > *boundary)
{
return None;
}
let center_x = left + (right - left) * 0.5;
let idx = boundaries
.iter()
.position(|boundary| center_x < *boundary)
.unwrap_or(boundaries.len());
match column_idx {
Some(existing) if existing != idx => return None,
Some(_) => {}
None => column_idx = Some(idx),
}
}
let idx = column_idx?;
let mut edges = Vec::with_capacity(boundaries.len() + 2);
edges.push(region_left);
edges.extend_from_slice(boundaries);
edges.push(region_right);
let column_width = (edges[idx + 1] - edges[idx]).max(0.0);
if column_width <= 0.0 || self.width() > column_width * 0.8 {
return None;
}
Some(idx)
}
}
#[derive(Debug, Clone, Copy)]
struct BandGap {
start: f64,
end: f64,
}
fn element_points(element: &PathEl) -> impl Iterator<Item = Point> {
let mut points = [Point::ZERO; 3];
let count = match *element {
PathEl::MoveTo(p) | PathEl::LineTo(p) => {
points[0] = p;
1
}
PathEl::QuadTo(a, b) => {
points[0] = a;
points[1] = b;
2
}
PathEl::CurveTo(a, b, c) => {
points[0] = a;
points[1] = b;
points[2] = c;
3
}
PathEl::ClosePath => 0,
};
points.into_iter().take(count)
}
#[derive(Clone, Copy, Debug)]
#[doc(hidden)]
pub struct HorizontalRule {
pub left: f64,
pub right: f64,
pub y: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[doc(hidden)]
pub struct Ruling {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
struct ActualTextEntry {
actual_text: Option<String>,
consumed: bool,
saw_glyph: bool,
child_resolved: bool,
extent: Option<[f64; 4]>,
}
#[doc(hidden)]
pub struct TextExtractionDevice {
spans: Vec<TextSpan>,
last_y: f64,
last_end_x: f64,
recent_gaps: Vec<f64>,
last_fill: Option<([f64; 6], Option<BfString>)>,
last_widened: f64,
last_merge_face: Option<String>,
inserted_spaces: Vec<(usize, f64)>,
run_start: usize,
last_letter_gap: Option<f64>,
pending_tj_offset: f32,
glyph_widths: Vec<f64>,
style_cache: HashMap<u128, Vec<(Option<String>, GlyphStyle)>>,
sorted_glyph_widths: Vec<f64>,
nan_glyph_width_seen: bool,
cached_median_char_width: f64,
geometry_mode: GeometryMode,
deferred_rich_glyphs: Vec<DeferredGlyph>,
keep_ligatures: bool,
document_font_names: bool,
rules: Vec<HorizontalRule>,
rulings: Vec<Ruling>,
fills: Vec<[f64; 4]>,
images: Vec<[f64; 4]>,
path_boxes: Vec<[f64; 4]>,
clip_stack: Vec<kurbo::Rect>,
raw_runs: bool,
raw_bdc: Vec<Option<u32>>,
raw_bdc_stack: Vec<u32>,
raw_bdc_next: u32,
raw_bdc_changed: bool,
last_raw_declared: Option<String>,
raw_pen: [f64; 2],
raw_dir: [f64; 2],
raw_pen_valid: bool,
actual_text_stack: Vec<ActualTextEntry>,
actual_text: bool,
annotation_appearances: Vec<kurbo::Rect>,
last_glyph_text: Option<LastGlyphText>,
id: u64,
holds_last_glyph: bool,
last_glyph_guard: ClearLastGlyph,
}
static NEXT_DEVICE: AtomicU64 = AtomicU64::new(0);
struct ClearLastGlyph(u64);
impl ClearLastGlyph {
fn clear(&self) {
let _ = LAST_GLYPH.try_with(|slot| {
if slot.get() == Some(self.0) {
slot.set(None);
}
});
}
}
impl Drop for ClearLastGlyph {
fn drop(&mut self) {
self.clear();
}
}
#[cfg(test)]
pub(crate) fn last_glyph_is_held() -> bool {
LAST_GLYPH.with(|slot| slot.get().is_some())
}
thread_local! {
static LAST_GLYPH: Cell<Option<u64>> = const { Cell::new(None) };
}
struct LastGlyphText {
place: Option<GlyphPlace>,
span: usize,
offset: usize,
len: usize,
boxless: bool,
opened: bool,
char_bounds: usize,
width: f64,
previous_complete_run: Option<bool>,
deferred: usize,
span_count: usize,
tail_text: usize,
tail_boxes: usize,
}
#[derive(Clone)]
struct DeferredGlyph {
coeffs: [f64; 6],
font_size: f64,
glyph_width: f64,
needs_exact: bool,
outline: Option<BezPath>,
font_bbox: Option<kurbo::Rect>,
}
const MEDIAN_REFRESH: usize = 32;
const RAW_SIZE_TOLERANCE: f64 = 0.01;
impl Default for TextExtractionDevice {
fn default() -> Self {
Self::new()
}
}
impl TextExtractionDevice {
pub fn new() -> Self {
Self::with_mode(GeometryMode::Basic)
}
pub fn with_mode(geometry_mode: GeometryMode) -> Self {
let id = NEXT_DEVICE.fetch_add(1, AtomicOrdering::Relaxed);
Self {
spans: Vec::new(),
last_y: f64::NEG_INFINITY,
last_end_x: f64::NEG_INFINITY,
recent_gaps: Vec::new(),
last_fill: None,
last_widened: 0.0,
last_merge_face: None,
inserted_spaces: Vec::new(),
run_start: 0,
last_letter_gap: None,
pending_tj_offset: 0.0,
glyph_widths: Vec::new(),
style_cache: HashMap::new(),
sorted_glyph_widths: Vec::new(),
nan_glyph_width_seen: false,
cached_median_char_width: 0.0,
geometry_mode,
deferred_rich_glyphs: Vec::new(),
keep_ligatures: false,
document_font_names: false,
rules: Vec::new(),
rulings: Vec::new(),
fills: Vec::new(),
images: Vec::new(),
path_boxes: Vec::new(),
clip_stack: Vec::new(),
raw_runs: false,
raw_bdc: Vec::new(),
raw_bdc_stack: Vec::new(),
raw_bdc_next: 0,
raw_bdc_changed: false,
last_raw_declared: None,
raw_pen: [0.0, 0.0],
raw_dir: [1.0, 0.0],
raw_pen_valid: false,
actual_text_stack: Vec::new(),
actual_text: false,
annotation_appearances: Vec::new(),
last_glyph_text: None,
id,
holds_last_glyph: false,
last_glyph_guard: ClearLastGlyph(id),
}
}
fn push_placed_span(&mut self, text: &str, rect: kurbo::Rect) {
if text.is_empty() {
return;
}
let height = if rect.height().is_finite() && rect.height() > 0.0 {
rect.height()
} else {
0.0
};
let width = if rect.width().is_finite() && rect.width() > 0.0 {
rect.width()
} else {
0.0
};
self.recent_gaps.clear();
self.inserted_spaces.clear();
self.run_start = 0;
self.last_letter_gap = None;
self.last_merge_face = None;
self.spans.push(TextSpan {
text: text.to_string(),
x: rect.x0,
y: rect.y0,
width,
height,
font_size: height,
complete_run: false,
font_name: None,
is_bold: false,
is_italic: false,
color: None,
width_source: WidthSource::Estimate,
char_bounds: Vec::new(),
transform: Some([height / 1000.0, 0.0, 0.0, height / 1000.0, rect.x0, rect.y0]),
font_weight: None,
is_serif: None,
is_monospace: None,
render_mode: Some(0),
is_ocr_layer: false,
font_metrics: None,
geometry_mode: self.geometry_mode,
bounds_source: BoundsSource::Estimate,
tight_char_bounds: Vec::new(),
glyph_advances: Vec::new(),
glyph_bounds_sources: Vec::new(),
});
self.last_y = rect.y0;
self.last_end_x = rect.x1;
self.last_widened = 0.0;
self.pending_tj_offset = 0.0;
}
#[must_use]
pub fn keep_ligatures(mut self, keep: bool) -> Self {
self.keep_ligatures = keep;
self
}
pub fn raw_runs(mut self, raw: bool) -> Self {
self.raw_runs = raw;
self
}
#[must_use]
pub fn document_font_names(mut self, report: bool) -> Self {
self.document_font_names = report;
self
}
#[must_use]
pub fn actual_text(mut self, apply: bool) -> Self {
self.actual_text = apply;
self
}
fn in_annotation_appearance(&self) -> bool {
!self.annotation_appearances.is_empty()
}
fn note_drawn_box(&mut self, drawn: [f64; 4]) {
for open in self.actual_text_stack.iter_mut() {
open.extent = Some(match open.extent {
Some(before) => union(&before, &drawn),
None => drawn,
});
}
}
fn glyphless_placement(&self, extent: Option<[f64; 4]>) -> Option<kurbo::Rect> {
if let Some([x0, y0, x1, y1]) = extent {
if x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() {
return Some(kurbo::Rect::new(x0, y0, x1, y1));
}
}
self.annotation_appearances.last().copied()
}
fn emit_glyphless_replacement(&mut self, entry: ActualTextEntry) {
let answered = entry.consumed || entry.child_resolved;
if answered || entry.saw_glyph || entry.actual_text.is_none() {
if answered {
if let Some(parent) = self.actual_text_stack.last_mut() {
parent.child_resolved = true;
}
}
return;
}
let replacement = entry.actual_text.as_deref().unwrap_or("");
if let Some(at) = self.glyphless_placement(entry.extent) {
if !replacement.is_empty() {
self.push_placed_span(replacement, at);
}
}
if let Some(parent) = self.actual_text_stack.last_mut() {
parent.child_resolved = true;
}
}
pub(crate) fn clip_spans(&mut self, rect: [f64; 4]) {
self.spans.retain(|span| {
let centre = (span.x + span.width * 0.5, span.y + span.height * 0.5);
centre.0 >= rect[0] && centre.0 <= rect[2] && centre.1 >= rect[1] && centre.1 <= rect[3]
});
}
fn refresh_median_char_width(&mut self) {
if self.glyph_widths.is_empty() {
self.cached_median_char_width = 0.0;
return;
}
let fresh = &self.glyph_widths[self.sorted_glyph_widths.len()..];
if !self.nan_glyph_width_seen && fresh.iter().any(|w| w.is_nan()) {
self.nan_glyph_width_seen = true;
self.sorted_glyph_widths = Vec::new();
}
if self.nan_glyph_width_seen {
let mut sorted = self.glyph_widths.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
self.cached_median_char_width = sorted[sorted.len() / 2];
return;
}
let mut fresh = fresh.to_vec();
fresh.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let old = std::mem::take(&mut self.sorted_glyph_widths);
let mut merged = Vec::with_capacity(old.len() + fresh.len());
let (mut i, mut j) = (0, 0);
while i < old.len() && j < fresh.len() {
if fresh[j] < old[i] {
merged.push(fresh[j]);
j += 1;
} else {
merged.push(old[i]);
i += 1;
}
}
merged.extend_from_slice(&old[i..]);
merged.extend_from_slice(&fresh[j..]);
self.cached_median_char_width = merged[merged.len() / 2];
self.sorted_glyph_widths = merged;
}
fn glyph_style(&mut self, glyph: &Glyph<'_>) -> GlyphStyle {
let Glyph::Outline(outline) = glyph else {
return derive_glyph_style(glyph, self.document_font_names);
};
let resource = glyph.font_resource_name();
let styles = self
.style_cache
.entry(outline.font_cache_key())
.or_default();
if let Some((_, style)) = styles.iter().find(|(name, _)| name.as_deref() == resource) {
return style.clone();
}
let style = derive_glyph_style(glyph, self.document_font_names);
styles.push((resource.map(str::to_owned), style.clone()));
style
}
fn advance_pen(&mut self, text: &str, glyph_end_x: f64, widened: f64) {
if !crate::bidi::is_only_marks(text) {
self.last_end_x = glyph_end_x;
self.last_widened = widened;
}
}
fn advance_raw_pen(
&mut self,
text: &str,
x: f64,
y: f64,
adv_x: f64,
adv_y: f64,
glyph_width: f64,
) {
if !self.raw_runs || crate::bidi::is_only_marks(text) {
return;
}
self.raw_pen = [x + adv_x * glyph_width, y + adv_y * glyph_width];
self.raw_dir = [adv_x, adv_y];
self.raw_pen_valid = true;
}
fn evaluate_space_consensus(
&self,
gap: f64,
font_size: f64,
prev_text: &str,
next_text: &str,
) -> bool {
let mut confidence = 0.0;
if self.pending_tj_offset <= -TJ_SPACE_THRESHOLD_UNITS {
confidence += TJ_SIGNAL_WEIGHT;
}
let gap_reference = if self.cached_median_char_width > 0.0 {
self.cached_median_char_width * GAP_TO_MEDIAN_CHAR_FRACTION
} else {
font_size * GAP_TO_FONT_SIZE_FALLBACK_FRACTION
};
if gap > gap_reference {
confidence += GAP_SIGNAL_WEIGHT;
}
if let (Some(prev_last), Some(next_first)) =
(prev_text.chars().last(), next_text.chars().next())
{
let camel = prev_last.is_lowercase() && next_first.is_uppercase();
let digit_to_letter = prev_last.is_ascii_digit() && next_first.is_alphabetic();
let letter_to_digit = prev_last.is_alphabetic() && next_first.is_ascii_digit();
if camel || digit_to_letter || letter_to_digit {
confidence += HEURISTIC_SIGNAL_WEIGHT;
}
}
confidence >= SPACE_CONSENSUS_THRESHOLD
}
fn a_tracked_run(&self, gap: f64) -> bool {
if gap <= 0.0 || self.recent_gaps.len() < TRACKING_MIN_PAIRS {
return false;
}
self.recent_gaps
.iter()
.rev()
.take(TRACKING_MIN_PAIRS)
.all(|before| (before - gap).abs() <= TRACKING_TOLERANCE * gap)
}
fn take_back_tracked_spaces(
text: &mut String,
inserted: &mut Vec<(usize, f64)>,
run_start: usize,
tracking: f64,
) {
let between_letters = |at: usize| {
text[..at]
.chars()
.next_back()
.is_some_and(char::is_alphabetic)
&& text[at + 1..]
.chars()
.next()
.is_some_and(char::is_alphabetic)
};
let goes = |at: usize, gap: f64| {
at >= run_start
&& at < text.len()
&& text.as_bytes()[at] == b' '
&& (gap - tracking).abs() <= TRACKING_TOLERANCE * tracking
&& between_letters(at)
};
if !inserted.iter().any(|&(at, gap)| goes(at, gap)) {
return;
}
let goes: Vec<bool> = inserted.iter().map(|&(at, gap)| goes(at, gap)).collect();
let bytes = text.as_bytes();
let mut kept = String::with_capacity(text.len());
let mut still: Vec<(usize, f64)> = Vec::with_capacity(inserted.len());
let mut from = 0;
for (&(at, gap), &goes) in inserted.iter().zip(&goes) {
if at < from || at >= text.len() || bytes[at] != b' ' {
continue;
}
kept.push_str(&text[from..at]);
if goes {
from = at + 1;
} else {
still.push((kept.len(), gap));
kept.push(' ');
from = at + 1;
}
}
kept.push_str(&text[from..]);
*text = kept;
*inserted = still;
}
fn record_rule(&mut self, path: &BezPath, transform: Affine) {
if self.rules.len() >= MAX_RECORDED_RULES {
return;
}
let clip = self.clip_stack.last().copied();
let mut lo = (f64::INFINITY, f64::INFINITY);
let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
let flush = |lo: &mut (f64, f64), hi: &mut (f64, f64), rules: &mut Vec<HorizontalRule>| {
let (mut left, mut right) = (lo.0, hi.0);
let (mut bottom, mut top) = (lo.1, hi.1);
if let Some(clip) = clip {
left = left.max(clip.x0);
right = right.min(clip.x1);
bottom = bottom.max(clip.y0);
top = top.min(clip.y1);
}
if lo.0.is_finite()
&& rules.len() < MAX_RECORDED_RULES
&& bottom <= top
&& hi.1 - lo.1 <= RULE_MAX_THICKNESS
&& right - left >= RULE_MIN_LENGTH
{
rules.push(HorizontalRule {
left,
right,
y: (lo.1 + hi.1) * 0.5,
});
}
*lo = (f64::INFINITY, f64::INFINITY);
*hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
};
for element in path.elements() {
if matches!(element, PathEl::MoveTo(_) | PathEl::ClosePath) {
flush(&mut lo, &mut hi, &mut self.rules);
}
for point in element_points(element) {
let p = transform * point;
lo = (lo.0.min(p.x), lo.1.min(p.y));
hi = (hi.0.max(p.x), hi.1.max(p.y));
}
}
flush(&mut lo, &mut hi, &mut self.rules);
}
fn record_rulings(&mut self, path: &BezPath, transform: Affine) {
if self.rulings.len() >= MAX_RECORDED_RULINGS {
return;
}
let clip = self.clip_stack.last().copied();
let mut points: Vec<Point> = Vec::new();
let mut curved = false;
let mut subpaths: Vec<(Vec<Point>, bool)> = Vec::new();
for element in path.elements() {
if matches!(element, PathEl::MoveTo(_)) && !points.is_empty() {
subpaths.push((std::mem::take(&mut points), curved));
curved = false;
}
if matches!(element, PathEl::QuadTo(..) | PathEl::CurveTo(..)) {
curved = true;
}
for point in element_points(element) {
points.push(transform * point);
}
}
if !points.is_empty() {
subpaths.push((points, curved));
}
for (points, curved) in subpaths {
if curved || points.len() < 2 {
continue;
}
let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in &points {
x0 = x0.min(p.x);
y0 = y0.min(p.y);
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
let clipped = |r: Ruling| -> Option<Ruling> {
let Some(c) = clip else {
return Some(r);
};
let r = Ruling {
x0: r.x0.max(c.x0),
y0: r.y0.max(c.y0),
x1: r.x1.min(c.x1),
y1: r.y1.min(c.y1),
};
(r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r)
};
let (w, h) = (x1 - x0, y1 - y0);
let mut found: Vec<Ruling> = Vec::new();
if h <= RULE_MAX_THICKNESS && w >= RULING_MIN_LENGTH {
let y = (y0 + y1) * 0.5;
found.push(Ruling {
x0,
y0: y,
x1,
y1: y,
});
} else if w <= RULE_MAX_THICKNESS && h >= RULING_MIN_LENGTH {
let x = (x0 + x1) * 0.5;
found.push(Ruling {
x0: x,
y0,
x1: x,
y1,
});
} else if w > RULE_MAX_THICKNESS && h > RULE_MAX_THICKNESS {
let corner = |p: &Point| {
((p.x - x0).abs() < 0.5 || (p.x - x1).abs() < 0.5)
&& ((p.y - y0).abs() < 0.5 || (p.y - y1).abs() < 0.5)
};
if points.iter().all(corner) && points.len() <= 6 {
found.push(Ruling { x0, y0, x1, y1: y0 });
found.push(Ruling { x0, y0: y1, x1, y1 });
found.push(Ruling { x0, y0, x1: x0, y1 });
found.push(Ruling { x0: x1, y0, x1, y1 });
}
}
for r in found.into_iter().filter_map(clipped) {
if self.rulings.len() >= MAX_RECORDED_RULINGS {
return;
}
self.rulings.push(r);
}
}
}
#[doc(hidden)]
fn record_fill(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'_>) {
if self.fills.len() >= MAX_RECORDED_RULINGS {
return;
}
let Paint::Color(color) = paint else {
return;
};
let rgba = color.to_rgba().to_rgba8();
let [r, g, b, a] = rgba;
let luminance = (299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b)) / 1000;
if a < 26 || luminance >= FILL_WHITE_ABOVE {
return;
}
let mut points: Vec<Point> = Vec::new();
for element in path.elements() {
match element {
PathEl::QuadTo(..) | PathEl::CurveTo(..) => return,
PathEl::MoveTo(_) if !points.is_empty() => return,
_ => points.extend(element_points(element).map(|p| transform * p)),
}
}
if points.len() < 4 {
return;
}
let rect = transform.transform_rect_bbox(path.bounding_box());
let on_edge = |p: &Point| {
((p.x - rect.x0).abs() < 0.5 || (p.x - rect.x1).abs() < 0.5)
&& ((p.y - rect.y0).abs() < 0.5 || (p.y - rect.y1).abs() < 0.5)
};
if !points.iter().all(on_edge) {
return;
}
let mut rect = rect;
if let Some(clip) = self.clip_stack.last() {
rect = rect.intersect(*clip);
}
if rect.width() >= FILL_MIN_WIDTH && rect.height() >= FILL_MIN_HEIGHT {
self.fills.push([rect.x0, rect.y0, rect.x1, rect.y1]);
}
}
pub fn take_fills(&mut self) -> Vec<[f64; 4]> {
std::mem::take(&mut self.fills)
}
pub fn take_rulings(&mut self) -> Vec<Ruling> {
std::mem::take(&mut self.rulings)
}
#[doc(hidden)]
pub fn take_images(&mut self) -> Vec<[f64; 4]> {
std::mem::take(&mut self.images)
}
#[doc(hidden)]
pub fn take_graphics(&mut self, width: f64, height: f64) -> Vec<[f64; 4]> {
let boxes = std::mem::take(&mut self.path_boxes);
let mut clusters: Vec<([f64; 4], usize)> = Vec::new();
for b in boxes {
let mut merged = (b, 1usize);
let mut rest = Vec::with_capacity(clusters.len());
for c in clusters {
if touches(&merged.0, &c.0, GRAPHIC_GAP) {
merged = (union(&merged.0, &c.0), merged.1 + c.1);
} else {
rest.push(c);
}
}
rest.push(merged);
clusters = rest;
}
let page = (width * height).max(1.0);
clusters
.into_iter()
.filter(|(b, n)| {
let (w, h) = (b[2] - b[0], b[3] - b[1]);
*n >= 3 && w >= 20.0 && h >= 20.0 && w * h <= 0.5 * page
})
.map(|(b, _)| b)
.collect()
}
fn record_path_box(&mut self, path: &BezPath, transform: Affine) {
if self.path_boxes.len() >= MAX_RECORDED_PATH_BOXES {
return;
}
let mut rect = transform.transform_rect_bbox(path.bounding_box());
if let Some(clip) = self.clip_stack.last() {
rect = rect.intersect(*clip);
}
if rect.width().min(rect.height()) >= 1.5 {
self.path_boxes.push([rect.x0, rect.y0, rect.x1, rect.y1]);
}
}
fn record_image(&mut self, width: u32, height: u32, transform: Affine) {
if self.images.len() >= MAX_RECORDED_IMAGES {
return;
}
let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
let mut rect = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
if let Some(clip) = self.clip_stack.last() {
rect = rect.intersect(*clip);
}
if rect.width() >= 1.0 && rect.height() >= 1.0 {
self.images.push([rect.x0, rect.y0, rect.x1, rect.y1]);
}
}
pub fn into_text(mut self) -> String {
self.end_last_glyph();
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
self.spans.iter_mut().for_each(untrack_spaced_letters);
let keep_ligatures = self.keep_ligatures;
let rules = std::mem::take(&mut self.rules);
let mut blocks = group_spans_into_blocks(self.spans, &rules);
if !keep_ligatures {
for block in &mut blocks {
crate::ligatures::fold_text_block(block);
}
}
let lines: Vec<String> = blocks.iter().map(|b| b.text()).collect();
let stitched = stitch_hyphenated_lines(&lines);
normalize_text_output(&stitched)
}
pub fn into_blocks(mut self) -> Vec<TextBlock> {
self.end_last_glyph();
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
self.spans.iter_mut().for_each(untrack_spaced_letters);
let keep_ligatures = self.keep_ligatures;
let rules = std::mem::take(&mut self.rules);
let mut blocks = group_spans_into_blocks(self.spans, &rules);
if !keep_ligatures {
for block in &mut blocks {
crate::ligatures::fold_text_block(block);
}
}
blocks
}
pub fn into_paragraph_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
self.end_last_glyph();
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
self.spans.iter_mut().for_each(untrack_spaced_letters);
let rules = std::mem::take(&mut self.rules);
paragraph_blocks(self.spans, &rules, page, first_index, TableStrategy::Both)
}
#[doc(hidden)]
pub fn into_paragraph_blocks_and_rules(
self,
page: usize,
first_index: usize,
) -> (Vec<Block>, Vec<HorizontalRule>) {
self.into_paragraph_blocks_and_rules_with_strategy(
page,
first_index,
TableStrategy::Both,
false,
)
}
#[doc(hidden)]
pub fn into_paragraph_blocks_and_rules_with_strategy(
mut self,
page: usize,
first_index: usize,
table_strategy: TableStrategy,
cell_path_words: bool,
) -> (Vec<Block>, Vec<HorizontalRule>) {
self.end_last_glyph();
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
self.spans.iter_mut().for_each(untrack_spaced_letters);
let rules = std::mem::take(&mut self.rules);
let blocks = paragraph_blocks_with_cell_path_words(
self.spans,
&rules,
page,
first_index,
table_strategy,
cell_path_words,
);
(blocks, rules)
}
pub fn into_raw_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
self.end_last_glyph();
debug_assert!(self.raw_runs, "raw blocks need raw runs");
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
self.spans.iter_mut().for_each(untrack_spaced_letters);
if let Some(last) = self.spans.last_mut() {
last.complete_run = true;
}
let bdc = std::mem::take(&mut self.raw_bdc);
debug_assert_eq!(bdc.len(), self.spans.len());
raw_blocks(self.spans, &bdc, page, first_index)
}
#[allow(dead_code)]
pub(crate) fn into_spans(mut self) -> Vec<TextSpan> {
self.end_last_glyph();
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
self.spans.iter_mut().for_each(untrack_spaced_letters);
self.spans
}
fn end_last_glyph(&mut self) {
self.last_glyph_text = None;
self.holds_last_glyph = false;
self.last_glyph_guard.clear();
}
fn remember_last_glyph(
&mut self,
glyph: &OutlineGlyph,
mut record: LastGlyphText,
hidden: bool,
) {
let tail = self.spans.last();
if hidden || tail.is_some_and(TextSpan::is_hidden) {
self.last_glyph_text = None;
return;
}
record.span_count = self.spans.len();
record.tail_text = tail.map_or(0, |span| span.text.len());
record.tail_boxes = tail.map_or(0, |span| span.char_bounds.len());
record.place = glyph.place();
if record.place.is_none() {
self.last_glyph_text = None;
return;
}
if !self.holds_last_glyph {
let id = self.id;
self.holds_last_glyph = LAST_GLYPH.try_with(|slot| slot.set(Some(id))).is_ok();
}
self.last_glyph_text = self.holds_last_glyph.then_some(record);
}
fn last_glyph_still_last(&self, last: &LastGlyphText) -> bool {
let tail = self.spans.last();
self.spans.len() == last.span_count
&& tail.map_or(0, |span| span.text.len()) == last.tail_text
&& tail.map_or(0, |span| span.char_bounds.len()) == last.tail_boxes
}
fn reread_last_glyph(&mut self, glyph: &Glyph<'_>) {
let Some(last) = self.last_glyph_text.as_ref() else {
return;
};
if !self.last_glyph_still_last(last) {
self.last_glyph_text = None;
return;
}
let read = match (glyph, last.place) {
(Glyph::Outline(outline), Some(place)) => outline.reread_previous(place),
_ => None,
};
let Some(read) = read else {
return;
};
let last = self.last_glyph_text.as_mut().expect("checked above");
if last.boxless {
return;
}
let span = &mut self.spans[last.span];
let written = &span.text[last.offset..];
let unchanged = match &read {
Some(BfString::Char(c)) => written == c.encode_utf8(&mut [0; 4]),
Some(BfString::String(s)) => written == s,
None => written.is_empty(),
};
if unchanged {
return;
}
let now = match read {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None => String::new(),
};
span.text.truncate(last.offset);
span.text.push_str(&now);
last.len = now.len();
last.tail_text = span.text.len();
if !now.is_empty() {
return;
}
let last = self.last_glyph_text.take().expect("checked above");
self.deferred_rich_glyphs.truncate(last.deferred);
if last.opened {
self.spans.pop();
if self.raw_runs {
self.raw_bdc.pop();
if let (Some(prev), Some(was)) = (self.spans.last_mut(), last.previous_complete_run)
{
prev.complete_run = was;
}
}
} else {
let span = &mut self.spans[last.span];
span.char_bounds.truncate(last.char_bounds);
span.width = last.width;
}
}
fn record_deferred_glyph(
&mut self,
glyph: &Glyph<'_>,
composed: &Affine,
font_size: f64,
glyph_width: f64,
) {
let coeffs = composed.as_coeffs();
let is_rotated = coeffs[1].abs() > ROTATION_EPSILON || coeffs[2].abs() > ROTATION_EPSILON;
let is_sheared = (coeffs[0] - coeffs[3]).abs() > SHEAR_EPSILON;
let needs_exact = is_rotated || is_sheared;
let (outline, font_bbox) = match glyph {
Glyph::Outline(o) => {
let path = o.outline();
let bb = path.bounding_box();
if bb.width() <= 0.0 && bb.height() <= 0.0 {
(None, None)
} else if needs_exact {
(Some(path), Some(bb))
} else {
(None, Some(bb))
}
}
Glyph::Type3(_) => (None, None),
};
self.deferred_rich_glyphs.push(DeferredGlyph {
coeffs,
font_size,
glyph_width,
needs_exact,
outline,
font_bbox,
});
}
fn compute_tight_bounds(&mut self) {
let deferred = std::mem::take(&mut self.deferred_rich_glyphs);
if deferred.is_empty() {
return;
}
let mut idx = 0usize;
for span in &mut self.spans {
let n = span.char_bounds.len();
if n == 0 {
continue;
}
for _gi in 0..n {
if idx >= deferred.len() {
break;
}
let dg = &deferred[idx];
idx += 1;
let composed = Affine::new(dg.coeffs);
let (tight_bound, source) = if let Some(font_bbox) = dg.font_bbox {
if dg.needs_exact {
let page_bbox = match dg.outline {
Some(ref path) => (composed * path.clone()).bounding_box(),
None => {
let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
let b = transform_bbox_corners(&raw, &composed);
kurbo::Rect::new(b[0], b[1], b[2], b[3])
}
};
(
[page_bbox.x0, page_bbox.y0, page_bbox.x1, page_bbox.y1],
BoundsSource::Tight,
)
} else {
let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
(transform_bbox_corners(&raw, &composed), BoundsSource::Tight)
}
} else {
let x = dg.coeffs[4];
let y = dg.coeffs[5];
(
[x, y, x + dg.glyph_width, y + dg.font_size],
BoundsSource::Estimate,
)
};
span.tight_char_bounds.push(tight_bound);
span.glyph_advances.push(dg.glyph_width);
span.glyph_bounds_sources.push(source);
}
span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
}
}
}
impl Device<'_> for TextExtractionDevice {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option<i32>, actual_text: Option<&str>) {
if self.actual_text || self.in_annotation_appearance() {
self.actual_text_stack.push(ActualTextEntry {
actual_text: actual_text.map(str::to_string),
consumed: false,
saw_glyph: false,
child_resolved: false,
extent: None,
});
}
if !self.raw_runs {
return;
}
let id = self.raw_bdc_next;
self.raw_bdc_next += 1;
self.raw_bdc_stack.push(id);
self.raw_bdc_changed = true;
}
fn end_marked_content(&mut self) {
if let Some(entry) = self.actual_text_stack.pop() {
self.emit_glyphless_replacement(entry);
}
if !self.raw_runs {
return;
}
self.raw_bdc_stack.pop();
self.raw_bdc_changed = true;
}
fn draw_path(
&mut self,
path: &BezPath,
transform: Affine,
paint: &Paint<'_>,
mode: &PathDrawMode,
) {
self.record_rule(path, transform);
self.record_rulings(path, transform);
self.record_path_box(path, transform);
if matches!(mode, PathDrawMode::Fill(_)) {
self.record_fill(path, transform, paint);
}
if !self.actual_text_stack.is_empty() {
let mut drawn = transform.transform_rect_bbox(path.bounding_box());
if let Some(clip) = self.clip_stack.last() {
drawn = drawn.intersect(*clip);
}
self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
}
}
fn begin_annotation_appearance(&mut self, rect: kurbo::Rect) {
self.annotation_appearances.push(rect);
}
fn end_annotation_appearance(&mut self) {
self.annotation_appearances.pop();
}
fn push_clip_path(&mut self, clip_path: &ClipPath) {
let box_of_clip = clip_path.path.bounding_box();
let effective = match self.clip_stack.last() {
Some(outer) => outer.intersect(box_of_clip),
None => box_of_clip,
};
self.clip_stack.push(effective);
}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_image(&mut self, image: Image<'_, '_>, transform: Affine) {
self.record_image(image.width(), image.height(), transform);
if !self.actual_text_stack.is_empty() {
let (w, h) = (
f64::from(image.width().max(1)),
f64::from(image.height().max(1)),
);
let mut drawn = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
if let Some(clip) = self.clip_stack.last() {
drawn = drawn.intersect(*clip);
}
self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
}
}
fn pop_clip_path(&mut self) {
self.clip_stack.pop();
}
fn pop_transparency_group(&mut self) {}
fn draw_placed_text(&mut self, text: &str, rect: kurbo::Rect) {
self.push_placed_span(text, rect);
}
fn draw_glyph(
&mut self,
glyph: &Glyph<'_>,
transform: Affine,
glyph_transform: Affine,
paint: &Paint<'_>,
draw_mode: &GlyphDrawMode,
) {
let color = paint_to_rgba(paint);
let glyph_hidden = matches!(draw_mode, GlyphDrawMode::Invisible)
|| color.is_some_and(|color| color[3] == 0);
let replaced = self.actual_text
&& self
.actual_text_stack
.iter()
.any(|entry| entry.actual_text.is_some());
if replaced || glyph_hidden {
self.last_glyph_text = None;
} else {
self.reread_last_glyph(glyph);
}
let before = self.last_glyph_text.take();
let unicode = glyph.as_unicode();
let placed = (transform * glyph_transform).as_coeffs();
let repeats_fill = matches!(draw_mode, GlyphDrawMode::Stroke(_))
&& self
.last_fill
.as_ref()
.is_some_and(|(at, filled)| *at == placed && *filled == unicode);
self.last_fill = match draw_mode {
GlyphDrawMode::Fill => Some((placed, unicode.clone())),
_ => None,
};
if repeats_fill {
self.last_glyph_text = before;
return;
}
let reads_as_nothing = unicode.is_none();
let glyph_text = match unicode {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None => String::new(),
};
let composed = transform * glyph_transform;
let coeffs = composed.as_coeffs();
let x = coeffs[4];
let y = coeffs[5];
let glyph_scale = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt().abs();
let draw_size = glyph_scale * 1000.0;
let font_size = if self.raw_runs {
(coeffs[0] * coeffs[3] - coeffs[1] * coeffs[2]).abs().sqrt() * 1000.0
} else {
draw_size
};
let (glyph_width, glyph_ws) = glyph_width_and_source(glyph, draw_size);
let widened = match glyph {
Glyph::Outline(outline) => outline.advance_width().map_or(0.0, |w| {
(glyph_width - f64::from(w) / 1000.0 * font_size).max(0.0)
}),
Glyph::Type3(_) => 0.0,
};
let glyph_end_x = x + glyph_width;
let glyph_bound = [x, y, glyph_end_x, y + font_size];
let style = self.glyph_style(glyph);
let actual_idx = if self.actual_text {
self.actual_text_stack
.iter()
.rposition(|e| e.actual_text.is_some())
} else {
None
};
let text = if let Some(idx) = actual_idx {
let replacement = if self.actual_text_stack[idx].consumed {
String::new()
} else {
let found = self.actual_text_stack[idx]
.actual_text
.clone()
.unwrap_or_default();
self.actual_text_stack[idx].consumed = true;
found
};
if replacement.is_empty() {
self.last_y = y;
self.advance_pen(&glyph_text, glyph_end_x, widened);
let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
let (adv_x, adv_y) = if adv_len > 0.0 {
(coeffs[0] / adv_len, coeffs[1] / adv_len)
} else {
(1.0, 0.0)
};
self.advance_raw_pen(&glyph_text, x, y, adv_x, adv_y, glyph_width);
self.pending_tj_offset = 0.0;
return;
}
replacement
} else {
if reads_as_nothing {
if let Glyph::Outline(outline) = glyph {
let record = LastGlyphText {
place: None,
span: self.spans.len().saturating_sub(1),
offset: 0,
len: 0,
boxless: true,
opened: false,
char_bounds: 0,
width: 0.0,
previous_complete_run: None,
deferred: self.deferred_rich_glyphs.len(),
span_count: 0,
tail_text: 0,
tail_boxes: 0,
};
self.remember_last_glyph(outline, record, glyph_hidden);
}
return;
}
if !self.actual_text_stack.is_empty() {
for open in self.actual_text_stack.iter_mut() {
open.saw_glyph = true;
}
self.note_drawn_box(glyph_bound);
}
glyph_text
};
let own_glyph = match glyph {
Glyph::Outline(outline) if actual_idx.is_none() => Some(outline),
_ => None,
};
let deferred_before = self.deferred_rich_glyphs.len();
if self.geometry_mode == GeometryMode::RichGeometry {
self.record_deferred_glyph(glyph, &composed, font_size, glyph_width);
}
if self.glyph_widths.len() < 4096 {
self.glyph_widths.push(glyph_width);
if self.glyph_widths.len().is_multiple_of(MEDIAN_REFRESH) {
self.refresh_median_char_width();
}
}
let gap = x - self.last_end_x;
let same_line = (y - self.last_y).abs() <= font_size.max(BAND_Y_TOLERANCE) * 0.35;
let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
let (adv_x, adv_y) = if adv_len > 0.0 {
(coeffs[0] / adv_len, coeffs[1] / adv_len)
} else {
(1.0, 0.0)
};
let vector = self.raw_runs
&& self.raw_pen_valid
&& (adv_y.abs() > adv_x.abs() || self.raw_dir[1].abs() > self.raw_dir[0].abs());
let (same_line, gap, dir_changed) = if vector {
let along = (x - self.raw_pen[0]) * adv_x + (y - self.raw_pen[1]) * adv_y;
let across_x = (x - self.raw_pen[0]) - along * adv_x;
let across_y = (y - self.raw_pen[1]) - along * adv_y;
let across = (across_x.powi(2) + across_y.powi(2)).sqrt();
let aligned = adv_x * self.raw_dir[0] + adv_y * self.raw_dir[1] > 0.999;
(aligned && across <= font_size * 0.35, along, !aligned)
} else {
(same_line, gap, false)
};
let adjacent = same_line && gap >= -font_size * 0.25 && gap < font_size * 0.5;
let same_font = !self.document_font_names
|| self
.spans
.last()
.is_some_and(|last| last.font_name == style.font_name);
let style_matches = self
.spans
.last()
.map(|last| {
self.last_merge_face == style.merge_face
&& same_font
&& last.is_bold == style.is_bold
&& last.is_italic == style.is_italic
&& last.color == color
})
.unwrap_or(false);
let same_size = !self.raw_runs
|| self.spans.last().is_some_and(|last| {
let big = last.font_size.max(font_size).max(1.0);
(last.font_size - font_size).abs() <= RAW_SIZE_TOLERANCE * big
});
let bdc_changed = std::mem::replace(&mut self.raw_bdc_changed, false);
let declared = if self.raw_runs {
match glyph {
Glyph::Outline(outline) => outline
.declared_name()
.map(|d| strip_subset_prefix(&d).to_string()),
Glyph::Type3(_) => None,
}
} else {
None
};
let same_declared = !self.raw_runs
|| match (&self.last_raw_declared, &declared) {
(Some(before), Some(now)) => before == now,
_ => true,
};
if adjacent
&& !self.spans.is_empty()
&& style_matches
&& same_size
&& same_declared
&& !bdc_changed
&& !dir_changed
{
let prev_char = self.spans.last().and_then(|last| last.text.chars().last());
let next_char = text.chars().next();
let letters = prev_char.is_some_and(char::is_alphabetic)
&& next_char.is_some_and(char::is_alphabetic);
let letter_or_space =
|c: Option<char>| c.is_some_and(|c| c.is_alphabetic() || c == ' ');
let drawn_gap = if letter_or_space(prev_char) && letter_or_space(next_char) {
let kern = f64::from(self.pending_tj_offset.max(0.0)) / 1000.0 * font_size;
gap + self.last_widened + kern
} else {
gap
};
let tracked = self.a_tracked_run(drawn_gap);
let want_space = {
let last = self.spans.last().expect("checked non-empty");
!last.text.ends_with(' ')
&& !text.starts_with(' ')
&& !tracked
&& self.evaluate_space_consensus(gap, font_size, &last.text, &text)
};
if letters {
if self.last_letter_gap.is_some_and(|before| {
(before - drawn_gap).abs() > TRACKING_TOLERANCE * drawn_gap
}) {
self.run_start = self.spans.last().map_or(0, |last| last.text.len() + 1);
}
self.last_letter_gap = Some(drawn_gap);
}
self.recent_gaps.push(drawn_gap);
if self.recent_gaps.len() > TRACKING_MIN_PAIRS * 2 {
self.recent_gaps.remove(0);
}
let span_index = self.spans.len() - 1;
let last = self.spans.last_mut().expect("checked non-empty");
if tracked {
Self::take_back_tracked_spaces(
&mut last.text,
&mut self.inserted_spaces,
self.run_start,
drawn_gap,
);
}
if want_space {
self.inserted_spaces.push((last.text.len(), drawn_gap));
last.text.push(' ');
}
let offset = last.text.len();
let char_bounds = last.char_bounds.len();
let width = last.width;
last.text.push_str(&text);
last.width = last.width.max(glyph_end_x - last.x);
last.height = last.height.max(font_size);
last.char_bounds.push(glyph_bound);
if glyph_ws == WidthSource::Estimate {
last.width_source = WidthSource::Estimate;
}
if let Some(outline) = own_glyph {
let record = LastGlyphText {
place: None,
span: span_index,
offset,
len: text.len(),
boxless: false,
opened: false,
char_bounds,
width,
previous_complete_run: None,
deferred: deferred_before,
span_count: 0,
tail_text: 0,
tail_boxes: 0,
};
self.remember_last_glyph(outline, record, glyph_hidden);
}
self.last_y = y;
self.advance_pen(&text, glyph_end_x, widened);
self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
self.pending_tj_offset = 0.0;
return;
}
self.last_y = y;
self.advance_pen(&text, glyph_end_x, widened);
self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
self.pending_tj_offset = 0.0;
self.recent_gaps.clear();
self.inserted_spaces.clear();
self.run_start = 0;
self.last_letter_gap = None;
let span = TextSpan {
text,
x,
y,
width: glyph_width,
height: font_size,
font_size,
font_name: style.font_name.clone(),
is_bold: style.is_bold,
is_italic: style.is_italic,
color,
width_source: glyph_ws,
char_bounds: vec![glyph_bound],
transform: Some(coeffs),
font_weight: style.font_weight,
is_serif: style.is_serif,
is_monospace: style.is_monospace,
render_mode: Some(render_mode_from_draw_mode(draw_mode)),
is_ocr_layer: style.is_ocr_layer,
font_metrics: style.font_metrics,
geometry_mode: self.geometry_mode,
bounds_source: BoundsSource::Advance,
tight_char_bounds: Vec::new(),
glyph_advances: Vec::new(),
glyph_bounds_sources: Vec::new(),
complete_run: false,
};
self.last_merge_face = style.merge_face;
let previous_complete_run = self.spans.last().map(|prev| prev.complete_run);
if self.raw_runs {
let run_end = !style_matches || !same_size || !same_declared || dir_changed;
if let Some(prev) = self.spans.last_mut() {
prev.complete_run = run_end;
}
self.raw_bdc.push(self.raw_bdc_stack.last().copied());
self.last_raw_declared = declared;
}
let record = own_glyph.map(|outline| {
let record = LastGlyphText {
place: None,
span: self.spans.len(),
offset: 0,
len: span.text.len(),
boxless: false,
opened: true,
char_bounds: 0,
width: 0.0,
previous_complete_run,
deferred: deferred_before,
span_count: 0,
tail_text: 0,
tail_boxes: 0,
};
(outline, record)
});
self.spans.push(span);
if let Some((outline, record)) = record {
self.remember_last_glyph(outline, record, glyph_hidden);
}
}
fn text_adjustment(&mut self, amount: f32) {
self.pending_tj_offset += amount;
}
}
#[derive(Debug, Default, Clone)]
struct GlyphStyle {
font_name: Option<String>,
merge_face: Option<String>,
is_bold: bool,
is_italic: bool,
font_weight: Option<u16>,
is_serif: Option<bool>,
is_monospace: Option<bool>,
is_ocr_layer: bool,
font_metrics: Option<FontMetrics>,
}
fn strip_subset_prefix(name: &str) -> &str {
match name.split_once('+') {
Some((prefix, rest)) if prefix.len() == 6 => rest,
_ => name,
}
}
fn name_style_hints(name: &str) -> (bool, bool) {
let lower = name.to_ascii_lowercase();
let italic = lower.contains("italic") || lower.contains("oblique") || lower.contains("slant");
let bold = lower.contains("bold")
|| lower.contains("demi")
|| lower.contains("semibold")
|| lower.contains("heavy")
|| lower.contains("black");
(bold, italic)
}
fn name_monospace_hint(name: &str) -> Option<bool> {
let squashed: String = name
.to_ascii_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
const MONOSPACE_FAMILIES: &[&str] = &[
"courier",
"monaco",
"menlo",
"consolas",
"liberationmono",
"dejavusansmono",
"notosansmono",
"sfmono",
"ubuntumono",
"robotomono",
"ptmono",
"droidsansmono",
"cascadiacode",
"cascadiamono",
"firacode",
"firamono",
"inconsolata",
"jetbrainsmono",
"sourcecodepro",
"lucidaconsole",
"nimbusmono",
"texgyrecursor",
"andalemono",
"lettergothic",
"prestigeelite",
"anonymouspro",
"oxygenmono",
"overpassmono",
"plexmono",
"spacemono",
"sharetechmono",
"cutivemono",
"novamono",
"fixedsys",
"typewriter",
"monospace",
];
if MONOSPACE_FAMILIES
.iter()
.any(|family| squashed.contains(family))
{
Some(true)
} else {
None
}
}
fn derive_glyph_style(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
let mut style = glyph_style_from_font(glyph, document_font_names);
style.is_ocr_layer = glyph
.font_resource_name()
.is_some_and(|name| name.as_bytes() == pdf_ocr::pipeline::OCR_FONT_NAME);
if let Glyph::Outline(outline) = glyph {
if let Some(raw) = outline.declared_name() {
let declared = strip_subset_prefix(&raw).to_string();
if !declared.is_empty() && style.font_name.as_deref() != Some(declared.as_str()) {
let (bold, italic) = name_style_hints(&declared);
style.is_bold |= bold;
style.is_italic |= italic;
style.is_monospace = style.is_monospace.or(name_monospace_hint(&declared));
}
}
}
style
}
fn glyph_style_from_font(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
match glyph {
Glyph::Outline(outline) => {
if let Some(data) = outline.font_data() {
let raw = data.postscript_name.as_deref().unwrap_or("");
let name = strip_subset_prefix(raw).to_string();
let weight_bold = data.weight.is_some_and(|w| w >= 700);
let (name_bold, name_italic) = name_style_hints(&name);
let font_name = if name.is_empty() { None } else { Some(name) };
GlyphStyle {
merge_face: font_name.clone(),
font_name,
is_bold: weight_bold || name_bold,
is_italic: data.is_italic || name_italic,
font_weight: data.weight.map(|w| w.clamp(1, 1000) as u16),
is_serif: Some(data.is_serif),
is_monospace: Some(data.is_monospace),
is_ocr_layer: false,
font_metrics: match (data.ascent, data.descent) {
(Some(ascent), Some(descent)) => Some(FontMetrics {
ascent,
descent,
cap_height: data.cap_height,
x_height: data.x_height,
}),
_ => None,
},
}
} else if document_font_names {
let face =
strip_subset_prefix(&outline.postscript_name().unwrap_or_default()).to_string();
let merge_face = if face.is_empty() {
None
} else {
Some(face.clone())
};
let raw = outline.document_name().unwrap_or_default();
let name = strip_subset_prefix(&raw).to_string();
let name = if name.is_empty() {
outline
.declared_name()
.map(|d| strip_subset_prefix(&d).to_string())
.unwrap_or_default()
} else {
name
};
let (name_bold, name_italic) = name_style_hints(&name);
let (face_bold, face_italic) = name_style_hints(&face);
let metrics = outline.font_metrics().map(|(a, d, c, x)| FontMetrics {
ascent: a,
descent: d,
cap_height: c,
x_height: x,
});
GlyphStyle {
font_name: if name.is_empty() { None } else { Some(name) },
merge_face,
is_bold: name_bold || face_bold,
is_italic: name_italic || face_italic,
font_weight: None,
is_serif: None,
is_monospace: None,
is_ocr_layer: false,
font_metrics: metrics,
}
} else {
let raw = outline.postscript_name().unwrap_or_default();
let name = strip_subset_prefix(&raw).to_string();
let merge_face = if name.is_empty() {
None
} else {
Some(name.clone())
};
let name = if name.is_empty() {
outline
.declared_name()
.map(|d| strip_subset_prefix(&d).to_string())
.unwrap_or_default()
} else {
name
};
let (name_bold, name_italic) = name_style_hints(&name);
let metrics = outline
.substitute_metrics()
.map(|(a, d, c, x)| FontMetrics {
ascent: a,
descent: d,
cap_height: c,
x_height: x,
});
let monospace = name_monospace_hint(&name);
GlyphStyle {
font_name: if name.is_empty() { None } else { Some(name) },
merge_face,
is_bold: name_bold,
is_italic: name_italic,
font_weight: None,
is_serif: None,
is_monospace: monospace,
is_ocr_layer: false,
font_metrics: metrics,
}
}
}
Glyph::Type3(_) => GlyphStyle::default(),
}
}
fn paint_to_rgba(paint: &Paint<'_>) -> Option<[u8; 4]> {
match paint {
Paint::Color(c) => Some(c.to_rgba().to_rgba8()),
Paint::Pattern(_) => None,
}
}
fn render_mode_from_draw_mode(mode: &GlyphDrawMode) -> u8 {
match mode {
GlyphDrawMode::Fill => 0,
GlyphDrawMode::Stroke(_) => 1,
GlyphDrawMode::Invisible => 3,
}
}
#[cfg(test)]
mod render_mode_tests {
use super::render_mode_from_draw_mode;
use pdf_render::pdf_interpret::{GlyphDrawMode, StrokeProps};
#[test]
fn render_mode_is_only_zero_one_three() {
assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Fill), 0);
assert_eq!(
render_mode_from_draw_mode(&GlyphDrawMode::Stroke(StrokeProps::default())),
1
);
assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Invisible), 3);
for m in [
GlyphDrawMode::Fill,
GlyphDrawMode::Stroke(StrokeProps::default()),
GlyphDrawMode::Invisible,
] {
assert!(matches!(render_mode_from_draw_mode(&m), 0 | 1 | 3));
}
}
}
fn glyph_width_and_source(glyph: &Glyph<'_>, font_size: f64) -> (f64, WidthSource) {
match glyph {
Glyph::Outline(outline) => {
if let Some(w) = outline.advance_width() {
let advance = (w as f64 / 1000.0 * font_size).max(font_size * 0.25);
(advance, WidthSource::Metric)
} else {
(font_size * 0.5, WidthSource::Estimate)
}
}
Glyph::Type3(_) => (font_size * 0.5, WidthSource::Estimate),
}
}
const DIRECTION_TOLERANCE_RAD: f64 = 0.008_727;
const QUARTER_TURN_TOLERANCE_RAD: f64 = 0.174_533;
fn span_is_quarter_turned(span: &TextSpan) -> bool {
let direction = span_direction(span);
direction_gap(direction, (0.0, 1.0)) <= QUARTER_TURN_TOLERANCE_RAD
|| direction_gap(direction, (-1.0, 0.0)) <= QUARTER_TURN_TOLERANCE_RAD
|| direction_gap(direction, (0.0, -1.0)) <= QUARTER_TURN_TOLERANCE_RAD
}
type Direction = (f64, f64);
type PlacedSpan = (usize, TextSpan);
fn baseline_direction(coeffs: &[f64; 6]) -> Direction {
let (a, b) = (coeffs[0], coeffs[1]);
let length = (a * a + b * b).sqrt();
if length <= f64::EPSILON {
(1.0, 0.0)
} else {
(a / length, b / length)
}
}
fn span_direction(span: &TextSpan) -> Direction {
span.transform
.as_ref()
.map(baseline_direction)
.unwrap_or((1.0, 0.0))
}
fn direction_gap(lhs: Direction, rhs: Direction) -> f64 {
let cross = lhs.0 * rhs.1 - lhs.1 * rhs.0;
let dot = lhs.0 * rhs.0 + lhs.1 * rhs.1;
cross.atan2(dot).abs()
}
fn directions_agree(lhs: Direction, rhs: Direction) -> bool {
direction_gap(lhs, rhs) <= DIRECTION_TOLERANCE_RAD
}
fn baseline_coordinates(span: &TextSpan, direction: Direction) -> (f64, f64) {
(
span.x * direction.0 + span.y * direction.1,
-span.x * direction.1 + span.y * direction.0,
)
}
fn baseline_end(span: &TextSpan, direction: Direction) -> f64 {
let start = baseline_coordinates(span, direction).0;
let from_width = start + span.width;
match span.char_bounds.last() {
Some(bound) => {
let last = bound[0] * direction.0 + bound[1] * direction.1;
let advance = bound[2] - bound[0];
(last + advance).max(from_width)
}
None => from_width,
}
}
const ROTATION_EPSILON: f64 = 1e-6;
const SHEAR_EPSILON: f64 = 1e-3;
fn transform_bbox_corners(local_bbox: &[f64; 4], affine: &Affine) -> [f64; 4] {
use kurbo::Point;
let corners = [
*affine * Point::new(local_bbox[0], local_bbox[1]),
*affine * Point::new(local_bbox[2], local_bbox[1]),
*affine * Point::new(local_bbox[2], local_bbox[3]),
*affine * Point::new(local_bbox[0], local_bbox[3]),
];
let x0 = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let y0 = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
let x1 = corners
.iter()
.map(|p| p.x)
.fold(f64::NEG_INFINITY, f64::max);
let y1 = corners
.iter()
.map(|p| p.y)
.fold(f64::NEG_INFINITY, f64::max);
[x0, y0, x1, y1]
}
fn split_spans_covering_siblings(spans: &mut Vec<TextSpan>) {
let mut changed = true;
while changed {
changed = false;
let mut at = 0usize;
while at < spans.len() {
if let Some((left, right)) = split_cover_at_sibling(spans, at) {
spans[at] = left;
spans.insert(at + 1, right);
changed = true;
at += 2;
} else {
at += 1;
}
if spans.len() > 4096 {
return;
}
}
}
}
fn split_cover_at_sibling(spans: &[TextSpan], cover: usize) -> Option<(TextSpan, TextSpan)> {
const SIBLING_WIDTH_EM: f64 = 0.6;
const COVER_MARGIN: f64 = 1.0;
let (cover_left, cover_right) = {
let span = &spans[cover];
(span.x, span.measured_right())
};
if cover_right <= cover_left {
return None;
}
let chars: Vec<char> = spans[cover].text.chars().collect();
if chars.len() < 3 || chars.len() != spans[cover].char_bounds.len() {
return None;
}
for (sib, sibling) in spans.iter().enumerate() {
if sib == cover {
continue;
}
let em = sibling.font_size.max(1.0);
if sibling.width > SIBLING_WIDTH_EM * em {
continue;
}
if (sibling.y - spans[cover].y).abs() > 0.35 * spans[cover].font_size.max(em) {
continue;
}
let (sib_left, sib_right) = (sibling.x, sibling.measured_right());
if !(sib_left > cover_left + COVER_MARGIN && sib_right < cover_right - COVER_MARGIN) {
continue;
}
let centre = (sib_left + sib_right) * 0.5;
let boxes = &spans[cover].char_bounds;
let mut cut: Option<usize> = None;
let mut best = f64::INFINITY;
for i in 1..chars.len() {
if !(chars[i - 1].is_whitespace() || chars[i].is_whitespace()) {
continue;
}
let edge = (boxes[i - 1][2] + boxes[i][0]) * 0.5;
if edge < sib_left || edge > sib_right {
continue;
}
let distance = (edge - centre).abs();
if distance < best {
best = distance;
cut = Some(i);
}
}
let Some(i) = cut else { continue };
let mut left = spans[cover].clone();
let mut right = spans[cover].clone();
left.text = chars[..i].iter().collect();
right.text = chars[i..].iter().collect();
left.char_bounds = boxes[..i].to_vec();
right.char_bounds = boxes[i..].to_vec();
split_parallel_glyph_vecs(&mut left, &mut right, i, chars.len());
left.width = (boxes[i - 1][2] - left.x).max(0.0);
right.x = boxes[i][0];
right.width = (cover_right - right.x).max(0.0);
return Some((left, right));
}
None
}
fn split_parallel_glyph_vecs(left: &mut TextSpan, right: &mut TextSpan, at: usize, len: usize) {
if left.tight_char_bounds.len() == len {
right.tight_char_bounds = left.tight_char_bounds[at..].to_vec();
left.tight_char_bounds.truncate(at);
} else {
left.tight_char_bounds.clear();
right.tight_char_bounds.clear();
}
if left.glyph_advances.len() == len {
right.glyph_advances = left.glyph_advances[at..].to_vec();
left.glyph_advances.truncate(at);
} else {
left.glyph_advances.clear();
right.glyph_advances.clear();
}
if left.glyph_bounds_sources.len() == len {
right.glyph_bounds_sources = left.glyph_bounds_sources[at..].to_vec();
left.glyph_bounds_sources.truncate(at);
} else {
left.glyph_bounds_sources.clear();
right.glyph_bounds_sources.clear();
}
}
fn collapse_overprinted_spans(spans: &mut Vec<TextSpan>) {
if spans.len() < 2 {
return;
}
let mut deduped: Vec<TextSpan> = Vec::with_capacity(spans.len());
for span in spans.drain(..) {
if let Some(last) = deduped.last_mut() {
if spans_are_overprint_duplicates(last, &span) {
let choose_incoming = span.text.chars().count() > last.text.chars().count()
|| (span.text.chars().count() == last.text.chars().count()
&& span.width > last.width);
let preferred_text = if choose_incoming {
span.text.clone()
} else {
last.text.clone()
};
let left = last.x.min(span.x);
let right = last.right().max(span.right());
last.x = left;
last.y = (last.y + span.y) * 0.5;
last.width = (right - left).max(last.width).max(span.width);
last.height = last.height.max(span.height);
last.font_size = last.font_size.max(span.font_size);
last.text = preferred_text;
continue;
}
}
deduped.push(span);
}
*spans = deduped;
}
fn spans_are_overprint_duplicates(lhs: &TextSpan, rhs: &TextSpan) -> bool {
let lhs_text = lhs.text.trim();
let rhs_text = rhs.text.trim();
if lhs_text.is_empty() || rhs_text.is_empty() {
return false;
}
let size = lhs.font_size.max(rhs.font_size);
let drift = (lhs.y - rhs.y).abs();
let lhs_left = lhs.x;
let lhs_right = lhs.measured_right();
let rhs_left = rhs.x;
let rhs_right = rhs.measured_right();
let overlap = (lhs_right.min(rhs_right) - lhs_left.max(rhs_left)).max(0.0);
let min_width = (lhs_right - lhs_left).min(rhs_right - rhs_left).max(1.0);
let share = overlap / min_width;
let marks = lhs_text.chars().filter(|c| !c.is_whitespace()).count();
let needed = if marks >= 3 { 0.5 } else { 0.7 };
if lhs_text == rhs_text && marks >= 2 && drift <= size * 0.25 && share >= needed {
return true;
}
if drift > size * 0.12 || share < 0.85 {
return false;
}
lhs_text == rhs_text || lhs_text.starts_with(rhs_text) || rhs_text.starts_with(lhs_text)
}
fn second_run_carries_on_from_the_first(prev: &TextSpan, curr: &TextSpan) -> bool {
let glyphs = if curr.char_bounds.is_empty() {
curr.text.chars().count()
} else {
curr.char_bounds.len()
};
if glyphs == 0 {
return false;
}
let mean_advance = curr.width / glyphs as f64;
let direction = span_direction(prev);
let (curr_along, _) = baseline_coordinates(curr, direction);
baseline_end(prev, direction) - curr_along <= mean_advance
}
fn space_drawn_over_previous_run(prev: &TextSpan, curr: &TextSpan, gap: f64) -> bool {
if gap >= -0.1 * prev.font_size {
return false;
}
let mut next = curr.text.chars();
if next.next() != Some(' ') || !next.next().is_some_and(|c| c.is_lowercase()) {
return false;
}
prev.text
.chars()
.last()
.is_some_and(|c| c.is_alphabetic() || ('\u{FB00}'..='\u{FB06}').contains(&c))
}
fn trim_overlapping_word_prefix(prev: &str, curr: &str) -> Option<String> {
let prev_chars: Vec<char> = prev.trim_end().chars().collect();
let curr_chars: Vec<char> = curr.trim_start().chars().collect();
let max = prev_chars.len().min(curr_chars.len());
for len in (4..=max).rev() {
let prev_start = prev_chars.len() - len;
if prev_chars[prev_start..] != curr_chars[..len] {
continue;
}
if !curr_chars[..len].iter().all(|ch| ch.is_alphanumeric()) {
continue;
}
let prev_boundary = prev_start == 0 || !prev_chars[prev_start - 1].is_alphanumeric();
let curr_boundary = len == curr_chars.len() || !curr_chars[len].is_alphanumeric();
if !prev_boundary || !curr_boundary {
continue;
}
return Some(curr_chars[len..].iter().collect());
}
None
}
fn compute_adaptive_column_gap(bands: &[TextBand]) -> f64 {
let mut all_gaps: Vec<f64> = Vec::new();
for band in bands {
if band.spans.len() < 2 {
continue;
}
let mut sorted = band.spans.clone();
sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
let mut prev_right = sorted[0].right();
for span in sorted.iter().skip(1) {
let gap = span.x - prev_right;
if gap > 0.0 {
all_gaps.push(gap);
}
prev_right = prev_right.max(span.right());
}
}
if all_gaps.is_empty() {
return COLUMN_GAP_THRESHOLD_FALLBACK;
}
all_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let min_gap = all_gaps[0];
if min_gap > COLUMN_GAP_THRESHOLD_MIN {
return (min_gap * 0.75).clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
}
let mut best_break_threshold = 0.0f64;
let mut best_ratio = 1.5f64; for pair in all_gaps.windows(2) {
if pair[0] > 0.5 {
let ratio = pair[1] / pair[0];
if ratio > best_ratio {
best_ratio = ratio;
best_break_threshold = (pair[0] + pair[1]) * 0.5;
}
}
}
if best_break_threshold > 0.0 {
return best_break_threshold.clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
}
let mid = all_gaps.len() / 2;
let median = if all_gaps.len().is_multiple_of(2) {
(all_gaps[mid - 1] + all_gaps[mid]) * 0.5
} else {
all_gaps[mid]
};
(median * COLUMN_GAP_MEDIAN_MULTIPLIER)
.clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX)
}
#[derive(Debug, Clone, Copy)]
struct PageStats {
median_font_size: f64,
#[allow(dead_code)]
median_char_width: f64,
median_line_spacing: f64,
}
impl PageStats {
fn from_spans(spans: &[TextSpan]) -> Self {
if spans.is_empty() {
return Self {
median_font_size: 12.0,
median_char_width: 6.0,
median_line_spacing: 0.0,
};
}
let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let median_font_size = sizes[sizes.len() / 2];
let mut char_widths: Vec<f64> = spans
.iter()
.filter_map(|s| {
let chars = s.text.chars().count();
if chars > 0 && s.width > 0.0 {
Some(s.width / chars as f64)
} else {
None
}
})
.collect();
let median_char_width = if char_widths.is_empty() {
median_font_size * 0.5
} else {
char_widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
char_widths[char_widths.len() / 2]
};
let band_tolerance = (median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
let mut ys: Vec<f64> = spans.iter().map(|s| s.y).collect();
ys.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
let mut band_ys: Vec<f64> = Vec::new();
for y in ys {
if band_ys
.last()
.map(|prev: &f64| (prev - y).abs() > band_tolerance)
.unwrap_or(true)
{
band_ys.push(y);
}
}
let median_line_spacing = if band_ys.len() < 2 {
0.0
} else {
let mut spacings: Vec<f64> = band_ys
.windows(2)
.map(|pair| (pair[0] - pair[1]).abs())
.collect();
spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let q1_index = spacings.len() / 4;
spacings[q1_index]
};
Self {
median_font_size,
median_char_width,
median_line_spacing,
}
}
}
const XY_CUT_MAX_DEPTH: usize = 12;
const XY_CUT_VERTICAL_GAP_FLOOR: f64 = 10.0;
const XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER: f64 = 1.8;
fn the_cut_is_inside_a_grid(bands: &[TextBand], cut_y: f64, gap: f64) -> bool {
let threshold = compute_adaptive_column_gap(bands);
let band_y = |band: &TextBand| {
band.spans
.iter()
.map(|span| span.y)
.fold(f64::NEG_INFINITY, f64::max)
};
let mut above: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) > cut_y).collect();
let mut below: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) <= cut_y).collect();
above.sort_by(|a, b| band_y(a).partial_cmp(&band_y(b)).unwrap_or(Ordering::Equal));
below.sort_by(|a, b| band_y(b).partial_cmp(&band_y(a)).unwrap_or(Ordering::Equal));
if above.len() < GRID_SIDE_ROWS || below.len() < GRID_SIDE_ROWS {
return false;
}
let touching: Vec<&TextBand> = above
.iter()
.take(GRID_SIDE_ROWS)
.chain(below.iter().take(GRID_SIDE_ROWS))
.copied()
.collect();
let mut steps: Vec<f64> = touching
.windows(2)
.map(|pair| (band_y(pair[0]) - band_y(pair[1])).abs())
.filter(|step| *step > 0.0)
.collect();
steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let pitch = match steps.first() {
Some(pitch) => *pitch,
None => return false,
};
if gap > GRID_GROUP_GAP_PITCHES * pitch {
return false;
}
let reference = touching
.iter()
.map(|band| band.gap_midpoints(threshold))
.max_by_key(Vec::len)
.unwrap_or_default();
if reference.len() < GRID_MIN_BOUNDARIES {
return false;
}
let tolerance = (threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
let mut unseparated = 0usize;
for band in &touching {
let midpoints = band.gap_midpoints(threshold);
if midpoints.is_empty() {
unseparated += 1;
if unseparated > GRID_MAX_UNSEPARATED {
return false;
}
continue;
}
if !midpoints
.iter()
.all(|mid| reference.iter().any(|edge| (edge - mid).abs() <= tolerance))
{
return false;
}
}
true
}
const GRID_SIDE_ROWS: usize = 2;
const GRID_GROUP_GAP_PITCHES: f64 = 5.0;
const GRID_MIN_BOUNDARIES: usize = 2;
const GRID_MAX_UNSEPARATED: usize = 1;
#[derive(Debug, Clone, Copy, PartialEq)]
struct TableRegion {
left: f64,
right: f64,
top: f64,
bottom: f64,
ruled: bool,
rows: usize,
}
impl TableRegion {
fn holds(&self, span: &TextSpan) -> bool {
let centre = span.x + (span.right() - span.x) * 0.5;
span.y <= self.top && span.y >= self.bottom && centre >= self.left && centre <= self.right
}
}
#[derive(Debug, Clone)]
struct InkRun {
left: f64,
right: f64,
chars: usize,
bulleted: bool,
text: String,
}
fn is_leader(span: &TextSpan) -> bool {
let text = span.text.trim();
!text.is_empty()
&& text
.chars()
.all(|c| matches!(c, '.' | '\u{00B7}' | '\u{2026}' | ' '))
}
fn opens_with_bullet(text: &str) -> bool {
matches!(text.chars().find(|c| !c.is_whitespace()),
Some(c) if crate::lists::BULLET_CHARS.contains(&c)
&& !matches!(c, '-' | '\u{2013}' | '\u{2014}'))
}
fn ink_runs(band: &TextBand, gap: f64) -> Vec<InkRun> {
let mut runs: Vec<InkRun> = Vec::new();
for span in &band.spans {
let chars = span.text.chars().filter(|c| !c.is_whitespace()).count();
if chars == 0 || is_leader(span) {
continue;
}
let right = if span.width > 0.0 {
span.measured_right()
} else {
span.right()
};
match runs.last_mut() {
Some(run) if span.x - run.right < gap => {
run.right = run.right.max(right);
run.chars += chars;
run.text.push(' ');
run.text.push_str(span.text.trim());
}
_ => runs.push(InkRun {
left: span.x,
right,
chars,
bulleted: opens_with_bullet(&span.text),
text: span.text.trim().to_string(),
}),
}
}
runs
}
fn run_gaps(runs: &[InkRun]) -> Vec<(f64, f64)> {
runs.windows(2)
.map(|pair| (pair[0].right, pair[1].left))
.collect()
}
fn heads_again(first: &[InkRun], here: &[InkRun]) -> bool {
first.len() >= 3
&& here.len() == first.len()
&& first
.iter()
.zip(here)
.skip(1)
.all(|(a, b)| a.text == b.text && a.text.chars().any(char::is_alphabetic))
}
fn strip_of(strips: &[(f64, f64)], gap: (f64, f64), slack: f64) -> Option<usize> {
strips
.iter()
.position(|strip| gap.1.min(strip.1) - gap.0.max(strip.0) > -slack)
}
fn table_regions(
spans: &[TextSpan],
stats: &PageStats,
rules: &[HorizontalRule],
) -> Vec<TableRegion> {
table_regions_seen(spans, stats, rules, None)
}
fn table_regions_seen(
spans: &[TextSpan],
stats: &PageStats,
rules: &[HorizontalRule],
seen: Option<&mut Vec<(TableRegion, &'static str)>>,
) -> Vec<TableRegion> {
if spans.len() < TABLE_REGION_MIN_ROWS * 2 {
return Vec::new();
}
let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
let em = stats.median_font_size.max(1.0);
let gap = TABLE_REGION_GAP_EM * em;
let tolerance = TABLE_REGION_EDGE_EM * em;
let pitch = stats.median_line_spacing.max(em * 1.2);
let runs: Vec<Vec<InkRun>> = bands.iter().map(|band| ink_runs(band, gap)).collect();
let pair_runs: Vec<Vec<InkRun>> = bands
.iter()
.map(|band| ink_runs(band, TABLE_REGION_PAIR_GAP_EM * em))
.collect();
let is_row = |runs: &[InkRun]| {
runs.len() >= 3
&& runs
.iter()
.filter(|run| run.bulleted && run.chars > 1)
.count()
< 2
&& runs
.iter()
.filter(|run| run.chars <= TABLE_REGION_SHORT_CELL)
.count()
>= 2
&& runs
.iter()
.skip(1)
.all(|run| run.chars <= TABLE_REGION_LONG_CELL)
};
let min_rows = if seen.is_some() {
2
} else {
TABLE_REGION_MIN_ROWS
};
let mut regions = Vec::new();
let mut i = 0;
while i < bands.len() {
if !is_row(&runs[i]) {
i += 1;
continue;
}
let mut strips = run_gaps(&runs[i]);
let mut values_left = runs[i]
.iter()
.skip(1)
.map(|run| run.left)
.fold(f64::INFINITY, f64::min);
let mut last_row = i;
let mut rows = 1;
let mut loose = 0;
let mut above = bands[i].y;
let mut j = i + 1;
while j < bands.len() {
let here = &runs[j];
if here.is_empty() {
j += 1;
continue;
}
if above - bands[j].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
break;
}
above = bands[j].y;
let gaps = run_gaps(here);
let found: Vec<Option<usize>> = gaps
.iter()
.map(|gap| strip_of(&strips, *gap, tolerance))
.collect();
if heads_again(&runs[i], here) {
break;
}
let line: Vec<&str> = here.iter().map(|run| run.text.as_str()).collect();
if crate::tables::announces_a_table(&line.join(" ")) {
break;
}
if is_row(here) {
let known = found.iter().flatten().count();
if known < 2.min(strips.len()) || known * 2 < gaps.len() {
break;
}
for (gap, at) in gaps.iter().zip(&found) {
match at {
Some(k) => {
let (lo, hi) = (strips[*k].0.max(gap.0), strips[*k].1.min(gap.1));
if hi - lo >= TABLE_REGION_MIN_STRIP {
strips[*k] = (lo, hi);
}
}
None => strips.push(*gap),
}
}
values_left = here
.iter()
.skip(1)
.map(|run| run.left)
.fold(values_left, f64::min);
last_row = j;
rows += 1;
loose = 0;
} else {
let first_edge = strips.iter().map(|s| s.1).fold(f64::INFINITY, f64::min);
let label = here.len() == 1
&& here[0].left < first_edge
&& (here[0].right < values_left - tolerance
|| here[0].chars <= TABLE_REGION_LONG_CELL);
let sparse = here.len() == 2 && found.iter().all(Option::is_some);
let wrapped = here.len() == 1
&& strips
.iter()
.all(|strip| here[0].left > strip.0 || here[0].right < strip.1);
if !(label || sparse || wrapped) {
break;
}
if sparse {
last_row = j;
}
loose += 1;
if loose > TABLE_REGION_MAX_LOOSE {
break;
}
}
j += 1;
}
let mut first_row = i;
let mut k = i;
let mut heads = 0;
while k > 0 && heads < TABLE_REGION_MAX_HEADS {
k -= 1;
let here = &runs[k];
if here.is_empty() {
continue;
}
if bands[k].y - bands[first_row].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
break;
}
let over_values = here.iter().all(|run| run.left >= values_left - tolerance);
let gaps = run_gaps(here);
if (!over_values && gaps.is_empty())
|| !gaps
.iter()
.all(|gap| strip_of(&strips, *gap, tolerance).is_some())
{
break;
}
first_row = k;
heads += 1;
}
if rows >= min_rows && strips.len() >= 2 {
let chosen = &bands[first_row..=last_row];
let mut left = chosen
.iter()
.map(TextBand::left)
.fold(f64::INFINITY, f64::min);
if let Some(cut) = prose_stub_edge(&runs[first_row..=last_row], em) {
left = cut;
}
let right = chosen
.iter()
.map(TextBand::right)
.fold(f64::NEG_INFINITY, f64::max);
let top = bands[first_row]
.spans
.iter()
.map(|span| span.y)
.fold(f64::NEG_INFINITY, f64::max);
let bottom = bands[last_row]
.spans
.iter()
.map(|span| span.y)
.fold(f64::INFINITY, f64::min);
regions.push(TableRegion {
left,
right,
top,
bottom,
ruled: false,
rows,
});
}
i = last_row + 1;
}
if let Some(seen) = seen {
seen.extend(regions.iter().map(|r| (*r, "aligned")));
regions.retain(|r| r.rows >= TABLE_REGION_MIN_ROWS);
seen.extend(
booktabs_regions(&bands, &runs, rules, pitch, em)
.into_iter()
.map(|r| (r, "booktabs")),
);
seen.extend(
pair_regions(&bands, &pair_runs, pitch, tolerance, &[], 2)
.into_iter()
.map(|r| (r, "pair")),
);
seen.extend(
leader_regions(&bands, &runs, pitch, &[], 2)
.into_iter()
.map(|r| (r, "leader")),
);
}
for ruled in booktabs_regions(&bands, &runs, rules, pitch, em) {
regions.retain(|r| !(r.top <= ruled.top + em && r.bottom >= ruled.bottom - em));
if !regions
.iter()
.any(|r| r.bottom <= ruled.top && r.top >= ruled.bottom)
{
regions.push(ruled);
}
}
let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
regions.extend(pair_regions(
&bands,
&pair_runs,
pitch,
tolerance,
&taken,
TABLE_REGION_MIN_ROWS,
));
let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
regions.extend(leader_regions(
&bands,
&runs,
pitch,
&taken,
TABLE_REGION_MIN_ROWS,
));
regions
}
fn booktabs_regions(
bands: &[TextBand],
runs: &[Vec<InkRun>],
rules: &[HorizontalRule],
pitch: f64,
em: f64,
) -> Vec<TableRegion> {
let mut joined: Vec<HorizontalRule> = rules.to_vec();
joined.sort_by(|a, b| {
b.y.partial_cmp(&a.y)
.unwrap_or(Ordering::Equal)
.then(a.left.partial_cmp(&b.left).unwrap_or(Ordering::Equal))
});
let mut merged: Vec<HorizontalRule> = Vec::with_capacity(joined.len());
for rule in joined {
match merged.last_mut() {
Some(last)
if (last.y - rule.y).abs() <= RULE_JOIN_TOLERANCE
&& rule.left - last.right <= RULE_JOIN_TOLERANCE =>
{
last.right = last.right.max(rule.right);
}
_ => merged.push(rule),
}
}
let mut rules: Vec<&HorizontalRule> = merged
.iter()
.filter(|rule| rule.right - rule.left >= TABLE_REGION_RULE_MIN_EM * em)
.collect();
rules.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
let same_extent = |a: &HorizontalRule, b: &HorizontalRule| {
let overlap = a.right.min(b.right) - a.left.max(b.left);
let narrower = (a.right - a.left).min(b.right - b.left);
overlap >= 0.9 * narrower && narrower >= 0.8 * (a.right - a.left).max(b.right - b.left)
};
let mut regions: Vec<TableRegion> = Vec::new();
let mut t = 0;
while t < rules.len() {
let top = rules[t];
let mut found: Option<(usize, TableRegion)> = None;
for (f, foot) in rules.iter().enumerate().skip(t + 1) {
if !same_extent(top, foot) {
continue;
}
let inside: Vec<usize> = (0..bands.len())
.filter(|&k| bands[k].y < top.y && bands[k].y > foot.y && !runs[k].is_empty())
.collect();
let within = |k: usize| {
runs[k]
.iter()
.filter(|r| r.chars > TABLE_REGION_MARGIN_MARK)
.all(|r| r.left >= top.left - em && r.right <= top.right + em)
};
let contained = inside.iter().all(|&k| within(k));
let celled = inside.iter().filter(|&&k| runs[k].len() >= 2).count();
let tight = inside
.windows(2)
.all(|w| bands[w[0]].y - bands[w[1]].y <= TABLE_REGION_MAX_STEP_PITCHES * pitch);
if !contained || !tight {
break;
}
if inside.len() >= TABLE_REGION_MIN_ROWS && celled >= 2 {
let top_y = inside
.first()
.map(|&k| {
bands[k]
.spans
.iter()
.map(|s| s.y)
.fold(f64::NEG_INFINITY, f64::max)
})
.unwrap_or(top.y);
let mut last = *inside.last().unwrap_or(&0);
for k in last + 1..bands.len() {
if runs[k].is_empty() {
continue;
}
let in_extent = within(k);
if runs[k].len() < 2
|| !in_extent
|| bands[last].y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
|| foot.y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
{
break;
}
last = k;
}
let bottom_y = bands[last]
.spans
.iter()
.map(|s| s.y)
.fold(f64::INFINITY, f64::min);
found = Some((
f,
TableRegion {
left: top.left.min(foot.left),
right: top.right.max(foot.right),
top: top_y,
bottom: bottom_y,
ruled: true,
rows: inside.len(),
},
));
}
}
match found {
Some((f, region)) => {
regions.push(region);
t = f + 1;
}
None => t += 1,
}
}
regions
}
fn prose_stub_edge(rows: &[Vec<InkRun>], em: f64) -> Option<f64> {
let firsts: Vec<&InkRun> = rows
.iter()
.filter(|r| r.len() >= 2)
.map(|r| &r[0])
.collect();
if firsts.len() < TABLE_REGION_MIN_ROWS {
return None;
}
let long: Vec<&&InkRun> = firsts
.iter()
.filter(|r| r.chars > TABLE_REGION_LONG_CELL)
.collect();
if long.len() * 2 < firsts.len() {
return None;
}
let mut rights: Vec<f64> = long.iter().map(|r| r.right).collect();
rights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let median = rights[rights.len() / 2];
let flush = rights
.iter()
.filter(|r| (*r - median).abs() <= TABLE_REGION_JUSTIFIED_EM * em)
.count();
if flush * 3 < rights.len() * 2 {
return None;
}
rows.iter()
.filter(|r| r.len() >= 2)
.map(|r| r[1].left)
.min_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.map(|x| x - 0.5 * em)
}
const TABLE_REGION_JUSTIFIED_EM: f64 = 0.5;
fn pair_regions(
bands: &[TextBand],
runs: &[Vec<InkRun>],
pitch: f64,
tolerance: f64,
taken: &[(f64, f64)],
min_rows: usize,
) -> Vec<TableRegion> {
let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
let is_pair = |runs: &[InkRun]| {
runs.len() == 2
&& runs[0].chars <= TABLE_REGION_SHORT_CELL
&& !runs[0].bulleted
&& !runs[1].bulleted
&& !is_list_marker(&runs[0].text)
};
let mut regions = Vec::new();
let mut i = 0;
while i < bands.len() {
if !is_pair(&runs[i]) || !free(bands[i].y) {
i += 1;
continue;
}
let (key, value) = (runs[i][0].left, runs[i][1].left);
let (mut last_row, mut rows, mut loose) = (i, 1, 0);
let mut above = bands[i].y;
let mut j = i + 1;
while j < bands.len() {
let here = &runs[j];
if here.is_empty() {
j += 1;
continue;
}
if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
break;
}
above = bands[j].y;
if is_pair(here)
&& (here[0].left - key).abs() <= tolerance
&& (here[1].left - value).abs() <= tolerance
{
last_row = j;
rows += 1;
loose = 0;
} else if here.len() == 1 && (here[0].left - value).abs() <= tolerance {
loose += 1;
if loose > TABLE_REGION_MAX_LOOSE {
break;
}
} else {
break;
}
j += 1;
}
if rows >= min_rows {
let chosen = &bands[i..=last_row];
regions.push(TableRegion {
left: chosen
.iter()
.map(TextBand::left)
.fold(f64::INFINITY, f64::min),
right: chosen
.iter()
.map(TextBand::right)
.fold(f64::NEG_INFINITY, f64::max),
top: bands[i]
.spans
.iter()
.map(|span| span.y)
.fold(f64::NEG_INFINITY, f64::max),
bottom: bands[last_row]
.spans
.iter()
.map(|span| span.y)
.fold(f64::INFINITY, f64::min),
ruled: true,
rows,
});
}
i = last_row + 1;
}
regions
}
fn split_at_leader(span: TextSpan) -> Vec<TextSpan> {
let chars: Vec<char> = span.text.chars().collect();
let (mut start, mut end, mut dots) = (0usize, 0usize, 0usize);
let mut k = 0;
while k < chars.len() {
if chars[k] != '.' {
k += 1;
continue;
}
let (from, mut count, mut to) = (k, 0usize, k);
while to < chars.len()
&& (chars[to] == '.'
|| (chars[to] == ' ' && to + 1 < chars.len() && chars[to + 1] == '.'))
{
if chars[to] == '.' {
count += 1;
}
to += 1;
}
if count >= TABLE_REGION_LEADER_DOTS {
(start, end, dots) = (from, to, count);
}
k = to.max(k + 1);
}
let label: String = chars[..start].iter().collect();
let tail: String = chars[end..].iter().collect::<String>().trim().to_string();
if dots < TABLE_REGION_LEADER_DOTS
|| label.trim().is_empty()
|| tail.chars().count() > TABLE_REGION_SHORT_CELL
{
return vec![span];
}
let n = chars.len().max(1) as f64;
let exact = span.char_bounds.len() == chars.len();
let at = |i: usize, right: bool| -> f64 {
if exact {
span.char_bounds[i][if right { 2 } else { 0 }]
} else {
span.x + span.width * ((i + usize::from(right)) as f64 / n)
}
};
let label_end = label.trim_end().chars().count().max(1) - 1;
let label_right = at(label_end, true);
let mut out = Vec::with_capacity(2);
let mut head = span.clone();
head.text = chars[..end].iter().collect();
head.width = (label_right - span.x).max(0.0);
head.char_bounds.clear();
head.tight_char_bounds.clear();
head.glyph_advances.clear();
head.glyph_bounds_sources.clear();
out.push(head);
if !tail.is_empty() {
let first = chars[end..]
.iter()
.position(|c| !c.is_whitespace())
.map_or(end, |k| end + k);
let mut figure = span.clone();
figure.text = tail;
figure.x = at(first, false);
figure.width = (span.x + span.width - figure.x).max(0.0);
figure.char_bounds.clear();
figure.tight_char_bounds.clear();
figure.glyph_advances.clear();
figure.glyph_bounds_sources.clear();
out.push(figure);
}
out
}
fn fold_leaders(spans: &mut Vec<TextSpan>, cell_path_words: bool) {
let mut kept: Vec<TextSpan> = Vec::with_capacity(spans.len());
for span in spans.drain(..) {
match kept.last_mut() {
Some(label) if is_leader(&span) => {
const ABUT_EPS: f64 = 1e-6;
let dots_left = span
.char_bounds
.iter()
.map(|b| b[0])
.fold(f64::INFINITY, f64::min);
let dots_right = span
.char_bounds
.iter()
.map(|b| b[2])
.fold(f64::NEG_INFINITY, f64::max);
let dots_top = span
.char_bounds
.iter()
.map(|b| b[3])
.fold(f64::NEG_INFINITY, f64::max);
let dots_bottom = span
.char_bounds
.iter()
.map(|b| b[1])
.fold(f64::INFINITY, f64::min);
let abuts = dots_left <= label.x + label.width + ABUT_EPS
&& dots_top > label.y
&& dots_bottom < label.y + label.height;
if abuts {
label.text.push_str(span.text.trim());
label.char_bounds.extend(span.char_bounds.iter().copied());
label
.tight_char_bounds
.extend(span.tight_char_bounds.iter().copied());
label
.glyph_advances
.extend(span.glyph_advances.iter().copied());
label
.glyph_bounds_sources
.extend(span.glyph_bounds_sources.iter().copied());
label.width = (dots_right - label.x).max(label.width).max(0.0);
continue;
}
if !cell_path_words {
label.text.push(' ');
label.text.push_str(span.text.trim());
continue;
}
label.text.push(' ');
label.text.push_str(span.text.trim());
label.char_bounds.extend(span.char_bounds.iter().copied());
label
.tight_char_bounds
.extend(span.tight_char_bounds.iter().copied());
label
.glyph_advances
.extend(span.glyph_advances.iter().copied());
label
.glyph_bounds_sources
.extend(span.glyph_bounds_sources.iter().copied());
}
_ => kept.push(span),
}
}
*spans = kept;
}
fn leader_regions(
bands: &[TextBand],
runs: &[Vec<InkRun>],
pitch: f64,
taken: &[(f64, f64)],
min_rows: usize,
) -> Vec<TableRegion> {
let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
let is_leader_row = |k: usize| {
let dots: usize = bands[k]
.spans
.iter()
.map(|span| {
if is_leader(span) {
span.text.chars().filter(|c| !c.is_whitespace()).count()
} else {
span.text
.trim_end()
.chars()
.rev()
.take_while(|c| *c == '.' || *c == ' ')
.filter(|c| *c == '.')
.count()
}
})
.sum();
dots >= TABLE_REGION_LEADER_DOTS
&& runs[k].len() >= 2
&& runs[k]
.last()
.is_some_and(|run| run.chars <= TABLE_REGION_SHORT_CELL)
};
let mut regions = Vec::new();
let mut i = 0;
while i < bands.len() {
if !is_leader_row(i) || !free(bands[i].y) {
i += 1;
continue;
}
let (mut last_row, mut rows, mut loose) = (i, 1, 0);
let mut above = bands[i].y;
let mut j = i + 1;
while j < bands.len() {
if runs[j].is_empty() {
j += 1;
continue;
}
if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
break;
}
above = bands[j].y;
if is_leader_row(j) {
last_row = j;
rows += 1;
loose = 0;
} else if runs[j].len() == 1 {
loose += 1;
if loose > 2 {
break;
}
} else {
break;
}
j += 1;
}
if rows >= min_rows {
let chosen = &bands[i..=last_row];
regions.push(TableRegion {
left: chosen
.iter()
.map(TextBand::left)
.fold(f64::INFINITY, f64::min),
right: chosen
.iter()
.map(TextBand::right)
.fold(f64::NEG_INFINITY, f64::max),
top: bands[i]
.spans
.iter()
.map(|span| span.y)
.fold(f64::NEG_INFINITY, f64::max),
bottom: bands[last_row]
.spans
.iter()
.map(|span| span.y)
.fold(f64::INFINITY, f64::min),
ruled: true,
rows,
});
}
i = last_row + 1;
}
regions
}
fn is_list_marker(text: &str) -> bool {
let text = text.trim();
text.chars().count() <= 5
&& (text.ends_with('.') || text.ends_with(')') || text.starts_with('('))
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct TableProposal {
pub detector: &'static str,
pub accepted: bool,
pub hull: [f64; 4],
}
#[doc(hidden)]
pub fn table_proposals(spans: &[TextSpan], rules: &[HorizontalRule]) -> Vec<TableProposal> {
if spans.is_empty() || PageTurn::of(spans) != PageTurn::None {
return Vec::new();
}
let stats = PageStats::from_spans(spans);
let spans: Vec<TextSpan> = spans.iter().cloned().flat_map(split_at_leader).collect();
let mut seen = Vec::new();
table_regions_seen(&spans, &stats, rules, Some(&mut seen));
if let Some((columns, _)) = try_vertical_cut(&spans, &stats, rules) {
for column in &columns {
let mut more = Vec::new();
table_regions_seen(column, &stats, rules, Some(&mut more));
for found in more {
if !seen.contains(&found) {
seen.push(found);
}
}
}
}
seen.into_iter()
.map(|(r, detector)| {
let top = r.top
+ spans
.iter()
.filter(|s| r.holds(s))
.map(|s| s.height)
.fold(0.0, f64::max);
TableProposal {
detector,
accepted: r.rows >= TABLE_REGION_MIN_ROWS,
hull: [r.left, r.bottom, r.right, top],
}
})
.collect()
}
fn xy_cut_round_tables(
spans: Vec<TextSpan>,
stats: &PageStats,
rules: &[HorizontalRule],
merge_turned_runs: bool,
cell_path_words: bool,
) -> Vec<(Vec<TextBlock>, bool)> {
let tables = table_regions(&spans, stats, rules);
let spans = if merge_turned_runs {
merge_rotated_runs(spans)
} else {
spans
};
if tables.is_empty() {
return xy_cut_recursive(spans, 0, stats, rules)
.into_iter()
.map(|region| (region, false))
.collect();
}
let ruled_across = |columns: &[Vec<TextSpan>]| {
tables.iter().filter(|table| table.ruled).any(|table| {
columns
.iter()
.filter(|column| column.iter().any(|span| table.holds(span)))
.count()
> 1
})
};
if let Some((columns, _)) =
try_vertical_cut(&spans, stats, rules).filter(|(columns, _)| !ruled_across(columns))
{
return columns
.into_iter()
.flat_map(|column| {
xy_cut_round_tables(column, stats, rules, merge_turned_runs, cell_path_words)
})
.collect();
}
let mut held: Vec<Vec<TextSpan>> = vec![Vec::new(); tables.len()];
let mut rest = Vec::new();
for span in spans {
match tables.iter().position(|table| table.holds(&span)) {
Some(at) => held[at].push(span),
None => rest.push(span),
}
}
let mut regions: Vec<(Vec<TextBlock>, bool)> = xy_cut_recursive(rest, 0, stats, rules)
.into_iter()
.map(|region| (region, false))
.collect();
for (table, spans) in tables.iter().zip(held) {
if spans.is_empty() {
continue;
}
let at = regions
.iter()
.position(|(region, _)| {
let spans = region.iter().flat_map(|line| line.spans.iter());
let (mut top, mut left, mut right) =
(f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for span in spans {
top = top.max(span.y);
left = left.min(span.x);
right = right.max(span.right());
}
top < table.top && left < table.right && right > table.left
})
.unwrap_or(regions.len());
let mut bands = group_spans_into_bands_with_stats(spans, stats);
for band in &mut bands {
fold_leaders(&mut band.spans, cell_path_words);
}
let mut rows = Vec::with_capacity(bands.len());
let mut k = 0;
while k < bands.len() {
k = push_row(&bands, k, &mut rows);
}
regions.insert(at, (rows, true));
}
regions
}
const TABLE_REGION_GAP_EM: f64 = 2.0;
const TABLE_REGION_MIN_STRIP: f64 = 2.0;
const TABLE_REGION_EDGE_EM: f64 = 1.5;
const TABLE_REGION_SHORT_CELL: usize = 16;
const TABLE_REGION_LONG_CELL: usize = 40;
const TABLE_REGION_MIN_ROWS: usize = 3;
const TABLE_REGION_MAX_LOOSE: usize = 5;
const TABLE_REGION_MAX_HEADS: usize = 2;
const TABLE_REGION_MARGIN_MARK: usize = 2;
const RULE_JOIN_TOLERANCE: f64 = 2.0;
const TABLE_REGION_RULE_MIN_EM: f64 = 10.0;
const TABLE_REGION_PAIR_GAP_EM: f64 = 1.0;
const TABLE_REGION_LEADER_DOTS: usize = 4;
const TABLE_REGION_PAIR_STEP_PITCHES: f64 = 2.5;
const TABLE_REGION_MAX_STEP_PITCHES: f64 = 4.0;
fn leading_of_a_region(spans: &[TextSpan], stats: &PageStats) -> f64 {
let tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
let mut ordered: Vec<&TextSpan> = spans.iter().collect();
ordered.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
let mut bands: Vec<(f64, f64)> = Vec::new();
for span in ordered {
match bands.last_mut() {
Some(band) if (band.0 - span.y).abs() <= tolerance => {
band.1 = band.1.min(span.x);
}
_ => bands.push((span.y, span.x)),
}
}
let aligned = LEADING_ALIGNED_EM * stats.median_font_size;
let mut steps: Vec<f64> = bands
.windows(2)
.filter(|pair| (pair[0].1 - pair[1].1).abs() <= aligned)
.map(|pair| (pair[0].0 - pair[1].0).abs())
.filter(|step| {
*step >= LEADING_MIN_EM * stats.median_font_size
&& *step <= LEADING_MAX_EM * stats.median_font_size
})
.map(|step| (step * 10.0).round() / 10.0)
.collect();
steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let mut best = (0usize, 0.0f64);
let mut i = 0;
while i < steps.len() {
let mut j = i;
while j < steps.len() && steps[j] == steps[i] {
j += 1;
}
if j - i > best.0 {
best = (j - i, steps[i]);
}
i = j;
}
if best.0 < LEADING_MIN_OCCURRENCES {
return 0.0;
}
best.1
}
const LEADING_MIN_OCCURRENCES: usize = 2;
const LEADING_MIN_EM: f64 = 0.8;
const LEADING_MAX_EM: f64 = 3.0;
const LEADING_ALIGNED_EM: f64 = 0.5;
const HORIZONTAL_CUT_LEADING_FLOOR: f64 = 1.05;
const HUNG_MARKER_GAP_EM: f64 = 0.6;
const RUNNING_LETTERS: usize = 30;
const XY_CUT_MIN_SPANS_PER_COLUMN: usize = 2;
const XY_CUT_MIN_CHARS_PER_BAND: f64 = 8.0;
fn group_spans_into_blocks(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<TextBlock> {
if spans.is_empty() {
return Vec::new();
}
let turn = PageTurn::of(&spans);
let spans = turn.into_frame(spans);
let stats = PageStats::from_spans(&spans);
let rules = turn.rules_in_frame(rules);
let spans = if turn == PageTurn::None {
merge_rotated_runs(spans)
} else {
spans
};
let blocks = xy_cut_recursive(spans, 0, &stats, &rules)
.into_iter()
.flatten()
.collect();
let blocks = reassemble_cross_direction_runs(blocks);
turn.back(blocks)
}
fn raw_direction(span: &TextSpan) -> u8 {
let Some(t) = span.transform else {
return 0;
};
let (a, b) = (t[0], t[1]);
if a.abs() >= b.abs() {
if a >= 0.0 {
0
} else {
2
}
} else if b >= 0.0 {
1
} else {
3
}
}
fn raw_is_base14(font_name: Option<&str>) -> bool {
font_name.is_some_and(|name| {
name.starts_with("Helvetica")
|| name.starts_with("Times")
|| name.starts_with("Courier")
|| name.starts_with("Symbol")
|| name.starts_with("ZapfDingbats")
})
}
fn raw_metrics(span: &TextSpan) -> (f64, f64) {
match span.font_name.as_deref() {
Some("Helvetica") => (f64::from(1.075f32), f64::from(-0.299f32)),
Some("Helvetica-Bold") => (1.07, -0.307),
_ => span
.font_metrics
.map(|m| (m.ascent / 1000.0, m.descent / 1000.0))
.unwrap_or_else(|| {
if raw_is_base14(span.font_name.as_deref()) {
(1.075, -0.299)
} else {
(0.718, -0.207)
}
}),
}
}
fn raw_tall_box(span: &TextSpan) -> [f64; 4] {
let (ascender, descender) = raw_metrics(span);
[
span.x,
span.y + descender * span.font_size,
span.x + span.width,
span.y + ascender * span.font_size,
]
}
const RAW_LINE_GAP_EM: f64 = 0.6;
const RAW_LINE_BACK_EM: f64 = 0.6;
fn raw_line_of(spans: &[TextSpan]) -> Vec<usize> {
let mut lines = Vec::with_capacity(spans.len());
let mut line = 0;
let mut end = f64::NEG_INFINITY;
let mut low = f64::NEG_INFINITY;
let mut high = f64::NEG_INFINITY;
let mut direction = 0;
let mut size = 0.0f64;
for span in spans {
let dir = raw_direction(span);
let tall = raw_tall_box(span);
let overlap = tall[1] <= high && tall[3] >= low;
let gap = span.x - end;
let em = size.max(span.font_size);
if !lines.is_empty()
&& (dir != direction
|| !overlap
|| span.x < end - RAW_LINE_BACK_EM * em
|| gap > RAW_LINE_GAP_EM * em)
{
line += 1;
}
direction = dir;
end = span.x + span.width;
low = tall[1];
high = tall[3];
size = span.font_size;
lines.push(line);
}
lines
}
pub(crate) fn raw_text_and_box(spans: &[TextSpan]) -> (String, [f64; 4]) {
let mut text = String::new();
let lines = raw_line_of(spans);
for (i, span) in spans.iter().enumerate() {
if i > 0 && lines[i] != lines[i - 1] {
text.push('\n');
}
text.push_str(&span.text);
}
let mut bbox = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for span in spans {
bbox[0] = bbox[0].min(span.x);
bbox[1] = bbox[1].min(span.y);
bbox[2] = bbox[2].max(span.x + span.width);
bbox[3] = bbox[3].max(span.y + span.height);
}
(text, bbox)
}
fn push_raw_block(blocks: &mut Vec<Block>, spans: Vec<TextSpan>, page: usize, index: usize) {
if spans.is_empty() {
return;
}
let (text, bbox) = raw_text_and_box(&spans);
let mut block = Block::new(BlockKind::Paragraph, page, bbox, text, index);
block.spans = spans;
blocks.push(block);
}
const RAW_FWD_OVERLAP_FRAC: f64 = 0.5;
const RAW_BLOCK_GAP_EM: f64 = 0.15;
const RAW_BLOCK_WIDTH_PT: f64 = 150.0;
const RAW_SIZE_UP_RATIO: f64 = 1.2;
struct RawLine {
spans: Vec<TextSpan>,
bdc_first: Option<u32>,
bdc_last: Option<u32>,
x0: f64,
x1: f64,
low: f64,
high: f64,
direction: u8,
size: f64,
}
impl RawLine {
fn width(&self) -> f64 {
self.x1 - self.x0
}
fn height(&self) -> f64 {
self.high - self.low
}
}
fn raw_block_split(before: &RawLine, after: &RawLine) -> bool {
if after.direction != before.direction {
return true;
}
if let (Some(a), Some(b)) = (before.bdc_last, after.bdc_first) {
if a == b {
return false;
}
}
let overlap = before.high.min(after.high) - before.low.max(after.low);
let motion = after.x0 - before.x1;
if overlap > 0.0 {
if motion <= 0.5 {
return false;
}
return overlap / before.height() < RAW_FWD_OVERLAP_FRAC;
}
if motion > 0.5 {
return true;
}
let ratio = after.size / before.size;
if ratio >= RAW_SIZE_UP_RATIO {
return false;
}
let gap_em = -overlap / before.size.max(after.size);
!(ratio >= 1.0 && gap_em <= RAW_BLOCK_GAP_EM && before.width() <= RAW_BLOCK_WIDTH_PT)
}
fn raw_blocks(
spans: Vec<TextSpan>,
bdc: &[Option<u32>],
page: usize,
first_index: usize,
) -> Vec<Block> {
if spans.is_empty() {
return Vec::new();
}
debug_assert_eq!(bdc.len(), spans.len());
let turn = PageTurn::of(&spans);
let spans = turn.into_frame(spans);
let line_of = raw_line_of(&spans);
let mut lines: Vec<RawLine> = Vec::new();
for (i, span) in spans.into_iter().enumerate() {
let tall = raw_tall_box(&span);
let dir = raw_direction(&span);
let fresh = lines.is_empty() || line_of[i] != line_of[i - 1];
if fresh {
lines.push(RawLine {
spans: Vec::new(),
bdc_first: bdc[i],
bdc_last: bdc[i],
x0: span.x,
x1: span.x + span.width,
low: tall[1],
high: tall[3],
direction: dir,
size: span.font_size,
});
}
if let Some(line) = lines.last_mut() {
line.bdc_last = bdc[i];
line.x0 = line.x0.min(span.x);
line.x1 = line.x1.max(span.x + span.width);
line.low = line.low.min(tall[1]);
line.high = line.high.max(tall[3]);
line.spans.push(span);
}
}
let mut blocks = Vec::new();
let mut current: Vec<TextSpan> = Vec::new();
for i in 0..lines.len() {
let (head, tail) = lines.split_at_mut(i);
let line = &mut tail[0];
let cut = i > 0 && raw_block_split(&head[i - 1], line);
if cut {
let index = first_index + blocks.len();
push_raw_block(&mut blocks, std::mem::take(&mut current), page, index);
}
current.append(&mut line.spans);
}
if !current.is_empty() {
let index = first_index + blocks.len();
push_raw_block(&mut blocks, current, page, index);
}
turn.back_blocks(blocks)
}
fn paragraph_blocks(
spans: Vec<TextSpan>,
rules: &[HorizontalRule],
page: usize,
first_index: usize,
table_strategy: TableStrategy,
) -> Vec<Block> {
paragraph_blocks_with_cell_path_words(spans, rules, page, first_index, table_strategy, false)
}
fn paragraph_blocks_with_cell_path_words(
spans: Vec<TextSpan>,
rules: &[HorizontalRule],
page: usize,
first_index: usize,
table_strategy: TableStrategy,
cell_path_words: bool,
) -> Vec<Block> {
if spans.is_empty() {
return Vec::new();
}
let turn = PageTurn::of(&spans);
let spans = turn.into_frame(spans);
let stats = PageStats::from_spans(&spans);
let rules = turn.rules_in_frame(rules);
let spans: Vec<TextSpan> = spans.into_iter().flat_map(split_at_leader).collect();
let (regions, whole): (Vec<Vec<TextBlock>>, Vec<bool>) = xy_cut_round_tables(
spans,
&stats,
&rules,
turn == PageTurn::None,
cell_path_words,
)
.into_iter()
.unzip();
let direction = page_direction(®ions.iter().flatten().cloned().collect::<Vec<_>>());
let regions: Vec<Vec<TextBlock>> = regions
.into_iter()
.map(|region| match direction {
Some(direction) => reassemble_with_direction(direction, region),
None => region,
})
.collect();
let blocks = crate::blocks::paragraphs_with_cell_path_words(
page,
first_index,
®ions,
&whole,
&rules,
stats.median_line_spacing,
table_strategy,
cell_path_words,
);
let blocks = join_marker_continuations(blocks, first_index);
turn.back_blocks(blocks)
}
pub(crate) fn join_double_spaced_runs(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
let sizes: Vec<f64> = blocks.iter().map(block_size).collect();
let single = |i: usize| {
matches!(blocks[i].kind, BlockKind::Paragraph) && block_line_count(&blocks[i]) == 1
};
let gap_above = |i: usize| {
let em = sizes[i - 1];
let em = if em > 0.0 { em } else { 10.0 };
(blocks[i - 1].bbox[1] - blocks[i].bbox[3]) / em
};
let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
let mut i = 0;
while i < blocks.len() {
if !single(i) {
out.push(blocks[i].clone());
i += 1;
continue;
}
let mut run = vec![i];
let mut j = i + 1;
while j < blocks.len() {
let a = run[run.len() - 1];
if blocks[j].page != blocks[a].page || !single(j) {
break;
}
let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
if (sizes[j] - sizes[a]).abs() > DOUBLE_SPACED_SIZE_TOLERANCE_PT {
break;
}
if (blocks[j].bbox[0] - blocks[run[0]].bbox[0]).abs() > DOUBLE_SPACED_LEFT_EM * em {
break;
}
let gap = gap_above(j);
if !(DOUBLE_SPACED_GAP_MIN_EM..=DOUBLE_SPACED_GAP_MAX_EM).contains(&gap) {
break;
}
run.push(j);
j += 1;
}
if run.len() < 2 {
out.push(blocks[i].clone());
i += 1;
continue;
}
let gaps: Vec<f64> = run.iter().skip(1).map(|&k| gap_above(k)).collect();
let median = median_gap(gaps);
let right = run
.iter()
.map(|&k| blocks[k].bbox[2])
.fold(f64::NEG_INFINITY, f64::max);
let mut current = blocks[run[0]].clone();
for pair in run.windows(2) {
let (a, c) = (pair[0], pair[1]);
let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
let upper = blocks[a].text.trim();
let lower = blocks[c].text.trim();
let fills_the_measure = blocks[a].bbox[2] >= right - DOUBLE_SPACED_SHORT_EM * em;
let join = fills_the_measure
&& gap_above(c) <= DOUBLE_SPACED_GAP_RATIO * median
&& !starts_with_marker(lower)
&& !double_spaced_guard(upper, lower);
if join {
if is_hyphen_wrap_candidate(¤t.text, lower) {
current.text.pop();
current.text.push_str(lower);
} else {
current.text.push(' ');
current.text.push_str(lower);
}
current.spans.extend(blocks[c].spans.iter().cloned());
current.bbox = [
current.bbox[0].min(blocks[c].bbox[0]),
current.bbox[1].min(blocks[c].bbox[1]),
current.bbox[2].max(blocks[c].bbox[2]),
current.bbox[3].max(blocks[c].bbox[3]),
];
} else {
out.push(current);
current = blocks[c].clone();
}
}
out.push(current);
i = j;
}
for (n, block) in out.iter_mut().enumerate() {
block.index = first_index + n;
}
out
}
fn join_marker_continuations(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
for block in blocks {
let join = match out.last() {
Some(prev) if prev.page == block.page => {
let prev_lines = lines_of_block(prev);
let next_lines = lines_of_block(&block);
match (prev_lines.first(), prev_lines.last(), next_lines.first()) {
(Some(first), Some(last), Some(c)) => {
marker_line_hangs_onto(prev, first, last, &block, c)
}
_ => false,
}
}
_ => false,
};
if join {
let prev = out.last_mut().expect("a previous block was checked");
let text = crate::blocks::join_lines(&[prev.text.clone(), block.text.clone()]);
prev.spans.extend(block.spans);
prev.text = text;
crate::blocks::recompute_bbox(prev);
} else {
out.push(block);
}
}
for (n, block) in out.iter_mut().enumerate() {
block.index = first_index + n;
}
out
}
const DOUBLE_SPACED_GAP_MIN_EM: f64 = 0.6;
const DOUBLE_SPACED_GAP_MAX_EM: f64 = 3.0;
const DOUBLE_SPACED_SIZE_TOLERANCE_PT: f64 = 0.5;
const DOUBLE_SPACED_LEFT_EM: f64 = 0.5;
const DOUBLE_SPACED_SHORT_EM: f64 = 1.5;
const DOUBLE_SPACED_GAP_RATIO: f64 = 1.5;
fn block_size(block: &Block) -> f64 {
let mut sizes: Vec<f64> = block
.spans
.iter()
.flat_map(|span| {
let votes = span.char_bounds.len().max(1);
std::iter::repeat_n(span.font_size, votes)
})
.collect();
if sizes.is_empty() {
return 0.0;
}
sizes.sort_by(|a, b| a.total_cmp(b));
sizes[sizes.len() / 2]
}
fn block_line_count(block: &Block) -> usize {
let mut baselines: Vec<f64> = block
.spans
.iter()
.map(|span| (span.y * 2.0).round() / 2.0)
.collect();
baselines.sort_by(|a, b| a.total_cmp(b));
baselines.dedup();
baselines.len()
}
fn median_gap(mut gaps: Vec<f64>) -> f64 {
gaps.sort_by(|a, b| a.total_cmp(b));
match gaps.len() {
0 => 0.0,
len if len % 2 == 1 => gaps[len / 2],
len => (gaps[len / 2 - 1] + gaps[len / 2]) / 2.0,
}
}
fn starts_with_marker(text: &str) -> bool {
starts_with_bracket_number(text)
|| starts_with_numbered_marker(text)
|| starts_with_letter_marker(text)
|| starts_with_roman_marker(text)
|| starts_with_bullet(text)
|| starts_with_section_marker(text)
}
fn starts_with_section_marker(text: &str) -> bool {
let trimmed = text.trim_start();
trimmed.starts_with('§') || trimmed.starts_with("Art.")
}
fn starts_with_bracket_number(text: &str) -> bool {
let Some(rest) = text.trim_start().strip_prefix('[') else {
return false;
};
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
(3..=5).contains(&digits) && rest.chars().nth(digits) == Some(']')
}
fn starts_with_numbered_marker(text: &str) -> bool {
let trimmed = text.trim_start();
let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
(1..=3).contains(&digits) && matches!(rest.chars().nth(digits), Some('.') | Some(')'))
}
fn starts_with_letter_marker(text: &str) -> bool {
let trimmed = text.trim_start();
let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
let mut chars = rest.chars();
matches!(
(chars.next(), chars.next(), chars.next()),
(Some(letter), Some(stop), Some(space))
if letter.is_ascii_lowercase()
&& matches!(stop, '.' | ')')
&& space.is_whitespace()
)
}
fn starts_with_roman_marker(text: &str) -> bool {
let trimmed = text.trim_start();
let numerals = trimmed
.chars()
.take_while(|c| matches!(c, 'I' | 'V' | 'X'))
.count();
if !(1..=4).contains(&numerals) {
return false;
}
let mut chars = trimmed.chars().skip(numerals);
matches!((chars.next(), chars.next()), (Some('.'), Some(space)) if space.is_whitespace())
}
fn starts_with_bullet(text: &str) -> bool {
let mut chars = text.trim_start().chars();
matches!(
(chars.next(), chars.next()),
(Some(bullet), Some(space))
if matches!(bullet, '•' | '●' | '▪' | '■' | '–' | '—' | '-' | '*')
&& space.is_whitespace()
)
}
fn double_spaced_guard(upper: &str, lower: &str) -> bool {
starts_with_number_then_capital(lower)
|| ends_in_digit(upper)
|| holds_key_value(upper)
|| lower.ends_with(':')
|| reads_as_heading(lower)
|| ends_sentence_then_capital(upper, lower)
}
fn starts_with_number_then_capital(text: &str) -> bool {
let digits = text.chars().take_while(|c| c.is_ascii_digit()).count();
if !(1..=4).contains(&digits) {
return false;
}
let mut chars = text.chars().skip(digits);
let mut spaces = 0;
for c in chars.by_ref() {
if c.is_whitespace() {
spaces += 1;
} else {
return spaces > 0 && c.is_ascii_uppercase();
}
}
false
}
fn ends_in_digit(text: &str) -> bool {
text.trim_end()
.trim_end_matches(|c: char| !(c.is_alphanumeric() || c == '_'))
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_digit())
}
fn holds_key_value(text: &str) -> bool {
text.match_indices(':')
.any(|(at, _)| !text[at + 1..].trim_start().is_empty())
}
fn reads_as_heading(text: &str) -> bool {
text.split_whitespace().count() <= 3 && !text.ends_with(['.', '!', '?', ';', ','])
}
fn ends_sentence_then_capital(upper: &str, lower: &str) -> bool {
upper.ends_with(['.', '!', '?']) && lower.chars().next().is_some_and(|c| c.is_uppercase())
}
const MARKER_BULLETS: [char; 13] = [
'\u{2022}', '\u{25CF}', '\u{25AA}', '\u{25E6}', '\u{2023}', '\u{2043}', '\u{2219}', '\u{00B7}', '\u{2013}', '\u{2014}', '-', '*', '>', ];
fn is_roman_digit(c: char) -> bool {
matches!(
c.to_ascii_lowercase(),
'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm'
)
}
fn marker_prefix(text: &str) -> Option<(usize, usize)> {
let trimmed = text.trim_start();
if trimmed.is_empty() {
return None;
}
let first = trimmed.chars().next()?;
if MARKER_BULLETS.contains(&first) {
let rest = &trimmed[first.len_utf8()..];
if rest.trim().is_empty() {
return None;
}
return Some((first.len_utf8(), 1));
}
if first == '(' || first == '[' {
let closing = if first == '(' { ')' } else { ']' };
let body = &trimmed[first.len_utf8()..];
let digits = body.chars().take_while(|c| c.is_ascii_digit()).count();
if (1..=3).contains(&digits) {
let mut rest = &body[digits..];
if rest.starts_with(closing) {
rest = &rest[closing.len_utf8()..];
if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
return Some((first.len_utf8() + digits + closing.len_utf8(), 2 + digits));
}
}
}
if first == '(' {
let letters = body.chars().take_while(|c| c.is_ascii_alphabetic()).count();
if (1..=6).contains(&letters) {
let word = &body[..letters];
if letters == 1 || word.chars().all(is_roman_digit) {
let mut rest = &body[letters..];
if rest.starts_with(')') {
rest = &rest[1..];
if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
return Some((1 + letters + 1, 2 + letters));
}
}
}
}
}
return None;
}
if first.is_ascii_digit() {
let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
if (1..=3).contains(&digits) {
let rest = &trimmed[digits..];
if matches!(rest.chars().next(), Some('.') | Some(')')) {
let after = &rest[1..];
if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
return Some((digits + 1, digits + 1));
}
}
}
return None;
}
if first.is_ascii_alphabetic() {
let letters = trimmed
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.count();
if letters == 1 {
let rest = &trimmed[1..];
if matches!(rest.chars().next(), Some('.') | Some(')')) {
let after = &rest[1..];
if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
return Some((2, 2));
}
}
return None;
}
if (2..=6).contains(&letters) {
let word = &trimmed[..letters];
if word.chars().all(is_roman_digit) {
let rest = &trimmed[letters..];
if matches!(rest.chars().next(), Some('.') | Some(')')) {
let after = &rest[1..];
if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
return Some((letters + 1, letters + 1));
}
}
}
}
return None;
}
None
}
fn span_bottom_top(span: &TextSpan) -> (f64, f64) {
let mut bottom = f64::INFINITY;
let mut top = f64::NEG_INFINITY;
for bounds in &span.char_bounds {
bottom = bottom.min(bounds[1]);
top = top.max(bounds[3]);
}
if bottom.is_finite() && top.is_finite() && top > bottom {
return (bottom, top);
}
(span.y - span.font_size * 0.2, span.y + span.font_size * 0.8)
}
fn inked_chars(text: &str) -> usize {
text.chars().filter(|c| !c.is_whitespace()).count()
}
fn text_left_after_marker(block: &Block, line: &[usize], marker_inked: usize) -> Option<f64> {
if marker_inked == 0 {
return None;
}
let mut seen = 0usize;
for i in line {
let span = &block.spans[*i];
let count = inked_chars(&span.text);
if seen == marker_inked {
if count == 0 {
continue;
}
return Some(span.x);
}
if seen > marker_inked {
return None;
}
seen += count;
}
None
}
fn marker_line_hangs_onto(
a_block: &Block,
a_first: &[usize],
a_last: &[usize],
c_block: &Block,
c_line: &[usize],
) -> bool {
let a_text = line_text(a_block, a_first);
let c_text = line_text(c_block, c_line);
let Some((_, a_inked)) = marker_prefix(&a_text) else {
return false;
};
if marker_prefix(&c_text).is_some() {
return false;
}
if line_text(a_block, a_last).trim_end().ends_with(':') {
return false;
}
let anchor_of = |block: &Block, line: &[usize]| -> Option<usize> {
line.iter()
.copied()
.max_by_key(|i| inked_chars(&block.spans[*i].text))
};
let (Some(a_at), Some(c_at)) = (anchor_of(a_block, a_last), anchor_of(c_block, c_line)) else {
return false;
};
let a_anchor = &a_block.spans[a_at];
let c_anchor = &c_block.spans[c_at];
let em = a_anchor.font_size;
if !em.is_finite() || em <= 0.0 {
return false;
}
if a_anchor.y <= c_anchor.y {
return false;
}
let left_of = |block: &Block, line: &[usize]| {
line.iter()
.map(|i| block.spans[*i].x)
.fold(f64::INFINITY, f64::min)
};
let right_of = |block: &Block, line: &[usize]| {
line.iter()
.map(|i| crate::blocks::span_right(&block.spans[*i]))
.fold(f64::NEG_INFINITY, f64::max)
};
let a_left = left_of(a_block, a_first);
let a_right = right_of(a_block, a_last);
let c_left = left_of(c_block, c_line);
let c_right = right_of(c_block, c_line);
if !(a_left.is_finite() && a_right.is_finite() && c_left.is_finite() && c_right.is_finite()) {
return false;
}
if let Some(text_left) = text_left_after_marker(a_block, a_first, a_inked) {
if (c_left - text_left).abs() > MARKER_TEXT_LEFT_TOL_EM * em {
return false;
}
} else if !(c_left > a_left + MARKER_HANG_MIN_EM * em
&& c_left < a_left + MARKER_HANG_MAX_EM * em)
{
return false;
}
if a_right < a_right.max(c_right) - MARKER_WRAP_SLACK_EM * em {
return false;
}
let a_bottom = a_last
.iter()
.map(|i| span_bottom_top(&a_block.spans[*i]).0)
.fold(f64::INFINITY, f64::min);
let c_top = c_line
.iter()
.map(|i| span_bottom_top(&c_block.spans[*i]).1)
.fold(f64::NEG_INFINITY, f64::max);
if !(a_bottom.is_finite() && c_top.is_finite()) {
return false;
}
if ((a_bottom - c_top) / em) >= MARKER_GAP_MAX_EM {
return false;
}
true
}
pub(crate) fn lines_of_block(block: &Block) -> Vec<Vec<usize>> {
let mut order: Vec<usize> = (0..block.spans.len()).collect();
order.sort_by(|a, b| {
block.spans[*b]
.y
.partial_cmp(&block.spans[*a].y)
.unwrap_or(Ordering::Equal)
.then_with(|| {
block.spans[*a]
.x
.partial_cmp(&block.spans[*b].x)
.unwrap_or(Ordering::Equal)
})
});
let mut lines: Vec<Vec<usize>> = Vec::new();
let mut tops: Vec<f64> = Vec::new();
for idx in order {
let size = block.spans[idx].font_size;
let tolerance = if size > 0.0 { 0.5 * size } else { 1e-6 };
if let (Some(last), Some(top)) = (lines.last_mut(), tops.last().copied()) {
if (top - block.spans[idx].y).abs() < tolerance {
last.push(idx);
continue;
}
}
lines.push(vec![idx]);
tops.push(block.spans[idx].y);
}
for line in &mut lines {
line.sort_by(|a, b| {
block.spans[*a]
.x
.partial_cmp(&block.spans[*b].x)
.unwrap_or(Ordering::Equal)
});
}
lines
}
pub(crate) fn line_text(block: &Block, line: &[usize]) -> String {
let spans: Vec<TextSpan> = line.iter().map(|i| block.spans[*i].clone()).collect();
TextBlock { spans }.text()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PageTurn {
None,
Quarter,
Half,
ThreeQuarter,
}
impl PageTurn {
fn of(spans: &[TextSpan]) -> Self {
let page_total: usize = spans.iter().map(glyph_weight).sum();
if page_total == 0 {
return Self::None;
}
let turned: Vec<&TextSpan> = spans
.iter()
.filter(|span| is_a_proper_rotation(span))
.collect();
let turned_total: usize = turned.iter().copied().map(glyph_weight).sum();
let Some((page, share_of_turned)) = heaviest_direction(turned.into_iter()) else {
return Self::None;
};
let share = share_of_turned * turned_total as f64 / page_total as f64;
if share <= 0.5 {
return Self::None;
}
[Self::Quarter, Self::Half, Self::ThreeQuarter]
.into_iter()
.find(|turn| directions_agree(page, turn.direction()))
.unwrap_or(Self::None)
}
fn direction(self) -> Direction {
match self {
Self::None => (1.0, 0.0),
Self::Quarter => (0.0, 1.0),
Self::Half => (-1.0, 0.0),
Self::ThreeQuarter => (0.0, -1.0),
}
}
fn rotation(self) -> (f64, f64) {
match self {
Self::None => (1.0, 0.0),
Self::Quarter => (0.0, -1.0),
Self::Half => (-1.0, 0.0),
Self::ThreeQuarter => (0.0, 1.0),
}
}
fn into_frame(self, mut spans: Vec<TextSpan>) -> Vec<TextSpan> {
if self == Self::None {
return spans;
}
let rotation = self.rotation();
for span in &mut spans {
let along = baseline_extent(span);
turn_span(span, rotation);
span.width = along;
}
spans
}
fn rules_in_frame(self, rules: &[HorizontalRule]) -> Cow<'_, [HorizontalRule]> {
let rotation = self.rotation();
match self {
Self::None => Cow::Borrowed(rules),
Self::Half => Cow::Owned(
rules
.iter()
.map(|rule| {
let (left, y) = turn_point(rule.left, rule.y, rotation);
let (right, _) = turn_point(rule.right, rule.y, rotation);
HorizontalRule {
left: left.min(right),
right: left.max(right),
y,
}
})
.collect(),
),
Self::Quarter | Self::ThreeQuarter => Cow::Owned(Vec::new()),
}
}
fn back(self, mut blocks: Vec<TextBlock>) -> Vec<TextBlock> {
if self == Self::None {
return blocks;
}
let (cos, sin) = self.rotation();
for span in blocks.iter_mut().flat_map(|block| block.spans.iter_mut()) {
turn_span(span, (cos, -sin));
span.width = page_space_width(span);
}
blocks
}
fn back_blocks(self, mut blocks: Vec<Block>) -> Vec<Block> {
if self == Self::None {
return blocks;
}
let (cos, sin) = self.rotation();
for block in blocks.iter_mut() {
for span in block.spans.iter_mut() {
turn_span(span, (cos, -sin));
span.width = page_space_width(span);
}
crate::blocks::recompute_bbox(block);
}
blocks
}
}
fn baseline_extent(span: &TextSpan) -> f64 {
let direction = span_direction(span);
baseline_end(span, direction) - baseline_coordinates(span, direction).0
}
fn page_space_width(span: &TextSpan) -> f64 {
span.char_bounds
.iter()
.map(|bound| bound[2] - span.x)
.reduce(f64::max)
.unwrap_or(span.width)
}
fn turn_span(span: &mut TextSpan, rotation: (f64, f64)) {
let (x, y) = turn_point(span.x, span.y, rotation);
span.x = x;
span.y = y;
if let Some(coeffs) = span.transform.as_mut() {
let (a, b) = turn_point(coeffs[0], coeffs[1], rotation);
let (c, d) = turn_point(coeffs[2], coeffs[3], rotation);
let (e, f) = turn_point(coeffs[4], coeffs[5], rotation);
*coeffs = [a, b, c, d, e, f];
}
for bound in &mut span.char_bounds {
let (advance, size) = (bound[2] - bound[0], bound[3] - bound[1]);
let (x0, y0) = turn_point(bound[0], bound[1], rotation);
*bound = [x0, y0, x0 + advance, y0 + size];
}
}
fn turn_point(x: f64, y: f64, (cos, sin): (f64, f64)) -> (f64, f64) {
(x * cos - y * sin, x * sin + y * cos)
}
fn reassemble_cross_direction_runs(blocks: Vec<TextBlock>) -> Vec<TextBlock> {
let Some(page) = page_direction(&blocks) else {
return blocks;
};
reassemble_with_direction(page, blocks)
}
fn reassemble_with_direction(page: Direction, blocks: Vec<TextBlock>) -> Vec<TextBlock> {
let mut kept: Vec<TextBlock> = Vec::with_capacity(blocks.len());
let mut cross: Vec<PlacedSpan> = Vec::new();
for block in blocks {
let at = kept.len();
let mut page_spans = Vec::with_capacity(block.spans.len());
for span in block.spans {
if directions_agree(page, span_direction(&span)) {
page_spans.push(span);
} else {
cross.push((at, span));
}
}
if !page_spans.is_empty() {
kept.push(TextBlock { spans: page_spans });
}
}
if cross.is_empty() {
return kept;
}
let runs = lines_along_their_own_baselines(cross);
for (at, run) in runs.into_iter().rev() {
let at = at.min(kept.len());
kept.insert(at, run);
}
kept
}
fn page_direction(blocks: &[TextBlock]) -> Option<Direction> {
dominant_direction(blocks.iter().flat_map(|block| block.spans.iter()))
}
pub(crate) fn aggregate_bounds_source(sources: &[BoundsSource]) -> BoundsSource {
if sources.contains(&BoundsSource::Estimate) {
BoundsSource::Estimate
} else if sources.contains(&BoundsSource::Tight) {
BoundsSource::Tight
} else {
BoundsSource::Advance
}
}
fn retrack_a_finished_span(span: &mut TextSpan, chars: &[char]) -> bool {
let letters: Vec<char> = chars.iter().copied().filter(|c| *c != ' ').collect();
if letters.len() != span.char_bounds.len() || letters.len() < 4 {
return false;
}
if letters.iter().any(|c| c.is_lowercase()) || !letters.iter().any(|c| c.is_uppercase()) {
return false;
}
let singles = span
.text
.split(' ')
.filter(|w| w.chars().count() == 1 && w.chars().all(char::is_uppercase))
.count();
if singles < 4 {
return false;
}
let gaps: Vec<f64> = span
.char_bounds
.windows(2)
.map(|p| p[1][0] - p[0][2])
.collect();
let mut sorted = gaps.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
let tracking = sorted[(sorted.len() - 1) / 2];
if tracking <= TRACKED_GAP_MIN_EM * span.font_size {
return false;
}
let inside_a_word = tracking * (1.0 + TRACKING_TOLERANCE + 0.1);
let mut spaced_before = vec![false; letters.len()];
let mut k = 0;
for c in chars {
if *c == ' ' {
if k < letters.len() {
spaced_before[k] = true;
}
} else {
k += 1;
}
}
let mut text = String::with_capacity(chars.len());
for (k, c) in letters.iter().enumerate() {
if k > 0 && spaced_before[k] && gaps[k - 1] > inside_a_word {
text.push(' ');
}
text.push(*c);
}
if chars.last() == Some(&' ') {
text.push(' ');
}
let changed = text != span.text;
span.text = text;
changed
}
fn untrack_spaced_letters(span: &mut TextSpan) {
let chars: Vec<char> = span.text.chars().collect();
if chars.len() < 7 || !chars.contains(&' ') {
return;
}
let chars: Vec<char> = if retrack_a_finished_span(span, &chars) {
span.text.chars().collect()
} else {
chars
};
let aligned = span.char_bounds.len() == chars.len();
let tight = span.tight_char_bounds.len() == chars.len();
let mut words: Vec<(usize, usize)> = Vec::new();
let mut at = 0;
while at < chars.len() {
if chars[at] == ' ' {
at += 1;
continue;
}
let start = at;
while at < chars.len() && chars[at] != ' ' {
at += 1;
}
words.push((start, at));
}
let small = |(a, b): (usize, usize)| {
(b - a == 1 || b - a == 2)
&& chars[a..b]
.iter()
.all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
&& (b - a == 1 || chars[a..b].iter().any(|c| c.is_uppercase()))
};
let mut drop = vec![false; chars.len()];
let mut w = 0;
while w < words.len() {
if !small(words[w]) {
w += 1;
continue;
}
let mut end = w + 1;
while end < words.len()
&& small(words[end])
&& (1..=2).contains(&(words[end].0 - words[end - 1].1))
{
end += 1;
}
while end > w + 1 {
let last_double = (w + 1..end)
.rev()
.find(|&i| words[i].0 - words[i - 1].1 >= 2);
match last_double {
Some(at)
if words[at..end]
.iter()
.map(|&(a, b)| chars[a..b].iter().filter(|c| c.is_uppercase()).count())
.sum::<usize>()
< 2 =>
{
end = at;
}
_ => break,
}
}
let run = &words[w..end];
let singles = run
.iter()
.filter(|&&(a, b)| b - a == 1 && chars[a].is_uppercase())
.count();
if singles >= 4 && run.len() >= 4 {
let seps: Vec<(usize, usize)> = run.windows(2).map(|p| (p[0].1, p[1].0)).collect();
let boundary: Option<Vec<bool>> = seps
.iter()
.any(|(a, b)| b - a >= 2)
.then(|| seps.iter().map(|(a, b)| b - a >= 2).collect());
if let Some(boundary) = boundary {
for (&(a, b), keep) in seps.iter().zip(boundary) {
let from = if keep { a + 1 } else { a };
for d in drop.iter_mut().take(b).skip(from) {
*d = true;
}
}
}
}
w = end;
}
if !drop.contains(&true) {
return;
}
span.text = chars
.iter()
.zip(&drop)
.filter(|(_, d)| !**d)
.map(|(c, _)| *c)
.collect();
if aligned {
let mut k = 0;
span.char_bounds.retain(|_| {
k += 1;
!drop[k - 1]
});
}
if tight {
let mut k = 0;
span.tight_char_bounds.retain(|_| {
k += 1;
!drop[k - 1]
});
}
if span.glyph_advances.len() == chars.len() {
let mut k = 0;
span.glyph_advances.retain(|_| {
k += 1;
!drop[k - 1]
});
}
if span.glyph_bounds_sources.len() == chars.len() {
let mut k = 0;
span.glyph_bounds_sources.retain(|_| {
k += 1;
!drop[k - 1]
});
span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
}
}
fn dominant_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<Direction> {
heaviest_direction(spans).map(|(direction, _)| direction)
}
fn glyph_weight(span: &TextSpan) -> usize {
if span.char_bounds.is_empty() {
span.text.chars().count().max(1)
} else {
span.char_bounds.len()
}
}
fn is_a_proper_rotation(span: &TextSpan) -> bool {
match span.transform {
None => true,
Some([a, b, c, d, _, _]) => a * d - b * c > 0.0,
}
}
fn heaviest_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<(Direction, f64)> {
let mut directions: Vec<(Direction, usize)> = Vec::new();
for span in spans {
let direction = span_direction(span);
let weight = glyph_weight(span);
match directions
.iter_mut()
.find(|(known, _)| directions_agree(*known, direction))
{
Some(entry) => entry.1 += weight,
None => directions.push((direction, weight)),
}
}
let total: usize = directions.iter().map(|(_, weight)| *weight).sum();
let mut winner: Option<(Direction, usize)> = None;
for (direction, weight) in directions {
if winner.map(|(_, best)| weight > best).unwrap_or(true) {
winner = Some((direction, weight));
}
}
winner.map(|(direction, weight)| (direction, weight as f64 / total as f64))
}
fn lines_along_their_own_baselines(spans: Vec<PlacedSpan>) -> Vec<(usize, TextBlock)> {
let mut groups: Vec<(Direction, Vec<PlacedSpan>)> = Vec::new();
for (at, span) in spans {
let direction = span_direction(&span);
match groups
.iter_mut()
.find(|(known, _)| directions_agree(*known, direction))
{
Some((_, group)) => group.push((at, span)),
None => groups.push((direction, vec![(at, span)])),
}
}
let mut lines: Vec<(usize, TextBlock)> = Vec::new();
for (direction, group) in groups {
let mut rows: Vec<(f64, usize, Vec<TextSpan>)> = Vec::new();
for (at, span) in group {
let across = baseline_coordinates(&span, direction).1;
let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
match rows
.iter_mut()
.find(|(row_across, _, _)| (row_across - across).abs() <= tolerance)
{
Some((_, first, row)) => {
*first = (*first).min(at);
row.push(span);
}
None => rows.push((across, at, vec![span])),
}
}
rows.sort_by(|a, b| {
a.1.cmp(&b.1)
.then_with(|| b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal))
});
for (_, at, mut row) in rows {
row.sort_by(|a, b| {
baseline_coordinates(a, direction)
.0
.partial_cmp(&baseline_coordinates(b, direction).0)
.unwrap_or(Ordering::Equal)
});
lines.push((at, TextBlock { spans: row }));
}
}
lines.sort_by(|a, b| a.0.cmp(&b.0));
lines
}
const ROTATED_RUN_MAX_GAP_EM: f64 = 2.0;
const LONE_RUN_MAX_GAP_EM: f64 = 1.0;
fn merge_rotated_runs(spans: Vec<TextSpan>) -> Vec<TextSpan> {
if !spans.iter().any(span_is_quarter_turned) {
return spans;
}
struct Baseline {
direction: Direction,
across: f64,
members: Vec<usize>,
}
let mut baselines: Vec<Baseline> = Vec::new();
'spans: for (index, span) in spans.iter().enumerate() {
if !span_is_quarter_turned(span) {
continue;
}
let direction = span_direction(span);
let across = baseline_coordinates(span, direction).1;
let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
for baseline in &mut baselines {
if !directions_agree(baseline.direction, direction)
|| (baseline.across - across).abs() > tolerance
{
continue;
}
let first = &spans[baseline.members[0]];
if first.font_name != span.font_name
|| first.is_bold != span.is_bold
|| first.is_italic != span.is_italic
|| first.color != span.color
{
continue;
}
baseline.members.push(index);
continue 'spans;
}
baselines.push(Baseline {
direction,
across,
members: vec![index],
});
}
let mut split: Vec<Baseline> = Vec::with_capacity(baselines.len());
for baseline in baselines {
let mut members = baseline.members;
members.sort_by(|a, b| {
baseline_coordinates(&spans[*a], baseline.direction)
.0
.partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
.unwrap_or(Ordering::Equal)
});
let mut piece = vec![members[0]];
for pair in members.windows(2) {
let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
let end = baseline_end(prev, baseline.direction);
let start = baseline_coordinates(curr, baseline.direction).0;
let intervened = (start > end)
&& spans.iter().enumerate().any(|(index, span)| {
!members.contains(&index)
&& span_is_quarter_turned(span)
&& directions_agree(span_direction(span), baseline.direction)
&& {
let (along, across) = baseline_coordinates(span, baseline.direction);
let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
along > end
&& along < start
&& (baseline.across - across).abs() <= tolerance
}
});
let size = prev.font_size.max(f64::MIN_POSITIVE);
if start - end > ROTATED_RUN_MAX_GAP_EM * size || intervened {
split.push(Baseline {
direction: baseline.direction,
across: baseline.across,
members: std::mem::take(&mut piece),
});
}
piece.push(pair[1]);
}
split.push(Baseline {
direction: baseline.direction,
across: baseline.across,
members: piece,
});
}
let mut baselines = split;
{
struct Seat {
baseline: f64,
size: f64,
}
let seats: Vec<Seat> = baselines
.iter()
.map(|baseline| {
let first = baseline
.members
.iter()
.min_by(|a, b| {
baseline_coordinates(&spans[**a], baseline.direction)
.0
.partial_cmp(&baseline_coordinates(&spans[**b], baseline.direction).0)
.unwrap_or(Ordering::Equal)
})
.copied()
.unwrap_or(0);
Seat {
baseline: spans[first].y,
size: baseline
.members
.iter()
.map(|i| spans[*i].font_size)
.fold(0.0, f64::max),
}
})
.collect();
let mut keep = vec![false; baselines.len()];
for (ordinal, baseline) in baselines.iter().enumerate() {
let mut members = baseline.members.clone();
members.sort_by(|a, b| {
baseline_coordinates(&spans[*a], baseline.direction)
.0
.partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
.unwrap_or(Ordering::Equal)
});
let mut wide = false;
for pair in members.windows(2) {
let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
let gap = baseline_coordinates(curr, baseline.direction).0
- baseline_end(prev, baseline.direction);
let size = prev.font_size.max(f64::MIN_POSITIVE);
if gap > LONE_RUN_MAX_GAP_EM * size && gap <= ROTATED_RUN_MAX_GAP_EM * size {
wide = true;
break;
}
}
if wide {
keep[ordinal] = true;
continue;
}
keep[ordinal] = seats.iter().enumerate().any(|(other, seat)| {
other != ordinal
&& (seats[ordinal].baseline - seat.baseline).abs()
<= 0.5 * seats[ordinal].size.max(seat.size)
});
}
let mut kept: Vec<Baseline> = Vec::with_capacity(baselines.len());
for (baseline, keep) in baselines.into_iter().zip(keep) {
if keep {
kept.push(baseline);
}
}
baselines = kept;
if baselines.is_empty() {
return spans;
}
}
let mut runs: Vec<Vec<usize>> = Vec::new();
for baseline in &baselines {
let mut members = baseline.members.clone();
members.sort_by(|a, b| {
baseline_coordinates(&spans[*a], baseline.direction)
.0
.partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
.unwrap_or(Ordering::Equal)
});
let mut run = vec![members[0]];
for pair in members.windows(2) {
let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
let gap = baseline_coordinates(curr, baseline.direction).0
- baseline_end(prev, baseline.direction);
if gap > ROTATED_RUN_MAX_GAP_EM * prev.font_size.max(f64::MIN_POSITIVE) {
runs.push(std::mem::take(&mut run));
}
run.push(pair[1]);
}
runs.push(run);
}
let mut replacement: Vec<Option<TextSpan>> = (0..spans.len()).map(|_| None).collect();
let mut folded = vec![false; spans.len()];
for run in runs.iter().filter(|run| run.len() >= 2) {
let members: Vec<TextSpan> = run.iter().map(|i| spans[*i].clone()).collect();
let text = TextBlock {
spans: members.clone(),
}
.visual_text();
let reading_first = &members[0];
let x = members
.iter()
.map(|span| span.x)
.fold(f64::INFINITY, f64::min);
let y = members
.iter()
.map(|span| span.y)
.fold(f64::INFINITY, f64::min);
let right = members
.iter()
.map(|span| span.measured_right())
.fold(f64::NEG_INFINITY, f64::max);
let height = members.iter().map(|span| span.height).fold(0.0, f64::max);
let font_size = members
.iter()
.map(|span| span.font_size)
.fold(0.0, f64::max);
let mut char_bounds = Vec::new();
let mut tight_char_bounds = Vec::new();
let mut glyph_advances = Vec::new();
let mut glyph_bounds_sources = Vec::new();
for span in &members {
char_bounds.extend(span.char_bounds.iter().copied());
tight_char_bounds.extend(span.tight_char_bounds.iter().copied());
glyph_advances.extend(span.glyph_advances.iter().copied());
glyph_bounds_sources.extend(span.glyph_bounds_sources.iter().copied());
}
let merged = TextSpan {
text,
x,
y,
width: (right - x).max(0.0),
height,
font_size,
font_name: reading_first.font_name.clone(),
is_bold: reading_first.is_bold,
is_italic: reading_first.is_italic,
color: reading_first.color,
width_source: if members
.iter()
.any(|span| span.width_source == WidthSource::Estimate)
{
WidthSource::Estimate
} else {
WidthSource::Metric
},
char_bounds,
geometry_mode: reading_first.geometry_mode,
bounds_source: aggregate_bounds_source(&glyph_bounds_sources),
tight_char_bounds,
glyph_advances,
glyph_bounds_sources,
transform: reading_first.transform,
font_weight: reading_first.font_weight,
is_serif: reading_first.is_serif,
is_ocr_layer: members.iter().any(|span| span.is_ocr_layer),
is_monospace: reading_first.is_monospace,
render_mode: reading_first.render_mode,
font_metrics: reading_first.font_metrics,
complete_run: members.last().is_some_and(|span| span.complete_run),
};
let first_drawn = run.iter().copied().min().unwrap_or(0);
for index in run {
folded[*index] = *index != first_drawn;
}
replacement[first_drawn] = Some(merged);
}
let mut out: Vec<TextSpan> = Vec::with_capacity(spans.len());
for (index, span) in spans.into_iter().enumerate() {
if folded[index] {
continue;
}
match replacement[index].take() {
Some(merged) => out.push(merged),
None => out.push(span),
}
}
out
}
fn xy_cut_recursive(
spans: Vec<TextSpan>,
depth: usize,
stats: &PageStats,
rules: &[HorizontalRule],
) -> Vec<Vec<TextBlock>> {
if spans.is_empty() {
return Vec::new();
}
if depth >= XY_CUT_MAX_DEPTH {
return vec![band_based_blocks(spans, stats, rules)];
}
let vcut = try_vertical_cut(&spans, stats, rules);
let hcut = try_horizontal_cut(&spans, stats);
let (chosen, _) = match (vcut, hcut) {
(Some((v_groups, v_gap)), Some((h_groups, h_gap))) => {
if v_gap >= h_gap {
(Some(v_groups), v_gap)
} else {
(Some(h_groups), h_gap)
}
}
(Some((v_groups, v_gap)), None) => (Some(v_groups), v_gap),
(None, Some((h_groups, h_gap))) => (Some(h_groups), h_gap),
(None, None) => (None, 0.0),
};
if let Some(groups) = chosen {
let mut out = Vec::new();
for group in groups {
out.extend(xy_cut_recursive(group, depth + 1, stats, rules));
}
return out;
}
vec![band_based_blocks(spans, stats, rules)]
}
fn band_based_blocks(
spans: Vec<TextSpan>,
stats: &PageStats,
rules: &[HorizontalRule],
) -> Vec<TextBlock> {
group_spans_into_blocks_legacy_with_stats(spans, stats, rules)
}
#[allow(dead_code)]
fn median_font_size(spans: &[TextSpan]) -> f64 {
if spans.is_empty() {
return 12.0;
}
let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
sizes[sizes.len() / 2]
}
fn is_marker_or_space(hung: &HashSet<usize>, at: usize, span: &TextSpan) -> bool {
hung.contains(&at)
|| (!span.text.chars().any(|c| !c.is_whitespace())
&& span.measured_right() - span.x <= span.font_size.max(1.0))
}
fn both_sides_run_on(bands: &[TextBand], cut_x: f64) -> bool {
let mut left: Vec<usize> = Vec::new();
let mut right: Vec<usize> = Vec::new();
for band in bands {
let (mut l, mut r) = (0usize, 0usize);
let (mut l_ink, mut r_ink) = (false, false);
for span in &band.spans {
let inked = span.text.chars().any(|c| !c.is_whitespace());
let chars = span.text.chars().filter(|c| c.is_alphabetic()).count();
if span.x + (span.measured_right() - span.x) * 0.5 < cut_x {
l += chars;
l_ink |= inked;
} else {
r += chars;
r_ink |= inked;
}
}
if l_ink {
left.push(l);
}
if r_ink {
right.push(r);
}
}
let median = |v: &mut Vec<usize>| {
v.sort_unstable();
v.get(v.len() / 2).copied().unwrap_or(0)
};
median(&mut left) > RUNNING_LETTERS && median(&mut right) > RUNNING_LETTERS
}
fn try_vertical_cut(
spans: &[TextSpan],
stats: &PageStats,
rules: &[HorizontalRule],
) -> Option<(Vec<Vec<TextSpan>>, f64)> {
if spans.len() < 2 * XY_CUT_MIN_SPANS_PER_COLUMN {
return None;
}
let peeled = peel_line_numbers(spans);
let spans: &[TextSpan] = &peeled;
let region_left = spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
let region_right = spans
.iter()
.map(TextSpan::right)
.fold(f64::NEG_INFINITY, f64::max);
let region_width = region_right - region_left;
if region_width <= 0.0 {
return None;
}
let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
let all_bands: Vec<usize> = (0..bands.len()).collect();
if region_is_ruled(&bands, &all_bands, rules) {
return None;
}
let adaptive = compute_adaptive_column_gap(&bands);
let floor = stats.median_font_size.max(XY_CUT_VERTICAL_GAP_FLOOR);
let min_gap = adaptive.min(floor).max(XY_CUT_VERTICAL_GAP_FLOOR);
let word_space = (adaptive / COLUMN_GAP_MEDIAN_MULTIPLIER).max(0.5);
let (mut skipped, figures) = a_margin_column(&bands, region_width, min_gap, word_space);
let hung: Vec<HashSet<usize>> = bands
.iter()
.map(|row| {
(0..row.spans.len())
.filter(|&at| {
let span = &row.spans[at];
let apart_before = row.spans[..at]
.iter()
.rev()
.find(|prev| prev.text.chars().any(|c| !c.is_whitespace()))
.is_none_or(|prev| {
span.x - prev.measured_right()
>= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
});
span.text.chars().filter(|c| !c.is_whitespace()).count() == 1
&& apart_before
&& row.spans[at + 1..]
.iter()
.find(|next| next.text.chars().any(|c| !c.is_whitespace()))
.is_some_and(|next| {
next.x - span.measured_right()
>= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
})
})
.collect()
})
.collect();
let scan =
|skipped: &[HashSet<usize>], from: usize, past_markers: bool| -> Option<(f64, f64)> {
let mut intervals: Vec<(f64, f64)> = bands
.iter()
.enumerate()
.skip(from)
.flat_map(|(band, row)| {
let skipped = &skipped[band];
let hung = &hung[band];
row.spans
.iter()
.enumerate()
.filter(move |(at, s)| {
!skipped.contains(at)
&& (!past_markers || !is_marker_or_space(hung, *at, s))
})
.map(|(_, s)| {
let r = if s.width > 0.0 {
s.measured_right()
} else {
s.right()
};
(s.x, r.max(s.x + 0.001))
})
})
.collect();
intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
let mut cursor = intervals.first()?.1;
let mut best: Option<(f64, f64)> = None; for (left, right) in intervals.iter().skip(1) {
if *left > cursor {
let gap = *left - cursor;
if gap >= min_gap && !matches!(best, Some((b, _)) if b >= gap) {
best = Some((gap, (cursor + *left) * 0.5));
}
}
cursor = cursor.max(*right);
}
best
};
let mut head = 0;
let mut best_gap = scan(&skipped, 0, false);
let numbered = figures && skipped.iter().any(|band| !band.is_empty());
if best_gap.is_none() && numbered {
for drop in 1..=HEAD_BANDS_MAX.min(bands.len().saturating_sub(2)) {
if bands[drop - 1].spans.len() > HEAD_BAND_MAX_SPANS {
break;
}
if let Some(found) = scan(&skipped, drop, false) {
best_gap = Some(found);
head = drop;
break;
}
}
}
let mut past_markers = false;
if best_gap.is_none() {
best_gap = scan(&skipped, 0, true);
past_markers = best_gap.is_some();
}
let (gap_size, cut_x) = best_gap?;
let cut_x = if past_markers {
bands
.iter()
.zip(&hung)
.flat_map(|(row, hung)| {
row.spans
.iter()
.enumerate()
.filter(|(at, s)| is_marker_or_space(hung, *at, s))
.map(|(_, s)| s.x)
.collect::<Vec<_>>()
})
.filter(|x| (x - cut_x).abs() < gap_size * 0.5)
.fold(cut_x, f64::min)
- 0.01
} else {
cut_x
};
let margin_of = |sets: &[HashSet<usize>]| -> Vec<TextSpan> {
bands
.iter()
.enumerate()
.flat_map(|(band, row)| {
let set = &sets[band];
row.spans
.iter()
.enumerate()
.filter(move |(at, _)| set.contains(at))
.map(|(_, s)| s.clone())
})
.collect()
};
let mut margin = margin_of(&skipped);
let inside = |spans: &[TextSpan], cut_x: f64, gap: f64| {
spans
.iter()
.all(|s| s.x >= cut_x - gap && s.measured_right() <= cut_x + gap)
};
let (gap_size, cut_x) = if margin.is_empty() || inside(&margin, cut_x, gap_size) {
(gap_size, cut_x)
} else {
skipped = vec![HashSet::new(); bands.len()];
margin.clear();
scan(&skipped, head, past_markers)?
};
let head_spans: Vec<TextSpan> = bands[..head].iter().flat_map(|b| b.spans.clone()).collect();
let in_margin: HashSet<(u64, u64)> = margin
.iter()
.chain(&head_spans)
.map(|s| (s.x.to_bits(), s.y.to_bits()))
.collect();
let mut left_group = Vec::new();
let mut right_group = Vec::new();
for span in spans {
if in_margin.contains(&(span.x.to_bits(), span.y.to_bits())) {
continue;
}
let midpoint = span.x + (span.right() - span.x) * 0.5;
if midpoint < cut_x {
left_group.push(span.clone());
} else {
right_group.push(span.clone());
}
}
if !columns_are_dense(&left_group, &right_group, stats) {
return None;
}
let aligned: Vec<TextSpan> = if in_margin.is_empty() {
spans.to_vec()
} else {
spans
.iter()
.filter(|s| !in_margin.contains(&(s.x.to_bits(), s.y.to_bits())))
.cloned()
.collect()
};
let aligned: Vec<TextSpan> = if past_markers {
aligned
.into_iter()
.filter(|s| s.text.chars().filter(|c| !c.is_whitespace()).count() > 1)
.collect()
} else {
aligned
};
if past_markers && !both_sides_run_on(&bands[head..], cut_x) {
return None;
}
if !columns_are_band_aligned(&aligned, cut_x, region_left, region_right, stats) {
return None;
}
if a_column_of_labels(&bands[head..], cut_x, region_left, region_right, min_gap) {
return None;
}
if the_cut_is_a_column_of_a_table(&bands[head..], cut_x, gap_size, &skipped[head..]) {
return None;
}
let mut groups = Vec::new();
if !head_spans.is_empty() {
groups.push(head_spans);
}
groups.push(left_group);
groups.push(right_group);
if !margin.is_empty() {
groups.push(margin);
}
Some((groups, gap_size))
}
fn peel_line_numbers(spans: &[TextSpan]) -> Vec<TextSpan> {
let mut out = Vec::with_capacity(spans.len());
for (at, span) in spans.iter().enumerate() {
match peel_one(span, at, spans) {
Some((figure, rest)) => {
out.push(figure);
out.push(rest);
}
None => match peel_trailing(span, at, spans) {
Some((rest, figure)) => {
out.push(rest);
out.push(figure);
}
None => out.push(span.clone()),
},
}
}
out
}
fn peel_one(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
let digits = span.text.chars().take_while(char::is_ascii_digit).count();
if !(1..=3).contains(&digits) || !span.text[digits..].starts_with(' ') {
return None;
}
let bounds = &span.char_bounds;
if bounds.len() <= digits {
return None;
}
let size = span.font_size.max(1.0);
let figure_right = bounds[digits - 1][2];
let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
let first = (digits..bounds.len()).find(|&k| bounds[k][0] >= figure_right + clear)?;
let rest_x = bounds[first][0];
let tolerance = size * PEEL_EDGE_EM;
if span.x >= rest_x - tolerance {
return None;
}
let on_edge = spans
.iter()
.enumerate()
.filter(|(other, s)| *other != at && (s.x - rest_x).abs() <= tolerance)
.count();
if on_edge < MARGIN_COLUMN_MIN_BANDS {
return None;
}
let rest_text = span.text[digits..].trim_start();
if rest_text.is_empty() {
return None;
}
let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
if v.len() == bounds.len() {
v[lo..hi].to_vec()
} else {
Vec::new()
}
};
let right = span.measured_right();
let mut figure = span.clone();
figure.text = span.text[..digits].to_string();
figure.width = figure_right - span.x;
figure.char_bounds = bounds[..digits].to_vec();
figure.tight_char_bounds = cut(&span.tight_char_bounds, 0, digits);
let mut rest = span.clone();
rest.text = rest_text.to_string();
rest.x = rest_x;
rest.width = (right - rest_x).max(0.0);
rest.char_bounds = bounds[first..].to_vec();
if let Some(t) = rest.transform.as_mut() {
t[4] += bounds[first][0] - bounds[0][0];
t[5] += bounds[first][1] - bounds[0][1];
}
rest.tight_char_bounds = cut(&span.tight_char_bounds, first, bounds.len());
if span.glyph_advances.len() == bounds.len() {
figure.glyph_advances = span.glyph_advances[..digits].to_vec();
rest.glyph_advances = span.glyph_advances[first..].to_vec();
} else {
figure.glyph_advances.clear();
rest.glyph_advances.clear();
}
if span.glyph_bounds_sources.len() == bounds.len() {
figure.glyph_bounds_sources = span.glyph_bounds_sources[..digits].to_vec();
rest.glyph_bounds_sources = span.glyph_bounds_sources[first..].to_vec();
} else {
figure.glyph_bounds_sources.clear();
rest.glyph_bounds_sources.clear();
}
Some((figure, rest))
}
fn peel_trailing(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
let body = span.text.trim_end();
let tail_ws = span.text[body.len()..].chars().count();
let digits = body.chars().rev().take_while(char::is_ascii_digit).count();
if !(1..=3).contains(&digits) || !body[..body.len() - digits].ends_with(' ') {
return None;
}
let rest_text = body[..body.len() - digits].trim_end();
if rest_text.is_empty() {
return None;
}
let bounds = &span.char_bounds;
if bounds.len() <= tail_ws + digits {
return None;
}
let figure_end = bounds.len() - tail_ws;
let figure_start = figure_end - digits;
let size = span.font_size.max(1.0);
let figure_left = bounds[figure_start][0];
let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
let last = (0..figure_start)
.rev()
.find(|&k| bounds[k][2] <= figure_left - clear)?;
let rest_right = bounds[last][2];
let tolerance = size * PEEL_EDGE_EM;
let figure_right = bounds[figure_end - 1][2];
if figure_right <= rest_right + tolerance {
return None;
}
let on_edge = spans
.iter()
.enumerate()
.filter(|(other, s)| *other != at && (s.measured_right() - rest_right).abs() <= tolerance)
.count();
if on_edge < MARGIN_COLUMN_MIN_BANDS {
return None;
}
let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
if v.len() == bounds.len() {
v[lo..hi].to_vec()
} else {
Vec::new()
}
};
let mut rest = span.clone();
rest.text = rest_text.to_string();
rest.width = (rest_right - span.x).max(0.0);
rest.char_bounds = bounds[..=last].to_vec();
rest.tight_char_bounds = cut(&span.tight_char_bounds, 0, last + 1);
let mut figure = span.clone();
figure.text = body[body.len() - digits..].to_string();
figure.x = figure_left;
figure.width = (figure_right - figure_left).max(0.0);
figure.char_bounds = bounds[figure_start..figure_end].to_vec();
figure.tight_char_bounds = cut(&span.tight_char_bounds, figure_start, figure_end);
if let Some(t) = figure.transform.as_mut() {
t[4] += bounds[figure_start][0] - bounds[0][0];
t[5] += bounds[figure_start][1] - bounds[0][1];
}
if span.glyph_advances.len() == bounds.len() {
rest.glyph_advances = span.glyph_advances[..=last].to_vec();
figure.glyph_advances = span.glyph_advances[figure_start..figure_end].to_vec();
} else {
rest.glyph_advances.clear();
figure.glyph_advances.clear();
}
if span.glyph_bounds_sources.len() == bounds.len() {
rest.glyph_bounds_sources = span.glyph_bounds_sources[..=last].to_vec();
figure.glyph_bounds_sources = span.glyph_bounds_sources[figure_start..figure_end].to_vec();
} else {
rest.glyph_bounds_sources.clear();
figure.glyph_bounds_sources.clear();
}
Some((rest, figure))
}
fn counts_the_lines(column: &[(usize, usize, f64)], bands: &[TextBand]) -> bool {
let mut members: Vec<(usize, u32)> = Vec::with_capacity(column.len());
for &(band, at, _) in column {
let text = bands[band].spans[at].text.trim();
if text.chars().count() > MARGIN_FIGURE_MAX_CHARS {
return false;
}
match text.parse::<u32>() {
Ok(value) => members.push((band, value)),
Err(_) => return false,
}
}
if members.len() < MARGIN_COLUMN_MIN_BANDS {
return false;
}
members.sort_unstable();
let rising = members
.windows(2)
.all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1);
let step = members.iter().fold(0, |g, &(_, v)| gcd(g, v));
rising && step >= 2
}
fn gcd(a: u32, b: u32) -> u32 {
if b == 0 {
a
} else {
gcd(b, a % b)
}
}
const HEAD_BANDS_MAX: usize = 2;
const HEAD_BAND_MAX_SPANS: usize = 3;
const PEEL_EDGE_EM: f64 = 0.5;
fn only_figures(text: &str) -> bool {
let t = text.trim();
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit())
}
const MARGIN_FIGURE_MAX_CHARS: usize = 3;
const MARGIN_COLUMN_MAX_WIDTH_FRACTION: f64 = 0.10;
const MARGIN_COLUMN_MAX_CHAR_SHARE: f64 = 0.15;
const MARGIN_COLUMN_MIN_CLEARANCE_EM: f64 = 0.2;
const MARGIN_COLUMN_MIN_BANDS: usize = 3;
fn a_margin_column(
bands: &[TextBand],
region_width: f64,
min_gap: f64,
word_space: f64,
) -> (Vec<HashSet<usize>>, bool) {
let mut out: Vec<HashSet<usize>> = vec![HashSet::new(); bands.len()];
if bands.len() < MARGIN_COLUMN_MIN_BANDS || region_width <= 0.0 {
return (out, false);
}
let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
for (band, row) in bands.iter().enumerate() {
let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
if letters == 0 {
continue;
}
for (at, span) in row.spans.iter().enumerate() {
let right = span.measured_right().max(span.x);
if right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width {
continue;
}
if span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64 {
continue;
}
let before = row
.spans
.iter()
.filter(|other| other.measured_right() <= span.x)
.map(TextSpan::measured_right)
.fold(f64::NEG_INFINITY, f64::max);
let after = row
.spans
.iter()
.filter(|other| other.x >= right)
.map(|other| other.x)
.fold(f64::INFINITY, f64::min);
if !before.is_finite() || !after.is_finite() {
continue;
}
let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
if span.x - before <= clear || after - right <= clear {
continue;
}
candidates.push((band, at, span.x + (right - span.x) * 0.5));
}
}
if candidates.len() < MARGIN_COLUMN_MIN_BANDS {
return (out, false);
}
candidates.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(Ordering::Equal));
let mut columns: Vec<Vec<(usize, usize, f64)>> = Vec::new();
for candidate in candidates {
match columns.last_mut() {
Some(column) if candidate.2 - column[column.len() - 1].2 <= min_gap => {
column.push(candidate)
}
_ => columns.push(vec![candidate]),
}
}
let mut found: Vec<&Vec<(usize, usize, f64)>> = columns
.iter()
.filter(|column| {
let mut on: Vec<usize> = column.iter().map(|c| c.0).collect();
on.sort_unstable();
on.dedup();
on.len() >= MARGIN_COLUMN_MIN_BANDS
})
.collect();
if found.len() > 1 {
found.retain(|column| counts_the_lines(column, bands));
}
if found.len() != 1 {
return (out, false);
}
let column = found.pop().expect("one column");
for (band, at, _) in column.iter() {
out[*band].insert(*at);
}
if !column
.iter()
.all(|&(band, at, _)| only_figures(&bands[band].spans[at].text))
{
return (out, false);
}
let centre = column.iter().map(|c| c.2).sum::<f64>() / column.len() as f64;
for (band, row) in bands.iter().enumerate() {
let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
for (at, span) in row.spans.iter().enumerate() {
if out[band].contains(&at) || span.text.trim().chars().count() > MARGIN_FIGURE_MAX_CHARS
{
continue;
}
let right = span.measured_right().max(span.x);
if (span.x + (right - span.x) * 0.5 - centre).abs() > min_gap
|| right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width
|| span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64
{
continue;
}
let after = row
.spans
.iter()
.filter(|other| other.x >= right)
.map(|other| other.x)
.fold(f64::INFINITY, f64::min);
let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
let before = row
.spans
.iter()
.filter(|other| other.measured_right() <= span.x)
.map(TextSpan::measured_right)
.fold(f64::NEG_INFINITY, f64::max);
let apart = if after.is_finite() {
after - right > clear
} else {
!before.is_finite() || span.x - before > clear
};
if apart {
out[band].insert(at);
}
}
}
(out, true)
}
fn the_cut_is_a_column_of_a_table(
bands: &[TextBand],
cut_x: f64,
gap: f64,
ignored: &[HashSet<usize>],
) -> bool {
if gap <= 0.0 {
return false;
}
let (ink_left, ink_right) = measured_extent(bands, ignored);
let left = one_side_of_a_cut(
bands,
cut_x,
true,
(cut_x - ink_left).max(0.0),
gap,
ignored,
);
let right = one_side_of_a_cut(
bands,
cut_x,
false,
(ink_right - cut_x).max(0.0),
gap,
ignored,
);
let (left, right) = match (left, right) {
(Some(left), Some(right)) => (left, right),
_ => return false,
};
let banks = |side: &SideOfACut| (side.broken as f64) > BANK_BREAK_SHARE * side.inked as f64;
if banks(&left) && banks(&right) {
return true;
}
(banks(&left) && right.fill <= GRID_SLICE_FILL)
|| (banks(&right) && left.fill <= GRID_SLICE_FILL)
}
fn measured_extent(bands: &[TextBand], ignored: &[HashSet<usize>]) -> (f64, f64) {
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
for (b, band) in bands.iter().enumerate() {
let skip = ignored.get(b);
for (i, span) in band.spans.iter().enumerate() {
if skip.is_some_and(|set| set.contains(&i)) {
continue;
}
left = left.min(span.x);
right = right.max(span.measured_right());
}
}
if left.is_finite() && right > left {
(left, right)
} else {
(0.0, 0.0)
}
}
struct SideOfACut {
inked: usize,
broken: usize,
fill: f64,
}
fn one_side_of_a_cut(
bands: &[TextBand],
cut_x: f64,
is_left: bool,
measure: f64,
gap: f64,
ignored: &[HashSet<usize>],
) -> Option<SideOfACut> {
if measure <= 0.0 {
return None;
}
let mut inked = 0usize;
let mut broken = 0usize;
let mut fills: Vec<f64> = Vec::new();
for (at, band) in bands.iter().enumerate() {
let skipped = ignored.get(at);
let mut ink: Vec<(f64, f64)> = band
.spans
.iter()
.enumerate()
.filter(|(span, _)| skipped.is_none_or(|set| !set.contains(span)))
.map(|(_, span)| span)
.filter(|span| {
let middle = span.x + (span.measured_right() - span.x) * 0.5;
(middle < cut_x) == is_left
})
.map(|span| (span.x, span.measured_right()))
.collect();
if ink.is_empty() {
continue;
}
inked += 1;
ink.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
let widest = ink
.windows(2)
.map(|pair| pair[1].0 - pair[0].1)
.fold(0.0_f64, f64::max);
if widest >= gap {
broken += 1;
}
let from = ink.iter().map(|pair| pair.0).fold(f64::INFINITY, f64::min);
let to = ink
.iter()
.map(|pair| pair.1)
.fold(f64::NEG_INFINITY, f64::max);
if to > from {
fills.push(((to - from) / measure).min(1.0));
}
}
if inked < BANK_MIN_ROWS || fills.is_empty() {
return None;
}
fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
Some(SideOfACut {
inked,
broken,
fill: fills[fills.len() / 2],
})
}
fn a_column_of_labels(
bands: &[TextBand],
cut_x: f64,
region_left: f64,
region_right: f64,
min_gap: f64,
) -> bool {
fn on(band: &TextBand, left: bool, cut_x: f64) -> Vec<&TextSpan> {
band.spans
.iter()
.filter(|span| {
let middle = span.x + (span.measured_right() - span.x) * 0.5;
(middle < cut_x) == left
})
.collect()
}
let inked = |left: bool| -> Vec<usize> {
(0..bands.len())
.filter(|at| !on(&bands[*at], left, cut_x).is_empty())
.collect()
};
let (thin, wide, thin_is_left) = {
let (l, r) = (inked(true), inked(false));
if l.len() <= r.len() {
(l, r, true)
} else {
(r, l, false)
}
};
if thin.len() < LABEL_MIN_ROWS || thin.len() * 4 > wide.len() * 3 {
return false;
}
let measure = if thin_is_left {
cut_x - region_left
} else {
region_right - cut_x
};
if measure <= 0.0 {
return false;
}
let mut fills: Vec<f64> = Vec::with_capacity(thin.len());
for at in &thin {
let slice = on(&bands[*at], thin_is_left, cut_x);
let broken = slice
.windows(2)
.any(|pair| pair[1].x - pair[0].measured_right() >= min_gap);
if broken {
return false;
}
let from = slice.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
let to = slice
.iter()
.map(|s| s.measured_right())
.fold(f64::NEG_INFINITY, f64::max);
if to > from {
fills.push(((to - from) / measure).min(1.0));
}
}
if fills.is_empty() {
return false;
}
fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
if fills[fills.len() / 2] > GRID_SLICE_FILL {
return false;
}
let mut steps: Vec<f64> = wide
.windows(2)
.map(|pair| bands[pair[0]].y - bands[pair[1]].y)
.filter(|step| *step > 0.0)
.collect();
if steps.len() < 2 {
return false;
}
steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let leading = steps[steps.len() / 2];
if leading <= 0.0 {
return false;
}
let opens_the_wide_side = |at: usize| match wide.iter().position(|w| *w >= at) {
Some(0) => true,
Some(where_) if wide[where_] == at => {
bands[wide[where_ - 1]].y - bands[at].y > leading * LABEL_PITCH_JUMP
}
_ => false,
};
thin.iter().all(|at| {
*at == 0
|| opens_the_wide_side(*at)
|| (thin.binary_search(&(at - 1)).is_ok() && bands[at - 1].y - bands[*at].y <= leading)
})
}
fn columns_are_band_aligned(
spans: &[TextSpan],
cut_x: f64,
region_left: f64,
region_right: f64,
stats: &PageStats,
) -> bool {
let left_width = (cut_x - region_left).max(1.0);
let right_width = (region_right - cut_x).max(1.0);
const MAX_SINGLE_SIDE_FRACTION: f64 = 0.70;
let _ = (left_width, right_width, MAX_SINGLE_SIDE_FRACTION);
for span in spans {
if span.x < cut_x && span.measured_right() > cut_x {
return false;
}
}
const OUTLIER: f64 = 1.3;
let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
let mut widths: (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
let mut single: Vec<(bool, f64)> = Vec::new();
for band in &bands {
let mut left_lo = f64::INFINITY;
let mut left_hi = f64::NEG_INFINITY;
let mut right_lo = f64::INFINITY;
let mut right_hi = f64::NEG_INFINITY;
for span in &band.spans {
let r = span.measured_right();
if span.x + (r - span.x) * 0.5 < cut_x {
left_lo = left_lo.min(span.x);
left_hi = left_hi.max(r);
} else {
right_lo = right_lo.min(span.x);
right_hi = right_hi.max(r);
}
}
let has_left = left_hi > left_lo || left_lo.is_finite();
let has_right = right_hi > right_lo || right_lo.is_finite();
if has_left {
widths.0.push((left_hi - left_lo).max(0.0));
}
if has_right {
widths.1.push((right_hi - right_lo).max(0.0));
}
match (has_left, has_right) {
(true, false) => single.push((true, (left_hi - left_lo).max(0.0))),
(false, true) => single.push((false, (right_hi - right_lo).max(0.0))),
_ => {}
}
}
let inid = spans
.iter()
.filter(|span| {
let t = span.text.trim_start();
let b = t.as_bytes();
span.x <= region_left + 2.0 * span.font_size.max(1.0)
&& b.len() >= 4
&& b[0] == b'('
&& b[1].is_ascii_digit()
&& b[2].is_ascii_digit()
&& b[3] == b')'
})
.count()
>= 3;
let median = |v: &mut Vec<f64>| -> Option<f64> {
if v.is_empty() {
return None;
}
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
Some(if inid {
v[(v.len() * 9 / 10).min(v.len() - 1)]
} else {
v[v.len() / 2]
})
};
let left_median = median(&mut widths.0);
let right_median = median(&mut widths.1);
for (is_left, w) in single {
let m = if is_left { left_median } else { right_median };
if let Some(m) = m {
if m > 0.0 && w > m * OUTLIER {
return false;
}
}
}
true
}
fn columns_are_dense(left: &[TextSpan], right: &[TextSpan], stats: &PageStats) -> bool {
for col in [left, right] {
if col.len() < XY_CUT_MIN_SPANS_PER_COLUMN {
return false;
}
let bands = group_spans_into_bands_with_stats(col.to_vec(), stats);
if bands.is_empty() {
return false;
}
let total_chars: usize = col.iter().map(|s| s.text.chars().count()).sum();
let chars_per_band = total_chars as f64 / bands.len() as f64;
if chars_per_band < XY_CUT_MIN_CHARS_PER_BAND {
return false;
}
}
true
}
fn try_horizontal_cut(spans: &[TextSpan], stats: &PageStats) -> Option<(Vec<Vec<TextSpan>>, f64)> {
if spans.len() < 2 {
return None;
}
let mut sorted = spans.to_vec();
sorted.sort_by(|a, b| {
b.y.partial_cmp(&a.y)
.unwrap_or(Ordering::Equal)
.then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
});
let min_gap = if stats.median_line_spacing > 0.0 {
stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
} else {
stats.median_font_size * XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER
};
let leading = leading_of_a_region(spans, stats);
let min_gap = if leading > 0.0 {
min_gap.max(leading * HORIZONTAL_CUT_LEADING_FLOOR)
} else {
min_gap
};
let bands = group_spans_into_bands(sorted.clone());
let mut best: Option<(f64, f64)> = None; let tolerance = stats.median_font_size * BAND_Y_FRACTION;
let mut band_bottom = sorted[0].y;
for span in sorted.iter().skip(1) {
if (band_bottom - span.y).abs() <= tolerance {
band_bottom = band_bottom.min(span.y);
continue;
}
let gap = band_bottom - span.y;
if gap >= min_gap {
let cut_y = (band_bottom + span.y) * 0.5;
match best {
Some((best_gap, _)) if best_gap >= gap => {}
_ => best = Some((gap, cut_y)),
}
}
band_bottom = span.y;
}
let (gap_size, cut_y) = best?;
if the_cut_is_inside_a_grid(&bands, cut_y, gap_size) {
return None;
}
let mut top_group = Vec::new();
let mut bottom_group = Vec::new();
for span in spans {
if span.y > cut_y {
top_group.push(span.clone());
} else {
bottom_group.push(span.clone());
}
}
if top_group.is_empty() || bottom_group.is_empty() {
return None;
}
Some((vec![top_group, bottom_group], gap_size))
}
#[allow(dead_code)]
fn group_spans_into_blocks_legacy(spans: Vec<TextSpan>) -> Vec<TextBlock> {
let bands = group_spans_into_bands(spans);
group_spans_into_blocks_legacy_from_bands(bands, &[])
}
fn group_spans_into_blocks_legacy_with_stats(
spans: Vec<TextSpan>,
stats: &PageStats,
rules: &[HorizontalRule],
) -> Vec<TextBlock> {
let bands = group_spans_into_bands_with_stats(spans, stats);
group_spans_into_blocks_legacy_from_bands(bands, rules)
}
fn group_spans_into_blocks_legacy_from_bands(
bands: Vec<TextBand>,
rules: &[HorizontalRule],
) -> Vec<TextBlock> {
if bands.is_empty() {
return Vec::new();
}
let column_gap_threshold = compute_adaptive_column_gap(&bands);
let mut blocks = Vec::new();
let mut idx = 0;
while idx < bands.len() {
let gap_midpoints = bands[idx].gap_midpoints(column_gap_threshold);
if gap_midpoints.is_empty() {
idx = push_row(&bands, idx, &mut blocks);
continue;
}
let mut boundaries = gap_midpoints.clone();
let mut band_indices = vec![idx];
let mut gapped_band_count = 1usize;
let mut region_left = bands[idx].left();
let mut region_right = bands[idx].right();
let mut next_idx = idx + 1;
while next_idx < bands.len() {
let next_band = &bands[next_idx];
let next_gap_midpoints = next_band.gap_midpoints(column_gap_threshold);
if next_gap_midpoints.is_empty() {
if next_band
.fits_single_column(&boundaries, region_left, region_right)
.is_some()
{
band_indices.push(next_idx);
next_idx += 1;
continue;
}
break;
}
if !boundaries_match(&boundaries, &next_gap_midpoints, column_gap_threshold) {
break;
}
update_boundaries(&mut boundaries, &next_gap_midpoints, gapped_band_count);
gapped_band_count += 1;
band_indices.push(next_idx);
region_left = region_left.min(next_band.left());
region_right = region_right.max(next_band.right());
next_idx += 1;
}
if region_is_columnar(&bands, &band_indices, &boundaries, gapped_band_count)
&& !region_is_ruled(&bands, &band_indices, rules)
&& !region_is_a_grid(&bands, &band_indices, &boundaries)
{
append_column_region_blocks(&bands, &band_indices, &boundaries, &mut blocks);
idx = next_idx;
} else {
idx = push_row(&bands, idx, &mut blocks);
}
}
blocks
}
fn push_row(bands: &[TextBand], idx: usize, blocks: &mut Vec<TextBlock>) -> usize {
if let Some(next) = append_wrapped_table_row(bands, idx, blocks) {
return next;
}
blocks.push(bands[idx].row_block());
idx + 1
}
fn cell_edges(band: &TextBand) -> Vec<f64> {
let mut edges: Vec<f64> = band.spans.iter().map(|span| span.x).collect();
edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
edges.dedup_by(|a, b| (*a - *b).abs() <= CELL_EDGE_TOLERANCE);
edges
}
fn cell_of(span: &TextSpan, edges: &[f64]) -> Option<usize> {
let idx = edges
.iter()
.rposition(|edge| span.x >= edge - CELL_EDGE_TOLERANCE)?;
if (span.x - edges[idx]).abs() > CELL_EDGE_TOLERANCE {
return None;
}
if let Some(next_edge) = edges.get(idx + 1) {
if span.measured_right() > next_edge - CELL_EDGE_TOLERANCE {
return None;
}
}
Some(idx)
}
fn band_line_height(band: &TextBand) -> f64 {
band.spans
.iter()
.map(|span| span.height.max(span.font_size))
.fold(0.0_f64, f64::max)
}
fn band_fills_one_cell(band: &TextBand, edges: &[f64]) -> Option<usize> {
let mut filled: Option<usize> = None;
for span in &band.spans {
let cell = cell_of(span, edges)?;
match filled {
Some(seen) if seen != cell => return None,
_ => filled = Some(cell),
}
}
filled
}
fn band_matches_grid(band: &TextBand, edges: &[f64]) -> bool {
let mut occupied = vec![false; edges.len()];
for span in &band.spans {
match cell_of(span, edges) {
Some(cell) => occupied[cell] = true,
None => return false,
}
}
occupied.iter().filter(|seen| **seen).count() >= TABLE_GRID_MIN_COLUMNS
}
fn append_wrapped_table_row(
bands: &[TextBand],
idx: usize,
blocks: &mut Vec<TextBlock>,
) -> Option<usize> {
let head = &bands[idx];
if head.spans.len() < TABLE_ROW_MIN_CELLS {
return None;
}
let edges = cell_edges(head);
if edges.len() < TABLE_ROW_MIN_CELLS {
return None;
}
let line_height = band_line_height(head);
if line_height <= 0.0 {
return None;
}
let head_cells: Vec<usize> = head
.spans
.iter()
.map(|span| cell_of(span, &edges))
.collect::<Option<Vec<usize>>>()?;
let above = idx.checked_sub(1)?;
let row_pitch = bands[above].y - head.y;
if row_pitch <= 0.0 {
return None;
}
let max_line_gap = (line_height * WRAPPED_CELL_LINE_GAP_FACTOR)
.min(row_pitch * WRAPPED_CELL_MAX_PITCH_FRACTION);
let mut corroborator = above;
loop {
if band_matches_grid(&bands[corroborator], &edges) {
break;
}
if band_fills_one_cell(&bands[corroborator], &edges).is_none()
|| idx - corroborator >= TABLE_GRID_MAX_LOOKBACK
|| corroborator == 0
{
return None;
}
corroborator -= 1;
}
let mut continuations: Vec<(&TextBand, usize)> = Vec::new();
let mut last_line_in_cell = vec![head.y; edges.len()];
let mut next_idx = idx + 1;
while next_idx < bands.len() {
let band = &bands[next_idx];
if band.spans.len() >= head.spans.len() {
break;
}
let Some(cell) = band_fills_one_cell(band, &edges) else {
break;
};
if !head_cells.contains(&cell) {
break;
}
let line_gap = last_line_in_cell[cell] - band.y;
if line_gap <= 0.0 || line_gap > max_line_gap {
break;
}
last_line_in_cell[cell] = band.y;
continuations.push((band, cell));
next_idx += 1;
}
if continuations.is_empty() {
return None;
}
let wrapped_cells: Vec<usize> = continuations.iter().map(|(_, cell)| *cell).collect();
if head_cells.iter().all(|cell| wrapped_cells.contains(cell)) {
return None;
}
for cell in 0..edges.len() {
let row_spans: Vec<TextSpan> = head
.spans
.iter()
.zip(&head_cells)
.filter(|(_, span_cell)| **span_cell == cell)
.map(|(span, _)| span.clone())
.collect();
if !row_spans.is_empty() {
blocks.push(TextBlock { spans: row_spans });
}
for (band, wrapped_cell) in &continuations {
if *wrapped_cell == cell {
blocks.push(band.row_block());
}
}
}
Some(next_idx)
}
fn group_spans_into_bands(spans: Vec<TextSpan>) -> Vec<TextBand> {
let stats = PageStats::from_spans(&spans);
group_spans_into_bands_with_stats(spans, &stats)
}
fn group_spans_into_bands_with_stats(mut spans: Vec<TextSpan>, stats: &PageStats) -> Vec<TextBand> {
if spans.is_empty() {
return Vec::new();
}
spans.sort_by(|a, b| {
b.y.partial_cmp(&a.y)
.unwrap_or(Ordering::Equal)
.then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
});
let page_tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
let mut bands: Vec<TextBand> = Vec::new();
for span in spans {
let tolerance = (span.height * BAND_Y_FRACTION)
.max(page_tolerance)
.max(BAND_Y_TOLERANCE);
if let Some(band) = bands
.iter_mut()
.find(|band| (band.y - span.y).abs() <= tolerance)
{
let span_count = band.spans.len() as f64;
band.y = (band.y * span_count + span.y) / (span_count + 1.0);
band.spans.push(span);
} else {
bands.push(TextBand::new(span));
}
}
for band in &mut bands {
band.sort_spans();
}
bands.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
bands
}
fn boundaries_match(boundaries: &[f64], gap_midpoints: &[f64], column_gap_threshold: f64) -> bool {
let tolerance = (column_gap_threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
boundaries.len() == gap_midpoints.len()
&& boundaries
.iter()
.zip(gap_midpoints)
.all(|(lhs, rhs)| (lhs - rhs).abs() <= tolerance)
}
fn update_boundaries(boundaries: &mut [f64], gap_midpoints: &[f64], seen_gapped_bands: usize) {
for (boundary, midpoint) in boundaries.iter_mut().zip(gap_midpoints) {
*boundary =
(*boundary * seen_gapped_bands as f64 + midpoint) / (seen_gapped_bands as f64 + 1.0);
}
}
fn region_is_columnar(
bands: &[TextBand],
band_indices: &[usize],
boundaries: &[f64],
gapped_band_count: usize,
) -> bool {
if boundaries.is_empty()
|| gapped_band_count < MIN_COLUMN_GAPPED_BANDS
|| band_indices.is_empty()
|| (gapped_band_count as f64 / band_indices.len() as f64) < MIN_COLUMN_GAP_SUPPORT
{
return false;
}
let mut non_empty_slices = 0usize;
let mut dense_slices = 0usize;
let mut slices_per_column = vec![0usize; boundaries.len() + 1];
for &band_idx in band_indices {
let slices = bands[band_idx].split_by_boundaries(boundaries);
for (column_idx, slice) in slices.iter().enumerate() {
if slice.is_empty() {
continue;
}
non_empty_slices += 1;
slices_per_column[column_idx] += 1;
let char_count = slice
.iter()
.map(|span| span.text.chars().count())
.sum::<usize>();
if slice.len() >= 2 || char_count >= 8 {
dense_slices += 1;
}
}
}
if non_empty_slices < boundaries.len() + 2 {
return false;
}
if slices_per_column.contains(&0) {
return false;
}
let mut marked = 0usize;
let markers_first = band_indices.iter().all(|&band_idx| {
let slices = bands[band_idx].split_by_boundaries(boundaries);
let joined = |slice: &Vec<TextSpan>| -> String {
slice
.iter()
.map(|span| span.text.as_str())
.collect::<String>()
};
let Some(first) = slices.first() else {
return true;
};
let text = joined(first);
let text = text.trim();
if text.is_empty() {
return true;
}
let item_follows = slices
.iter()
.skip(1)
.map(joined)
.find(|t| !t.trim().is_empty())
.is_some_and(|t| t.chars().any(char::is_alphabetic));
marked += 1;
is_bullet_or_list_marker(text) && item_follows
});
if markers_first && marked >= 2 {
return false;
}
(dense_slices as f64 / non_empty_slices as f64) >= MIN_DENSE_SLICE_RATIO
}
fn is_bullet_or_list_marker(text: &str) -> bool {
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => {
crate::lists::BULLET_CHARS.contains(&c) || crate::lists::is_a_pointer_or_a_picture(c)
}
_ => is_list_marker(text),
}
}
fn region_is_a_grid(bands: &[TextBand], band_indices: &[usize], boundaries: &[f64]) -> bool {
if band_indices.len() < GRID_MIN_ROWS || boundaries.is_empty() {
return false;
}
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
for &band_idx in band_indices {
for span in &bands[band_idx].spans {
left = left.min(span.x);
right = right.max(span.measured_right());
}
}
if !(left.is_finite() && right > left) {
return false;
}
let edges: Vec<f64> = std::iter::once(left)
.chain(boundaries.iter().copied())
.chain(std::iter::once(right))
.collect();
let mut present = vec![0usize; boundaries.len() + 1];
let mut fills: Vec<f64> = Vec::new();
for &band_idx in band_indices {
for (column, slice) in bands[band_idx]
.split_by_boundaries(boundaries)
.iter()
.enumerate()
{
if slice.is_empty() {
continue;
}
present[column] += 1;
let slice_left = slice
.iter()
.map(|span| span.x)
.fold(f64::INFINITY, f64::min);
let slice_right = slice
.iter()
.map(TextSpan::measured_right)
.fold(f64::NEG_INFINITY, f64::max);
let width = edges[column + 1] - edges[column];
if width > 0.0 && slice_right > slice_left {
fills.push(((slice_right - slice_left) / width).min(1.0));
}
}
}
let needed = GRID_COLUMN_SUPPORT * band_indices.len() as f64;
if present.iter().any(|seen| (*seen as f64) < needed) {
return false;
}
if fills.is_empty() {
return false;
}
fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
fills[fills.len() / 2] <= GRID_SLICE_FILL
}
fn region_is_ruled(bands: &[TextBand], band_indices: &[usize], rules: &[HorizontalRule]) -> bool {
if rules.is_empty() || band_indices.len() < MIN_RULED_ROW_SEPARATORS + 1 {
return false;
}
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
for &band_idx in band_indices {
for span in &bands[band_idx].spans {
left = left.min(span.x);
right = right.max(span.measured_right());
}
}
let width = right - left;
if width <= 0.0 {
return false;
}
let mut gaps = 0usize;
let mut ruled = 0usize;
for pair in band_indices.windows(2) {
let upper = bands[pair[0]].y;
let lower = bands[pair[1]].y;
if upper <= lower {
continue;
}
gaps += 1;
let crossed = rules.iter().any(|rule| {
rule.y > lower
&& rule.y < upper
&& (rule.right.min(right) - rule.left.max(left)) >= width * RULE_SPAN_FRACTION
});
if crossed {
ruled += 1;
}
}
gaps > 0
&& ruled >= MIN_RULED_ROW_SEPARATORS
&& (ruled as f64 / gaps as f64) >= MIN_RULED_GAP_SUPPORT
}
fn append_column_region_blocks(
bands: &[TextBand],
band_indices: &[usize],
boundaries: &[f64],
blocks: &mut Vec<TextBlock>,
) {
let column_count = boundaries.len() + 1;
let mut column_bands = vec![Vec::<TextSpan>::new(); column_count];
for &band_idx in band_indices {
let slices = bands[band_idx].split_by_boundaries(boundaries);
for (column_idx, slice) in slices.into_iter().enumerate() {
if slice.is_empty() {
continue;
}
column_bands[column_idx].push(TextSpan::default());
let marker_idx = column_bands[column_idx].len() - 1;
column_bands[column_idx][marker_idx] = TextSpan {
x: f64::NEG_INFINITY,
y: bands[band_idx].y,
..TextSpan::default()
};
column_bands[column_idx].extend(slice);
}
}
for spans in column_bands {
let mut current: Vec<TextSpan> = Vec::new();
for span in spans {
if span.x == f64::NEG_INFINITY {
if !current.is_empty() {
current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
blocks.push(TextBlock {
spans: std::mem::take(&mut current),
});
}
continue;
}
current.push(span);
}
if !current.is_empty() {
current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
blocks.push(TextBlock { spans: current });
}
}
}
fn stitch_hyphenated_lines(lines: &[String]) -> String {
let mut out = String::new();
for (idx, line) in lines.iter().enumerate() {
if idx == 0 {
out.push_str(line);
continue;
}
let next_trimmed = line.trim_start();
let should_merge = is_hyphen_wrap_candidate(&out, next_trimmed);
if should_merge {
out.pop(); out.push_str(next_trimmed);
} else {
out.push('\n');
out.push_str(line);
}
}
out
}
pub(crate) fn is_hyphen_wrap_candidate(accumulated: &str, next_trimmed: &str) -> bool {
if !accumulated.ends_with('-') {
return false;
}
let before_hyphen = accumulated.chars().rev().nth(1);
if !before_hyphen.is_some_and(|c| c.is_alphabetic()) {
return false;
}
let alpha_prefix_len = accumulated
.chars()
.rev()
.skip(1) .take_while(|c| c.is_alphabetic())
.count();
if alpha_prefix_len < 3 {
return false;
}
let first_next = next_trimmed.chars().next();
if !first_next.is_some_and(|c| c.is_ascii_lowercase()) {
return false;
}
let next_alpha_len = next_trimmed
.chars()
.take_while(|c| c.is_ascii_lowercase())
.count();
if next_alpha_len < 3 {
return false;
}
true
}
pub(crate) fn normalize_text_output(text: &str) -> String {
if text.is_empty() {
return String::new();
}
let mut lines: Vec<&str> = Vec::new();
for line in text.split('\n') {
lines.push(line.trim_end());
}
while lines.last() == Some(&"") {
lines.pop();
}
if lines.is_empty() {
return String::new();
}
let mut result = String::with_capacity(text.len());
let mut consecutive_empty = 0u32;
for (i, line) in lines.iter().enumerate() {
if line.is_empty() || *line == "\x0C" {
if line.is_empty() {
consecutive_empty += 1;
if consecutive_empty <= 2 {
result.push('\n');
}
} else {
consecutive_empty = 0;
result.push_str(line);
if i + 1 < lines.len() {
result.push('\n');
}
}
} else {
consecutive_empty = 0;
result.push_str(line);
if i + 1 < lines.len() {
result.push('\n');
}
}
}
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
TextSpan {
text: text.into(),
x,
y,
width,
height: 12.0,
font_size: 12.0,
..TextSpan::default()
}
}
fn boxed(text: &str, x: f64) -> TextSpan {
let n = text.chars().count();
let mut s = span(text, x, 100.0, 6.0 * n as f64);
s.char_bounds = (0..n)
.map(|i| {
let left = x + 6.0 * i as f64;
[left, 100.0, left + 6.0, 112.0]
})
.collect();
s
}
#[test]
fn a_circle_from_another_source_keeps_its_place() {
let mut mark = boxed("\u{20DD}", 98.0);
mark.char_bounds = vec![[98.0, 100.0, 110.0, 112.0]];
mark.width = 12.0;
let enclosing = TextBlock {
spans: vec![mark.clone(), boxed("c", 100.0)],
};
assert!(
enclosing.text().starts_with('\u{20DD}'),
"{:?}",
enclosing.text()
);
let lone = TextBlock { spans: vec![mark] };
assert_eq!(lone.text(), "\u{20DD}");
}
fn tracked_row(words: &[&str], tracking: f64, gap: Option<(usize, f64)>) -> TextBlock {
let mut spans = Vec::new();
let mut x = 0.0;
for (w, word) in words.iter().enumerate() {
if w > 0 {
match gap {
Some((at, wide)) if at == w => x += wide,
_ => {
spans.push(span(" ", x, 0.0, 3.0));
x += 3.0 + tracking;
}
}
}
for c in word.chars() {
spans.push(span(&c.to_string(), x, 0.0, 8.0));
x += 8.0 + tracking;
}
}
TextBlock { spans }
}
#[test]
fn a_row_tracked_by_drawn_spaces_reads_as_its_words() {
let row = tracked_row(&["POWELL", "INDUSTRIES,", "INC."], 5.0, None);
assert_eq!(row.text(), "POWELL INDUSTRIES, INC.");
}
#[test]
fn a_row_of_letters_with_no_drawn_space_is_left_alone() {
let row = tracked_row(&["ABCDE"], 5.0, None);
assert_eq!(row.text(), "A B C D E");
let row = tracked_row(&["xyzw", "abcd"], 5.0, None);
assert_eq!(row.text(), "x y z w a b c d");
}
#[test]
fn lower_case_between_drawn_spaces_does_not_make_the_capitals_tracked() {
let row = tracked_row(&["ABCD", "xyzw", "EFGH"], 5.0, None);
assert_eq!(row.text(), "A B C D x y z w E F G H");
}
#[test]
fn a_single_letter_after_the_drawn_space_is_not_a_tracked_word() {
let row = tracked_row(&["FCFC", "S"], 5.0, None);
assert_eq!(row.text(), "F C F C S");
}
#[test]
fn a_gap_far_wider_than_the_tracking_keeps_its_space() {
let row = tracked_row(&["NEW", "YORK", "DESK"], 5.0, Some((2, 40.0)));
assert_eq!(row.text(), "NEW YORK DESK");
}
#[test]
fn letter_spacing_by_space_characters_reads_as_its_words() {
let mut tracked = span(
"P O W E L L I N D U S T R I E S , I N C .",
0.0,
0.0,
200.0,
);
untrack_spaced_letters(&mut tracked);
assert_eq!(tracked.text, "POWELL INDUSTRIES, INC.");
let mut apart = span("A B C D E F G", 0.0, 0.0, 100.0);
untrack_spaced_letters(&mut apart);
assert_eq!(apart.text, "A B C D E F G");
let mut formula = span("x y z w a b c d", 0.0, 0.0, 100.0);
untrack_spaced_letters(&mut formula);
assert_eq!(formula.text, "x y z w a b c d");
let mut label = span("F C F C S", 0.0, 0.0, 100.0);
untrack_spaced_letters(&mut label);
assert_eq!(label.text, "F C F C S");
}
fn consensus_spaced(letters: &str, advance: f64, gaps: &[f64], size: f64) -> TextSpan {
let mut x = 0.0;
let mut bounds = Vec::new();
for (k, _) in letters.chars().enumerate() {
bounds.push([x, 0.0, x + advance, size]);
x += advance + gaps.get(k).copied().unwrap_or(0.0);
}
let text = letters
.chars()
.map(String::from)
.collect::<Vec<_>>()
.join(" ");
TextSpan {
char_bounds: bounds,
font_size: size,
..span(&text, 0.0, 0.0, x)
}
}
#[test]
fn a_short_tracked_word_reads_as_one() {
let mut penn = consensus_spaced("PENN", 3.0, &[0.9, 0.9, 0.9], 6.2);
untrack_spaced_letters(&mut penn);
assert_eq!(penn.text, "PENN");
let mut kerned = consensus_spaced(
"OUTSTANDING",
30.0,
&[3.0, 3.0, 3.0, 3.0, 0.6, 3.0, 3.0, 3.0, 1.5, 3.0],
60.0,
);
untrack_spaced_letters(&mut kerned);
assert_eq!(kerned.text, "OUTSTANDING");
}
#[test]
fn a_gap_wider_than_the_tracking_is_a_word_boundary() {
let mut two = consensus_spaced("ABCDEF", 8.0, &[1.0, 1.0, 3.0, 1.0, 1.0], 12.0);
untrack_spaced_letters(&mut two);
assert_eq!(two.text, "ABC DEF");
let mut short = consensus_spaced("IAMOK", 8.0, &[3.0, 1.0, 3.0, 1.0], 12.0);
untrack_spaced_letters(&mut short);
assert_eq!(short.text, "I AM OK");
}
#[test]
fn a_finished_span_is_left_alone_unless_it_is_tracked_capitals() {
let mut solid = consensus_spaced("ABCDE", 8.0, &[0.0, 0.0, 0.0, 0.0], 12.0);
untrack_spaced_letters(&mut solid);
assert_eq!(solid.text, "A B C D E");
let mut lower = consensus_spaced("xyzwv", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
untrack_spaced_letters(&mut lower);
assert_eq!(lower.text, "x y z w v");
let mut mixed = consensus_spaced("ABCDxyz", 8.0, &[1.0; 6], 12.0);
untrack_spaced_letters(&mut mixed);
assert_eq!(mixed.text, "A B C D x y z");
let mut drawn = consensus_spaced("ABCDE", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
drawn.char_bounds.push([100.0, 0.0, 104.0, 12.0]);
untrack_spaced_letters(&mut drawn);
assert_eq!(drawn.text, "A B C D E");
}
fn sized(text: &str, x: f64, y: f64, width: f64, size: f64) -> TextSpan {
TextSpan {
font_size: size,
height: size,
..span(text, x, y, width)
}
}
#[test]
fn a_label_drawn_again_as_its_shadow_is_one_label() {
let first = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
let shadow = sized("KFTC", 264.13, 656.97, 12.88, 4.9);
assert!(spans_are_overprint_duplicates(&first, &shadow));
let small = sized("NRST", 385.24, 476.85, 10.93, 4.90);
let large = sized("NRST", 387.79, 477.16, 12.33, 5.52);
assert!(spans_are_overprint_duplicates(&small, &large));
}
#[test]
fn a_column_head_set_twice_half_over_itself_is_one_head() {
let first = sized("2003", 346.22, 549.77, 20.16, 10.08);
let copy = sized("2003", 354.21, 550.63, 20.16, 10.08);
assert!(spans_are_overprint_duplicates(&first, ©));
let left = sized("2003", 346.22, 549.77, 20.16, 10.08);
let right = sized("2003", 358.0, 549.77, 20.16, 10.08);
assert!(!spans_are_overprint_duplicates(&left, &right));
let a = sized("12", 100.0, 100.0, 10.0, 10.0);
let b = sized("12", 104.0, 100.3, 10.0, 10.0);
assert!(!spans_are_overprint_duplicates(&a, &b));
}
#[test]
fn a_shadow_needs_the_same_text_of_two_characters_close_by() {
let one = sized("1", 100.0, 100.0, 2.5, 5.0);
let other = sized("1", 100.5, 100.9, 2.5, 5.0);
assert!(!spans_are_overprint_duplicates(&one, &other));
let a = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
let b = sized("KCLN", 264.13, 656.97, 12.88, 4.9);
assert!(!spans_are_overprint_duplicates(&a, &b));
let line = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
let below = sized("KFTC", 263.19, 654.50, 12.88, 4.9);
assert!(!spans_are_overprint_duplicates(&line, &below));
}
#[test]
fn a_tracked_span_that_loses_its_spaces_says_what_its_letters_are() {
let text = "A B C D E F G H";
let mut tracked = span(text, 0.0, 0.0, 100.0);
tracked.glyph_bounds_sources = text
.chars()
.map(|c| {
if c == ' ' {
BoundsSource::Estimate
} else {
BoundsSource::Tight
}
})
.collect();
tracked.bounds_source = BoundsSource::Estimate;
untrack_spaced_letters(&mut tracked);
assert_eq!(tracked.text, "ABCD EFGH");
assert_eq!(tracked.glyph_bounds_sources.len(), 9);
assert_eq!(
tracked.bounds_source,
BoundsSource::Estimate,
"the one space kept is still a space"
);
let mut all_tight = span("A B C D E F G H", 0.0, 0.0, 100.0);
all_tight.glyph_bounds_sources = vec![BoundsSource::Tight; 16];
all_tight.bounds_source = BoundsSource::Estimate;
untrack_spaced_letters(&mut all_tight);
assert_eq!(all_tight.bounds_source, BoundsSource::Tight);
}
fn two_column_page() -> Vec<TextSpan> {
const LEFT_X: f64 = 40.0;
const RIGHT_X: f64 = 320.0;
const COLUMN_WIDTH: f64 = 250.0;
const TOP: f64 = 700.0;
const LEADING: f64 = 14.0;
const LINES: usize = 20;
let mut out = vec![span(
"A Trace-based Just-in-Time Compiler",
40.0,
740.0,
530.0,
)];
for i in 0..LINES {
out.push(span(
&format!("left line {i:02} of the two-column academic body text"),
LEFT_X,
TOP - (i as f64) * LEADING,
COLUMN_WIDTH,
));
}
for i in 0..(LINES - 1) {
out.push(span(
&format!("right line {i:02} of the two-column academic body text"),
RIGHT_X,
TOP - (i as f64) * LEADING,
COLUMN_WIDTH,
));
}
out
}
#[test]
fn a_two_column_body_is_read_column_by_column() {
let spans = two_column_page();
let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
let produced: Vec<String> = group_spans_into_blocks(spans, &[])
.into_iter()
.flat_map(|b| b.spans.into_iter().map(|s| s.text))
.collect();
assert_eq!(produced, expected);
}
fn narrow_gutter_page() -> Vec<TextSpan> {
narrow_gutter_page_with_gutter(13.2)
}
fn narrow_gutter_page_with_gutter(gutter: f64) -> Vec<TextSpan> {
const LEFT_X: f64 = 54.0;
const COLUMN_WIDTH: f64 = 240.0;
const FONT_SIZE: f64 = 9.0;
const TOP: f64 = 700.0;
const LEADING: f64 = 11.0;
const LINES: usize = 20;
let right_x = LEFT_X + COLUMN_WIDTH + gutter;
let line = |text: &str, x: f64, y: f64| TextSpan {
text: text.into(),
x,
y,
width: COLUMN_WIDTH,
height: FONT_SIZE,
font_size: FONT_SIZE,
..TextSpan::default()
};
let mut out = vec![TextSpan {
text: "Trace-based Just-in-Time Type Specialization".into(),
x: LEFT_X,
y: 740.0,
width: 490.0,
height: 14.0,
font_size: 14.0,
..TextSpan::default()
}];
for i in 0..LINES {
out.push(line(
&format!("left line {i:02} of the two-column academic body of this paper"),
LEFT_X,
TOP - (i as f64) * LEADING,
));
}
for i in 0..(LINES - 1) {
out.push(line(
&format!("right line {i:02} of the two-column academic body of this paper"),
right_x,
TOP - (i as f64) * LEADING,
));
}
out
}
#[test]
fn a_thirteen_point_gutter_in_nine_point_type_is_a_gutter() {
let spans = narrow_gutter_page();
let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
let stats = PageStats::from_spans(&body);
let (groups, gap) = try_vertical_cut(&body, &stats, &[])
.expect("no column cut found on a 13.2 pt gutter in 9 pt type");
assert_eq!(groups.len(), 2);
assert!(
(gap - 13.2).abs() < 0.5,
"the cut was taken on a gap of {gap}, not on the gutter"
);
assert!(
groups[0].iter().all(|s| s.text.starts_with("left")),
"the left group holds text from the right column"
);
assert!(
groups[1].iter().all(|s| s.text.starts_with("right")),
"the right group holds text from the left column"
);
}
#[test]
fn the_narrow_gutter_page_reads_column_by_column() {
let spans = narrow_gutter_page();
let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
let produced: Vec<String> = group_spans_into_blocks(spans, &[])
.into_iter()
.flat_map(|b| b.spans.into_iter().map(|s| s.text))
.collect();
assert_eq!(produced, expected);
}
#[test]
fn a_gap_narrower_than_the_type_is_not_a_gutter() {
let spans = narrow_gutter_page_with_gutter(6.0);
let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
let stats = PageStats::from_spans(&body);
assert!(
try_vertical_cut(&body, &stats, &[]).is_none(),
"a six point gap in nine point type was taken for a column gutter"
);
}
fn nine(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
TextSpan {
height: 9.0,
font_size: 9.0,
..span(text, x, y, width)
}
}
fn columns_with_hung_markers() -> Vec<TextSpan> {
let mut out = Vec::new();
for i in 0..12 {
let y = 700.0 - 12.0 * i as f64;
out.push(nine(
&format!("left line {i:02} of the running text of this annual report"),
72.0,
y,
225.0,
));
if i % 4 == 0 {
out.push(nine("t", 305.0, y, 3.3));
out.push(nine(" ", 308.3, y, 5.0));
}
out.push(nine(
&format!("right line {i:02} of the running text of this annual report"),
320.0,
y,
225.0,
));
}
out
}
#[test]
fn a_gutter_is_found_past_the_markers_hung_into_it() {
let body = columns_with_hung_markers();
let stats = PageStats::from_spans(&body);
let (groups, _) = try_vertical_cut(&body, &stats, &[])
.expect("no cut past the hung markers and their space runs");
assert_eq!(groups.len(), 2);
assert!(
groups[0].iter().all(|s| s.text.starts_with("left")),
"a hung marker went to the left column"
);
}
#[test]
fn a_column_of_cells_found_past_a_space_is_not_cut_off() {
let mut body = vec![
nine("Exhibit", 192.0, 728.0, 28.0),
nine(" ", 326.0, 728.0, 2.0),
nine("Method of Filing", 413.0, 728.0, 63.0),
];
for i in 0..10 {
let y = 713.0 - 18.0 * i as f64;
if i % 3 == 2 {
body.push(nine(
"101.SCH XBRL Taxonomy Extension Schema Document",
52.0,
y,
213.0,
));
} else {
body.push(nine(&format!("{i}.1"), 52.0, y, 16.0));
body.push(nine(
"Certification of Chief Financial Officer required",
92.0,
y,
226.0,
));
}
body.push(nine("Filed herewith electronically", 330.0, y, 100.0));
}
let stats = PageStats::from_spans(&body);
assert!(
try_vertical_cut(&body, &stats, &[]).is_none(),
"the cell column of a table was cut off as a column of text"
);
}
#[test]
fn a_closing_glyph_is_not_a_hung_marker() {
let mut body = Vec::new();
for i in 0..10 {
let y = 700.0 - 12.0 * i as f64;
let width = [221.0, 204.0, 213.0, 196.0, 218.0][i % 5];
body.push(nine(
&format!("left line {i:02} of the running text of this annual report (see"),
72.0,
y,
width,
));
body.push(nine(")", 72.0 + width + 1.0, y, 3.0));
body.push(nine(
&format!("right line {i:02} of the running text of this annual report"),
305.0,
y,
225.0,
));
}
let stats = PageStats::from_spans(&body);
assert!(
try_vertical_cut(&body, &stats, &[]).is_none(),
"a line's closing glyph was looked past as a hung marker"
);
}
#[test]
fn a_statement_parted_by_tab_runs_is_not_cut() {
let mut body = Vec::new();
for i in 0..10 {
let y = 700.0 - 12.0 * i as f64;
body.push(nine(
&format!("Corporate, other and eliminations ({i:02})"),
15.0,
y,
106.0,
));
let mut x = 125.0;
for cell in [
"$ 438,082",
"$ 9,836,950",
"$ 3,304,152",
"$ 5,919,819",
"$ 429,405",
"$ 3,692,551",
"$ 840,333",
"$ 1,049,328",
"$ 6,086,009",
] {
body.push(nine(cell, x, y, 38.0));
body.push(nine("\t", x + 44.0, y, 2.4));
x += 51.0;
}
}
let stats = PageStats::from_spans(&body);
assert!(
try_vertical_cut(&body, &stats, &[]).is_none(),
"a statement parted by tab runs was cut as two columns of text"
);
}
#[test]
fn a_column_of_nil_dashes_is_not_a_list() {
let mut spans = Vec::new();
for i in 0..5 {
let y = 700.0 - 14.0 * i as f64;
spans.push(nine("\u{2013}", 300.0, y, 5.0));
spans.push(nine(&format!("22,795,60{i}"), 360.0, y, 40.0));
spans.push(nine(&format!("22,788,10{i}"), 430.0, y, 40.0));
}
assert!(
!is_bullet_or_list_marker("22,795,604"),
"the fixture's figures read as markers"
);
let bands = group_spans_into_bands(spans);
let indices: Vec<usize> = (0..bands.len()).collect();
let boundaries = vec![330.0, 415.0];
let gaps = indices.len();
assert!(
region_is_columnar(&bands, &indices, &boundaries, gaps),
"a column of nil dashes beside amounts was taken for a list"
);
}
#[test]
fn rows_of_amounts_keep_a_statement_from_reading_as_text() {
let mut body = vec![
nine(
"Consolidated statement of financial position for the year",
15.0,
712.0,
240.0,
),
nine(
"Consolidated statement of comprehensive income for the year",
300.0,
712.0,
240.0,
),
];
for i in 0..9 {
let y = 700.0 - 12.0 * i as f64;
let mut x = 15.0;
for cell in [
"438,082",
"9,836,950",
"3,304,152",
"5,919,819",
"429,405",
"3,692,551",
"840,333",
"1,049,328",
"6,086,009",
"1,360,471",
] {
body.push(nine(cell, x, y, 44.0));
body.push(nine("\t", x + 48.0, y, 2.4));
x += 54.0;
}
}
let stats = PageStats::from_spans(&body);
assert!(
try_vertical_cut(&body, &stats, &[]).is_none(),
"a statement of figures under two long headers was cut as two columns of text"
);
}
#[test]
fn an_item_that_opens_with_punctuation_keeps_its_bullet() {
let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
height: size,
font_size: size,
..span(text, x, y, width)
};
let mut spans = vec![sized(
"The following items were reported during the course of the Financial Year:",
86.8,
720.0,
313.3,
10.2,
)];
for (k, (item, width)) in [
("\u{201c}Revenue increased\u{201d} across the group;", 190.0),
("(Formerly the Northern Division) was sold;", 180.0),
("2024 results were restated for the change.", 175.0),
(
"\u{201c}Margins improved\u{201d} in the second half.",
170.0,
),
]
.iter()
.enumerate()
{
let y = 685.6 - 34.4 * k as f64;
spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
spans.push(sized(item, 131.5, y, *width, 10.2));
}
for k in 0..4 {
spans.push(sized(
"Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
86.8,
548.0 - 17.2 * k as f64,
394.8,
10.2,
));
}
let blocks = group_spans_into_blocks(spans, &[]);
let bare = blocks
.iter()
.filter(|b| b.text().trim() == "\u{2022}")
.count();
assert_eq!(
bare,
0,
"items opening with punctuation lost their bullets: {:?}",
blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
);
}
#[test]
fn a_column_of_bullets_is_read_with_its_items() {
let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
height: size,
font_size: size,
..span(text, x, y, width)
};
let mut spans = vec![
sized(
"Mermaid's principal activities during the course of the Financial Year were:",
86.8,
720.0,
313.3,
10.2,
),
sized("PRINCIPAL ACTIVITIES", 416.8, 718.6, 123.3, 9.1),
];
for (k, (item, width)) in [
("Operating crewed vessel charters;", 138.4),
("Vessel manning, management and logistics;", 179.8),
("Operating supply base facilities; and", 149.1),
("Equipment hire.", 67.1),
]
.iter()
.enumerate()
{
let y = 685.6 - 34.4 * k as f64;
spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
spans.push(sized(item, 131.5, y, *width, 10.2));
}
for k in 0..4 {
spans.push(sized(
"Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
86.8,
548.0 - 17.2 * k as f64,
394.8,
10.2,
));
}
let blocks = group_spans_into_blocks(spans, &[]);
let bare = blocks
.iter()
.filter(|b| b.text().trim() == "\u{2022}")
.count();
assert_eq!(
bare,
0,
"the bullets were read as a column of their own: {:?}",
blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
);
}
#[test]
fn a_peeled_line_keeps_its_origin_on_its_first_glyph() {
let glued = TextSpan {
text: "10 example, aluminum and steel".into(),
x: 298.8,
y: 100.0,
width: 229.4,
height: 8.5,
font_size: 8.5,
transform: Some([8.5, 0.0, 0.0, 8.5, 298.8, 100.0]),
char_bounds: std::iter::once([298.8, 100.0, 302.0, 108.5])
.chain(std::iter::once([302.0, 100.0, 305.2, 108.5]))
.chain((0..27).map(|k| {
let x = 310.9 + k as f64 * 4.0;
[x, 100.0, x + 4.0, 108.5]
}))
.collect(),
..TextSpan::default()
};
let mut spans = vec![glued];
for i in 0..3 {
spans.push(TextSpan {
text: "a line of the right column".into(),
x: 310.9,
y: 90.0 - i as f64 * 10.0,
width: 200.0,
height: 8.5,
font_size: 8.5,
..TextSpan::default()
});
}
let (figure, rest) = peel_one(&spans[0], 0, &spans).expect("the figure is peeled");
assert_eq!(figure.text, "10");
assert_eq!(rest.text, "example, aluminum and steel");
assert!((rest.x - 310.9).abs() < 1e-9);
let t = rest.transform.expect("a transform");
assert!(
(t[4] - rest.x).abs() < 1e-9 && (t[5] - 100.0).abs() < 1e-9,
"the rest's transform still points at the figure: {t:?}"
);
}
#[test]
fn a_margin_column_of_mixed_labels_is_not_figures() {
let band = |y: f64, label: &str| {
let mut b = TextBand::new(TextSpan {
text: "a line of the left column running its measure".into(),
x: 77.0,
y,
width: 200.0,
height: 9.0,
font_size: 9.0,
..TextSpan::default()
});
b.spans.push(TextSpan {
text: label.into(),
x: 288.0,
y,
width: 6.0,
height: 9.0,
font_size: 9.0,
..TextSpan::default()
});
b.spans.push(TextSpan {
text: "a line of the right column running its measure".into(),
x: 300.0,
y,
width: 200.0,
height: 9.0,
font_size: 9.0,
..TextSpan::default()
});
b
};
let column = |labels: &[&str]| {
let bands: Vec<TextBand> = labels
.iter()
.enumerate()
.map(|(i, l)| band(700.0 - i as f64 * 11.0, l))
.collect();
a_margin_column(&bands, 423.0, 10.0, 2.5)
};
let (skipped, figures) = column(&["5", "10", "15", "20"]);
assert!(figures && skipped.iter().all(|b| b.len() == 1));
let (_, figures) = column(&["i", "1", "ii", "iii"]);
assert!(!figures, "a mixed column was read as a column of figures");
}
#[test]
fn a_counting_column_beside_a_bank_of_figures_is_the_margin() {
let span = |text: &str, x: f64, width: f64, y: f64| TextSpan {
text: text.into(),
x,
y,
width,
height: 9.0,
font_size: 9.0,
..TextSpan::default()
};
let band = |y: f64, figure: &str, label: &str| {
let mut b = TextBand::new(span("a line of the", 77.0, 60.0, y));
b.spans.push(span(figure, 150.0, 12.0, y));
b.spans
.push(span("left column running its measure", 170.0, 107.0, y));
b.spans.push(span(label, 288.0, 6.0, y));
b.spans.push(span(
"a line of the right column running its measure",
300.0,
200.0,
y,
));
b
};
let column = |labels: &[&str]| {
let bands: Vec<TextBand> = ["0.0", "4.0", "64.9", "3.8"]
.iter()
.zip(labels)
.enumerate()
.map(|(i, (f, l))| band(700.0 - i as f64 * 11.0, f, l))
.collect();
a_margin_column(&bands, 423.0, 10.0, 2.5)
};
let (skipped, figures) = column(&["5", "10", "15", "20"]);
assert!(
figures && skipped.iter().all(|b| b.len() == 1 && b.contains(&3)),
"the line numbers beside a bank were not the margin: {skipped:?}"
);
let (skipped, _) = column(&["1", "2", "3", "4"]);
assert!(
skipped.iter().all(HashSet::is_empty),
"a table's row numbers were taken for line numbers: {skipped:?}"
);
let (skipped, _) = column(&["2018", "2020", "2022", "2024"]);
assert!(
skipped.iter().all(HashSet::is_empty),
"a column of years was taken for line numbers: {skipped:?}"
);
}
#[test]
fn a_span_the_scan_skipped_is_not_a_cell_boundary() {
let mut spans = Vec::new();
for band in 0..4 {
let y = 700.0 - band as f64 * 14.0;
spans.push(span("the committee considered the whole", 72.0, y, 150.0));
spans.push(span("12", 240.0, y, 10.0));
spans.push(span("of the proposals put before it and", 300.0, y, 150.0));
}
let stats = PageStats::from_spans(&spans);
let bands = group_spans_into_bands_with_stats(spans.clone(), &stats);
let cut_x = 270.0;
let gap = 50.0;
let counted = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &[])
.expect("the left side has ink on every band");
assert_eq!(
(counted.inked, counted.broken),
(4, 4),
"the line numbers are not being read as a second run of ink"
);
let skipped: Vec<HashSet<usize>> = bands
.iter()
.map(|band| {
band.spans
.iter()
.enumerate()
.filter(|(_, s)| s.text.trim() == "12")
.map(|(i, _)| i)
.collect()
})
.collect();
let without = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &skipped)
.expect("the left side still has ink on every band");
assert_eq!(
(without.inked, without.broken),
(4, 0),
"a span the scan skipped is still being measured as a cell boundary"
);
assert!(
!the_cut_is_a_column_of_a_table(&bands, cut_x, gap, &skipped),
"the cut was refused for a column the scan had already skipped"
);
}
#[test]
fn a_column_gap_is_found_on_measured_edges_not_estimated_ones() {
let spans = two_column_page();
let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
let stats = PageStats::from_spans(&body);
let overshoots = body.iter().any(|s| s.right() > s.measured_right() + 1.0);
assert!(
overshoots,
"fixture no longer exercises the overshoot; right() must exceed measured_right() for this test to mean anything"
);
let cut = try_vertical_cut(&body, &stats, &[]);
assert!(
cut.is_some(),
"no column cut found on a clean two-column body"
);
}
fn block_texts(spans: Vec<TextSpan>) -> Vec<String> {
block_texts_with_rules(spans, &[])
}
fn block_texts_with_rules(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<String> {
group_spans_into_blocks(spans, rules)
.into_iter()
.map(|block| block.text())
.collect()
}
fn leaf_texts(spans: Vec<TextSpan>) -> Vec<String> {
let bands = group_spans_into_bands(spans);
group_spans_into_blocks_legacy_from_bands(bands, &[])
.iter()
.map(TextBlock::text)
.collect()
}
#[test]
fn empty_device_produces_empty_text() {
let dev = TextExtractionDevice::new();
assert!(dev.into_text().is_empty());
}
#[test]
fn single_column_stays_row_major() {
let texts = block_texts(vec![
span("Single Column Line 1", 40.0, 700.0, 140.0),
span("Single Column Line 2", 40.0, 684.0, 140.0),
span("Single Column Line 3", 40.0, 668.0, 140.0),
]);
assert_eq!(
texts,
vec![
"Single Column Line 1",
"Single Column Line 2",
"Single Column Line 3",
]
);
}
fn two_column_region_spans() -> Vec<TextSpan> {
vec![
span("Header", 200.0, 740.0, 80.0),
span("Left column line one", 40.0, 700.0, 115.0),
span("Right column line one", 320.0, 700.0, 120.0),
span("Left column line two", 40.0, 684.0, 115.0),
span("Right column line two", 320.0, 684.0, 120.0),
span("Left column line three", 40.0, 668.0, 125.0),
span("Right column line three", 320.0, 668.0, 130.0),
span("Footer", 200.0, 620.0, 80.0),
]
}
#[test]
fn two_column_region_reads_column_major() {
let texts = block_texts(two_column_region_spans());
assert_eq!(
texts,
vec![
"Header",
"Left column line one",
"Left column line two",
"Left column line three",
"Right column line one",
"Right column line two",
"Right column line three",
"Footer",
]
);
}
fn rules_between_the_rows() -> Vec<HorizontalRule> {
[692.0, 676.0]
.into_iter()
.map(|y| HorizontalRule {
left: 35.0,
right: 445.0,
y,
})
.collect()
}
#[test]
fn a_ruled_two_column_region_is_read_row_by_row() {
let texts = block_texts_with_rules(two_column_region_spans(), &rules_between_the_rows());
assert_eq!(
texts,
vec![
"Header",
"Left column line one Right column line one",
"Left column line two Right column line two",
"Left column line three Right column line three",
"Footer",
]
);
}
#[test]
fn a_rule_that_crosses_only_one_column_leaves_the_columns_alone() {
let short: Vec<HorizontalRule> = rules_between_the_rows()
.into_iter()
.map(|rule| HorizontalRule {
right: 160.0,
..rule
})
.collect();
let texts = block_texts_with_rules(two_column_region_spans(), &short);
assert_eq!(
texts,
vec![
"Header",
"Left column line one",
"Left column line two",
"Left column line three",
"Right column line one",
"Right column line two",
"Right column line three",
"Footer",
]
);
}
#[test]
fn a_single_rule_does_not_make_a_region_a_table() {
let bands = group_spans_into_bands(two_column_region_spans());
let rows: Vec<usize> = bands
.iter()
.enumerate()
.filter(|(_, band)| band.y < 720.0 && band.y > 640.0)
.map(|(idx, _)| idx)
.collect();
assert_eq!(
rows.len(),
3,
"the fixture's three rows are the region here"
);
let both = rules_between_the_rows();
assert!(
region_is_ruled(&bands, &rows, &both),
"a rule in every gap of the region is a ruled table"
);
assert!(
!region_is_ruled(&bands, &rows, &both[..1]),
"one rule is not a table"
);
}
#[test]
fn one_path_of_many_rules_still_stops_at_the_cap() {
let mut path = BezPath::new();
for row in 0..(MAX_RECORDED_RULES * 2) {
let y = 700.0 - row as f64;
path.move_to((40.0, y));
path.line_to((440.0, y));
path.close_path();
}
let mut device = TextExtractionDevice::new();
device.record_rule(&path, Affine::IDENTITY);
assert_eq!(device.rules.len(), MAX_RECORDED_RULES);
}
#[test]
fn a_rule_is_recorded_at_the_width_the_clip_leaves_it() {
let mut wide = BezPath::new();
wide.move_to((40.0, 600.0));
wide.line_to((440.0, 600.0));
let mut device = TextExtractionDevice::new();
device.push_clip_path(&ClipPath {
path: kurbo::Rect::new(40.0, 500.0, 160.0, 700.0).to_path(0.1),
fill: pdf_render::pdf_interpret::FillRule::NonZero,
});
device.record_rule(&wide, Affine::IDENTITY);
assert_eq!(device.rules.len(), 1, "the visible part is still a rule");
assert!(
device.rules[0].right < 161.0,
"the rule was recorded past its clip: {:?}",
device.rules[0]
);
let mut above = BezPath::new();
above.move_to((40.0, 800.0));
above.line_to((440.0, 800.0));
device.record_rule(&above, Affine::IDENTITY);
assert_eq!(
device.rules.len(),
1,
"a rule the clip cuts away entirely was recorded: {:?}",
device.rules
);
device.pop_clip_path();
device.record_rule(&wide, Affine::IDENTITY);
assert_eq!(
device.rules[1].right, 440.0,
"the clip outlived the pop: {:?}",
device.rules[1]
);
}
#[test]
fn a_region_with_a_rule_in_a_minority_of_its_gaps_is_not_a_table() {
let mut spans = Vec::new();
for row in 0..6 {
let y = 700.0 - row as f64 * 16.0;
spans.push(span("Left column line", 40.0, y, 115.0));
spans.push(span("Right column line", 320.0, y, 120.0));
}
let bands = group_spans_into_bands(spans);
let rows: Vec<usize> = (0..bands.len()).collect();
assert_eq!(bands.len(), 6, "six rows, so five gaps between them");
let rule_at = |y: f64| HorizontalRule {
left: 35.0,
right: 445.0,
y,
};
let two = vec![rule_at(692.0), rule_at(676.0)];
let three = vec![rule_at(692.0), rule_at(676.0), rule_at(660.0)];
assert!(
!region_is_ruled(&bands, &rows, &two),
"two of five is prose"
);
assert!(
region_is_ruled(&bands, &rows, &three),
"three of five is a table"
);
}
#[test]
fn mixed_single_and_multi_column_regions_preserve_shared_bands() {
let texts = block_texts(vec![
span("Intro paragraph", 40.0, 740.0, 180.0),
span("L1 words here", 40.0, 700.0, 110.0),
span("R1 words here", 320.0, 700.0, 110.0),
span("L2 words here", 40.0, 684.0, 110.0),
span("R2 words here", 320.0, 684.0, 110.0),
span("L3 words here", 40.0, 668.0, 110.0),
span("R3 words here", 320.0, 668.0, 110.0),
span("Outro paragraph", 40.0, 620.0, 180.0),
]);
assert_eq!(
texts,
vec![
"Intro paragraph",
"L1 words here",
"L2 words here",
"L3 words here",
"R1 words here",
"R2 words here",
"R3 words here",
"Outro paragraph",
]
);
}
#[test]
fn a_patent_front_page_is_read_column_by_column() {
let fields = [
("(21) Appl. No.: 10/290,458", 150.0),
("(22) Filed: Nov. 8, 2002", 140.0),
("(51) Int. Cl. H01B 1/12", 160.0),
("(52) U.S. Cl. 252/500; 528/210", 150.0),
("(58) Field of Search .......... 252/500; 528/422", 219.0),
("(56) References Cited", 140.0),
];
let mut spans = Vec::new();
for (k, (field, width)) in fields.iter().enumerate() {
let y = 700.0 - k as f64 * 16.0;
spans.push(span(field, 77.0, y, *width));
if k != 4 {
spans.push(span(
"Journal of Polymer Science, vol. 38, pp. 3390",
310.0,
y,
220.0,
));
}
}
let texts = block_texts(spans);
assert!(
texts
.iter()
.all(|t| !(t.contains(")") && t.contains("Journal"))),
"a field was joined to the reference beside it: {texts:?}"
);
}
#[test]
fn short_table_like_rows_fall_back_to_row_major() {
let texts = block_texts(vec![
span("Name", 40.0, 700.0, 30.0),
span("Age", 320.0, 700.0, 20.0),
span("Alice", 40.0, 684.0, 35.0),
span("30", 320.0, 684.0, 15.0),
span("Bob", 40.0, 668.0, 24.0),
span("25", 320.0, 668.0, 15.0),
]);
assert_eq!(texts, vec!["Name Age", "Alice 30", "Bob 25"]);
}
#[test]
fn a_wrapped_cell_is_emitted_with_its_own_cell() {
let texts = block_texts(vec![
span("Index", 60.0, 700.0, 30.0),
span("Failure condition", 200.0, 700.0, 100.0),
span("Section", 400.0, 700.0, 42.0),
span("15-003", 60.0, 680.0, 36.0),
span("In a table not organized", 200.0, 680.0, 144.0),
span("UA1:7.5-2", 400.0, 680.0, 54.0),
span("a cell without a Scope", 200.0, 668.0, 132.0),
]);
assert_eq!(
texts,
vec![
"Index Failure condition Section",
"15-003",
"In a table not organized",
"a cell without a Scope",
"UA1:7.5-2",
]
);
}
#[test]
fn a_row_whose_every_cell_wraps_stays_row_major() {
let texts = leaf_texts(vec![
span("Index", 60.0, 700.0, 30.0),
span("Failure condition", 200.0, 700.0, 100.0),
span("Section", 400.0, 700.0, 42.0),
span("15-003", 60.0, 680.0, 36.0),
span("In a table not organized", 200.0, 680.0, 144.0),
span("UA1:7.5-2", 400.0, 680.0, 54.0),
span("is the index", 60.0, 674.0, 72.0),
span("a cell without a Scope", 200.0, 668.0, 132.0),
span("of the suite", 400.0, 662.0, 72.0),
]);
assert_eq!(
texts,
vec![
"Index Failure condition Section",
"15-003 In a table not organized UA1:7.5-2",
"is the index",
"a cell without a Scope",
"of the suite",
]
);
}
#[test]
fn a_row_with_an_empty_cell_is_a_row_and_not_a_wrap() {
let texts = block_texts(vec![
span("Index", 60.0, 700.0, 30.0),
span("Type", 200.0, 700.0, 24.0),
span("How", 400.0, 700.0, 18.0),
span("15-003", 60.0, 680.0, 36.0),
span("Object", 200.0, 680.0, 36.0),
span("Machine", 400.0, 680.0, 42.0),
span("15-004", 60.0, 666.0, 36.0),
span("Rope", 400.0, 666.0, 24.0),
]);
assert_eq!(
texts,
vec!["Index Type How", "15-003 Object Machine", "15-004 Rope"]
);
}
#[test]
fn a_single_cell_row_under_a_wrapped_line_is_still_a_row() {
let texts = block_texts(vec![
span("Index", 60.0, 700.0, 30.0),
span("Type", 200.0, 700.0, 24.0),
span("How", 400.0, 700.0, 18.0),
span("15-003", 60.0, 680.0, 36.0),
span("In a table not organized", 200.0, 680.0, 144.0),
span("UA1:7.5-2", 400.0, 680.0, 54.0),
span("a cell without a Scope", 200.0, 668.0, 132.0),
span("15-004", 60.0, 656.0, 36.0),
]);
assert_eq!(
texts,
vec![
"Index Type How",
"15-003",
"In a table not organized",
"a cell without a Scope",
"UA1:7.5-2",
"15-004",
]
);
}
#[test]
fn a_line_a_whole_row_pitch_down_is_a_row_and_not_a_wrap() {
let texts = block_texts(vec![
span("Index", 60.0, 700.0, 30.0),
span("Type", 200.0, 700.0, 24.0),
span("How", 400.0, 700.0, 18.0),
span("15-003", 60.0, 680.0, 36.0),
span("Object", 200.0, 680.0, 36.0),
span("Machine", 400.0, 680.0, 42.0),
span("Rope", 200.0, 660.0, 24.0),
]);
assert_eq!(
texts,
vec!["Index Type How", "15-003 Object Machine", "Rope"]
);
}
#[test]
fn three_column_regions_are_supported() {
let texts = block_texts(vec![
span("Column one line one", 40.0, 700.0, 105.0),
span("Column two line one", 220.0, 700.0, 105.0),
span("Column three line one", 400.0, 700.0, 120.0),
span("Column one line two", 40.0, 684.0, 105.0),
span("Column two line two", 220.0, 684.0, 105.0),
span("Column three line two", 400.0, 684.0, 120.0),
span("Column one line three", 40.0, 668.0, 120.0),
span("Column two line three", 220.0, 668.0, 120.0),
span("Column three line three", 400.0, 668.0, 135.0),
]);
assert_eq!(
texts,
vec![
"Column one line one",
"Column one line two",
"Column one line three",
"Column two line one",
"Column two line two",
"Column two line three",
"Column three line one",
"Column three line two",
"Column three line three",
]
);
}
#[test]
fn text_block_concatenation_spaced() {
let block = TextBlock {
spans: vec![span("A", 0.0, 0.0, 6.0), span("B", 20.0, 0.0, 6.0)],
};
assert_eq!(block.text(), "A B");
}
#[test]
fn a_space_drawn_over_a_ligature_is_no_word_space() {
let block = TextBlock {
spans: vec![
span("Specifi", 0.0, 0.0, 60.0),
span(" cally", 56.4, 0.0, 36.0),
],
};
assert_eq!(block.text(), "Specifically");
assert_eq!(crate::ligatures::fold(&block.text()), "Specifically");
}
#[test]
fn a_space_drawn_over_an_initial_is_no_word_space() {
let block = TextBlock {
spans: vec![span("T", 0.0, 0.0, 8.0), span(" ransition", 0.8, 0.0, 60.0)],
};
assert_eq!(block.text(), "Transition");
}
#[test]
fn a_real_word_space_stays() {
let block = TextBlock {
spans: vec![span("There", 0.0, 0.0, 30.0), span(" is", 32.4, 0.0, 15.0)],
};
assert_eq!(block.text(), "There is");
}
#[test]
fn an_overlapping_run_that_starts_with_a_capital_or_digit_keeps_its_space() {
let digit = TextBlock {
spans: vec![span("v", 0.0, 0.0, 8.0), span(" 2", 4.4, 0.0, 10.0)],
};
assert_eq!(digit.text(), "v 2");
let capital = TextBlock {
spans: vec![span("v", 0.0, 0.0, 8.0), span(" N", 4.4, 0.0, 10.0)],
};
assert_eq!(capital.text(), "v N");
}
#[test]
fn adaptive_column_gap_fallback_for_no_gaps() {
let bands = vec![
TextBand::new(span("Hello", 40.0, 700.0, 80.0)),
TextBand::new(span("World", 40.0, 684.0, 80.0)),
];
let threshold = compute_adaptive_column_gap(&bands);
assert!((threshold - COLUMN_GAP_THRESHOLD_FALLBACK).abs() < 0.01);
}
#[test]
fn adaptive_column_gap_uses_median() {
let mut bands = Vec::new();
for y in [700.0, 684.0, 668.0] {
let mut band = TextBand::new(span("word1", 40.0, y, 30.0));
band.spans.push(span("word2", 74.0, y, 30.0)); band.spans.push(span("word3", 108.0, y, 30.0)); bands.push(band);
}
let threshold = compute_adaptive_column_gap(&bands);
assert!(
(10.0..=14.0).contains(&threshold),
"expected ~12, got {threshold}"
);
}
#[test]
fn adaptive_column_gap_clamps_to_min() {
let mut bands = Vec::new();
for y in [700.0, 684.0, 668.0, 652.0] {
let mut band = TextBand::new(span("abc", 0.0, y, 18.0));
band.spans.push(span("def", 20.0, y, 18.0));
bands.push(band);
}
let threshold = compute_adaptive_column_gap(&bands);
assert!(
(threshold - COLUMN_GAP_THRESHOLD_MIN).abs() < 0.01,
"expected {COLUMN_GAP_THRESHOLD_MIN}, got {threshold}"
);
}
#[test]
fn adaptive_column_gap_all_large_gaps_uses_fraction_of_min() {
let mut band = TextBand::new(span("Left", 0.0, 700.0, 30.0));
band.spans.push(span("Right", 80.0, 700.0, 30.0)); let bands = vec![band];
let threshold = compute_adaptive_column_gap(&bands);
assert!(
(threshold - 37.5).abs() < 0.01,
"expected 37.5 (0.75×50), got {threshold}"
);
}
#[test]
fn normalize_trims_trailing_whitespace_per_line() {
assert_eq!(
normalize_text_output("hello \nworld \n"),
"hello\nworld\n"
);
}
#[test]
fn normalize_collapses_excess_newlines() {
assert_eq!(
normalize_text_output("hello\n\n\n\n\nworld\n"),
"hello\n\n\nworld\n"
);
}
#[test]
fn normalize_preserves_double_newline() {
assert_eq!(
normalize_text_output("paragraph one\n\nparagraph two\n"),
"paragraph one\n\nparagraph two\n"
);
}
#[test]
fn normalize_preserves_form_feed() {
assert_eq!(
normalize_text_output("page1\n\n\x0Cpage2\n"),
"page1\n\n\x0Cpage2\n"
);
}
#[test]
fn normalize_adds_trailing_newline() {
assert_eq!(normalize_text_output("hello"), "hello\n");
}
#[test]
fn normalize_empty_input() {
assert_eq!(normalize_text_output(""), "");
}
#[test]
fn normalize_only_whitespace() {
assert_eq!(normalize_text_output(" \n \n"), "");
}
#[test]
fn hyphen_stitch_joins_wrapped_word() {
let lines = vec!["the aver-".into(), "age rainfall".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "the average rainfall");
}
#[test]
fn hyphen_stitch_handles_leading_whitespace() {
let lines = vec!["pre-".into(), " dict the outcome".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "predict the outcome");
}
#[test]
fn hyphen_stitch_capital_continuation_not_stitched() {
let lines = vec!["Section three-".into(), "Summary here".into()];
assert_eq!(
stitch_hyphenated_lines(&lines),
"Section three-\nSummary here"
);
}
#[test]
fn hyphen_stitch_bullet_dash_not_stitched() {
let lines = vec!["Items:".into(), "-".into(), "milk".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "Items:\n-\nmilk");
}
#[test]
fn hyphen_stitch_numeric_range_not_stitched() {
let lines = vec!["page 42-".into(), "seventy".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "page 42-\nseventy");
}
#[test]
fn hyphen_stitch_short_prefix_not_stitched() {
let lines = vec!["re-".into(), "organize".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "re-\norganize");
}
#[test]
fn hyphen_stitch_short_continuation_not_stitched() {
let lines = vec!["counter-".into(), "an example".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "counter-\nan example");
}
#[test]
fn hyphen_stitch_compound_word_midline_preserved() {
let lines = vec!["real-time system".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "real-time system");
}
#[test]
fn hyphen_stitch_single_line_unchanged() {
let lines = vec!["only line".into()];
assert_eq!(stitch_hyphenated_lines(&lines), "only line");
}
#[test]
fn hyphen_stitch_empty_input() {
let lines: Vec<String> = vec![];
assert_eq!(stitch_hyphenated_lines(&lines), "");
}
fn make_device_with_median(median: f64) -> TextExtractionDevice {
let mut dev = TextExtractionDevice::new();
for _ in 0..MEDIAN_REFRESH {
dev.glyph_widths.push(median);
}
dev.refresh_median_char_width();
assert!((dev.cached_median_char_width - median).abs() < 1e-9);
dev
}
#[test]
fn consensus_inserts_space_on_strong_tj_offset_alone() {
let mut dev = make_device_with_median(6.0);
dev.pending_tj_offset = -250.0; assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
}
#[test]
fn a_pair_kern_of_any_size_is_not_a_word_space() {
let mut dev = make_device_with_median(6.0);
dev.pending_tj_offset = 134.6;
assert!(!dev.evaluate_space_consensus(-1.15, 8.5, "RESUL", "TS"));
}
#[test]
fn consensus_inserts_space_on_geometric_gap_alone() {
let dev = make_device_with_median(6.0);
assert!(dev.evaluate_space_consensus(2.5, 12.0, "hello", "world"));
}
#[test]
fn consensus_no_space_on_kerning_gap() {
let dev = make_device_with_median(6.0);
assert!(!dev.evaluate_space_consensus(0.5, 12.0, "fi", "lm"));
}
#[test]
fn consensus_inserts_space_on_camel_case_plus_gap() {
let dev = make_device_with_median(6.0);
assert!(dev.evaluate_space_consensus(2.5, 12.0, "helloWorld", "Inc"));
}
#[test]
fn consensus_inserts_space_on_digit_letter_transition_with_gap() {
let dev = make_device_with_median(6.0);
assert!(dev.evaluate_space_consensus(2.5, 12.0, "123", "abc"));
}
#[test]
fn consensus_heuristic_alone_is_insufficient() {
let dev = make_device_with_median(6.0);
assert!(!dev.evaluate_space_consensus(0.5, 12.0, "camel", "Case"));
}
#[test]
fn consensus_falls_back_to_font_size_when_no_median() {
let dev = TextExtractionDevice::new();
assert!(dev.evaluate_space_consensus(1.9, 12.0, "a", "b"));
assert!(!dev.evaluate_space_consensus(1.5, 12.0, "a", "b"));
}
#[test]
fn consensus_ignores_tiny_tj_offsets() {
let mut dev = make_device_with_median(6.0);
dev.pending_tj_offset = 50.0;
assert!(!dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
}
#[test]
fn consensus_accepts_negative_tj_offsets() {
let mut dev = make_device_with_median(6.0);
dev.pending_tj_offset = -250.0;
assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
}
#[test]
fn text_adjustment_accumulates_until_glyph() {
let mut dev = TextExtractionDevice::new();
dev.text_adjustment(120.0);
dev.text_adjustment(140.0);
assert!((dev.pending_tj_offset - 260.0).abs() < 1e-6);
}
#[test]
fn xy_cut_header_body_footer_with_two_columns() {
let texts = block_texts(vec![
span("HEADLINE TITLE", 180.0, 760.0, 120.0),
span("Left col line A", 40.0, 700.0, 110.0),
span("Right col line A", 320.0, 700.0, 115.0),
span("Left col line B", 40.0, 684.0, 110.0),
span("Right col line B", 320.0, 684.0, 115.0),
span("Left col line C", 40.0, 668.0, 110.0),
span("Right col line C", 320.0, 668.0, 115.0),
span("FOOTER LINE TEXT", 180.0, 600.0, 120.0),
]);
assert_eq!(texts.first().map(String::as_str), Some("HEADLINE TITLE"));
assert_eq!(texts.last().map(String::as_str), Some("FOOTER LINE TEXT"));
let left_c_idx = texts.iter().position(|s| s == "Left col line C").unwrap();
let right_a_idx = texts.iter().position(|s| s == "Right col line A").unwrap();
assert!(
left_c_idx < right_a_idx,
"expected column-major ordering in body: {texts:?}"
);
}
#[test]
fn xy_cut_rejects_column_split_on_table_rows() {
let texts = block_texts(vec![
span("Name", 40.0, 700.0, 30.0),
span("Age", 320.0, 700.0, 20.0),
span("Alice", 40.0, 684.0, 35.0),
span("30", 320.0, 684.0, 15.0),
]);
assert_eq!(texts, vec!["Name Age", "Alice 30"]);
}
#[test]
fn xy_cut_rejects_column_split_when_one_band_is_full_width() {
let texts = block_texts(vec![
span(
"Full width intro spanning both columns here",
40.0,
740.0,
360.0,
),
span("Left A", 40.0, 700.0, 50.0),
span("Right A", 320.0, 700.0, 50.0),
span("Left B", 40.0, 684.0, 50.0),
span("Right B", 320.0, 684.0, 50.0),
]);
assert!(
texts[0].contains("Full width intro"),
"expected full-width intro first: {texts:?}"
);
}
#[test]
fn xy_cut_horizontal_split_for_zone_boundaries() {
let texts = block_texts(vec![
span("First paragraph body text", 40.0, 740.0, 200.0),
span("Second paragraph body", 40.0, 680.0, 180.0),
]);
assert_eq!(texts.len(), 2);
assert!(texts[0].starts_with("First"));
assert!(texts[1].starts_with("Second"));
}
#[test]
fn xy_cut_recursion_terminates_with_single_span() {
let texts = block_texts(vec![span("Only one span on the page", 40.0, 700.0, 180.0)]);
assert_eq!(texts, vec!["Only one span on the page"]);
}
#[test]
fn median_font_size_handles_mixed_sizes() {
let spans = vec![
TextSpan {
text: "small".into(),
width: 10.0,
height: 8.0,
font_size: 8.0,
..TextSpan::default()
},
TextSpan {
text: "medium".into(),
width: 10.0,
height: 12.0,
font_size: 12.0,
..TextSpan::default()
},
TextSpan {
text: "large".into(),
width: 10.0,
height: 24.0,
font_size: 24.0,
..TextSpan::default()
},
];
assert!((median_font_size(&spans) - 12.0).abs() < 1e-9);
}
#[test]
fn columns_band_aligned_accepts_aligned_columns() {
let spans = vec![
span("L1", 40.0, 700.0, 60.0),
span("R1", 300.0, 700.0, 60.0),
span("L2", 40.0, 684.0, 60.0),
span("R2", 300.0, 684.0, 60.0),
];
let stats = PageStats::from_spans(&spans);
assert!(columns_are_band_aligned(&spans, 200.0, 40.0, 360.0, &stats));
}
#[test]
fn columns_band_aligned_rejects_wide_single_side_band() {
let spans = vec![
span("Wide banner line across top", 40.0, 740.0, 280.0),
span("L1", 40.0, 700.0, 60.0),
span("R1", 300.0, 700.0, 60.0),
];
let stats = PageStats::from_spans(&spans);
assert!(!columns_are_band_aligned(
&spans, 200.0, 40.0, 360.0, &stats
));
}
#[test]
fn page_stats_computes_median_values() {
let spans = vec![
span("one", 40.0, 700.0, 30.0),
span("two", 40.0, 680.0, 30.0),
span("three", 40.0, 660.0, 50.0),
];
let stats = PageStats::from_spans(&spans);
assert!((stats.median_font_size - 12.0).abs() < 1e-9);
assert!((stats.median_char_width - 10.0).abs() < 1e-9);
assert!((stats.median_line_spacing - 20.0).abs() < 1e-9);
}
#[test]
fn page_stats_handles_empty_input() {
let stats = PageStats::from_spans(&[]);
assert!((stats.median_font_size - 12.0).abs() < 1e-9);
assert!((stats.median_char_width - 6.0).abs() < 1e-9);
assert_eq!(stats.median_line_spacing, 0.0);
}
#[test]
fn narrow_gutter_detected_with_adaptive_threshold() {
let mut spans = Vec::new();
for y in [700.0, 684.0, 668.0] {
spans.push(span("Lorem ipsum", 40.0, y, 100.0));
spans.push(span("dolor sit", 144.0, y, 80.0));
spans.push(span("amet consec", 236.0, y, 100.0));
spans.push(span("tetur adipi", 340.0, y, 80.0));
}
let texts = block_texts(spans);
assert!(
texts.len() >= 6,
"expected column-major output, got {texts:?}"
);
assert!(
texts[0].contains("Lorem"),
"first block should be left column: {texts:?}"
);
}
#[test]
fn one_table_is_not_cut_between_its_label_groups() {
let row = |label: &str, second: &str, third: &str, fourth: &str, y: f64| {
let mut spans = Vec::new();
if !label.is_empty() {
spans.push(span(label, 72.0, y, 58.0));
}
spans.push(span(second, 160.0, y, 90.0));
spans.push(span(third, 300.0, y, 30.0));
spans.push(span(fourth, 360.0, y, 40.0));
spans
};
let mut spans = Vec::new();
spans.extend(row("Study Design", "Case control", "7", "25.93", 700.0));
spans.extend(row("", "Cohort", "9", "33.33", 688.0));
spans.extend(row("", "Cross-sectional", "11", "40.74", 676.0));
spans.extend(row("Gender studied", "Both", "21", "77.78", 652.0));
spans.extend(row("", "Men", "3", "11.11", 640.0));
let stats = PageStats::from_spans(&spans);
let bands = group_spans_into_bands(spans.clone());
let cut_y = (676.0 + 652.0) * 0.5;
let gap = 676.0 - 652.0;
let unguarded = stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER;
assert!(
gap >= unguarded,
"the gap {gap} does not clear the unguarded threshold {unguarded}, so \
this fixture would pass without the guard"
);
let floor = leading_of_a_region(&spans, &stats) * HORIZONTAL_CUT_LEADING_FLOOR;
assert!(
gap >= floor,
"the #580 floor {floor} already refuses this gap {gap}, so the guard \
is not what this test measures"
);
assert!(
the_cut_is_inside_a_grid(&bands, cut_y, gap),
"the bands either side of the cut were not read as one grid"
);
assert!(
try_horizontal_cut(&spans, &stats).is_none(),
"the table was cut between its label groups"
);
}
#[test]
fn three_zones_equally_spaced_are_not_a_leading() {
let stats = PageStats::from_spans(&[span("body", 72.0, 700.0, 100.0)]);
let em = stats.median_font_size;
let zones = vec![
span("MASTHEAD", 72.0, 700.0, 120.0),
span("A centred title", 200.0, 680.0, 120.0),
span("by a named author", 140.0, 660.0, 120.0),
];
assert!(
20.0 >= LEADING_MIN_EM * em && 20.0 <= LEADING_MAX_EM * em,
"the fixture's gap is outside the bounds, so it never reaches the rule"
);
assert_eq!(
leading_of_a_region(&zones, &stats),
0.0,
"three zones that are not flush were read as a leading"
);
let lines = vec![
span("the first line of a paragraph", 72.0, 700.0, 180.0),
span("the second line of the same one", 72.0, 680.0, 180.0),
span("and the third line of it", 72.0, 660.0, 150.0),
];
assert_eq!(
leading_of_a_region(&lines, &stats),
20.0,
"three flush lines did not yield their own leading"
);
}
#[test]
fn two_bands_have_no_leading_so_the_only_cut_survives() {
let mut spans = Vec::new();
for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
spans.push(span(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y, 30.0));
spans.push(span("2011", 150.0, *y, 30.0));
}
spans.push(span("Federal Register banner", 72.0, 600.0, 260.0));
spans.push(span(
"the body of the notice begins here",
72.0,
575.0,
200.0,
));
let stats = PageStats::from_spans(&spans);
let region = vec![
span("Federal Register banner", 72.0, 600.0, 260.0),
span("the body of the notice begins here", 72.0, 575.0, 200.0),
];
assert_eq!(
leading_of_a_region(®ion, &stats),
0.0,
"a single gap was read as the leading of a two-band region"
);
let gap = 600.0 - 575.0;
assert!(
gap >= LEADING_MIN_EM * stats.median_font_size
&& gap <= LEADING_MAX_EM * stats.median_font_size,
"the fixture's gap is refused by the bounds, so it would not reach \
the occurrence rule"
);
assert!(
gap > stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER,
"the cut would not fire even unfloored, so this fixture proves nothing"
);
let texts = block_texts(spans);
assert!(
texts.iter().any(|t| t == "Federal Register banner"),
"the banner was glued to the body: {texts:?}"
);
}
#[test]
fn a_paragraph_is_not_cut_at_its_own_leading_because_a_table_is_tighter() {
fn line(text: &str, x: f64, y: f64) -> TextSpan {
span(text, x, y, 6.0 * text.chars().count() as f64)
}
let mut spans = Vec::new();
for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
spans.push(line(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y));
spans.push(line("2011", 150.0, *y));
spans.push(line(["11820", "2618", "3504", "2215"][row], 230.0, *y));
}
let prose = [
"the occurrence of brachygnathia inferior and hernias",
"showed statistically highly significant differences be",
"tween breeds and the most common defect was recorded",
"in the flocks of the region.",
];
for (text, y) in prose.iter().zip([640.0, 626.0, 612.0, 598.0]) {
spans.push(line(text, 72.0, y));
}
let stats = PageStats::from_spans(&spans);
let leading = leading_of_a_region(&spans, &stats);
assert!(
stats.median_line_spacing < leading,
"the fixture is not the defect: quartile {} is not under the leading {leading}",
stats.median_line_spacing
);
assert!(
stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER < leading,
"the unfloored threshold {} is not under the leading {leading}, so this \
fixture would pass without the fix",
stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
);
let texts: Vec<String> = paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both)
.into_iter()
.map(|block| block.text)
.collect();
let whole = prose.join(" ");
assert!(
texts.iter().any(|block| block == &whole),
"the paragraph was cut at its own leading: {texts:?}"
);
}
#[test]
fn xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns() {
let texts = block_texts(vec![
span("73022", 45.0, 750.0, 70.0),
span("Federal Register banner", 125.6, 750.0, 260.0),
span("Left column line one", 45.0, 725.0, 140.0),
span("Middle column line one", 222.0, 725.0, 140.0),
span("Right column line one", 399.0, 725.0, 120.0),
span("Left column line two", 45.0, 715.0, 140.0),
span("Middle column line two", 210.0, 715.0, 152.0),
span("Right column line two", 388.0, 715.0, 132.0),
span("Left column line three", 45.0, 705.0, 140.0),
span("Middle column line three", 235.0, 705.0, 135.0),
span("Right column line three", 408.0, 705.0, 118.0),
]);
assert_eq!(
texts,
vec![
"73022 Federal Register banner",
"Left column line one",
"Left column line two",
"Left column line three",
"Middle column line one",
"Middle column line two",
"Middle column line three",
"Right column line one",
"Right column line two",
"Right column line three",
]
);
}
#[test]
fn overlapping_fake_bold_spans_collapse_to_single_copy() {
let texts = block_texts(vec![
span("1 This is fakebold text.", 25.9, 785.3, 320.0),
span("1 This is fakebold text.", 26.2, 785.3, 320.0),
span("1 This is fakebold text.", 26.4, 785.3, 320.0),
span("1 This is fakebold text.", 26.7, 785.3, 320.0),
span("2 This is a fakebold", 27.0, 714.8, 142.0),
span(" fakebold", 169.8, 714.8, 70.0),
span(" fakebold", 170.1, 714.8, 70.0),
span(" fakebold word.", 170.4, 714.8, 110.0),
]);
assert_eq!(
texts,
vec!["1 This is fakebold text.", "2 This is a fakebold word.",]
);
}
fn turned_span(text: &str, degrees: f64, x: f64, y: f64, width: f64) -> TextSpan {
let (sin, cos) = degrees.to_radians().sin_cos();
let scale = 12.0 / 1000.0;
TextSpan {
transform: Some([cos * scale, sin * scale, -sin * scale, cos * scale, x, y]),
..span(text, x, y, width)
}
}
#[test]
fn two_lines_at_one_place_come_back_in_reading_order() {
let mut spans = vec![span("the page itself", 100.0, 700.0, 80.0)];
for (index, text) in ["first", "second"].iter().enumerate() {
let x = 60.0 + 14.0 * index as f64;
spans.push(turned_span(text, 90.0, x, 200.0, 30.0));
}
let blocks = vec![TextBlock { spans }];
let out = reassemble_cross_direction_runs(blocks);
let lines: Vec<String> = out.iter().map(|block| block.text()).collect();
assert_eq!(
lines,
vec!["first", "second", "the page itself"],
"a rotated block came back with its lines the wrong way round"
);
}
#[test]
fn the_pages_direction_is_weighed_in_glyphs_and_not_characters() {
let mut upright = span("fifffifl", 72.0, 700.0, 24.0);
upright.char_bounds = vec![[0.0; 4]; 2];
let mut turned = turned_span("TURNS", 90.0, 300.0, 300.0, 30.0);
turned.char_bounds = vec![[0.0; 4]; 5];
let direction = page_direction(&[TextBlock {
spans: vec![upright, turned],
}])
.expect("a direction");
assert!(
(direction.1).abs() > 0.5,
"two ligature glyphs outvoted five drawn ones, so the count is of characters"
);
}
#[test]
fn a_turned_run_ends_where_its_last_glyph_ends() {
const DEGREES: f64 = 30.0;
const ADVANCE: f64 = 7.2; let (sin, cos) = DEGREES.to_radians().sin_cos();
let mut hello = turned_span("HELLO", DEGREES, 100.0, 400.0, 0.0);
for index in 0..5 {
let x = 100.0 + cos * ADVANCE * index as f64;
let y = 400.0 + sin * ADVANCE * index as f64;
hello.char_bounds.push([x, y, x + ADVANCE, y + 12.0]);
hello.width = hello.width.max(x + ADVANCE - 100.0);
}
let world = turned_span(
"WORLD",
DEGREES,
100.0 + cos * ADVANCE * 5.0,
400.0 + sin * ADVANCE * 5.0,
ADVANCE * 5.0,
);
let block = TextBlock {
spans: vec![hello, world],
};
assert_eq!(
block.text(),
"HELLOWORLD",
"a space was invented at a seam the document draws closed"
);
}
#[test]
fn a_page_written_entirely_at_an_angle_keeps_its_own_bands() {
let blocks = vec![
TextBlock {
spans: vec![
turned_span("first line", 90.0, 100.0, 100.0, 60.0),
turned_span("carried on", 90.0, 100.0, 160.0, 60.0),
],
},
TextBlock {
spans: vec![turned_span("second line", 90.0, 120.0, 100.0, 66.0)],
},
];
let out = reassemble_cross_direction_runs(blocks.clone());
let shape: Vec<Vec<&str>> = out
.iter()
.map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
.collect();
assert_eq!(
shape,
vec![vec!["first line", "carried on"], vec!["second line"]],
"a page in one direction was rearranged by a rule that is about two"
);
}
#[test]
fn turned_glyphs_on_one_baseline_merge_into_one_span() {
let spans = vec![
turned_span("A", 90.0, 100.0, 100.0, 7.0),
turned_span("B", 90.0, 100.0, 107.0, 7.0),
turned_span("C", 90.0, 100.0, 128.0, 7.0),
turned_span("D", 90.0, 100.0, 135.0, 7.0),
];
let out = merge_rotated_runs(spans);
assert_eq!(out.len(), 1, "the run was not joined: {out:?}");
assert_eq!(out[0].text, "AB CD");
assert_eq!((out[0].x, out[0].y), (100.0, 100.0));
}
#[test]
fn turned_runs_split_where_the_white_is_wider_than_any_word_gap() {
let spans = vec![
turned_span("A", 90.0, 100.0, 100.0, 7.0),
turned_span("B", 90.0, 100.0, 107.0, 7.0),
turned_span("C", 90.0, 100.0, 127.0, 7.0),
turned_span("D", 90.0, 100.0, 180.0, 7.0),
turned_span("E", 90.0, 100.0, 187.0, 7.0),
];
let out = merge_rotated_runs(spans);
let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
assert_eq!(texts, vec!["AB C", "D", "E"]);
}
#[test]
fn a_bold_word_inside_a_turned_run_keeps_its_own_span() {
let lawful = turned_span("A", 90.0, 100.0, 100.0, 7.0);
let mut bold = turned_span("B", 90.0, 100.0, 107.0, 7.0);
bold.is_bold = true;
let out = merge_rotated_runs(vec![lawful, bold]);
let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
assert_eq!(texts, vec!["A", "B"]);
assert!(!out[0].is_bold && out[1].is_bold);
}
#[test]
fn a_skew_below_ten_degrees_is_left_alone() {
let spans = vec![
turned_span("A", 3.0, 100.0, 100.0, 7.0),
turned_span("B", 3.0, 107.0, 100.5, 7.0),
];
assert!(!spans.iter().any(span_is_quarter_turned));
let out = merge_rotated_runs(spans);
assert_eq!(out.len(), 2);
assert_eq!(out[0].text, "A");
assert_eq!(out[1].text, "B");
}
#[test]
fn upright_spans_come_back_untouched() {
let spans = vec![
span("upper", 100.0, 200.0, 35.0),
span("lower", 100.0, 100.0, 35.0),
];
let out = merge_rotated_runs(spans);
assert_eq!(out.len(), 2);
assert_eq!(out[0].text, "upper");
assert_eq!(out[1].text, "lower");
}
#[test]
fn a_turned_span_with_no_partner_is_left_exactly_as_it_was() {
let spans = vec![
span("body", 72.0, 700.0, 30.0),
turned_span("!", 90.0, 500.0, 300.0, 7.0),
];
let out = merge_rotated_runs(spans);
assert_eq!(out.len(), 2);
assert_eq!(
(out[1].text.as_str(), out[1].x, out[1].y),
("!", 500.0, 300.0)
);
}
#[test]
fn a_turned_spans_right_edge_is_measured() {
let turned = turned_span("a whole head", 90.0, 100.0, 540.0, 7.0);
assert_eq!(turned.right(), 107.0);
let upright = span("a whole head", 100.0, 540.0, 72.0);
assert_eq!(upright.right(), 172.0);
}
#[test]
fn a_lone_tight_run_is_left_as_drawn() {
let spans = vec![
turned_span("A", 90.0, 100.0, 100.0, 7.0),
turned_span("B", 90.0, 100.0, 107.0, 7.0),
];
let out = merge_rotated_runs(spans);
let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
assert_eq!(texts, vec!["A", "B"]);
}
#[test]
fn capitals_do_not_join_across_digits_set_between_them() {
let capitals = [
turned_span("O", 90.0, 100.0, 100.0, 7.0),
turned_span("W", 90.0, 100.0, 128.0, 7.0),
];
let mut zero = turned_span("0", 90.0, 101.8, 110.0, 7.0);
zero.font_name = Some("SmallDigits".to_string());
let mut one = turned_span("1", 90.0, 101.8, 117.0, 7.0);
one.font_name = Some("SmallDigits".to_string());
let out = merge_rotated_runs(vec![capitals[0].clone(), zero, one, capitals[1].clone()]);
let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
assert_eq!(texts, vec!["O", "0", "1", "W"]);
let out = merge_rotated_runs(capitals.to_vec());
assert_eq!(out.len(), 1, "the control run did not join: {out:?}");
assert_eq!(out[0].text, "O W");
}
#[test]
fn style_split_parts_of_one_run_are_not_siblings() {
let mut bold_a = turned_span("A", 90.0, 100.0, 100.0, 7.0);
bold_a.is_bold = true;
let mut bold_b = turned_span("B", 90.0, 100.0, 107.0, 7.0);
bold_b.is_bold = true;
let spans = vec![
bold_a,
bold_b,
turned_span("C", 90.0, 100.0, 114.0, 7.0),
turned_span("D", 90.0, 100.0, 121.0, 7.0),
];
let out = merge_rotated_runs(spans);
let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
assert_eq!(texts, vec!["A", "B", "C", "D"]);
}
#[test]
fn a_run_with_a_cut_scale_gap_merges() {
let spans = vec![
turned_span("A", 90.0, 100.0, 100.0, 7.0),
turned_span("B", 90.0, 100.0, 130.0, 7.0),
];
let out = merge_rotated_runs(spans);
assert_eq!(out.len(), 1);
assert_eq!(out[0].text, "A B");
}
#[test]
fn sibling_runs_beside_each_other_merge() {
let beside = vec![
turned_span("A", 90.0, 100.0, 100.0, 7.0),
turned_span("B", 90.0, 100.0, 107.0, 7.0),
turned_span("C", 90.0, 200.0, 100.0, 7.0),
turned_span("D", 90.0, 200.0, 107.0, 7.0),
];
let out = merge_rotated_runs(beside);
let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
assert_eq!(texts, vec!["AB", "CD"]);
let stacked = vec![
turned_span("A", 90.0, 100.0, 100.0, 7.0),
turned_span("B", 90.0, 100.0, 107.0, 7.0),
turned_span("C", 90.0, 100.0, 200.0, 7.0),
turned_span("D", 90.0, 100.0, 207.0, 7.0),
];
let out = merge_rotated_runs(stacked);
let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
assert_eq!(texts, vec!["A", "B", "C", "D"]);
}
#[test]
fn the_pages_direction_is_the_heaviest_and_a_tie_goes_to_the_first() {
let upright = TextBlock {
spans: vec![span("eight ch", 0.0, 700.0, 48.0)],
};
let turned = TextBlock {
spans: vec![turned_span("ninety deg", 90.0, 300.0, 300.0, 60.0)],
};
let heavier = page_direction(&[upright.clone(), turned.clone()]).expect("a direction");
assert!(
(heavier.1).abs() > 0.5,
"ten glyphs at ninety degrees outweigh eight upright ones, and did not"
);
let tie = TextBlock {
spans: vec![span("ninety deg", 0.0, 700.0, 60.0)],
};
let first = page_direction(&[tie, turned]).expect("a direction");
assert!(
(first.1).abs() < 0.5,
"a tie went to the direction that was not drawn first"
);
}
#[test]
fn a_turned_page_is_read_in_the_frame_it_was_written_in() {
let spans: Vec<TextSpan> = ["the first line", "the second line"]
.iter()
.enumerate()
.map(|(index, text)| turned_span(text, 90.0, 100.0 + 16.0 * index as f64, 100.0, 80.0))
.collect();
let lines = block_texts(spans);
assert_eq!(
lines,
vec!["the first line", "the second line"],
"the lines of a turned page were read in the bands of the frame it is drawn in"
);
}
#[test]
fn the_turn_into_the_frame_and_back_leaves_every_number_as_it_was() {
const ADVANCE: f64 = 7.2;
const X: f64 = 123.75;
const Y: f64 = 456.5;
let mut original = turned_span("TURNED", 90.0, X, Y, (X + ADVANCE) - X);
for index in 0..6 {
let y = Y + ADVANCE * index as f64;
original.char_bounds.push([X, y, X + ADVANCE, y + 12.0]);
}
for turn in [PageTurn::Quarter, PageTurn::Half, PageTurn::ThreeQuarter] {
let spans = turn.into_frame(vec![original.clone()]);
let blocks = turn.back(vec![TextBlock { spans }]);
let span = &blocks[0].spans[0];
assert_eq!(
(span.x, span.y),
(original.x, original.y),
"{turn:?} moved the span's origin"
);
assert_eq!(
span.transform, original.transform,
"{turn:?} left the baseline turned"
);
assert_eq!(
span.char_bounds, original.char_bounds,
"{turn:?} moved a glyph bound"
);
assert_eq!(
span.width, original.width,
"{turn:?} left a frame width behind"
);
}
}
#[test]
fn a_page_is_turned_by_a_quarter_turn_or_by_nothing() {
let cases = [
(0.0, PageTurn::None),
(90.0, PageTurn::Quarter),
(180.0, PageTurn::Half),
(270.0, PageTurn::ThreeQuarter),
(45.0, PageTurn::None),
(-52.0, PageTurn::None),
];
for (degrees, expected) in cases {
let spans = vec![turned_span("a line of it", degrees, 100.0, 100.0, 72.0)];
assert_eq!(
PageTurn::of(&spans),
expected,
"a page written at {degrees} degrees"
);
}
let page = vec![
span(
"the body of the page is written upright",
72.0,
700.0,
230.0,
),
turned_span("SANDBOX", 90.0, 300.0, 200.0, 42.0),
];
assert_eq!(
PageTurn::of(&page),
PageTurn::None,
"a turned watermark turned the page it is drawn on"
);
}
#[test]
fn only_a_half_turn_keeps_the_pages_ruling_lines() {
let rule = HorizontalRule {
left: 72.0,
right: 540.0,
y: 400.0,
};
let drawn = [rule];
let upright = PageTurn::None.rules_in_frame(&drawn);
assert_eq!(
(
upright.len(),
upright[0].left,
upright[0].right,
upright[0].y
),
(1, rule.left, rule.right, rule.y),
"an upright page's rules were touched at all"
);
let turned = PageTurn::Half.rules_in_frame(&drawn);
let rotation = PageTurn::Half.rotation();
let (left, y) = turn_point(rule.right, rule.y, rotation);
let (right, _) = turn_point(rule.left, rule.y, rotation);
assert_eq!(
(turned.len(), turned[0].left, turned[0].right, turned[0].y),
(1, left, right, y),
"a half turn lost the page's rules, or left them where they were drawn"
);
assert!(
turned[0].left < turned[0].right,
"the ends of a mirrored rule did not swap, so it reads as empty"
);
for turn in [PageTurn::Quarter, PageTurn::ThreeQuarter] {
assert!(
turn.rules_in_frame(&drawn).is_empty(),
"{turn:?} kept a rule that runs across its reading direction"
);
}
}
#[test]
fn a_page_with_no_majority_direction_is_not_turned() {
let quarter_each = vec![
span("rotation zero", 72.0, 700.0, 78.0),
turned_span("rotation ninety", 90.0, 200.0, 200.0, 90.0),
turned_span("rotation oneeighty", 180.0, 400.0, 400.0, 108.0),
turned_span("rotation twoseventy", 270.0, 500.0, 600.0, 114.0),
];
assert_eq!(
PageTurn::of(&quarter_each),
PageTurn::None,
"a plurality of a quarter of the glyphs turned the whole page"
);
let mut majority = quarter_each;
majority[1] = turned_span(
"a line of ninety degree text that is most of this page by a street",
90.0,
200.0,
200.0,
400.0,
);
assert_eq!(
PageTurn::of(&majority),
PageTurn::Quarter,
"a direction carrying most of the page did not turn it"
);
}
fn mirrored_span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
let scale = 12.0 / 1000.0;
TextSpan {
transform: Some([-scale, 0.0, 0.0, scale, x, y]),
..span(text, x, y, width)
}
}
#[test]
fn a_mirrored_page_is_not_a_half_turned_page() {
let mirrored = vec![
mirrored_span("the first line of a mirrored page", 400.0, 700.0, 300.0),
mirrored_span("and the second line below it", 400.0, 686.0, 260.0),
];
assert_eq!(
PageTurn::of(&mirrored),
PageTurn::None,
"a reflection was read as a half turn, and turning it reverses the page"
);
let half_turned: Vec<TextSpan> = vec![
turned_span(
"the first line of a turned page",
180.0,
400.0,
700.0,
300.0,
),
turned_span("and the second line below it", 180.0, 400.0, 686.0, 260.0),
];
assert_eq!(
PageTurn::of(&half_turned),
PageTurn::Half,
"a page genuinely drawn at 180 degrees is no longer turned into its frame"
);
let mut mostly_mirrored = mirrored;
mostly_mirrored.push(span("a short upright caption", 72.0, 100.0, 40.0));
assert_eq!(
PageTurn::of(&mostly_mirrored),
PageTurn::None,
"an upright minority carried the vote on a mirrored page"
);
}
#[test]
fn the_seam_allowance_counts_glyphs_and_not_characters() {
let prev = span("first", 0.0, 700.0, 30.0);
let mut curr = span("office", 24.5, 700.0, 30.0);
curr.char_bounds = vec![[0.0; 4]; 5];
assert!(
second_run_carries_on_from_the_first(&prev, &curr),
"a ligature shrank the seam allowance to the character count"
);
curr.char_bounds.clear();
assert!(
!second_run_carries_on_from_the_first(&prev, &curr),
"the character count is the fallback, so it has to be the stricter one here"
);
}
#[test]
fn g1_default_text_span_has_empty_metadata() {
let s = TextSpan::default();
assert_eq!(s.font_name, None);
assert!(!s.is_bold);
assert!(!s.is_italic);
assert_eq!(s.color, None);
}
#[test]
fn g1_strip_subset_prefix_handles_six_char_prefix() {
assert_eq!(strip_subset_prefix("AAAAAA+Helvetica"), "Helvetica");
assert_eq!(strip_subset_prefix("ABC+Helvetica"), "ABC+Helvetica");
assert_eq!(strip_subset_prefix("Helvetica-Bold"), "Helvetica-Bold");
}
#[test]
fn g1_name_style_hints_match_pdf_interpret_rules() {
assert_eq!(name_style_hints("Helvetica-Bold"), (true, false));
assert_eq!(name_style_hints("Times-Italic"), (false, true));
assert_eq!(name_style_hints("MyFont-BoldOblique"), (true, true));
assert_eq!(name_style_hints("Helvetica"), (false, false));
assert_eq!(name_style_hints("Roboto-DemiBold"), (true, false));
assert_eq!(name_style_hints("Roboto-Black"), (true, false));
assert_eq!(name_style_hints("Roboto-Oblique"), (false, true));
assert_eq!(name_style_hints("MyFont-Slanted"), (false, true));
}
#[test]
fn g2_default_text_span_has_estimate_width_source() {
let s = TextSpan::default();
assert_eq!(s.width_source, WidthSource::Estimate);
assert!(s.char_bounds.is_empty());
}
#[test]
fn g2_single_glyph_span_has_one_char_bound() {
let s = TextSpan {
text: "A".into(),
x: 10.0,
y: 100.0,
width: 7.22,
height: 10.0,
font_size: 10.0,
width_source: WidthSource::Metric,
char_bounds: vec![[10.0, 100.0, 17.22, 110.0]],
..Default::default()
};
assert_eq!(s.char_bounds.len(), 1);
let [x0, y0, x1, y1] = s.char_bounds[0];
assert!((x0 - 10.0).abs() < 0.001);
assert!((x1 - 17.22).abs() < 0.001);
assert!((y1 - y0 - s.font_size).abs() < 0.001);
}
#[test]
fn g2_merged_span_degrades_width_source_on_estimate() {
let mut s = TextSpan {
width_source: WidthSource::Metric,
char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
..Default::default()
};
s.char_bounds.push([7.0, 0.0, 12.0, 10.0]);
s.width_source = WidthSource::Estimate;
assert_eq!(s.width_source, WidthSource::Estimate);
assert_eq!(s.char_bounds.len(), 2);
}
#[test]
fn g2_width_source_variants_are_correct() {
assert_eq!(format!("{:?}", WidthSource::Metric), "Metric");
assert_eq!(format!("{:?}", WidthSource::Estimate), "Estimate");
assert_ne!(WidthSource::Metric, WidthSource::Estimate);
assert_eq!(WidthSource::default(), WidthSource::Estimate);
}
#[test]
fn m3_default_text_span_has_basic_mode() {
let s = TextSpan::default();
assert_eq!(s.geometry_mode, GeometryMode::Basic);
assert_eq!(s.bounds_source, BoundsSource::Advance);
assert!(s.tight_char_bounds.is_empty());
assert!(s.glyph_advances.is_empty());
}
#[test]
fn m3_geometry_mode_default_is_basic() {
assert_eq!(GeometryMode::default(), GeometryMode::Basic);
}
#[test]
fn m3_bounds_source_default_is_advance() {
assert_eq!(BoundsSource::default(), BoundsSource::Advance);
}
#[test]
fn m3_geometry_mode_variants_are_distinct() {
assert_ne!(GeometryMode::Basic, GeometryMode::RichGeometry);
assert_eq!(format!("{:?}", GeometryMode::Basic), "Basic");
assert_eq!(format!("{:?}", GeometryMode::RichGeometry), "RichGeometry");
}
#[test]
fn m3_bounds_source_variants_are_distinct() {
assert_ne!(BoundsSource::Advance, BoundsSource::Tight);
assert_ne!(BoundsSource::Tight, BoundsSource::Estimate);
assert_eq!(format!("{:?}", BoundsSource::Advance), "Advance");
assert_eq!(format!("{:?}", BoundsSource::Tight), "Tight");
assert_eq!(format!("{:?}", BoundsSource::Estimate), "Estimate");
}
#[test]
fn m3_text_span_rich_geometry_preserves_existing_fields() {
let s = TextSpan {
text: "Hello".into(),
x: 10.0,
y: 100.0,
width: 30.0,
height: 12.0,
font_size: 12.0,
font_name: Some("Helvetica".into()),
is_bold: true,
is_italic: false,
color: Some([0, 0, 0, 255]),
width_source: WidthSource::Metric,
char_bounds: vec![[10.0, 100.0, 40.0, 112.0]],
geometry_mode: GeometryMode::RichGeometry,
bounds_source: BoundsSource::Tight,
tight_char_bounds: vec![[8.5, 98.5, 41.5, 114.0]],
glyph_advances: vec![30.0],
glyph_bounds_sources: vec![BoundsSource::Tight],
..Default::default()
};
assert_eq!(s.text, "Hello");
assert!((s.x - 10.0).abs() < 0.001);
assert!((s.y - 100.0).abs() < 0.001);
assert_eq!(s.width_source, WidthSource::Metric);
assert_eq!(s.char_bounds.len(), 1);
assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
assert_eq!(s.bounds_source, BoundsSource::Tight);
assert_eq!(s.tight_char_bounds.len(), 1);
assert_eq!(s.glyph_advances.len(), 1);
assert!((s.glyph_advances[0] - 30.0).abs() < 0.001);
}
#[test]
fn m3_text_span_basic_mode_does_not_populate_tight_fields() {
let s = TextSpan {
text: "A".into(),
x: 0.0,
y: 0.0,
width: 7.0,
height: 10.0,
font_size: 10.0,
geometry_mode: GeometryMode::Basic,
bounds_source: BoundsSource::Advance,
..Default::default()
};
assert!(s.tight_char_bounds.is_empty());
assert!(s.glyph_advances.is_empty());
}
#[test]
fn m3_tight_char_bounds_are_optional_in_basic_mode() {
let s = TextSpan {
text: "test".into(),
x: 0.0,
y: 0.0,
width: 40.0,
height: 12.0,
font_size: 12.0,
geometry_mode: GeometryMode::Basic,
..Default::default()
};
assert!(s.tight_char_bounds.is_empty());
assert!(s.glyph_advances.is_empty());
}
#[test]
fn m3_test_span_constructor_preserves_all_fields() {
let s = span("hello", 10.0, 100.0, 30.0);
assert_eq!(s.geometry_mode, GeometryMode::Basic);
assert_eq!(s.bounds_source, BoundsSource::Advance);
assert!(s.tight_char_bounds.is_empty());
}
#[test]
fn m3_width_source_unchanged_by_m3() {
assert_eq!(WidthSource::Metric, WidthSource::Metric);
assert_eq!(WidthSource::Estimate, WidthSource::Estimate);
assert_eq!(WidthSource::default(), WidthSource::Estimate);
}
#[test]
fn m3_transform_bbox_corners_identity() {
let bbox = [0.0, 0.0, 10.0, 12.0];
let identity = kurbo::Affine::IDENTITY;
let result = transform_bbox_corners(&bbox, &identity);
assert!((result[0] - 0.0).abs() < 0.001);
assert!((result[1] - 0.0).abs() < 0.001);
assert!((result[2] - 10.0).abs() < 0.001);
assert!((result[3] - 12.0).abs() < 0.001);
}
#[test]
fn m3_transform_bbox_corners_translation() {
let bbox = [0.0, 0.0, 10.0, 12.0];
let t = kurbo::Affine::translate((50.0, 100.0));
let result = transform_bbox_corners(&bbox, &t);
assert!((result[0] - 50.0).abs() < 0.001);
assert!((result[1] - 100.0).abs() < 0.001);
assert!((result[2] - 60.0).abs() < 0.001);
assert!((result[3] - 112.0).abs() < 0.001);
}
#[test]
fn m3_transform_bbox_corners_scale() {
let bbox = [0.0, 0.0, 10.0, 12.0];
let s = kurbo::Affine::scale(2.0);
let result = transform_bbox_corners(&bbox, &s);
assert!((result[0] - 0.0).abs() < 0.001);
assert!((result[1] - 0.0).abs() < 0.001);
assert!((result[2] - 20.0).abs() < 0.001);
assert!((result[3] - 24.0).abs() < 0.001);
}
#[test]
fn m3_transform_bbox_corners_with_negative_bbox() {
let bbox = [-5.0, -200.0, 15.0, 800.0];
let s = kurbo::Affine::scale(0.012); let result = transform_bbox_corners(&bbox, &s);
assert!((result[0] - (-0.06)).abs() < 0.01);
assert!((result[1] - (-2.4)).abs() < 0.01);
assert!((result[2] - 0.18).abs() < 0.01);
assert!((result[3] - 9.6).abs() < 0.01);
}
#[test]
fn m3_tight_char_bounds_descender_detection() {
let s = TextSpan {
text: "g".into(),
x: 100.0,
y: 200.0, width: 8.0,
height: 12.0,
font_size: 12.0,
geometry_mode: GeometryMode::RichGeometry,
bounds_source: BoundsSource::Tight,
tight_char_bounds: vec![[99.0, 197.5, 109.0, 210.0]],
char_bounds: vec![[100.0, 200.0, 108.0, 212.0]],
glyph_advances: vec![8.0],
..Default::default()
};
assert!(
s.tight_char_bounds[0][1] < s.y,
"descender should extend below baseline"
);
}
#[test]
fn m3_tight_char_bounds_ascender_detection() {
let s = TextSpan {
text: "f".into(),
x: 100.0,
y: 200.0,
width: 7.0,
height: 12.0,
font_size: 12.0,
geometry_mode: GeometryMode::RichGeometry,
bounds_source: BoundsSource::Tight,
tight_char_bounds: vec![[95.0, 200.0, 108.0, 214.0]],
char_bounds: vec![[100.0, 200.0, 107.0, 212.0]],
glyph_advances: vec![7.0],
..Default::default()
};
let advance_y1 = s.y + s.height;
assert!(
s.tight_char_bounds[0][3] > advance_y1,
"ascender should extend above advance bounds"
);
}
#[test]
fn m3_tight_char_bounds_differ_from_advance() {
let s = TextSpan {
text: "A".into(),
x: 10.0,
y: 100.0,
width: 7.22,
height: 10.0,
font_size: 10.0,
geometry_mode: GeometryMode::RichGeometry,
bounds_source: BoundsSource::Tight,
char_bounds: vec![[10.0, 100.0, 17.22, 110.0]], tight_char_bounds: vec![[9.5, 100.2, 17.5, 109.5]], glyph_advances: vec![7.22],
..Default::default()
};
let ac = s.char_bounds[0];
let tc = s.tight_char_bounds[0];
let differs = (ac[0] - tc[0]).abs() > 0.01
|| (ac[1] - tc[1]).abs() > 0.01
|| (ac[2] - tc[2]).abs() > 0.01
|| (ac[3] - tc[3]).abs() > 0.01;
assert!(differs, "tight bounds should differ from advance bounds");
}
#[test]
fn m3_device_new_uses_basic_mode() {
let dev = TextExtractionDevice::new();
assert_eq!(dev.geometry_mode, GeometryMode::Basic);
assert!(dev.deferred_rich_glyphs.is_empty());
}
#[test]
fn m3_device_with_mode_rich_geometry() {
let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
assert_eq!(dev.geometry_mode, GeometryMode::RichGeometry);
assert!(dev.deferred_rich_glyphs.is_empty());
}
#[cfg(test)]
fn synthetic_glyph_rect() -> (BezPath, kurbo::Rect) {
let mut path = BezPath::new();
path.move_to((100.0, 0.0));
path.line_to((600.0, 0.0));
path.line_to((600.0, 700.0));
path.line_to((100.0, 700.0));
path.close_path();
let bbox = path.bounding_box();
(path, bbox)
}
#[test]
fn m3_basic_mode_skips_tight_bounds_computation() {
let mut dev = TextExtractionDevice::new();
dev.spans.push(TextSpan {
text: "A".into(),
char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
..Default::default()
});
let spans = dev.into_spans();
assert_eq!(spans.len(), 1);
assert!(spans[0].tight_char_bounds.is_empty());
assert!(spans[0].glyph_bounds_sources.is_empty());
assert_eq!(spans[0].bounds_source, BoundsSource::Advance);
}
#[test]
fn m3_rich_geometry_tight_bounds_match_rendered_outline() {
let (path, bbox) = synthetic_glyph_rect();
let composed = Affine::scale(0.01);
let oracle = (composed * path.clone()).bounding_box();
let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
dev.spans.push(TextSpan {
text: "A".into(),
char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
geometry_mode: GeometryMode::RichGeometry,
..Default::default()
});
dev.deferred_rich_glyphs.push(DeferredGlyph {
coeffs: composed.as_coeffs(),
font_size: 10.0,
glyph_width: 7.0,
needs_exact: false,
outline: Some(path),
font_bbox: Some(bbox),
});
let spans = dev.into_spans();
assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
assert_eq!(spans[0].bounds_source, BoundsSource::Tight);
let tb = spans[0].tight_char_bounds[0];
assert!(
(tb[0] - oracle.x0).abs() < 1e-6,
"x0: got {} want {}",
tb[0],
oracle.x0
);
assert!(
(tb[1] - oracle.y0).abs() < 1e-6,
"y0: got {} want {}",
tb[1],
oracle.y0
);
assert!(
(tb[2] - oracle.x1).abs() < 1e-6,
"x1: got {} want {}",
tb[2],
oracle.x1
);
assert!(
(tb[3] - oracle.y1).abs() < 1e-6,
"y1: got {} want {}",
tb[3],
oracle.y1
);
assert!(
tb[3] - tb[1] > 1.0,
"tight height must be commensurate with font size"
);
}
#[test]
fn m3_rich_geometry_tight_bounds_rotation_is_exact() {
let (path, bbox) = synthetic_glyph_rect();
let composed = Affine::scale(0.01) * Affine::rotate(std::f64::consts::FRAC_PI_2);
let oracle = (composed * path.clone()).bounding_box();
let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
dev.spans.push(TextSpan {
text: "A".into(),
char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
geometry_mode: GeometryMode::RichGeometry,
..Default::default()
});
dev.deferred_rich_glyphs.push(DeferredGlyph {
coeffs: composed.as_coeffs(),
font_size: 10.0,
glyph_width: 7.0,
needs_exact: true,
outline: Some(path),
font_bbox: Some(bbox),
});
let spans = dev.into_spans();
assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
let tb = spans[0].tight_char_bounds[0];
assert!(
(tb[0] - oracle.x0).abs() < 1e-6,
"x0: got {} want {}",
tb[0],
oracle.x0
);
assert!(
(tb[1] - oracle.y0).abs() < 1e-6,
"y0: got {} want {}",
tb[1],
oracle.y0
);
assert!(
(tb[2] - oracle.x1).abs() < 1e-6,
"x1: got {} want {}",
tb[2],
oracle.x1
);
assert!(
(tb[3] - oracle.y1).abs() < 1e-6,
"y1: got {} want {}",
tb[3],
oracle.y1
);
}
#[test]
fn m3_empty_device_rich_geometry_no_panic() {
let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
let text = dev.into_text();
assert!(text.is_empty());
}
#[test]
fn m3_bounds_source_estimate_fallback() {
let s = TextSpan {
geometry_mode: GeometryMode::RichGeometry,
bounds_source: BoundsSource::Estimate,
..Default::default()
};
assert_eq!(s.bounds_source, BoundsSource::Estimate);
assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
assert!(s.tight_char_bounds.is_empty());
}
fn line_block(text: &str, left: f64, top: f64, width: f64, size: f64) -> Block {
let span = TextSpan {
text: text.into(),
x: left,
y: top - size * 0.8,
width,
height: size,
font_size: size,
..TextSpan::default()
};
Block {
kind: BlockKind::Paragraph,
page: 0,
bbox: [left, top - size, left + width, top],
text: text.into(),
spans: vec![span],
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
}
fn run_of(texts: &[&str], pitch: f64, size: f64) -> Vec<Block> {
texts
.iter()
.enumerate()
.map(|(k, text)| line_block(text, 72.0, 700.0 - pitch * k as f64, 468.0, size))
.collect()
}
#[test]
fn a_double_spaced_paragraph_is_one_paragraph() {
fn line(text: &str, y: f64) -> TextSpan {
span(text, 72.0, y, 360.0)
}
let mut spans = Vec::new();
for (text, y) in [
"the committee met in ordinary session on tuesday morning",
"and heard the report of the working group on procedure",
"before adjourning for the midday recess.",
]
.iter()
.zip([700.0, 686.0, 672.0])
{
spans.push(line(text, y));
}
let run = [
"the occurrence of brachygnathia inferior and hernias showed",
"statistically highly significant differences between breeds and the line-",
"breaks in the middle of a word join back together without a",
"space while the other lines of the passage join with one plain",
"space each so that the whole passage reads as the typesetter",
"set it before the measure broke it into lines.",
];
for (text, y) in run.iter().zip([630.0, 606.0, 582.0, 558.0, 534.0, 510.0]) {
spans.push(line(text, y));
}
let blocks =
join_double_spaced_runs(paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both), 0);
assert_eq!(
blocks.len(),
2,
"the run joined and the body stayed: {:?}",
blocks.iter().map(|block| &block.text).collect::<Vec<_>>()
);
assert_eq!(blocks[0].index, 0);
assert_eq!(blocks[1].index, 1);
assert_eq!(
blocks[1].text,
"the occurrence of brachygnathia inferior and hernias showed statistically \
highly significant differences between breeds and the linebreaks in the \
middle of a word join back together without a space while the other lines \
of the passage join with one plain space each so that the whole passage \
reads as the typesetter set it before the measure broke it into lines."
);
assert_eq!(blocks[1].spans.len(), 6);
assert!(
blocks[1].bbox[1] < 510.0 && blocks[1].bbox[3] > 630.0,
"the box covers the run: {:?}",
blocks[1].bbox
);
}
#[test]
fn a_one_line_list_on_wide_leading_stays_items() {
let blocks = run_of(
&[
"• ripe red apples from the orchard",
"• green pears from the walled garden",
"• dark plums from the far paddock",
"• yellow quinces from the old trees",
"• blue sloes from the hedgerows",
],
24.0,
12.0,
);
let joined = join_double_spaced_runs(blocks, 0);
assert_eq!(joined.len(), 5);
}
#[test]
fn an_address_block_stays_lines() {
let blocks = run_of(
&[
"Jonathan Pryce",
"12 Red Lion Square",
"London WC1R 4QH",
"United Kingdom",
],
22.0,
12.0,
);
let joined = join_double_spaced_runs(blocks, 0);
assert_eq!(joined.len(), 4);
}
#[test]
fn label_and_value_lines_stay_apart() {
let blocks = run_of(
&["Ausfertigungsdatum: 20.12.1963", "Vollzitat:"],
24.0,
12.0,
);
let joined = join_double_spaced_runs(blocks, 3);
assert_eq!(joined.len(), 2);
assert_eq!(joined[0].index, 3);
assert_eq!(joined[1].index, 4);
}
#[test]
fn contents_lines_stay_apart() {
let blocks = run_of(
&[
"25 Independent Auditors' Reports",
"26 Consolidated Statements",
],
24.0,
12.0,
);
let joined = join_double_spaced_runs(blocks, 0);
assert_eq!(joined.len(), 2);
}
#[test]
fn two_paragraphs_on_one_pitch_stay_two() {
let blocks = run_of(
&[
"the surveyors counted every head twice over the long winter.",
"The year before the new regulations came into force was mild",
],
24.0,
12.0,
);
let joined = join_double_spaced_runs(blocks, 0);
assert_eq!(joined.len(), 2);
}
#[test]
fn section_lines_stay_apart() {
let blocks = run_of(
&[
"§ 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
"§ 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
],
24.0,
12.0,
);
let joined = join_double_spaced_runs(blocks, 0);
assert_eq!(joined.len(), 2);
}
#[test]
fn article_lines_stay_apart() {
let blocks = run_of(
&[
"Art. 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
"Art. 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
],
24.0,
12.0,
);
let joined = join_double_spaced_runs(blocks, 0);
assert_eq!(joined.len(), 2);
}
#[test]
fn headings_never_join() {
let mut blocks = run_of(
&[
"the occurrence of brachygnathia inferior and hernias showed",
"statistically highly significant differences between breeds and the passage",
],
24.0,
12.0,
);
blocks[1].kind = BlockKind::Heading { level: 2 };
let joined = join_double_spaced_runs(blocks, 0);
assert_eq!(joined.len(), 2);
}
#[test]
fn m3_multiple_glyphs_tight_bounds_consistent_count() {
let s = TextSpan {
text: "abc".into(),
x: 0.0,
y: 0.0,
width: 25.0,
height: 12.0,
font_size: 12.0,
geometry_mode: GeometryMode::RichGeometry,
bounds_source: BoundsSource::Tight,
char_bounds: vec![[0., 0., 8., 12.], [8., 0., 16., 12.], [16., 0., 25., 12.]],
tight_char_bounds: vec![
[-1., -2., 9., 14.],
[7., -2., 17., 14.],
[15., -2., 26., 14.],
],
glyph_advances: vec![8.0, 8.0, 9.0],
..Default::default()
};
assert_eq!(s.tight_char_bounds.len(), 3);
assert_eq!(s.glyph_advances.len(), 3);
assert_eq!(s.char_bounds.len(), 3);
for &adv in &s.glyph_advances {
assert!(adv >= 0.0, "glyph advance must be non-negative");
}
}
fn styled_span(text: &str, x: f64, y: f64, width: f64, bold: bool) -> TextSpan {
let mut out = span(text, x, y, width);
out.is_bold = bold;
out
}
fn block_of(spans: Vec<TextSpan>) -> Block {
let mut block = Block {
kind: crate::blocks::BlockKind::Paragraph,
page: 0,
bbox: [0.0; 4],
text: String::new(),
spans,
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
};
let lines = lines_of_block(&block);
let texts: Vec<String> = lines.iter().map(|line| line_text(&block, line)).collect();
block.text = crate::blocks::join_lines(&texts);
crate::blocks::recompute_bbox(&mut block);
block
}
#[test]
fn an_inid_field_keeps_its_continuation() {
let upper = block_of(vec![
styled_span("(73)", 72.0, 700.0, 30.0, true),
styled_span(
"Assignee: Schlumberger Technology",
110.0,
700.0,
400.0,
true,
),
]);
let lower = block_of(vec![styled_span(
"Corporation, Sugar Land, TX (US)",
110.0,
688.0,
380.0,
false,
)]);
let joined = join_marker_continuations(vec![upper, lower], 0);
assert_eq!(
joined.len(),
1,
"the field was cut off its continuation: {:?}",
joined.iter().map(|b| &b.text).collect::<Vec<_>>()
);
assert!(
joined[0]
.text
.contains("Schlumberger Technology Corporation"),
"the join lost the middle: {:?}",
joined[0].text
);
assert_eq!(joined[0].spans.len(), 3);
}
#[test]
fn a_bulleted_item_keeps_its_hung_line() {
let upper = block_of(vec![styled_span(
"\u{2022} Making investments in research and development and in",
72.0,
700.0,
440.0,
true,
)]);
let lower = block_of(vec![styled_span(
"anticipate future waves of innovation across the industry",
100.0,
688.0,
400.0,
false,
)]);
let joined = join_marker_continuations(vec![upper, lower], 0);
assert_eq!(
joined.len(),
1,
"the item was cut off its hung line: {:?}",
joined.iter().map(|b| &b.text).collect::<Vec<_>>()
);
}
#[test]
fn a_field_label_ending_in_a_colon_stays_apart() {
let upper = block_of(vec![styled_span(
"(56) References cited:",
72.0,
700.0,
440.0,
true,
)]);
let lower = block_of(vec![styled_span(
"A list of patents and publications considered",
100.0,
688.0,
380.0,
false,
)]);
let joined = join_marker_continuations(vec![upper, lower], 3);
assert_eq!(
joined.len(),
2,
"a label ending in a colon was joined to its value: {:?}",
joined.iter().map(|b| &b.text).collect::<Vec<_>>()
);
assert_eq!(joined[0].index, 3);
assert_eq!(joined[1].index, 4);
}
#[test]
fn a_short_marker_line_is_not_joined() {
let upper = block_of(vec![styled_span(
"(12) United States Patent",
72.0,
700.0,
150.0,
true,
)]);
let lower = block_of(vec![styled_span(
"Palaniappan et al.",
100.0,
688.0,
200.0,
false,
)]);
let joined = join_marker_continuations(vec![upper, lower], 0);
assert_eq!(
joined.len(),
2,
"a short marker line was joined to the line under it: {:?}",
joined.iter().map(|b| &b.text).collect::<Vec<_>>()
);
}
#[test]
fn the_next_marker_starts_a_new_block() {
let upper = block_of(vec![styled_span(
"(73) Assignee: Schlumberger Technology",
72.0,
700.0,
440.0,
true,
)]);
let lower = block_of(vec![styled_span(
"(21) Appl. No.: 09/789,473",
100.0,
688.0,
380.0,
false,
)]);
let joined = join_marker_continuations(vec![upper, lower], 0);
assert_eq!(
joined.len(),
2,
"the next marker was joined to the item above it: {:?}",
joined.iter().map(|b| &b.text).collect::<Vec<_>>()
);
}
#[test]
fn a_wrapped_marker_item_keeps_its_hanging_continuation() {
let upper = block_of(vec![
styled_span(
"\u{2022} Making investments in research and",
72.0,
700.0,
440.0,
true,
),
styled_span(
"development and in new facilities built",
72.0,
688.0,
440.0,
true,
),
styled_span(
"to meet demand across the coming years to",
72.0,
676.0,
440.0,
true,
),
]);
let lower = block_of(vec![styled_span(
"anticipate future waves of innovation across the industry",
100.0,
664.0,
400.0,
false,
)]);
let joined = join_marker_continuations(vec![upper, lower], 0);
assert_eq!(
joined.len(),
1,
"the wrapped item was cut off its hung line: {:?}",
joined.iter().map(|b| &b.text).collect::<Vec<_>>()
);
assert!(
joined[0].text.contains("coming years to anticipate"),
"the join lost the middle: {:?}",
joined[0].text
);
assert_eq!(joined[0].spans.len(), 4);
}
#[test]
fn a_short_heading_line_over_a_full_paragraph_is_not_cut() {
let block = block_of(vec![
span("Contingent liabilities", 72.0, 700.0, 150.0),
span(
"Goodwill represents the excess of the consideration transferred",
72.0,
688.0,
440.0,
),
span(
"over the fair value of the identifiable net assets acquired",
72.0,
676.0,
440.0,
),
span(
"and is tested for impairment at least once a year.",
72.0,
664.0,
440.0,
),
]);
let out = join_marker_continuations(vec![block], 0);
assert_eq!(
out.len(),
1,
"a short heading line was cut off its paragraph: {:?}",
out.iter().map(|b| &b.text).collect::<Vec<_>>()
);
assert_eq!(out[0].spans.len(), 4);
assert_eq!(out[0].index, 0);
}
#[test]
fn a_marker_line_whose_next_line_does_not_hang_stays_apart() {
let upper = block_of(vec![styled_span(
"(73) Assignee: Schlumberger Technology",
72.0,
700.0,
440.0,
true,
)]);
let lower = block_of(vec![styled_span(
"Corporation, Sugar Land, TX (US)",
72.0,
688.0,
380.0,
false,
)]);
let joined = join_marker_continuations(vec![upper, lower], 0);
assert_eq!(
joined.len(),
2,
"a marker line was joined to a line that does not hang under it: {:?}",
joined.iter().map(|b| &b.text).collect::<Vec<_>>()
);
}
}