use kurbo::{Affine, BezPath, PathEl, Point, Shape};
use pdf_render::pdf_interpret::cmap::BfString;
use pdf_render::pdf_interpret::font::Glyph;
use pdf_render::pdf_interpret::{
BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
};
use std::borrow::Cow;
use std::cmp::Ordering;
const BAND_Y_TOLERANCE: f64 = 5.0;
const BAND_Y_FRACTION: f64 = 0.30;
const PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER: f64 = 1.8;
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 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 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_RULES: usize = 512;
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 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 font_metrics: Option<FontMetrics>,
}
impl TextSpan {
fn right(&self) -> f64 {
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)]
pub struct TextBlock {
pub spans: Vec<TextSpan>,
}
impl TextBlock {
pub fn text(&self) -> String {
crate::bidi::logical_order(&self.visual_text()).into_owned()
}
fn visual_text(&self) -> String {
if self.spans.is_empty() {
return String::new();
}
let mut result = self.spans[0].text.clone();
let mut anchor = &self.spans[0];
for pair in self.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);
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) {
result.push_str(&trimmed);
continue;
}
}
if gap > prev.font_size * 0.25 {
result.push(' ');
}
result.push_str(&curr.text);
}
result
}
}
#[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))
});
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.width.max(span.estimated_width()) * 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)]
struct HorizontalRule {
left: f64,
right: f64,
y: f64,
}
#[doc(hidden)]
pub struct TextExtractionDevice {
spans: Vec<TextSpan>,
last_y: f64,
last_end_x: f64,
pending_tj_offset: f32,
glyph_widths: Vec<f64>,
cached_median_char_width: f64,
geometry_mode: GeometryMode,
deferred_rich_glyphs: Vec<DeferredGlyph>,
keep_ligatures: bool,
rules: Vec<HorizontalRule>,
clip_stack: Vec<kurbo::Rect>,
marked_content: Vec<MarkedContent>,
open_actual_texts: usize,
}
#[derive(Clone, Debug)]
struct MarkedContent {
actual: Option<ActualText>,
content_spoken_for: bool,
}
#[derive(Clone, Debug)]
struct ActualText {
text: String,
spent: bool,
}
#[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;
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 {
Self {
spans: Vec::new(),
last_y: f64::NEG_INFINITY,
last_end_x: f64::NEG_INFINITY,
pending_tj_offset: 0.0,
glyph_widths: Vec::new(),
cached_median_char_width: 0.0,
geometry_mode,
deferred_rich_glyphs: Vec::new(),
keep_ligatures: false,
rules: Vec::new(),
clip_stack: Vec::new(),
marked_content: Vec::new(),
open_actual_texts: 0,
}
}
fn take_actual_text(&mut self) -> Option<String> {
let actual = self
.marked_content
.iter_mut()
.rev()
.find_map(|entry| entry.actual.as_mut())?;
if actual.spent {
return Some(String::new());
}
actual.spent = true;
Some(actual.text.clone())
}
fn place_for_glyphless_text(&self) -> Option<kurbo::Rect> {
if let Some(clip) = self.clip_stack.last() {
return Some(*clip);
}
let last = self.spans.last()?;
if !self.last_end_x.is_finite() || !self.last_y.is_finite() {
return None;
}
let height = last.font_size.max(1.0);
Some(kurbo::Rect::new(
self.last_end_x,
self.last_y,
self.last_end_x,
self.last_y + height,
))
}
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.spans.push(TextSpan {
text: text.to_string(),
x: rect.x0,
y: rect.y0,
width,
height,
font_size: height,
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),
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.pending_tj_offset = 0.0;
}
#[must_use]
pub fn keep_ligatures(mut self, keep: bool) -> Self {
self.keep_ligatures = keep;
self
}
fn refresh_median_char_width(&mut self) {
if self.glyph_widths.is_empty() {
self.cached_median_char_width = 0.0;
return;
}
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];
}
fn advance_pen(&mut self, text: &str, glyph_end_x: f64) {
if !crate::bidi::is_only_marks(text) {
self.last_end_x = glyph_end_x;
}
}
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.abs() >= 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 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);
}
pub fn into_text(mut self) -> String {
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
let keep_ligatures = self.keep_ligatures;
let rules = std::mem::take(&mut self.rules);
let blocks = group_spans_into_blocks(self.spans, &rules);
let lines: Vec<String> = blocks.iter().map(|b| b.text()).collect();
let stitched = stitch_hyphenated_lines(&lines);
let text = normalize_text_output(&stitched);
if keep_ligatures {
return text;
}
match crate::ligatures::fold(&text) {
Cow::Borrowed(_) => text,
Cow::Owned(folded) => folded,
}
}
pub fn into_blocks(mut self) -> Vec<TextBlock> {
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
let rules = std::mem::take(&mut self.rules);
group_spans_into_blocks(self.spans, &rules)
}
#[allow(dead_code)]
pub(crate) fn into_spans(mut self) -> Vec<TextSpan> {
if self.geometry_mode == GeometryMode::RichGeometry {
self.compute_tight_bounds();
}
self.spans
}
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;
}
let mut span_bounds_source = BoundsSource::Advance;
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);
if source == BoundsSource::Tight && span_bounds_source != BoundsSource::Estimate {
span_bounds_source = BoundsSource::Tight;
} else if source == BoundsSource::Estimate {
span_bounds_source = BoundsSource::Estimate;
}
}
span.bounds_source = span_bounds_source;
}
}
}
impl Device<'_> for TextExtractionDevice {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) {
self.record_rule(path, transform);
}
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<'_, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {
self.clip_stack.pop();
}
fn pop_transparency_group(&mut self) {}
fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option<i32>, actual_text: Option<&str>) {
let actual = actual_text.map(|text| ActualText {
text: text.to_string(),
spent: false,
});
if actual.is_some() {
self.open_actual_texts += 1;
}
self.marked_content.push(MarkedContent {
actual,
content_spoken_for: false,
});
}
fn end_marked_content(&mut self) {
let Some(entry) = self.marked_content.pop() else {
return;
};
let Some(actual) = entry.actual else {
return;
};
self.open_actual_texts -= 1;
if actual.spent || entry.content_spoken_for {
return;
}
for open in &mut self.marked_content {
open.content_spoken_for = true;
}
if actual.text.is_empty() {
return;
}
if let Some(rect) = self.place_for_glyphless_text() {
self.push_placed_span(&actual.text, rect);
}
}
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,
) {
if self.open_actual_texts > 0 {
for entry in &mut self.marked_content {
entry.content_spoken_for = true;
}
}
let own_text = || match glyph.as_unicode() {
Some(BfString::Char(c)) => Some(c.to_string()),
Some(BfString::String(s)) => Some(s),
None => None,
};
let (text, suppressed) = match self.take_actual_text() {
Some(replacement) if replacement.is_empty() => (own_text().unwrap_or_default(), true),
Some(replacement) => (replacement, false),
None => match own_text() {
Some(text) => (text, false),
None => return,
},
};
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 font_size = glyph_scale * 1000.0;
let (glyph_width, glyph_ws) = glyph_width_and_source(glyph, font_size);
let glyph_end_x = x + glyph_width;
let glyph_bound = [x, y, glyph_end_x, y + font_size];
let style = derive_glyph_style(glyph);
let color = paint_to_rgba(paint);
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();
}
}
if suppressed {
self.last_y = y;
self.advance_pen(&text, glyph_end_x);
self.pending_tj_offset = 0.0;
return;
}
let same_line = (y - self.last_y).abs() <= font_size.max(BAND_Y_TOLERANCE) * 0.35;
let gap = x - self.last_end_x;
let adjacent = same_line && gap >= -font_size * 0.25 && gap < font_size * 0.5;
let style_matches = self
.spans
.last()
.map(|last| {
last.font_name == style.font_name
&& last.is_bold == style.is_bold
&& last.is_italic == style.is_italic
&& last.color == color
})
.unwrap_or(false);
if adjacent && !self.spans.is_empty() && style_matches {
let want_space = {
let last = self.spans.last().expect("checked non-empty");
!last.text.ends_with(' ')
&& !text.starts_with(' ')
&& self.evaluate_space_consensus(gap, font_size, &last.text, &text)
};
let last = self.spans.last_mut().expect("checked non-empty");
if want_space {
last.text.push(' ');
}
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;
}
self.last_y = y;
self.advance_pen(&text, glyph_end_x);
self.pending_tj_offset = 0.0;
return;
}
self.last_y = y;
self.advance_pen(&text, glyph_end_x);
self.pending_tj_offset = 0.0;
let span = TextSpan {
text,
x,
y,
width: glyph_width,
height: font_size,
font_size,
font_name: style.font_name,
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)),
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(),
};
self.spans.push(span);
}
fn text_adjustment(&mut self, amount: f32) {
self.pending_tj_offset += amount;
}
}
#[derive(Debug, Default, Clone)]
struct GlyphStyle {
font_name: Option<String>,
is_bold: bool,
is_italic: bool,
font_weight: Option<u16>,
is_serif: Option<bool>,
is_monospace: Option<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 derive_glyph_style(glyph: &Glyph<'_>) -> 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);
GlyphStyle {
font_name: if name.is_empty() { None } else { Some(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),
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 {
let raw = outline.postscript_name().unwrap_or_default();
let name = strip_subset_prefix(&raw).to_string();
let (name_bold, name_italic) = name_style_hints(&name);
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) },
is_bold: name_bold,
is_italic: name_italic,
font_weight: None,
is_serif: None,
is_monospace: None,
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;
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 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 same_baseline = (lhs.y - rhs.y).abs() <= lhs.font_size.max(rhs.font_size) * 0.12;
if !same_baseline {
return false;
}
let lhs_left = lhs.x;
let lhs_right = lhs.right();
let rhs_left = rhs.x;
let rhs_right = rhs.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 heavily_overlaps = overlap / min_width >= 0.85;
if !heavily_overlaps {
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 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;
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 blocks = xy_cut_recursive(spans, 0, &stats, &rules);
let blocks = reassemble_cross_direction_runs(blocks);
turn.back(blocks)
}
#[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 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;
};
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()))
}
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
}
fn xy_cut_recursive(
spans: Vec<TextSpan>,
depth: usize,
stats: &PageStats,
rules: &[HorizontalRule],
) -> Vec<TextBlock> {
if spans.is_empty() {
return Vec::new();
}
if depth >= XY_CUT_MAX_DEPTH {
return 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;
}
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 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 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 mut intervals: Vec<(f64, f64)> = spans
.iter()
.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[0].1;
let mut best_gap: Option<(f64, f64)> = None; for (left, right) in intervals.iter().skip(1) {
if *left > cursor {
let gap = *left - cursor;
if gap >= min_gap {
match best_gap {
Some((best, _)) if best >= gap => {}
_ => {
let cut_x = (cursor + *left) * 0.5;
best_gap = Some((gap, cut_x));
}
}
}
}
cursor = cursor.max(*right);
}
let (gap_size, cut_x) = best_gap?;
let mut left_group = Vec::new();
let mut right_group = Vec::new();
for span in spans {
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;
}
if !columns_are_band_aligned(spans, cut_x, region_left, region_right, stats) {
return None;
}
Some((vec![left_group, right_group], gap_size))
}
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 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(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 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?;
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)
{
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;
}
(dense_slices as f64 / non_empty_slices as f64) >= MIN_DENSE_SLICE_RATIO
}
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
}
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 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"
);
}
#[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 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 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 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 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 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());
}
#[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");
}
}
}