xberg_native_pdf/lib.rs
1// SPDX-License-Identifier: MIT
2#![allow(clippy::type_complexity)]
3#![allow(clippy::too_many_arguments)]
4#![allow(clippy::needless_range_loop)]
5#![allow(clippy::enum_variant_names)]
6#![allow(clippy::wrong_self_convention)]
7#![allow(clippy::explicit_counter_loop)]
8#![allow(clippy::doc_overindented_list_items)]
9#![allow(clippy::should_implement_trait)]
10#![allow(clippy::redundant_guards)]
11#![allow(clippy::regex_creation_in_loops)]
12#![allow(clippy::manual_find)]
13#![allow(clippy::match_like_matches_macro)]
14#![allow(clippy::collapsible_match)]
15#![cfg_attr(test, allow(dead_code))]
16#![cfg_attr(test, allow(unused_variables))]
17
18//! # xberg-native-pdf
19//!
20//! A PDF parsing, extraction and rendering library — the PDF engine behind
21//! [`xberg`](https://github.com/xberg-io/xberg).
22//!
23//! **This crate began as a fork.** The upstream project it derives from ships bindings for
24//! 19 languages, a CLI and an MCP server; this crate strips all of that down to the single
25//! Rust library `xberg` consumes, and is not endorsed by or affiliated with upstream. The
26//! upstream project, its authors and its licence are named in the repository's
27//! `ATTRIBUTIONS.md`, which is the single place that record is kept.
28//!
29//! ## Core Features
30//!
31//! ### Reading & Extraction
32//! - **Text Extraction**: Character, span, and page-level with font metadata and bounding boxes
33//! - **Reading Order**: 4 pluggable strategies (XY-Cut, Structure Tree, Geometric, Simple)
34//! - **Complex Scripts**: RTL (Arabic/Hebrew), CJK (Japanese/Korean/Chinese), Devanagari, Thai
35//! - **Image Extraction**: Content streams, Form XObjects, inline images
36//! - **Forms & Annotations**: Read form fields, all annotation types, bookmarks
37//! - **Text Search**: Regex and case-insensitive search with page-level results
38//!
39//! ## Quick Start
40//!
41//! ```ignore
42//! use xberg_native_pdf::PdfDocument;
43//! use xberg_native_pdf::pipeline::{TextPipeline, TextPipelineConfig};
44//!
45//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
46//! // Open a PDF
47//! let mut doc = PdfDocument::open("paper.pdf")?;
48//!
49//! // Plain text, in reading order
50//! let text = doc.extract_text(0)?;
51//!
52//! // Or drive the reading-order pipeline yourself (multi-column support)
53//! let spans = doc.extract_spans(0)?;
54//! let config = TextPipelineConfig::default();
55//! let pipeline = TextPipeline::with_config(config.clone());
56//! let ordered_spans = pipeline.process(spans, Default::default())?;
57//! # Ok(())
58//! # }
59//! ```
60//!
61//! ## License
62//!
63//! MIT ([LICENSE](LICENSE) or <https://opensource.org/licenses/MIT>).
64//!
65//! The upstream project is offered as MIT OR Apache-2.0; this crate is
66//! distributed under the MIT arm alone. Copyright in the bulk of the code
67//! is held by Yury Fedoseev and the upstream contributors — see LICENSE,
68//! which carries both notices, and ATTRIBUTIONS.md.
69
70#![warn(missing_docs)]
71// The crate contains no `unsafe`. The last two blocks were `mem::transmute`s to
72// `'static` in the rasterizer's font cache, needed only because harfrust and
73// ttf-parser each held their own borrowed parse of the same bytes. Migrating to
74// fontations replaced those with a borrowed `FontRef` rebuilt per call plus an
75// owned, `Arc`-backed `harfrust::Font`, so nothing has to be transmuted.
76//
77// `deny`, not `forbid`, for exactly ONE audited exception: the `global_asm!`
78// __memcmpeq shim below, which is `unsafe` by definition and cannot be written any
79// other way. `forbid` cannot be relaxed by an inner `allow`, so with `forbid` the
80// crate does not compile on linux-x86_64 at all -- which is precisely how the
81// v1.0.1 publish failed. Every other use of unsafe is still a hard error. ~keep
82#![deny(unsafe_code)]
83#![cfg_attr(docsrs, feature(doc_cfg))]
84
85/// The root of every `tracing` target this crate emits.
86///
87/// `module_path!()` at the crate root evaluates to the `[lib] name`, so this is the live
88/// value rather than a copy of it and cannot drift when the crate is renamed.
89///
90/// It exists because consumers filter on this prefix, and the last time the name moved they
91/// were matching a hardcoded string: the capture sink kept filtering on the former prefix,
92/// every record was rejected, and for twelve days no warning about unparseable fonts reached
93/// a caller (GH#697). Nothing failed to compile. Filter on this constant, not on a literal.
94pub const LOG_TARGET_ROOT: &str = module_path!();
95
96// Glibc 2.34 compatibility (#416): LLVM may emit calls to __memcmpeq@GLIBC_2.35,
97// which does not exist in glibc 2.34 (Amazon Linux 2023, some Ubuntu 22.04 builds). ~keep
98
99// A weak stub redirecting to plain memcmp satisfies the reference on older glibc;
100// glibc 2.35's own definition wins when available. global_asm! works with both
101// GNU ld and lld, unlike --defsym which lld rejects for PLT-resolved symbols. ~keep
102// The single audited exception to the crate-level `deny(unsafe_code)` above:
103// `global_asm!` is unsafe by definition. It emits no Rust code -- only a weak ELF
104// alias -- so it cannot violate any Rust invariant.
105#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
106#[allow(unsafe_code)]
107mod memcmpeq_shim {
108 // `global_asm!` emits a global symbol regardless of the module it sits in, so
109 // nesting it here to scope the `allow` changes nothing about the emitted alias.
110 core::arch::global_asm!(
111 ".weak __memcmpeq",
112 ".type __memcmpeq, @function",
113 "__memcmpeq:",
114 "jmp memcmp@PLT",
115 );
116}
117
118pub mod error;
119
120pub(crate) mod cache;
121
122/// Third-party code vendored directly into this crate rather than pulled in
123/// as a Cargo dependency (see `ATTRIBUTIONS.md`). Implementation detail —
124/// never part of the public API.
125pub(crate) mod vendor;
126
127pub mod document;
128pub(crate) mod lexer;
129pub mod object;
130pub mod objstm;
131pub(crate) mod parser;
132/// Parser configuration options
133pub mod parser_config;
134pub mod xref;
135pub mod xref_reconstruction;
136
137pub mod decoders;
138
139// PDF function evaluators (Type 4 PostScript calculator) ~keep
140pub mod functions;
141
142pub mod color;
143
144// Pluggable cryptographic backend (FIPS / sovereign-jurisdiction
145// providers). Issue #236. ~keep
146pub mod crypto;
147
148pub mod encryption;
149
150pub mod geometry;
151pub mod layout;
152
153pub mod content;
154pub mod extractors;
155pub mod fonts;
156pub mod optional_content;
157pub mod text;
158
159/// Core annotation types and enums per PDF spec
160pub mod annotation_types;
161pub mod annotations;
162/// Content elements for PDF generation
163pub mod elements;
164pub mod outline;
165/// True/destructive redaction + document sanitization (#231).
166pub mod redaction;
167/// PDF logical structure (Tagged PDFs)
168pub mod structure;
169
170/// Structured per-page extraction (`extract_structured`, #536)
171pub mod structured;
172
173pub mod converters;
174
175pub mod pipeline;
176
177pub mod fdf;
178
179pub mod xfa;
180
181pub mod search;
182
183pub mod rendering;
184
185pub mod api;
186
187pub use pipeline::XYCutStrategy;
188
189pub mod config;
190
191pub use annotation_types::{
192 AnnotationBorderStyle, AnnotationColor, AnnotationFlags, AnnotationSubtype, BorderEffectStyle, BorderStyleType,
193 CaretSymbol, FileAttachmentIcon, FreeTextIntent, HighlightMode, LineEndingStyle, QuadPoint, ReplyType, StampType,
194 TextAlignment, TextAnnotationIcon, TextMarkupType, WidgetFieldType,
195};
196pub use annotations::{Annotation, LinkAction, LinkDestination};
197pub use config::{DocumentType, ExtractionProfile};
198pub use document::{ExtractedImageRef, ImageFormat, PageRotation, PdfDocument, ReadingOrder};
199pub use error::{Error, Result};
200pub use extractors::images::{PdfFilter, PdfImageHandle};
201pub use layout::PageText;
202pub use outline::{Destination, OutlineItem};
203pub use redaction::{
204 FontInfoMetrics, OcgPolicy, RedactionOptions, RedactionRegion, RedactionReport, RegionSet, redact_content_stream,
205};
206pub use structured::{ColumnMode, RegionRole, StructuredPage, StructuredRegion};
207
208pub use fonts::global_cache::{clear_global_font_cache, global_font_cache_stats, set_global_font_cache_capacity};
209
210pub use fonts::cmap::{clear_cmap_cache, cmap_cache_size};
211
212pub(crate) mod utils {
213 //! Internal utility functions for the library.
214
215 use std::cmp::Ordering;
216
217 /// Safely truncate a string to at most `max_bytes` from the start
218 /// without splitting a multi-byte UTF-8 character.
219 ///
220 /// Returns the full string if it is shorter than `max_bytes`.
221 /// When truncation lands inside a multi-byte character, the boundary
222 /// is rounded **down** to the nearest char boundary (floor).
223 #[inline]
224 pub fn safe_prefix(s: &str, max_bytes: usize) -> &str {
225 if s.len() <= max_bytes {
226 return s;
227 }
228 let mut end = max_bytes;
229 while end > 0 && !s.is_char_boundary(end) {
230 end -= 1;
231 }
232 &s[..end]
233 }
234
235 /// Safely take the last `max_bytes` of a string without splitting
236 /// a multi-byte UTF-8 character.
237 ///
238 /// Returns the full string if it is shorter than `max_bytes`.
239 /// When the computed start offset lands inside a multi-byte character,
240 /// the boundary is rounded **up** to the nearest char boundary (ceil).
241 #[inline]
242 pub fn safe_suffix(s: &str, max_bytes: usize) -> &str {
243 if s.len() <= max_bytes {
244 return s;
245 }
246 let start = s.len() - max_bytes;
247 let mut safe_start = start;
248 while safe_start < s.len() && !s.is_char_boundary(safe_start) {
249 safe_start += 1;
250 }
251 &s[safe_start..]
252 }
253
254 /// Y-band tolerance used by `row_aware_span_cmp`.
255 ///
256 /// Two spans whose top-Y differs by less than this amount are treated
257 /// as lying on the same row. Chosen to absorb typographic baseline
258 /// jitter for 10-12pt body text and glyph-cluster offsets in CJK
259 /// fonts without merging adjacent 14pt-leading lines.
260 pub const ROW_BAND_TOLERANCE_PT: f32 = 3.0;
261
262 /// Row-aware reading-order comparator for spans.
263 ///
264 /// Sorts primarily by "row band" (top-Y quantized to
265 /// `ROW_BAND_TOLERANCE_PT`, larger Y first per PDF Spec ISO 32000-1:2008
266 /// §8.3.2.3) and secondarily by X (left-to-right within a row). This
267 /// keeps tabular layouts where cells in the same logical row have
268 /// slightly different Y values (font-metric jitter, superscripts, CJK
269 /// glyph centering) from being interleaved by a strict Y sort.
270 ///
271 /// Uses `i32` band keys so the ordering is a valid total order —
272 /// comparing raw Y values with tolerance is non-transitive and would
273 /// break `sort_by`.
274 #[inline]
275 pub fn row_aware_span_cmp(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
276 // Non-finite Y (NaN/±Inf) cannot be quantized into an i32 band —
277 // `as i32` saturates, collapsing distinct non-finite values into
278 // the same band and reordering them unpredictably against finite
279 // spans. Fall back to `safe_float_cmp` so non-finite values follow
280 // the same NaN-last / total-order policy used everywhere else. ~keep
281 if !a_y.is_finite() || !b_y.is_finite() {
282 return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(a_x, b_x));
283 }
284 let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
285 let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
286 match band_b.cmp(&band_a) {
287 Ordering::Equal => safe_float_cmp(a_x, b_x),
288 other => other,
289 }
290 }
291
292 /// Dominant text-matrix rotation of a page's spans, if any.
293 ///
294 /// Returns the snapped rotation (`90` / `180` / `-90`) shared by at
295 /// least half of the page's non-whitespace spans, or `None` when the
296 /// page is predominantly upright (or empty). The half-or-more majority
297 /// mirrors the existing vertical-CJK (tategaki) vote: at most one
298 /// rotation group can dominate, and a marginal stamp or figure label
299 /// can never hijack the page frame. Rotations are grouped with the same
300 /// 0.5° tolerance `order_rotated_blocks` uses, so free-angle (skewed)
301 /// text never forms a quadrant group.
302 pub(crate) fn dominant_rotation(spans: &[crate::layout::TextSpan]) -> Option<f32> {
303 let mut groups: Vec<(f32, usize)> = Vec::new();
304 let mut total = 0usize;
305 for s in spans {
306 if s.text.trim().is_empty() {
307 continue;
308 }
309 total += 1;
310 if s.rotation_degrees == 0.0 {
311 continue;
312 }
313 match groups.iter_mut().find(|(k, _)| (*k - s.rotation_degrees).abs() < 0.5) {
314 Some(g) => g.1 += 1,
315 None => groups.push((s.rotation_degrees, 1)),
316 }
317 }
318 groups
319 .into_iter()
320 .max_by_key(|&(_, n)| n)
321 .filter(|&(_, n)| n * 2 >= total && total > 0)
322 .map(|(deg, _)| deg)
323 }
324
325 /// Right-to-left variant of [`row_aware_span_cmp`] (issues #656/#657).
326 ///
327 /// Identical row banding (lines top-to-bottom), but orders spans
328 /// **right-to-left within a row** (X descending). A pure-RTL line's
329 /// logical reading order *is* its rightmost-first geometric order, so
330 /// sorting word-spans by descending X reconstructs logical order
331 /// directly from page geometry — independent of whether the producer
332 /// stored the run in visual or logical order. Used by the tagged
333 /// struct-tree assemblers, which otherwise have no span-order pass for
334 /// RTL (the untagged `reverse_rtl_visual_order_runs` is never reached
335 /// on tagged pages).
336 ///
337 /// Retained as a tested geometric utility: the tagged RTL assembler now
338 /// orders pure-RTL spans via `document::PdfDocument::order_pure_rtl_spans`
339 /// (font-relative line grouping), which subsumes the fixed-band comparator,
340 /// so this has no production caller at present.
341 #[inline]
342 #[allow(dead_code)]
343 pub fn row_aware_span_cmp_rtl(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
344 if !a_y.is_finite() || !b_y.is_finite() {
345 return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(b_x, a_x));
346 }
347 let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
348 let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
349 match band_b.cmp(&band_a) {
350 Ordering::Equal => safe_float_cmp(b_x, a_x),
351 other => other,
352 }
353 }
354
355 /// Sort spans into tategaki (vertical-writing) reading order:
356 /// right-to-left across columns, top-to-bottom within each column (PDF
357 /// user-space Y increases upward, so top-first means Y descending).
358 ///
359 /// Columns are found by single-linkage clustering of X-centers: order
360 /// the centers right-to-left, then start a new column whenever the gap
361 /// to the previous center exceeds `tol` (the median span width —
362 /// tategaki CJK body text is functionally monospaced, so this
363 /// approximates the column pitch: wide enough to keep one column
364 /// together, narrow enough to separate the next).
365 ///
366 /// Comparing raw X-centers against a `|a - b| <= tol` tolerance
367 /// *inside* a sort comparator is not transitive — a chain of spans
368 /// each within `tol` of its neighbor can span far more than `tol`
369 /// overall, so "same column" isn't an equivalence relation and
370 /// `sort_by` can panic with "does not correctly implement a total
371 /// order". Clustering into columns first and sorting by `(column, Y)`
372 /// avoids this: every comparison is between two discrete, precomputed
373 /// keys, which is transitive by construction. It's also more accurate
374 /// than quantizing each X-center into a fixed-size band independently
375 /// (e.g. `round(x / tol)`) — banding can split two spans that are only
376 /// a couple points apart into different buckets if they straddle a
377 /// bucket boundary, even though they're well within `tol` of each
378 /// other; single-linkage clustering only looks at the gap between
379 /// neighbors, so it has no such boundary effect.
380 pub fn sort_vertical_tategaki<T>(items: Vec<T>, get_bbox: impl Fn(&T) -> &crate::geometry::Rect) -> Vec<T> {
381 if items.len() < 2 {
382 return items;
383 }
384
385 let mut widths: Vec<f32> = items.iter().map(|it| get_bbox(it).width.max(1.0)).collect();
386 widths.sort_by(|a, b| safe_float_cmp(*a, *b));
387 let tol = widths[widths.len() / 2].max(1.0);
388
389 let centers: Vec<f32> = items
390 .iter()
391 .map(|it| {
392 let b = get_bbox(it);
393 b.x + b.width * 0.5
394 })
395 .collect();
396 let ys: Vec<f32> = items.iter().map(|it| get_bbox(it).y).collect();
397
398 // Right-to-left pass assigning column ids. Stable sort keeps ties
399 // in input order, so clustering is deterministic. ~keep
400 let mut order: Vec<usize> = (0..items.len()).collect();
401 order.sort_by(|&a, &b| safe_float_cmp(centers[b], centers[a]));
402
403 let mut column = vec![0u32; items.len()];
404 let mut current = 0u32;
405 let mut prev = centers[order[0]];
406 for &idx in &order[1..] {
407 let center = centers[idx];
408 // A NaN gap (either end non-finite) never chains, so a
409 // non-finite center always starts its own column. ~keep
410 let gap = prev - center;
411 if gap.is_nan() || gap > tol {
412 current += 1;
413 }
414 column[idx] = current;
415 prev = center;
416 }
417
418 // Column ascending (columns were numbered right-to-left above),
419 // then top-to-bottom within a column. Both keys are total orders. ~keep
420 order.sort_by(|&a, &b| column[a].cmp(&column[b]).then_with(|| safe_float_cmp(ys[b], ys[a])));
421
422 let mut slots: Vec<Option<T>> = items.into_iter().map(Some).collect();
423 order
424 .into_iter()
425 .map(|i| slots[i].take().expect("each index appears once"))
426 .collect()
427 }
428
429 /// Safely compare two floating point numbers, handling NaN cases.
430 ///
431 /// NaN values are treated as equal to each other and greater than all other values.
432 /// This ensures that sorting operations never panic due to NaN comparisons.
433 ///
434 /// # Examples
435 ///
436 /// ```ignore
437 /// # use std::cmp::Ordering;
438 /// # use xberg_native_pdf::utils::safe_float_cmp;
439 /// assert_eq!(safe_float_cmp(1.0, 2.0), Ordering::Less);
440 /// assert_eq!(safe_float_cmp(2.0, 1.0), Ordering::Greater);
441 /// assert_eq!(safe_float_cmp(1.0, 1.0), Ordering::Equal);
442 ///
443 /// // NaN handling
444 /// assert_eq!(safe_float_cmp(f32::NAN, f32::NAN), Ordering::Equal);
445 /// assert_eq!(safe_float_cmp(f32::NAN, 1.0), Ordering::Greater);
446 /// assert_eq!(safe_float_cmp(1.0, f32::NAN), Ordering::Less);
447 /// ```
448 #[inline]
449 pub fn safe_float_cmp(a: f32, b: f32) -> Ordering {
450 match (a.is_nan(), b.is_nan()) {
451 (true, true) => Ordering::Equal,
452 (true, false) => Ordering::Greater,
453 (false, true) => Ordering::Less,
454 (false, false) => a.partial_cmp(&b).unwrap(),
455 }
456 }
457
458 /// Sort `items` into row-band reading order, computing each element's band
459 /// key once instead of re-quantizing on every `row_aware_span_cmp`
460 /// comparison.
461 ///
462 /// When all `y`/`x` are finite this is a cached-key stable sort with the
463 /// same order as `sort_by(row_aware_span_cmp)` (band descending, then `x`
464 /// ascending — `f32::total_cmp` equals `safe_float_cmp` for finite values,
465 /// and both are stable on ties). Otherwise it falls back to the comparator
466 /// so the NaN/±∞ policy is unchanged.
467 pub fn sort_by_row_band<T>(items: &mut [T], get_y: impl Fn(&T) -> f32, get_x: impl Fn(&T) -> f32) {
468 let all_finite = items.iter().all(|it| get_y(it).is_finite() && get_x(it).is_finite());
469 if !all_finite {
470 items.sort_by(|a, b| row_aware_span_cmp(get_y(a), get_x(a), get_y(b), get_x(b)));
471 return;
472 }
473 items.sort_by_cached_key(|it| {
474 let band = (get_y(it) / ROW_BAND_TOLERANCE_PT).round() as i32;
475 // Reverse band → larger Y (higher on page) first, matching the
476 // comparator's `band_b.cmp(&band_a)`. ~keep
477 (std::cmp::Reverse(band), F32Ord(get_x(it)))
478 });
479 }
480
481 /// Sort into visual rows without making row membership depend on an
482 /// absolute coordinate-grid boundary.
483 ///
484 /// Items are first ordered strictly top-to-bottom. Each row is then swept
485 /// from a fixed topmost anchor and sorted left-to-right. The tolerance is
486 /// proportional to the largest text scale seen in that row, bounded so it
487 /// absorbs baseline jitter without chaining adjacent lines together.
488 pub fn sort_by_visual_rows<T>(
489 items: &mut [T],
490 get_y: impl Fn(&T) -> f32,
491 get_x: impl Fn(&T) -> f32,
492 get_scale: impl Fn(&T) -> f32,
493 ) {
494 const ROW_TOLERANCE_SCALE_FACTOR: f32 = 0.25;
495
496 let all_finite = items
497 .iter()
498 .all(|item| get_y(item).is_finite() && get_x(item).is_finite() && get_scale(item).is_finite());
499 if !all_finite {
500 items.sort_by(|a, b| row_aware_span_cmp(get_y(a), get_x(a), get_y(b), get_x(b)));
501 return;
502 }
503
504 items.sort_by(|a, b| {
505 get_y(b)
506 .total_cmp(&get_y(a))
507 .then_with(|| get_x(a).total_cmp(&get_x(b)))
508 });
509
510 let mut row_start = 0;
511 while row_start < items.len() {
512 let anchor_y = get_y(&items[row_start]);
513 let mut row_scale = get_scale(&items[row_start]).abs();
514 let mut row_end = row_start + 1;
515 while row_end < items.len() {
516 let candidate_scale = get_scale(&items[row_end]).abs();
517 let tolerance =
518 (row_scale.max(candidate_scale) * ROW_TOLERANCE_SCALE_FACTOR).min(ROW_BAND_TOLERANCE_PT);
519 if anchor_y - get_y(&items[row_end]) > tolerance {
520 break;
521 }
522 row_scale = row_scale.max(candidate_scale);
523 row_end += 1;
524 }
525
526 items[row_start..row_end].sort_by(|a, b| get_x(a).total_cmp(&get_x(b)));
527 row_start = row_end;
528 }
529 }
530
531 /// Total-order wrapper over `f32` for use as a sort key. For finite values
532 /// `total_cmp` is identical to `safe_float_cmp` / `partial_cmp`.
533 #[derive(Clone, Copy, PartialEq)]
534 struct F32Ord(f32);
535 impl Eq for F32Ord {}
536 impl PartialOrd for F32Ord {
537 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
538 Some(self.cmp(other))
539 }
540 }
541 impl Ord for F32Ord {
542 fn cmp(&self, other: &Self) -> Ordering {
543 self.0.total_cmp(&other.0)
544 }
545 }
546
547 #[cfg(test)]
548 mod tests {
549 use super::*;
550
551 /// The cached-key sort must produce the identical permutation to
552 /// `sort_by(row_aware_span_cmp)` on finite inputs.
553 #[test]
554 fn test_sort_by_row_band_matches_comparator() {
555 let raw: Vec<(f32, f32)> = (0..500)
556 .map(|i| {
557 let y = ((i * 37 % 113) as f32) * 1.3;
558 let x = ((i * 71 % 97) as f32) * 2.1;
559 (y, x)
560 })
561 .collect();
562 let mut a = raw.clone();
563 let mut b = raw.clone();
564 sort_by_row_band(&mut a, |t| t.0, |t| t.1);
565 b.sort_by(|p, q| row_aware_span_cmp(p.0, p.1, q.0, q.1));
566 assert_eq!(a, b, "cached-key sort must match the comparator permutation");
567 }
568
569 #[test]
570 fn issue_1560_visual_rows_ignore_absolute_band_boundaries() {
571 let mut spans = [
572 (623.481_f32, 68.279_f32, 8.6_f32, "article"),
573 (622.401, 50.0, 8.6, "position"),
574 (622.401, 124.9, 8.6, "description"),
575 ];
576
577 sort_by_visual_rows(&mut spans, |span| span.0, |span| span.1, |span| span.2);
578
579 assert_eq!(spans.map(|span| span.3), ["position", "article", "description"]);
580 }
581
582 #[test]
583 fn issue_1560_visual_row_sort_is_translation_invariant() {
584 let source = [
585 (623.481_f32, 68.279_f32, 8.6_f32, "article"),
586 (622.401, 50.0, 8.6, "position"),
587 (610.0, 50.0, 8.6, "next-row"),
588 ];
589 let mut original = source;
590 let mut translated = source.map(|(y, x, scale, id)| (y + 1.6, x, scale, id));
591
592 sort_by_visual_rows(&mut original, |span| span.0, |span| span.1, |span| span.2);
593 sort_by_visual_rows(&mut translated, |span| span.0, |span| span.1, |span| span.2);
594
595 assert_eq!(
596 original.map(|span| span.3),
597 translated.map(|span| span.3),
598 "shifting a page across the old 3pt bands must not change reading order"
599 );
600 assert_eq!(original.map(|span| span.3), ["position", "article", "next-row"]);
601 }
602
603 #[test]
604 fn issue_1560_visual_row_tolerance_scales_down_for_tiny_text() {
605 let mut spans = [(10.0_f32, 90.0_f32, 1.0_f32, "top"), (9.7, 10.0, 1.0, "bottom")];
606
607 sort_by_visual_rows(&mut spans, |span| span.0, |span| span.1, |span| span.2);
608
609 assert_eq!(spans.map(|span| span.3), ["top", "bottom"]);
610 }
611
612 #[test]
613 fn test_safe_float_cmp_normal() {
614 assert_eq!(safe_float_cmp(1.0, 2.0), Ordering::Less);
615 assert_eq!(safe_float_cmp(2.0, 1.0), Ordering::Greater);
616 assert_eq!(safe_float_cmp(1.5, 1.5), Ordering::Equal);
617 }
618
619 #[test]
620 fn test_safe_float_cmp_nan() {
621 assert_eq!(safe_float_cmp(f32::NAN, f32::NAN), Ordering::Equal);
622 assert_eq!(safe_float_cmp(f32::NAN, 0.0), Ordering::Greater);
623 assert_eq!(safe_float_cmp(0.0, f32::NAN), Ordering::Less);
624 }
625
626 fn tategaki_rect(x: f32, y: f32, w: f32) -> crate::geometry::Rect {
627 crate::geometry::Rect::new(x, y, w, 12.0)
628 }
629
630 /// Two well-separated columns: rightmost column first, top-to-bottom
631 /// within each (the ordering the pre-fix comparator also produced
632 /// for the well-behaved case — this must not regress).
633 #[test]
634 fn test_sort_vertical_tategaki_two_columns() {
635 let items = vec![
636 ("D", tategaki_rect(300.0, 700.0, 12.0)),
637 ("F", tategaki_rect(300.0, 676.0, 12.0)),
638 ("B", tategaki_rect(500.0, 688.0, 12.0)),
639 ("C", tategaki_rect(500.0, 676.0, 12.0)),
640 ("A", tategaki_rect(500.0, 700.0, 12.0)),
641 ("E", tategaki_rect(300.0, 688.0, 12.0)),
642 ];
643 let sorted = sort_vertical_tategaki(items, |it| &it.1);
644 let order: String = sorted.iter().map(|it| it.0).collect();
645 assert_eq!(order, "ABCDEF");
646 }
647
648 /// A chain of X-centers each within `tol` of its neighbor but
649 /// spanning far more than `tol` overall made the old pairwise
650 /// `|a - b| <= tol` comparator non-transitive (A<B, B<C, C<A),
651 /// which panicked `sort_by` on Rust 1.81+. Single-linkage
652 /// clustering must read the whole chain as one column, top to
653 /// bottom, without panicking.
654 #[test]
655 fn test_sort_vertical_tategaki_chained_centers() {
656 let items: Vec<(usize, crate::geometry::Rect)> = (0..64)
657 .map(|i| (i, tategaki_rect(i as f32 * 8.0, ((i * 37) % 64) as f32 * 7.0, 10.0)))
658 .collect();
659 let sorted = sort_vertical_tategaki(items, |it| &it.1);
660 assert_eq!(sorted.len(), 64);
661 assert!(
662 sorted.windows(2).all(|w| w[0].1.y >= w[1].1.y),
663 "one chained column must read top-to-bottom"
664 );
665 }
666
667 /// Two spans only 2pt apart (well within `tol`) must land in the
668 /// same column even when their absolute X-centers straddle what
669 /// would be a fixed quantization-bucket boundary (e.g. `tol`
670 /// multiples of 100 straddling x=250). Single-linkage clustering
671 /// only looks at the gap between neighbors, so it has no such
672 /// boundary effect — unlike banding each center independently via
673 /// `round(x / tol)`.
674 #[test]
675 fn test_sort_vertical_tategaki_no_boundary_straddle_effect() {
676 let items = vec![
677 ("near", tategaki_rect(249.0, 700.0, 100.0)),
678 ("straddle", tategaki_rect(251.0, 690.0, 100.0)),
679 ("far", tategaki_rect(10.0, 680.0, 100.0)),
680 ];
681 let sorted = sort_vertical_tategaki(items, |it| &it.1);
682 let order: Vec<&str> = sorted.iter().map(|it| it.0).collect();
683 assert_eq!(order, vec!["near", "straddle", "far"]);
684 }
685
686 /// Non-finite coordinates must not panic the sort, and every item
687 /// must survive the permutation exactly once.
688 #[test]
689 fn test_sort_vertical_tategaki_non_finite() {
690 let mut items: Vec<(usize, crate::geometry::Rect)> = (0..32)
691 .map(|i| (i, tategaki_rect((i % 8) as f32 * 40.0, i as f32 * 5.0, 12.0)))
692 .collect();
693 items[3].1.x = f32::NAN;
694 items[11].1.y = f32::NAN;
695 items[17].1.width = f32::NAN;
696 items[23].1.x = f32::INFINITY;
697 let sorted = sort_vertical_tategaki(items, |it| &it.1);
698 let mut ids: Vec<usize> = sorted.iter().map(|it| it.0).collect();
699 ids.sort_unstable();
700 assert_eq!(ids, (0..32).collect::<Vec<_>>());
701 }
702
703 #[test]
704 fn test_safe_float_cmp_infinity() {
705 assert_eq!(safe_float_cmp(f32::INFINITY, f32::INFINITY), Ordering::Equal);
706 assert_eq!(safe_float_cmp(f32::INFINITY, 1.0), Ordering::Greater);
707 assert_eq!(safe_float_cmp(f32::NEG_INFINITY, f32::INFINITY), Ordering::Less);
708 }
709
710 /// Verify that sort_by using safe_float_cmp never panics with NaN values.
711 /// This is a regression test for the "total order" panic that affected 42
712 /// PDFs across 5 test datasets (issue found in v0.3.11-pre).
713 #[test]
714 fn test_sort_with_nan_does_not_panic() {
715 let mut values = [3.0_f32, f32::NAN, 1.0, f32::NAN, 2.0, f32::NAN, 0.5];
716 values.sort_by(|a, b| safe_float_cmp(*a, *b));
717 assert!(values[0..4].iter().all(|v| !v.is_nan()));
718 assert!(values[4..].iter().all(|v| v.is_nan()));
719 }
720
721 /// Verify transitivity: if a < b and b < c then a < c.
722 /// The previous `partial_cmp().unwrap_or(Equal)` pattern violated this
723 /// when NaN was involved, causing Rust's sort to panic.
724 #[test]
725 fn test_safe_float_cmp_transitivity() {
726 let a = 1.0_f32;
727 let b = 2.0_f32;
728 let nan = f32::NAN;
729
730 assert_eq!(safe_float_cmp(a, b), Ordering::Less);
731 assert_eq!(safe_float_cmp(b, nan), Ordering::Less);
732 assert_eq!(safe_float_cmp(a, nan), Ordering::Less);
733 }
734
735 /// Cells in the same tabular row with slightly-different Y values
736 /// must stay together and be ordered by X, not interleaved with
737 /// cells from other rows.
738 #[test]
739 fn test_row_aware_span_cmp_tolerates_y_jitter() {
740 // Row 1 at y ≈ 100 with small per-cell jitter.
741 // Row 2 at y ≈ 86 (14pt leading below).
742 // A strict Y sort would interleave them because some row-1
743 // cells have lower Y than some row-2 cells. ~keep
744 #[derive(Debug, Clone, Copy)]
745 struct Cell {
746 y: f32,
747 x: f32,
748 id: &'static str,
749 }
750 let mut cells = [
751 Cell {
752 y: 100.5,
753 x: 50.0,
754 id: "r1-c1",
755 },
756 Cell {
757 y: 99.7,
758 x: 150.0,
759 id: "r1-c2",
760 },
761 Cell {
762 y: 100.2,
763 x: 250.0,
764 id: "r1-c3",
765 },
766 Cell {
767 y: 86.4,
768 x: 50.0,
769 id: "r2-c1",
770 },
771 Cell {
772 y: 85.8,
773 x: 150.0,
774 id: "r2-c2",
775 },
776 Cell {
777 y: 86.1,
778 x: 250.0,
779 id: "r2-c3",
780 },
781 ];
782 cells.sort_by(|a, b| row_aware_span_cmp(a.y, a.x, b.y, b.x));
783 let order: Vec<&str> = cells.iter().map(|c| c.id).collect();
784 assert_eq!(
785 order,
786 vec!["r1-c1", "r1-c2", "r1-c3", "r2-c1", "r2-c2", "r2-c3"],
787 "cells from the same row must stay contiguous and X-sorted"
788 );
789 }
790
791 /// Row-aware comparator must still put distinct-leading rows in
792 /// top-to-bottom reading order.
793 #[test]
794 fn test_row_aware_span_cmp_distinct_rows_descending() {
795 let mut rows = [(100.0f32, 0.0f32, "top"), (50.0, 0.0, "middle"), (10.0, 0.0, "bottom")];
796 rows.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
797 assert_eq!(rows[0].2, "top");
798 assert_eq!(rows[1].2, "middle");
799 assert_eq!(rows[2].2, "bottom");
800 }
801
802 /// The comparator is used by sort_by, which requires a valid total
803 /// order. Run a randomized stress test to confirm no transitivity
804 /// panics.
805 #[test]
806 fn test_row_aware_span_cmp_is_total_order() {
807 let mut v: Vec<(f32, f32)> = (0..200).map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32)).collect();
808 v.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
809 }
810
811 /// #656/#657: the RTL variant keeps rows top-to-bottom but orders
812 /// X *descending* (right-to-left) within a row — a pure-RTL line's
813 /// logical reading order.
814 #[test]
815 fn test_row_aware_span_cmp_rtl_within_row_is_descending() {
816 let mut row = [
817 (100.0f32, 10.0f32, "leftmost"),
818 (100.0, 50.0, "mid"),
819 (100.0, 90.0, "rightmost"),
820 ];
821 row.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
822 assert_eq!(["rightmost", "mid", "leftmost"], [row[0].2, row[1].2, row[2].2]);
823 }
824
825 /// Rows still order top-to-bottom regardless of the within-row flip.
826 #[test]
827 fn test_row_aware_span_cmp_rtl_rows_top_to_bottom() {
828 let mut rows = [(10.0f32, 0.0f32, "bottom"), (100.0, 0.0, "top"), (50.0, 0.0, "middle")];
829 rows.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
830 assert_eq!(["top", "middle", "bottom"], [rows[0].2, rows[1].2, rows[2].2]);
831 }
832
833 /// Must be a valid total order for `sort_by` (no transitivity panic).
834 #[test]
835 fn test_row_aware_span_cmp_rtl_is_total_order() {
836 let mut v: Vec<(f32, f32)> = (0..200).map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32)).collect();
837 v.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
838 }
839
840 /// Sort a large array with mixed NaN/normal values to stress-test.
841 #[test]
842 fn test_sort_stress_with_nan() {
843 let mut values: Vec<f32> = (0..100).map(|i| i as f32).collect();
844 for i in (0..100).step_by(7) {
845 values[i] = f32::NAN;
846 }
847 values.sort_by(|a, b| safe_float_cmp(*a, *b));
848 }
849
850 #[test]
851 fn test_safe_prefix_ascii() {
852 assert_eq!(safe_prefix("hello", 3), "hel");
853 assert_eq!(safe_prefix("hello", 10), "hello");
854 assert_eq!(safe_prefix("", 5), "");
855 assert_eq!(safe_prefix("hi", 0), "");
856 }
857
858 #[test]
859 fn test_safe_prefix_multibyte() {
860 let text = "✚✳★✵";
861 assert_eq!(safe_prefix(text, 10), "✚✳★");
862 assert_eq!(safe_prefix(text, 9), "✚✳★");
863 assert_eq!(safe_prefix(text, 12), "✚✳★✵");
864 }
865
866 #[test]
867 fn test_safe_suffix_ascii() {
868 assert_eq!(safe_suffix("hello", 3), "llo");
869 assert_eq!(safe_suffix("hello", 10), "hello");
870 assert_eq!(safe_suffix("", 5), "");
871 assert_eq!(safe_suffix("hi", 0), "");
872 }
873
874 #[test]
875 fn test_safe_suffix_multibyte() {
876 let text = "AB✚✳★✵";
877 // 14 - 10 = 4, byte 4 is inside ✚ → rounds up to 5 ~keep
878 assert_eq!(safe_suffix(text, 10), "✳★✵");
879 }
880 }
881}
882
883/// Library version
884pub const VERSION: &str = env!("CARGO_PKG_VERSION");
885
886/// Library name
887pub const NAME: &str = env!("CARGO_PKG_NAME");
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 #[test]
894 fn test_version() {
895 let parts: Vec<&str> = VERSION.split('.').collect();
896 assert_eq!(parts.len(), 3, "VERSION must be three-part semver, got {VERSION}");
897 assert!(
898 parts
899 .iter()
900 .all(|p| p.chars().next().is_some_and(|c| c.is_ascii_digit())),
901 "every VERSION component must start with a digit, got {VERSION}"
902 );
903 }
904
905 #[test]
906 fn test_name() {
907 assert_eq!(NAME, "xberg-native-pdf");
908 }
909}