#![allow(clippy::type_complexity)]
#![allow(clippy::too_many_arguments)]
#![allow(clippy::needless_range_loop)]
#![allow(clippy::enum_variant_names)]
#![allow(clippy::wrong_self_convention)]
#![allow(clippy::explicit_counter_loop)]
#![allow(clippy::doc_overindented_list_items)]
#![allow(clippy::should_implement_trait)]
#![allow(clippy::redundant_guards)]
#![allow(clippy::regex_creation_in_loops)]
#![allow(clippy::manual_find)]
#![allow(clippy::match_like_matches_macro)]
#![allow(clippy::collapsible_match)]
#![cfg_attr(test, allow(dead_code))]
#![cfg_attr(test, allow(unused_variables))]
#![warn(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(all(feature = "fips", feature = "legacy-crypto"))]
compile_error!(
"Features `fips` and `legacy-crypto` are mutually exclusive. \
FIPS 140-3 forbids MD5 (pulled in by `legacy-crypto`). \
Build with: --no-default-features --features fips,icc"
);
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
core::arch::global_asm!(
".weak __memcmpeq",
".type __memcmpeq, @function",
"__memcmpeq:",
"jmp memcmp@PLT",
);
pub mod error;
pub(crate) mod cache;
pub mod document;
pub mod lexer;
pub mod object;
pub mod objstm;
pub mod parser;
pub mod parser_config;
pub mod xref;
pub mod xref_reconstruction;
pub mod decoders;
pub mod functions;
pub mod color;
pub mod crypto;
pub mod encryption;
pub mod geometry;
pub mod layout;
pub mod content;
pub mod extractors;
pub mod fonts;
pub mod optional_content;
pub mod text;
pub mod annotation_types;
pub mod annotations;
pub mod elements;
pub mod filename;
pub mod outline;
pub mod redaction;
pub mod split_bookmarks;
pub mod structure;
pub mod structured;
pub mod converters;
pub mod pipeline;
pub mod writer;
pub mod html_css;
pub mod fdf;
pub mod xfa;
pub mod editor;
pub mod search;
#[cfg(feature = "rendering")]
#[cfg_attr(docsrs, doc(cfg(feature = "rendering")))]
pub mod rendering;
#[cfg(feature = "rendering")]
#[cfg_attr(docsrs, doc(cfg(feature = "rendering")))]
pub mod debug;
#[cfg(feature = "signatures")]
#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
pub mod signatures;
#[cfg(feature = "parallel")]
#[cfg_attr(docsrs, doc(cfg(feature = "parallel")))]
pub mod parallel;
#[cfg(not(target_arch = "wasm32"))]
pub mod batch;
pub mod compliance;
pub mod api;
pub use pipeline::XYCutStrategy;
pub mod config;
pub mod hybrid;
#[cfg(any(feature = "ocr", feature = "ocr-tract"))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "ocr", feature = "ocr-tract"))))]
pub mod ocr;
#[cfg(not(target_arch = "wasm32"))]
pub mod ffi;
#[cfg(feature = "python")]
mod python;
#[cfg(any(target_arch = "wasm32", test))]
#[cfg(feature = "wasm")]
pub mod wasm;
pub use annotation_types::{
AnnotationBorderStyle, AnnotationColor, AnnotationFlags, AnnotationSubtype, BorderEffectStyle,
BorderStyleType, CaretSymbol, FileAttachmentIcon, FreeTextIntent, HighlightMode,
LineEndingStyle, QuadPoint, ReplyType, StampType, TextAlignment, TextAnnotationIcon,
TextMarkupType, WidgetFieldType,
};
pub use annotations::{Annotation, LinkAction, LinkDestination};
pub use config::{DocumentType, ExtractionProfile};
pub use document::{ExtractedImageRef, ImageFormat, PdfDocument, ReadingOrder};
pub use error::{Error, Result};
pub use extractors::images::{PdfFilter, PdfImageHandle};
pub use layout::PageText;
pub use outline::{Destination, OutlineItem};
pub use redaction::{
redact_content_stream, Classification, FontInfoMetrics, OcgPolicy, RedactionOptions,
RedactionRegion, RedactionReport, RegionSet,
};
pub use structured::{ColumnMode, RegionRole, StructuredPage, StructuredRegion};
pub use fonts::global_cache::{
clear_global_font_cache, global_font_cache_stats, set_global_font_cache_capacity,
};
pub use fonts::cmap::{clear_cmap_cache, cmap_cache_size};
#[cfg(feature = "parallel")]
pub use parallel::{extract_all_markdown_parallel, extract_all_text_parallel, ParallelExtractor};
pub(crate) mod utils {
use std::cmp::Ordering;
#[inline]
pub fn safe_prefix(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[inline]
pub fn safe_suffix(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let start = s.len() - max_bytes;
let mut safe_start = start;
while safe_start < s.len() && !s.is_char_boundary(safe_start) {
safe_start += 1;
}
&s[safe_start..]
}
pub const ROW_BAND_TOLERANCE_PT: f32 = 3.0;
#[inline]
pub fn row_band_then_x(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
if !a_y.is_finite() || !b_y.is_finite() {
return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(a_x, b_x));
}
let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
band_b.cmp(&band_a).then_with(|| safe_float_cmp(a_x, b_x))
}
pub fn row_aware_span_cmp(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
row_band_then_x(a_y, a_x, b_y, b_x).then_with(|| safe_float_cmp(b_y, a_y))
}
pub fn row_band_then_x_axis(
a_rot: f32,
a_y: f32,
a_x: f32,
b_rot: f32,
b_y: f32,
b_x: f32,
) -> Ordering {
quadrant_key(a_rot)
.cmp(&quadrant_key(b_rot))
.then_with(|| row_band_then_x(a_y, a_x, b_y, b_x))
}
#[inline]
fn quadrant_key(rot: f32) -> i32 {
if !rot.is_finite() {
return i32::MAX;
}
let norm = rot.rem_euclid(360.0);
for (q, angle) in [(0, 0.0), (1, 90.0), (2, 180.0), (3, 270.0)] {
if (norm - angle).abs() <= 0.5 || (norm - (angle + 360.0)).abs() <= 0.5 {
return q;
}
}
4 + (norm as i32)
}
pub(crate) fn dominant_rotation(spans: &[crate::layout::TextSpan]) -> Option<f32> {
let mut groups: Vec<(f32, usize)> = Vec::new();
let mut total = 0usize;
for s in spans {
if s.text.trim().is_empty() {
continue;
}
total += 1;
if s.rotation_degrees == 0.0 {
continue;
}
match groups
.iter_mut()
.find(|(k, _)| (*k - s.rotation_degrees).abs() < 0.5)
{
Some(g) => g.1 += 1,
None => groups.push((s.rotation_degrees, 1)),
}
}
groups
.into_iter()
.max_by_key(|&(_, n)| n)
.filter(|&(_, n)| n * 2 >= total && total > 0)
.map(|(deg, _)| deg)
}
#[inline]
#[allow(dead_code)]
pub fn row_aware_span_cmp_rtl(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
if !a_y.is_finite() || !b_y.is_finite() {
return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(b_x, a_x));
}
let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
match band_b.cmp(&band_a) {
Ordering::Equal => safe_float_cmp(b_x, a_x).then_with(|| safe_float_cmp(b_y, a_y)),
other => other,
}
}
pub fn sort_vertical_tategaki<T>(
items: Vec<T>,
get_bbox: impl Fn(&T) -> &crate::geometry::Rect,
) -> Vec<T> {
if items.len() < 2 {
return items;
}
let mut widths: Vec<f32> = items.iter().map(|it| get_bbox(it).width.max(1.0)).collect();
widths.sort_by(|a, b| safe_float_cmp(*a, *b));
let tol = widths[widths.len() / 2].max(1.0);
let centers: Vec<f32> = items
.iter()
.map(|it| {
let b = get_bbox(it);
b.x + b.width * 0.5
})
.collect();
let ys: Vec<f32> = items.iter().map(|it| get_bbox(it).y).collect();
let mut order: Vec<usize> = (0..items.len()).collect();
order.sort_by(|&a, &b| safe_float_cmp(centers[b], centers[a]));
let mut column = vec![0u32; items.len()];
let mut current = 0u32;
let mut prev = centers[order[0]];
for &idx in &order[1..] {
let center = centers[idx];
let gap = prev - center;
if gap.is_nan() || gap > tol {
current += 1;
}
column[idx] = current;
prev = center;
}
order.sort_by(|&a, &b| {
column[a]
.cmp(&column[b])
.then_with(|| safe_float_cmp(ys[b], ys[a]))
});
let mut slots: Vec<Option<T>> = items.into_iter().map(Some).collect();
order
.into_iter()
.map(|i| slots[i].take().expect("each index appears once"))
.collect()
}
#[inline]
pub fn safe_float_cmp(a: f32, b: f32) -> Ordering {
match (a.is_nan(), b.is_nan()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater, (false, true) => Ordering::Less, (false, false) => {
a.partial_cmp(&b).unwrap()
},
}
}
pub fn sort_by_row_band<T>(
items: &mut [T],
get_y: impl Fn(&T) -> f32,
get_x: impl Fn(&T) -> f32,
) {
let all_finite = items
.iter()
.all(|it| get_y(it).is_finite() && get_x(it).is_finite());
if !all_finite {
items.sort_by(|a, b| row_aware_span_cmp(get_y(a), get_x(a), get_y(b), get_x(b)));
return;
}
items.sort_by_cached_key(|it| {
let band = (get_y(it) / ROW_BAND_TOLERANCE_PT).round() as i32;
(std::cmp::Reverse(band), F32Ord(get_x(it)), std::cmp::Reverse(F32Ord(get_y(it))))
});
}
pub fn snap_baselines_to_rows(
all_spans: &[crate::layout::TextSpan],
indices: &[usize],
) -> Vec<f32> {
let edges = |i: usize| -> (f32, f32) {
let b = &all_spans[i].bbox;
let h = if b.height.is_finite() && b.height > 0.0 {
b.height
} else {
all_spans[i].font_size.max(1.0)
};
(b.y, b.y + h)
};
let quadrant = |i: usize| -> i32 {
let r = all_spans[i].rotation_degrees;
if !r.is_finite() {
return 0;
}
(r / 90.0).round().rem_euclid(4.0) as i32
};
const COMPARABLE_HEIGHT_RATIO: f32 = 2.0;
let distance = |a: usize, b: usize| -> f32 {
let (a_base, a_top) = edges(a);
let (b_base, b_top) = edges(b);
let by_baseline = (a_base - b_base).abs();
let (short, tall) = {
let (ha, hb) = (a_top - a_base, b_top - b_base);
(ha.min(hb), ha.max(hb))
};
if short > 0.0 && tall > short * COMPARABLE_HEIGHT_RATIO {
return by_baseline;
}
by_baseline.min((a_top - b_top).abs())
};
let mut snapped: Vec<f32> = indices.iter().map(|&i| all_spans[i].bbox.y).collect();
if indices.is_empty() {
return snapped;
}
let mut tally: std::collections::HashMap<i32, usize> = std::collections::HashMap::new();
for &i in indices {
let fs = all_spans[i].font_size;
if fs.is_finite() && fs > 0.0 {
*tally.entry((fs * 2.0).round() as i32).or_insert(0) += 1;
}
}
let modal = tally
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
.map(|(k, _)| k);
let mut order: Vec<usize> = (0..indices.len()).collect();
order.sort_by(|&a, &b| {
safe_float_cmp(all_spans[indices[b]].bbox.y, all_spans[indices[a]].bbox.y)
});
const MIN_OVERLAP_PT: f32 = 2.0;
const OVERLAP_FRACTION: f32 = 0.25;
let x_extent = |i: usize| -> (f32, f32) {
let b = &all_spans[i].bbox;
let w = if b.width.is_finite() && b.width > 0.0 {
b.width
} else {
0.0
};
(b.x, b.x + w)
};
let occupies_the_same_space = |i: usize, j: usize| -> bool {
if all_spans[i].text.trim().is_empty() || all_spans[j].text.trim().is_empty() {
return false;
}
let ((li, ri), (lj, rj)) = (x_extent(i), x_extent(j));
if !(li.is_finite() && ri.is_finite() && lj.is_finite() && rj.is_finite()) {
return false;
}
let overlap = ri.min(rj) - li.max(lj);
if overlap <= 0.0 {
return false;
}
let shorter = (ri - li).min(rj - lj).max(0.0);
overlap > MIN_OVERLAP_PT.max(shorter * OVERLAP_FRACTION)
};
let mut rows: Vec<usize> = Vec::new();
let mut members: Vec<Vec<usize>> = Vec::new();
let mut row_of: Vec<Option<usize>> = vec![None; indices.len()];
let mut rows_by_baseline: Vec<(f32, usize)> = Vec::new();
let h_max = indices
.iter()
.map(|&i| {
let (b, t) = edges(i);
(t - b).abs()
})
.filter(|h| h.is_finite())
.fold(0.0f32, f32::max);
let is_modal = |i: usize| -> bool {
modal.is_some_and(|m| ((all_spans[i].font_size * 2.0).round() as i32) == m)
};
for modal_pass in [true, false] {
for &pos in &order {
let i = indices[pos];
if row_of[pos].is_some() || is_modal(i) != modal_pass {
continue;
}
if !all_spans[i].bbox.y.is_finite() {
continue;
}
let q = quadrant(i);
let (base_i, top_i) = edges(i);
let window = ROW_BAND_TOLERANCE_PT + (top_i - base_i).abs() + h_max;
let (lo_b, hi_b) = (base_i - window, base_i + window);
let from = rows_by_baseline.partition_point(|&(b, _)| b < lo_b);
let to = rows_by_baseline.partition_point(|&(b, _)| b <= hi_b);
let mut candidates: Vec<usize> =
rows_by_baseline[from..to].iter().map(|&(_, r)| r).collect();
candidates.sort_unstable();
let best = candidates
.into_iter()
.filter(|&r| quadrant(rows[r]) == q)
.map(|r| (distance(i, rows[r]), r, rows[r]))
.min_by(|a, b| safe_float_cmp(a.0, b.0));
match best {
Some((d, r, seed))
if d <= ROW_BAND_TOLERANCE_PT
&& !members[r].iter().any(|&m| occupies_the_same_space(i, m)) =>
{
row_of[pos] = Some(seed);
members[r].push(i);
},
_ => {
let at = rows_by_baseline.partition_point(|&(b, _)| b <= base_i);
rows_by_baseline.insert(at, (base_i, rows.len()));
rows.push(i);
members.push(vec![i]);
row_of[pos] = Some(i);
},
}
}
}
for (pos, seed) in row_of.iter().enumerate() {
if let Some(seed) = seed {
snapped[pos] = all_spans[*seed].bbox.y;
}
}
snapped
}
#[derive(Clone, Copy, PartialEq)]
struct F32Ord(f32);
impl Eq for F32Ord {}
impl PartialOrd for F32Ord {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for F32Ord {
fn cmp(&self, other: &Self) -> Ordering {
self.0.total_cmp(&other.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row_span(y: f32, height: f32, text: &str) -> crate::layout::TextSpan {
row_span_at(0.0, y, height, text)
}
fn row_span_at(x: f32, y: f32, height: f32, text: &str) -> crate::layout::TextSpan {
crate::layout::TextSpan {
text: text.to_string(),
bbox: crate::geometry::Rect::new(x, y, 10.0, height),
font_size: height,
..Default::default()
}
}
#[test]
fn test_tall_centred_run_does_not_join_a_short_row_beside_it() {
let spans = vec![
row_span(723.0, 8.2, "Prescribed by Treasury"),
row_span(716.0, 8.3, "Department"),
row_span(709.1, 8.2, "Treasury Dept. Cir. 1076"),
row_span(711.5, 19.3, "DIRECT DEPOSIT SIGN-UP FORM"),
];
let idx: Vec<usize> = (0..spans.len()).collect();
let rows = snap_baselines_to_rows(&spans, &idx);
assert_ne!(
rows[3], rows[0],
"a run 2.4x the height of the line beside it does not share its row"
);
}
#[test]
fn comparable_runs_still_snap_on_their_better_edge() {
let spans = vec![
row_span_at(0.0, 700.0, 10.0, "body"),
row_span_at(12.0, 703.0, 7.0, "sup"),
];
let idx: Vec<usize> = (0..spans.len()).collect();
let rows = snap_baselines_to_rows(&spans, &idx);
assert_eq!(
rows[0], rows[1],
"runs of similar height still join on whichever edge agrees"
);
}
#[test]
fn test_sort_by_row_band_matches_comparator() {
let raw: Vec<(f32, f32)> = (0..500)
.map(|i| {
let y = ((i * 37 % 113) as f32) * 1.3;
let x = ((i * 71 % 97) as f32) * 2.1;
(y, x)
})
.collect();
let mut a = raw.clone();
let mut b = raw.clone();
sort_by_row_band(&mut a, |t| t.0, |t| t.1);
b.sort_by(|p, q| row_aware_span_cmp(p.0, p.1, q.0, q.1));
assert_eq!(a, b, "cached-key sort must match the comparator permutation");
}
#[test]
fn test_safe_float_cmp_normal() {
assert_eq!(safe_float_cmp(1.0, 2.0), Ordering::Less);
assert_eq!(safe_float_cmp(2.0, 1.0), Ordering::Greater);
assert_eq!(safe_float_cmp(1.5, 1.5), Ordering::Equal);
}
#[test]
fn test_safe_float_cmp_nan() {
assert_eq!(safe_float_cmp(f32::NAN, f32::NAN), Ordering::Equal);
assert_eq!(safe_float_cmp(f32::NAN, 0.0), Ordering::Greater);
assert_eq!(safe_float_cmp(0.0, f32::NAN), Ordering::Less);
}
fn tategaki_rect(x: f32, y: f32, w: f32) -> crate::geometry::Rect {
crate::geometry::Rect::new(x, y, w, 12.0)
}
#[test]
fn test_sort_vertical_tategaki_two_columns() {
let items = vec![
("D", tategaki_rect(300.0, 700.0, 12.0)),
("F", tategaki_rect(300.0, 676.0, 12.0)),
("B", tategaki_rect(500.0, 688.0, 12.0)),
("C", tategaki_rect(500.0, 676.0, 12.0)),
("A", tategaki_rect(500.0, 700.0, 12.0)),
("E", tategaki_rect(300.0, 688.0, 12.0)),
];
let sorted = sort_vertical_tategaki(items, |it| &it.1);
let order: String = sorted.iter().map(|it| it.0).collect();
assert_eq!(order, "ABCDEF");
}
#[test]
fn test_sort_vertical_tategaki_chained_centers() {
let items: Vec<(usize, crate::geometry::Rect)> = (0..64)
.map(|i| (i, tategaki_rect(i as f32 * 8.0, ((i * 37) % 64) as f32 * 7.0, 10.0)))
.collect();
let sorted = sort_vertical_tategaki(items, |it| &it.1);
assert_eq!(sorted.len(), 64);
assert!(
sorted.windows(2).all(|w| w[0].1.y >= w[1].1.y),
"one chained column must read top-to-bottom"
);
}
#[test]
fn test_sort_vertical_tategaki_no_boundary_straddle_effect() {
let items = vec![
("near", tategaki_rect(249.0, 700.0, 100.0)),
("straddle", tategaki_rect(251.0, 690.0, 100.0)),
("far", tategaki_rect(10.0, 680.0, 100.0)),
];
let sorted = sort_vertical_tategaki(items, |it| &it.1);
let order: Vec<&str> = sorted.iter().map(|it| it.0).collect();
assert_eq!(order, vec!["near", "straddle", "far"]);
}
#[test]
fn test_sort_vertical_tategaki_non_finite() {
let mut items: Vec<(usize, crate::geometry::Rect)> = (0..32)
.map(|i| (i, tategaki_rect((i % 8) as f32 * 40.0, i as f32 * 5.0, 12.0)))
.collect();
items[3].1.x = f32::NAN;
items[11].1.y = f32::NAN;
items[17].1.width = f32::NAN;
items[23].1.x = f32::INFINITY;
let sorted = sort_vertical_tategaki(items, |it| &it.1);
let mut ids: Vec<usize> = sorted.iter().map(|it| it.0).collect();
ids.sort_unstable();
assert_eq!(ids, (0..32).collect::<Vec<_>>());
}
#[test]
fn test_safe_float_cmp_infinity() {
assert_eq!(safe_float_cmp(f32::INFINITY, f32::INFINITY), Ordering::Equal);
assert_eq!(safe_float_cmp(f32::INFINITY, 1.0), Ordering::Greater);
assert_eq!(safe_float_cmp(f32::NEG_INFINITY, f32::INFINITY), Ordering::Less);
}
#[test]
fn test_sort_with_nan_does_not_panic() {
let mut values = [3.0_f32, f32::NAN, 1.0, f32::NAN, 2.0, f32::NAN, 0.5];
values.sort_by(|a, b| safe_float_cmp(*a, *b));
assert!(values[0..4].iter().all(|v| !v.is_nan()));
assert!(values[4..].iter().all(|v| v.is_nan()));
}
#[test]
fn test_safe_float_cmp_transitivity() {
let a = 1.0_f32;
let b = 2.0_f32;
let nan = f32::NAN;
assert_eq!(safe_float_cmp(a, b), Ordering::Less);
assert_eq!(safe_float_cmp(b, nan), Ordering::Less);
assert_eq!(safe_float_cmp(a, nan), Ordering::Less);
}
#[test]
fn test_row_aware_span_cmp_tolerates_y_jitter() {
#[derive(Debug, Clone, Copy)]
struct Cell {
y: f32,
x: f32,
id: &'static str,
}
let mut cells = [
Cell {
y: 100.5,
x: 50.0,
id: "r1-c1",
},
Cell {
y: 99.7,
x: 150.0,
id: "r1-c2",
},
Cell {
y: 100.2,
x: 250.0,
id: "r1-c3",
},
Cell {
y: 86.4,
x: 50.0,
id: "r2-c1",
},
Cell {
y: 85.8,
x: 150.0,
id: "r2-c2",
},
Cell {
y: 86.1,
x: 250.0,
id: "r2-c3",
},
];
cells.sort_by(|a, b| row_aware_span_cmp(a.y, a.x, b.y, b.x));
let order: Vec<&str> = cells.iter().map(|c| c.id).collect();
assert_eq!(
order,
vec!["r1-c1", "r1-c2", "r1-c3", "r2-c1", "r2-c2", "r2-c3"],
"cells from the same row must stay contiguous and X-sorted"
);
}
#[test]
fn test_row_aware_span_cmp_distinct_rows_descending() {
let mut rows = [
(100.0f32, 0.0f32, "top"),
(50.0, 0.0, "middle"),
(10.0, 0.0, "bottom"),
];
rows.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
assert_eq!(rows[0].2, "top");
assert_eq!(rows[1].2, "middle");
assert_eq!(rows[2].2, "bottom");
}
#[test]
fn test_row_aware_span_cmp_is_total_order() {
let mut v: Vec<(f32, f32)> = (0..200)
.map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32))
.collect();
v.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
}
#[test]
fn test_sub_band_baseline_difference_still_decides() {
assert_eq!(
row_aware_span_cmp(98.36, 232.08, 98.21, 232.08),
Ordering::Less,
"one band, one x: the baseline must decide, or sort stability does"
);
assert_eq!(row_aware_span_cmp(98.21, 232.08, 98.36, 232.08), Ordering::Greater);
}
#[test]
fn x_still_decides_within_a_band() {
assert_eq!(row_aware_span_cmp(98.36, 100.0, 98.21, 200.0), Ordering::Less);
assert_eq!(row_aware_span_cmp(98.21, 100.0, 98.36, 200.0), Ordering::Less);
}
#[test]
fn test_band_and_x_comparator_leaves_a_same_x_tie_open() {
assert_eq!(row_band_then_x(98.21, 232.08, 98.36, 232.08), Ordering::Equal);
assert_eq!(row_aware_span_cmp(98.21, 232.08, 98.36, 232.08), Ordering::Greater);
assert_eq!(
row_band_then_x(98.36, 100.0, 98.21, 200.0),
row_aware_span_cmp(98.36, 100.0, 98.21, 200.0)
);
}
#[test]
fn test_shared_row_key_leaves_the_baseline_to_decide() {
use crate::layout::TextSpan;
let span = |y: f32, text: &str| TextSpan {
text: text.to_string(),
bbox: crate::geometry::Rect::new(36.0, y, 100.0, 12.0),
font_size: 12.0,
..Default::default()
};
let spans = vec![span(745.73, " "), span(745.93, "Section Title")];
let idx: Vec<usize> = (0..spans.len()).collect();
let key = snap_baselines_to_rows(&spans, &idx);
assert_eq!(
key[0], key[1],
"the two runs are one row, so the hazard this guards is real"
);
assert_eq!(row_band_then_x_axis(0.0, key[0], 36.0, 0.0, key[1], 36.0), Ordering::Equal);
let ordered = row_band_then_x_axis(0.0, key[0], 36.0, 0.0, key[1], 36.0)
.then_with(|| safe_float_cmp(spans[1].bbox.y, spans[0].bbox.y));
assert_eq!(
ordered,
Ordering::Greater,
"the run drawn higher on the page must be read first"
);
}
#[test]
fn test_different_band_still_wins_over_x() {
assert_eq!(row_aware_span_cmp(120.0, 400.0, 98.0, 50.0), Ordering::Less);
}
#[test]
fn identical_geometry_is_equal() {
assert_eq!(row_aware_span_cmp(98.36, 232.08, 98.36, 232.08), Ordering::Equal);
}
#[test]
fn test_cached_key_sort_agrees_with_the_comparator() {
let data = [(98.21_f32, 232.08_f32), (98.36, 232.08), (98.30, 100.0)];
let mut by_key = data.to_vec();
sort_by_row_band(&mut by_key, |it| it.0, |it| it.1);
let mut by_cmp = data.to_vec();
by_cmp.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
assert_eq!(by_key, by_cmp);
}
#[test]
fn test_row_aware_span_cmp_rtl_within_row_is_descending() {
let mut row = [
(100.0f32, 10.0f32, "leftmost"),
(100.0, 50.0, "mid"),
(100.0, 90.0, "rightmost"),
];
row.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
assert_eq!(["rightmost", "mid", "leftmost"], [row[0].2, row[1].2, row[2].2]);
}
#[test]
fn test_row_aware_span_cmp_rtl_rows_top_to_bottom() {
let mut rows = [
(10.0f32, 0.0f32, "bottom"),
(100.0, 0.0, "top"),
(50.0, 0.0, "middle"),
];
rows.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
assert_eq!(["top", "middle", "bottom"], [rows[0].2, rows[1].2, rows[2].2]);
}
#[test]
fn test_row_aware_span_cmp_rtl_is_total_order() {
let mut v: Vec<(f32, f32)> = (0..200)
.map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32))
.collect();
v.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
}
#[test]
fn test_sort_stress_with_nan() {
let mut values: Vec<f32> = (0..100).map(|i| i as f32).collect();
for i in (0..100).step_by(7) {
values[i] = f32::NAN;
}
values.sort_by(|a, b| safe_float_cmp(*a, *b));
}
#[test]
fn test_safe_prefix_ascii() {
assert_eq!(safe_prefix("hello", 3), "hel");
assert_eq!(safe_prefix("hello", 10), "hello");
assert_eq!(safe_prefix("", 5), "");
assert_eq!(safe_prefix("hi", 0), "");
}
#[test]
fn test_safe_prefix_multibyte() {
let text = "✚✳★✵"; assert_eq!(safe_prefix(text, 10), "✚✳★"); assert_eq!(safe_prefix(text, 9), "✚✳★"); assert_eq!(safe_prefix(text, 12), "✚✳★✵"); }
#[test]
fn test_safe_suffix_ascii() {
assert_eq!(safe_suffix("hello", 3), "llo");
assert_eq!(safe_suffix("hello", 10), "hello");
assert_eq!(safe_suffix("", 5), "");
assert_eq!(safe_suffix("hi", 0), "");
}
#[test]
fn test_safe_suffix_multibyte() {
let text = "AB✚✳★✵"; assert_eq!(safe_suffix(text, 10), "✳★✵");
}
}
}
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const NAME: &str = env!("CARGO_PKG_NAME");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(VERSION.starts_with("0."));
}
#[test]
fn test_name() {
assert_eq!(NAME, "pdf_oxide");
}
}