pdf_oxide/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Allow some clippy lints that are too pedantic for this project
3#![allow(clippy::type_complexity)]
4#![allow(clippy::too_many_arguments)]
5#![allow(clippy::needless_range_loop)]
6#![allow(clippy::enum_variant_names)]
7#![allow(clippy::wrong_self_convention)]
8#![allow(clippy::explicit_counter_loop)]
9#![allow(clippy::doc_overindented_list_items)]
10#![allow(clippy::should_implement_trait)]
11#![allow(clippy::redundant_guards)]
12#![allow(clippy::regex_creation_in_loops)]
13#![allow(clippy::manual_find)]
14#![allow(clippy::match_like_matches_macro)]
15#![allow(clippy::collapsible_match)]
16// Allow unused for tests
17#![cfg_attr(test, allow(dead_code))]
18#![cfg_attr(test, allow(unused_variables))]
19
20//! # PDF Oxide
21//!
22//! The fastest PDF library for Python and Rust. 0.8ms mean text extraction — 5× faster than
23//! PyMuPDF, 15× faster than pypdf, 29× faster than pdfplumber. 100% pass rate on 3,830
24//! real-world PDFs. MIT licensed. A drop-in PyMuPDF alternative with no AGPL restrictions.
25//!
26//! ## Performance (v0.3.10)
27//!
28//! Benchmarked against 14 text extraction libraries on 3,830 PDFs from 3 public test suites
29//! (veraPDF, Mozilla pdf.js, DARPA SafeDocs). Single-thread, 60s timeout, no warm-up.
30//!
31//! ### Python PDF Libraries
32//!
33//! | Library | Mean | Pass Rate | License |
34//! |---------|------|-----------|---------|
35//! | **pdf_oxide** | **0.8ms** | **100%** | **MIT** |
36//! | PyMuPDF | 4.6ms | 99.3% | AGPL-3.0 |
37//! | pypdfium2 | 4.1ms | 99.2% | Apache-2.0 |
38//! | pymupdf4llm | 55.5ms | 99.1% | AGPL-3.0 |
39//! | pdftext | 7.3ms | 99.0% | GPL-3.0 |
40//! | pdfminer | 16.8ms | 98.8% | MIT |
41//! | pdfplumber | 23.2ms | 98.8% | MIT |
42//! | markitdown | 108.8ms | 98.6% | MIT |
43//! | pypdf | 12.1ms | 98.4% | BSD-3 |
44//!
45//! ### Rust PDF Libraries
46//!
47//! | Library | Mean | Pass Rate | Text Extraction |
48//! |---------|------|-----------|-----------------|
49//! | **pdf_oxide** | **0.8ms** | **100%** | **Built-in** |
50//! | oxidize_pdf | 13.5ms | 99.1% | Basic |
51//! | unpdf | 2.8ms | 95.1% | Basic |
52//! | pdf_extract | 4.08ms | 91.5% | Basic |
53//! | lopdf | 0.3ms | 80.2% | No built-in extraction |
54//!
55//! 99.5% text quality parity vs PyMuPDF and pypdfium2 across the full corpus.
56//! Full benchmark details: <https://pdf.oxide.fyi/docs/performance>
57//!
58//! ## Core Features
59//!
60//! ### Reading & Extraction
61//! - **Text Extraction**: Character, span, and page-level with font metadata and bounding boxes
62//! - **Reading Order**: 4 pluggable strategies (XY-Cut, Structure Tree, Geometric, Simple)
63//! - **Complex Scripts**: RTL (Arabic/Hebrew), CJK (Japanese/Korean/Chinese), Devanagari, Thai
64//! - **Format Conversion**: PDF → Markdown, HTML, PlainText
65//! - **Image Extraction**: Content streams, Form XObjects, inline images
66//! - **Forms & Annotations**: Read/write form fields, all annotation types, bookmarks
67//! - **Text Search**: Regex and case-insensitive search with page-level results
68//!
69//! ### Writing & Creation
70//! - **PDF Generation**: Fluent DocumentBuilder API for programmatic PDF creation
71//! - **Format Conversion**: Markdown → PDF, HTML → PDF, Plain Text → PDF, Image → PDF
72//! - **Advanced Graphics**: Path operations, image embedding, table generation
73//! - **Font Embedding**: Automatic font subsetting for compact output
74//! - **Interactive Forms**: Fillable forms with text fields, checkboxes, radio buttons, dropdowns
75//! - **QR Codes & Barcodes**: Code128, EAN-13, UPC-A (feature flag: `barcodes`)
76//!
77//! ### Editing
78//! - **DOM-like API**: Query and modify PDF content with strongly-typed wrappers
79//! - **Element Modification**: Find and replace text, modify images, paths, tables
80//! - **Page Operations**: Add, remove, reorder, merge, rotate, crop pages
81//! - **Encryption**: AES-256, password protection
82//! - **Incremental Saves**: Efficient appending without full rewrite
83//!
84//! ### Compliance
85//! - **PDF/A**: Validation and conversion
86//! - **PDF/UA**: Accessibility checks
87//! - **PDF/X**: Print production validation
88//!
89//! ## Quick Start - Rust
90//!
91//! ```ignore
92//! use pdf_oxide::PdfDocument;
93//! use pdf_oxide::pipeline::{TextPipeline, TextPipelineConfig};
94//! use pdf_oxide::pipeline::converters::OutputConverter;
95//! use pdf_oxide::pipeline::converters::MarkdownOutputConverter;
96//!
97//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
98//! // Open a PDF
99//! let mut doc = PdfDocument::open("paper.pdf")?;
100//!
101//! // Extract text with reading order (multi-column support)
102//! let spans = doc.extract_spans(0)?;
103//! let config = TextPipelineConfig::default();
104//! let pipeline = TextPipeline::with_config(config.clone());
105//! let ordered_spans = pipeline.process(spans, Default::default())?;
106//!
107//! // Convert to Markdown
108//! let converter = MarkdownOutputConverter::new();
109//! let markdown = converter.convert(&ordered_spans, &config)?;
110//! println!("{}", markdown);
111//! # Ok(())
112//! # }
113//! ```
114//!
115//! ## Quick Start - Python
116//!
117//! ```text
118//! from pdf_oxide import PdfDocument
119//!
120//! # Open and extract with automatic reading order
121//! doc = PdfDocument("paper.pdf")
122//! markdown = doc.to_markdown(0)
123//! print(markdown)
124//! ```
125//!
126//! ## License
127//!
128//! Licensed under either of:
129//!
130//! * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
131//! * MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
132//!
133//! at your option.
134
135#![warn(missing_docs)]
136#![cfg_attr(docsrs, feature(doc_cfg))]
137
138// Glibc 2.34 compatibility (#416): LLVM may emit calls to __memcmpeq@GLIBC_2.35,
139// which does not exist in glibc 2.34 (Amazon Linux 2023, some Ubuntu 22.04 builds).
140// `fips` and `legacy-crypto` are mutually exclusive: FIPS 140-3 forbids MD5
141// and RC4, which `legacy-crypto` pulls in. Build FIPS without legacy crypto:
142// cargo build --no-default-features --features fips,icc
143#[cfg(all(feature = "fips", feature = "legacy-crypto"))]
144compile_error!(
145 "Features `fips` and `legacy-crypto` are mutually exclusive. \
146 FIPS 140-3 forbids MD5 (pulled in by `legacy-crypto`). \
147 Build with: --no-default-features --features fips,icc"
148);
149
150// A weak stub redirecting to plain memcmp satisfies the reference on older glibc;
151// glibc 2.35's own definition wins when available. global_asm! works with both
152// GNU ld and lld, unlike --defsym which lld rejects for PLT-resolved symbols.
153#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
154core::arch::global_asm!(
155 ".weak __memcmpeq",
156 ".type __memcmpeq, @function",
157 "__memcmpeq:",
158 "jmp memcmp@PLT",
159);
160
161// Error handling
162pub mod error;
163
164// General-purpose caching utilities
165pub(crate) mod cache;
166
167// Core PDF parsing
168pub mod document;
169pub mod lexer;
170pub mod object;
171pub mod objstm;
172pub mod parser;
173/// Parser configuration options
174pub mod parser_config;
175pub mod xref;
176pub mod xref_reconstruction;
177
178// Stream decoders
179pub mod decoders;
180
181// PDF function evaluators (Type 4 PostScript calculator)
182pub mod functions;
183
184// Colour management (ICC profile handling)
185pub mod color;
186
187// Pluggable cryptographic backend (FIPS / sovereign-jurisdiction
188// providers). Issue #236.
189pub mod crypto;
190
191// Encryption support
192pub mod encryption;
193
194// Layout analysis
195pub mod geometry;
196pub mod layout;
197
198// Text extraction
199pub mod content;
200pub mod extractors;
201pub mod fonts;
202pub mod optional_content;
203pub mod text;
204
205// Document structure
206/// Core annotation types and enums per PDF spec
207pub mod annotation_types;
208pub mod annotations;
209/// Content elements for PDF generation
210pub mod elements;
211/// Cross-platform-safe filename slug helpers (shared, pure).
212pub mod filename;
213pub mod outline;
214/// True/destructive redaction + document sanitization (#231).
215pub mod redaction;
216/// Split a PDF into multiple PDFs at outline (bookmark) boundaries (#482).
217pub mod split_bookmarks;
218/// PDF logical structure (Tagged PDFs)
219pub mod structure;
220
221/// Structured per-page extraction (`extract_structured`, #536)
222pub mod structured;
223
224// Format converters
225pub mod converters;
226
227// Pipeline architecture for text extraction
228pub mod pipeline;
229
230// PDF writing/creation (v0.3.0)
231pub mod writer;
232
233// HTML + CSS → PDF pipeline (v0.3.35, issue #248). Hand-rolled tokenizer,
234// parser, selector matcher, cascade, layout glue, paginator, and paint
235// emitter. MIT/Apache-only deps (no MPL); see deny.toml + the v0.3.35
236// pre-flight audit doc for the rationale.
237pub mod html_css;
238
239// FDF/XFDF form data export (v0.3.3)
240pub mod fdf;
241
242// XFA forms support (v0.3.2)
243pub mod xfa;
244
245// PDF editing (v0.3.0)
246pub mod editor;
247
248// Text search (v0.3.0)
249pub mod search;
250
251// Page rendering to images (optional, v0.3.0)
252#[cfg(feature = "rendering")]
253#[cfg_attr(docsrs, doc(cfg(feature = "rendering")))]
254pub mod rendering;
255
256// Debug visualization for PDF analysis (optional, v0.3.0)
257#[cfg(feature = "rendering")]
258#[cfg_attr(docsrs, doc(cfg(feature = "rendering")))]
259pub mod debug;
260
261// Digital signatures (optional, v0.3.0)
262#[cfg(feature = "signatures")]
263#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
264pub mod signatures;
265
266// Parallel page extraction (optional, v0.3.10)
267#[cfg(feature = "parallel")]
268#[cfg_attr(docsrs, doc(cfg(feature = "parallel")))]
269pub mod parallel;
270
271// Batch processing API (v0.3.10)
272#[cfg(not(target_arch = "wasm32"))]
273pub mod batch;
274
275// PDF/A compliance validation (v0.3.0)
276pub mod compliance;
277
278// High-level API (v0.3.0)
279pub mod api;
280
281// Re-export specific types from pipeline for use by converters
282pub use pipeline::XYCutStrategy;
283
284// Configuration
285pub mod config;
286
287// Hybrid classical + ML orchestration
288pub mod hybrid;
289
290// OCR - PaddleOCR via a pluggable inference backend (optional).
291// Native ONNX Runtime when `ocr` is on; otherwise the pure-Rust
292// `tract` backend (`ocr-tract`, which `ml` implies and the
293// browser/Deno/edge `wasm-ocr` build uses — issue #524). Exposing OCR
294// wherever the tract backend is available costs only the small OCR
295// module itself and keeps it host-testable without a native dylib.
296#[cfg(any(feature = "ocr", feature = "ocr-tract"))]
297#[cfg_attr(docsrs, doc(cfg(any(feature = "ocr", feature = "ocr-tract"))))]
298pub mod ocr;
299
300// C FFI for Go, Node.js, C# bindings (not available on wasm32)
301#[cfg(not(target_arch = "wasm32"))]
302pub mod ffi;
303
304// Python bindings (optional)
305#[cfg(feature = "python")]
306mod python;
307
308// WASM bindings (optional)
309#[cfg(any(target_arch = "wasm32", test))]
310#[cfg(feature = "wasm")]
311pub mod wasm;
312
313// Re-exports
314pub use annotation_types::{
315 AnnotationBorderStyle, AnnotationColor, AnnotationFlags, AnnotationSubtype, BorderEffectStyle,
316 BorderStyleType, CaretSymbol, FileAttachmentIcon, FreeTextIntent, HighlightMode,
317 LineEndingStyle, QuadPoint, ReplyType, StampType, TextAlignment, TextAnnotationIcon,
318 TextMarkupType, WidgetFieldType,
319};
320pub use annotations::{Annotation, LinkAction, LinkDestination};
321pub use config::{DocumentType, ExtractionProfile};
322pub use document::{ExtractedImageRef, ImageFormat, PdfDocument, ReadingOrder};
323pub use error::{Error, Result};
324pub use extractors::images::{PdfFilter, PdfImageHandle};
325pub use layout::PageText;
326pub use outline::{Destination, OutlineItem};
327pub use redaction::{
328 redact_content_stream, Classification, FontInfoMetrics, OcgPolicy, RedactionOptions,
329 RedactionRegion, RedactionReport, RegionSet,
330};
331pub use structured::{ColumnMode, RegionRole, StructuredPage, StructuredRegion};
332
333// Global font cache for batch processing
334pub use fonts::global_cache::{
335 clear_global_font_cache, global_font_cache_stats, set_global_font_cache_capacity,
336};
337
338// Global CMap cache management
339pub use fonts::cmap::{clear_cmap_cache, cmap_cache_size};
340
341#[cfg(feature = "parallel")]
342pub use parallel::{extract_all_markdown_parallel, extract_all_text_parallel, ParallelExtractor};
343
344// Internal utilities
345pub(crate) mod utils {
346 //! Internal utility functions for the library.
347
348 use std::cmp::Ordering;
349
350 /// Safely truncate a string to at most `max_bytes` from the start
351 /// without splitting a multi-byte UTF-8 character.
352 ///
353 /// Returns the full string if it is shorter than `max_bytes`.
354 /// When truncation lands inside a multi-byte character, the boundary
355 /// is rounded **down** to the nearest char boundary (floor).
356 #[inline]
357 pub fn safe_prefix(s: &str, max_bytes: usize) -> &str {
358 if s.len() <= max_bytes {
359 return s;
360 }
361 let mut end = max_bytes;
362 while end > 0 && !s.is_char_boundary(end) {
363 end -= 1;
364 }
365 &s[..end]
366 }
367
368 /// Safely take the last `max_bytes` of a string without splitting
369 /// a multi-byte UTF-8 character.
370 ///
371 /// Returns the full string if it is shorter than `max_bytes`.
372 /// When the computed start offset lands inside a multi-byte character,
373 /// the boundary is rounded **up** to the nearest char boundary (ceil).
374 #[inline]
375 pub fn safe_suffix(s: &str, max_bytes: usize) -> &str {
376 if s.len() <= max_bytes {
377 return s;
378 }
379 let start = s.len() - max_bytes;
380 let mut safe_start = start;
381 while safe_start < s.len() && !s.is_char_boundary(safe_start) {
382 safe_start += 1;
383 }
384 &s[safe_start..]
385 }
386
387 /// Y-band tolerance used by `row_aware_span_cmp`.
388 ///
389 /// Two spans whose top-Y differs by less than this amount are treated
390 /// as lying on the same row. Chosen to absorb typographic baseline
391 /// jitter for 10-12pt body text and glyph-cluster offsets in CJK
392 /// fonts without merging adjacent 14pt-leading lines.
393 pub const ROW_BAND_TOLERANCE_PT: f32 = 3.0;
394
395 /// Row-aware reading-order comparator for spans.
396 ///
397 /// Sorts primarily by "row band" (top-Y quantized to
398 /// `ROW_BAND_TOLERANCE_PT`, larger Y first per PDF Spec ISO 32000-1:2008
399 /// §8.3.2.3) and secondarily by X (left-to-right within a row). This
400 /// keeps tabular layouts where cells in the same logical row have
401 /// slightly different Y values (font-metric jitter, superscripts, CJK
402 /// glyph centering) from being interleaved by a strict Y sort.
403 ///
404 /// Uses `i32` band keys so the ordering is a valid total order —
405 /// comparing raw Y values with tolerance is non-transitive and would
406 /// break `sort_by`.
407 #[inline]
408 pub fn row_aware_span_cmp(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
409 // Non-finite Y (NaN/±Inf) cannot be quantized into an i32 band —
410 // `as i32` saturates, collapsing distinct non-finite values into
411 // the same band and reordering them unpredictably against finite
412 // spans. Fall back to `safe_float_cmp` so non-finite values follow
413 // the same NaN-last / total-order policy used everywhere else.
414 if !a_y.is_finite() || !b_y.is_finite() {
415 return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(a_x, b_x));
416 }
417 let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
418 let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
419 // Larger Y = higher on page → descending band order.
420 match band_b.cmp(&band_a) {
421 Ordering::Equal => safe_float_cmp(a_x, b_x),
422 other => other,
423 }
424 }
425
426 /// Dominant text-matrix rotation of a page's spans, if any.
427 ///
428 /// Returns the snapped rotation (`90` / `180` / `-90`) shared by at
429 /// least half of the page's non-whitespace spans, or `None` when the
430 /// page is predominantly upright (or empty). The half-or-more majority
431 /// mirrors the existing vertical-CJK (tategaki) vote: at most one
432 /// rotation group can dominate, and a marginal stamp or figure label
433 /// can never hijack the page frame. Rotations are grouped with the same
434 /// 0.5° tolerance `order_rotated_blocks` uses, so free-angle (skewed)
435 /// text never forms a quadrant group.
436 pub(crate) fn dominant_rotation(spans: &[crate::layout::TextSpan]) -> Option<f32> {
437 let mut groups: Vec<(f32, usize)> = Vec::new();
438 let mut total = 0usize;
439 for s in spans {
440 if s.text.trim().is_empty() {
441 continue;
442 }
443 total += 1;
444 if s.rotation_degrees == 0.0 {
445 continue;
446 }
447 match groups
448 .iter_mut()
449 .find(|(k, _)| (*k - s.rotation_degrees).abs() < 0.5)
450 {
451 Some(g) => g.1 += 1,
452 None => groups.push((s.rotation_degrees, 1)),
453 }
454 }
455 groups
456 .into_iter()
457 .max_by_key(|&(_, n)| n)
458 .filter(|&(_, n)| n * 2 >= total && total > 0)
459 .map(|(deg, _)| deg)
460 }
461
462 /// Right-to-left variant of [`row_aware_span_cmp`] (issues #656/#657).
463 ///
464 /// Identical row banding (lines top-to-bottom), but orders spans
465 /// **right-to-left within a row** (X descending). A pure-RTL line's
466 /// logical reading order *is* its rightmost-first geometric order, so
467 /// sorting word-spans by descending X reconstructs logical order
468 /// directly from page geometry — independent of whether the producer
469 /// stored the run in visual or logical order. Used by the tagged
470 /// struct-tree assemblers, which otherwise have no span-order pass for
471 /// RTL (the untagged `reverse_rtl_visual_order_runs` is never reached
472 /// on tagged pages).
473 ///
474 /// Retained as a tested geometric utility: the tagged RTL assembler now
475 /// orders pure-RTL spans via `document::PdfDocument::order_pure_rtl_spans`
476 /// (font-relative line grouping), which subsumes the fixed-band comparator,
477 /// so this has no production caller at present.
478 #[inline]
479 #[allow(dead_code)]
480 pub fn row_aware_span_cmp_rtl(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
481 if !a_y.is_finite() || !b_y.is_finite() {
482 return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(b_x, a_x));
483 }
484 let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
485 let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
486 match band_b.cmp(&band_a) {
487 Ordering::Equal => safe_float_cmp(b_x, a_x), // X descending = RTL
488 other => other,
489 }
490 }
491
492 /// Sort spans into tategaki (vertical-writing) reading order:
493 /// right-to-left across columns, top-to-bottom within each column (PDF
494 /// user-space Y increases upward, so top-first means Y descending).
495 ///
496 /// Columns are found by single-linkage clustering of X-centers: order
497 /// the centers right-to-left, then start a new column whenever the gap
498 /// to the previous center exceeds `tol` (the median span width —
499 /// tategaki CJK body text is functionally monospaced, so this
500 /// approximates the column pitch: wide enough to keep one column
501 /// together, narrow enough to separate the next).
502 ///
503 /// Comparing raw X-centers against a `|a - b| <= tol` tolerance
504 /// *inside* a sort comparator is not transitive — a chain of spans
505 /// each within `tol` of its neighbor can span far more than `tol`
506 /// overall, so "same column" isn't an equivalence relation and
507 /// `sort_by` can panic with "does not correctly implement a total
508 /// order". Clustering into columns first and sorting by `(column, Y)`
509 /// avoids this: every comparison is between two discrete, precomputed
510 /// keys, which is transitive by construction. It's also more accurate
511 /// than quantizing each X-center into a fixed-size band independently
512 /// (e.g. `round(x / tol)`) — banding can split two spans that are only
513 /// a couple points apart into different buckets if they straddle a
514 /// bucket boundary, even though they're well within `tol` of each
515 /// other; single-linkage clustering only looks at the gap between
516 /// neighbors, so it has no such boundary effect.
517 pub fn sort_vertical_tategaki<T>(
518 items: Vec<T>,
519 get_bbox: impl Fn(&T) -> &crate::geometry::Rect,
520 ) -> Vec<T> {
521 if items.len() < 2 {
522 return items;
523 }
524
525 let mut widths: Vec<f32> = items.iter().map(|it| get_bbox(it).width.max(1.0)).collect();
526 widths.sort_by(|a, b| safe_float_cmp(*a, *b));
527 let tol = widths[widths.len() / 2].max(1.0);
528
529 let centers: Vec<f32> = items
530 .iter()
531 .map(|it| {
532 let b = get_bbox(it);
533 b.x + b.width * 0.5
534 })
535 .collect();
536 let ys: Vec<f32> = items.iter().map(|it| get_bbox(it).y).collect();
537
538 // Right-to-left pass assigning column ids. Stable sort keeps ties
539 // in input order, so clustering is deterministic.
540 let mut order: Vec<usize> = (0..items.len()).collect();
541 order.sort_by(|&a, &b| safe_float_cmp(centers[b], centers[a]));
542
543 let mut column = vec![0u32; items.len()];
544 let mut current = 0u32;
545 let mut prev = centers[order[0]];
546 for &idx in &order[1..] {
547 let center = centers[idx];
548 // A NaN gap (either end non-finite) never chains, so a
549 // non-finite center always starts its own column.
550 let gap = prev - center;
551 if gap.is_nan() || gap > tol {
552 current += 1;
553 }
554 column[idx] = current;
555 prev = center;
556 }
557
558 // Column ascending (columns were numbered right-to-left above),
559 // then top-to-bottom within a column. Both keys are total orders.
560 order.sort_by(|&a, &b| {
561 column[a]
562 .cmp(&column[b])
563 .then_with(|| safe_float_cmp(ys[b], ys[a]))
564 });
565
566 let mut slots: Vec<Option<T>> = items.into_iter().map(Some).collect();
567 order
568 .into_iter()
569 .map(|i| slots[i].take().expect("each index appears once"))
570 .collect()
571 }
572
573 /// Safely compare two floating point numbers, handling NaN cases.
574 ///
575 /// NaN values are treated as equal to each other and greater than all other values.
576 /// This ensures that sorting operations never panic due to NaN comparisons.
577 ///
578 /// # Examples
579 ///
580 /// ```ignore
581 /// # use std::cmp::Ordering;
582 /// # use pdf_oxide::utils::safe_float_cmp;
583 /// assert_eq!(safe_float_cmp(1.0, 2.0), Ordering::Less);
584 /// assert_eq!(safe_float_cmp(2.0, 1.0), Ordering::Greater);
585 /// assert_eq!(safe_float_cmp(1.0, 1.0), Ordering::Equal);
586 ///
587 /// // NaN handling
588 /// assert_eq!(safe_float_cmp(f32::NAN, f32::NAN), Ordering::Equal);
589 /// assert_eq!(safe_float_cmp(f32::NAN, 1.0), Ordering::Greater);
590 /// assert_eq!(safe_float_cmp(1.0, f32::NAN), Ordering::Less);
591 /// ```
592 #[inline]
593 pub fn safe_float_cmp(a: f32, b: f32) -> Ordering {
594 match (a.is_nan(), b.is_nan()) {
595 (true, true) => Ordering::Equal,
596 (true, false) => Ordering::Greater, // NaN > all numbers
597 (false, true) => Ordering::Less, // all numbers < NaN
598 (false, false) => {
599 // Both are normal numbers, safe to unwrap
600 a.partial_cmp(&b).unwrap()
601 },
602 }
603 }
604
605 /// Sort `items` into row-band reading order, computing each element's band
606 /// key once instead of re-quantizing on every `row_aware_span_cmp`
607 /// comparison.
608 ///
609 /// When all `y`/`x` are finite this is a cached-key stable sort with the
610 /// same order as `sort_by(row_aware_span_cmp)` (band descending, then `x`
611 /// ascending — `f32::total_cmp` equals `safe_float_cmp` for finite values,
612 /// and both are stable on ties). Otherwise it falls back to the comparator
613 /// so the NaN/±∞ policy is unchanged.
614 pub fn sort_by_row_band<T>(
615 items: &mut [T],
616 get_y: impl Fn(&T) -> f32,
617 get_x: impl Fn(&T) -> f32,
618 ) {
619 let all_finite = items
620 .iter()
621 .all(|it| get_y(it).is_finite() && get_x(it).is_finite());
622 if !all_finite {
623 items.sort_by(|a, b| row_aware_span_cmp(get_y(a), get_x(a), get_y(b), get_x(b)));
624 return;
625 }
626 // Cached-key stable sort. `total_cmp` matches `safe_float_cmp` for the
627 // finite values we gated on above.
628 items.sort_by_cached_key(|it| {
629 let band = (get_y(it) / ROW_BAND_TOLERANCE_PT).round() as i32;
630 // Reverse band → larger Y (higher on page) first, matching the
631 // comparator's `band_b.cmp(&band_a)`.
632 (std::cmp::Reverse(band), F32Ord(get_x(it)))
633 });
634 }
635
636 /// Total-order wrapper over `f32` for use as a sort key. For finite values
637 /// `total_cmp` is identical to `safe_float_cmp` / `partial_cmp`.
638 #[derive(Clone, Copy, PartialEq)]
639 struct F32Ord(f32);
640 impl Eq for F32Ord {}
641 impl PartialOrd for F32Ord {
642 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
643 Some(self.cmp(other))
644 }
645 }
646 impl Ord for F32Ord {
647 fn cmp(&self, other: &Self) -> Ordering {
648 self.0.total_cmp(&other.0)
649 }
650 }
651
652 #[cfg(test)]
653 mod tests {
654 use super::*;
655
656 /// The cached-key sort must produce the identical permutation to
657 /// `sort_by(row_aware_span_cmp)` on finite inputs.
658 #[test]
659 fn test_sort_by_row_band_matches_comparator() {
660 // Deterministic pseudo-random spans (no rng in tests).
661 let raw: Vec<(f32, f32)> = (0..500)
662 .map(|i| {
663 let y = ((i * 37 % 113) as f32) * 1.3;
664 let x = ((i * 71 % 97) as f32) * 2.1;
665 (y, x)
666 })
667 .collect();
668 let mut a = raw.clone();
669 let mut b = raw.clone();
670 sort_by_row_band(&mut a, |t| t.0, |t| t.1);
671 b.sort_by(|p, q| row_aware_span_cmp(p.0, p.1, q.0, q.1));
672 assert_eq!(a, b, "cached-key sort must match the comparator permutation");
673 }
674
675 #[test]
676 fn test_safe_float_cmp_normal() {
677 assert_eq!(safe_float_cmp(1.0, 2.0), Ordering::Less);
678 assert_eq!(safe_float_cmp(2.0, 1.0), Ordering::Greater);
679 assert_eq!(safe_float_cmp(1.5, 1.5), Ordering::Equal);
680 }
681
682 #[test]
683 fn test_safe_float_cmp_nan() {
684 assert_eq!(safe_float_cmp(f32::NAN, f32::NAN), Ordering::Equal);
685 assert_eq!(safe_float_cmp(f32::NAN, 0.0), Ordering::Greater);
686 assert_eq!(safe_float_cmp(0.0, f32::NAN), Ordering::Less);
687 }
688
689 fn tategaki_rect(x: f32, y: f32, w: f32) -> crate::geometry::Rect {
690 crate::geometry::Rect::new(x, y, w, 12.0)
691 }
692
693 /// Two well-separated columns: rightmost column first, top-to-bottom
694 /// within each (the ordering the pre-fix comparator also produced
695 /// for the well-behaved case — this must not regress).
696 #[test]
697 fn test_sort_vertical_tategaki_two_columns() {
698 let items = vec![
699 ("D", tategaki_rect(300.0, 700.0, 12.0)),
700 ("F", tategaki_rect(300.0, 676.0, 12.0)),
701 ("B", tategaki_rect(500.0, 688.0, 12.0)),
702 ("C", tategaki_rect(500.0, 676.0, 12.0)),
703 ("A", tategaki_rect(500.0, 700.0, 12.0)),
704 ("E", tategaki_rect(300.0, 688.0, 12.0)),
705 ];
706 let sorted = sort_vertical_tategaki(items, |it| &it.1);
707 let order: String = sorted.iter().map(|it| it.0).collect();
708 assert_eq!(order, "ABCDEF");
709 }
710
711 /// A chain of X-centers each within `tol` of its neighbor but
712 /// spanning far more than `tol` overall made the old pairwise
713 /// `|a - b| <= tol` comparator non-transitive (A<B, B<C, C<A),
714 /// which panicked `sort_by` on Rust 1.81+. Single-linkage
715 /// clustering must read the whole chain as one column, top to
716 /// bottom, without panicking.
717 #[test]
718 fn test_sort_vertical_tategaki_chained_centers() {
719 // Centers step by 8pt across 64 spans (630pt total span) — every
720 // adjacent pair is "same column" under a naive tolerance check,
721 // but the first and last are 500+pt apart.
722 let items: Vec<(usize, crate::geometry::Rect)> = (0..64)
723 .map(|i| (i, tategaki_rect(i as f32 * 8.0, ((i * 37) % 64) as f32 * 7.0, 10.0)))
724 .collect();
725 let sorted = sort_vertical_tategaki(items, |it| &it.1);
726 assert_eq!(sorted.len(), 64);
727 assert!(
728 sorted.windows(2).all(|w| w[0].1.y >= w[1].1.y),
729 "one chained column must read top-to-bottom"
730 );
731 }
732
733 /// Two spans only 2pt apart (well within `tol`) must land in the
734 /// same column even when their absolute X-centers straddle what
735 /// would be a fixed quantization-bucket boundary (e.g. `tol`
736 /// multiples of 100 straddling x=250). Single-linkage clustering
737 /// only looks at the gap between neighbors, so it has no such
738 /// boundary effect — unlike banding each center independently via
739 /// `round(x / tol)`.
740 #[test]
741 fn test_sort_vertical_tategaki_no_boundary_straddle_effect() {
742 let items = vec![
743 ("near", tategaki_rect(249.0, 700.0, 100.0)),
744 ("straddle", tategaki_rect(251.0, 690.0, 100.0)),
745 ("far", tategaki_rect(10.0, 680.0, 100.0)),
746 ];
747 let sorted = sort_vertical_tategaki(items, |it| &it.1);
748 // "near" and "straddle" are 2pt apart (tol = 100) so they must
749 // share a column and sort top-to-bottom relative to each other,
750 // both ahead of the genuinely distant "far" column.
751 let order: Vec<&str> = sorted.iter().map(|it| it.0).collect();
752 assert_eq!(order, vec!["near", "straddle", "far"]);
753 }
754
755 /// Non-finite coordinates must not panic the sort, and every item
756 /// must survive the permutation exactly once.
757 #[test]
758 fn test_sort_vertical_tategaki_non_finite() {
759 let mut items: Vec<(usize, crate::geometry::Rect)> = (0..32)
760 .map(|i| (i, tategaki_rect((i % 8) as f32 * 40.0, i as f32 * 5.0, 12.0)))
761 .collect();
762 items[3].1.x = f32::NAN;
763 items[11].1.y = f32::NAN;
764 items[17].1.width = f32::NAN;
765 items[23].1.x = f32::INFINITY;
766 let sorted = sort_vertical_tategaki(items, |it| &it.1);
767 let mut ids: Vec<usize> = sorted.iter().map(|it| it.0).collect();
768 ids.sort_unstable();
769 assert_eq!(ids, (0..32).collect::<Vec<_>>());
770 }
771
772 #[test]
773 fn test_safe_float_cmp_infinity() {
774 assert_eq!(safe_float_cmp(f32::INFINITY, f32::INFINITY), Ordering::Equal);
775 assert_eq!(safe_float_cmp(f32::INFINITY, 1.0), Ordering::Greater);
776 assert_eq!(safe_float_cmp(f32::NEG_INFINITY, f32::INFINITY), Ordering::Less);
777 }
778
779 /// Verify that sort_by using safe_float_cmp never panics with NaN values.
780 /// This is a regression test for the "total order" panic that affected 42
781 /// PDFs across 5 test datasets (issue found in v0.3.11-pre).
782 #[test]
783 fn test_sort_with_nan_does_not_panic() {
784 let mut values = [3.0_f32, f32::NAN, 1.0, f32::NAN, 2.0, f32::NAN, 0.5];
785 values.sort_by(|a, b| safe_float_cmp(*a, *b));
786 // NaN values should sort to the end (NaN > all numbers)
787 assert!(values[0..4].iter().all(|v| !v.is_nan()));
788 assert!(values[4..].iter().all(|v| v.is_nan()));
789 }
790
791 /// Verify transitivity: if a < b and b < c then a < c.
792 /// The previous `partial_cmp().unwrap_or(Equal)` pattern violated this
793 /// when NaN was involved, causing Rust's sort to panic.
794 #[test]
795 fn test_safe_float_cmp_transitivity() {
796 let a = 1.0_f32;
797 let b = 2.0_f32;
798 let nan = f32::NAN;
799
800 // a < b
801 assert_eq!(safe_float_cmp(a, b), Ordering::Less);
802 // b < NaN
803 assert_eq!(safe_float_cmp(b, nan), Ordering::Less);
804 // Therefore a < NaN (transitivity)
805 assert_eq!(safe_float_cmp(a, nan), Ordering::Less);
806 }
807
808 /// Cells in the same tabular row with slightly-different Y values
809 /// must stay together and be ordered by X, not interleaved with
810 /// cells from other rows.
811 #[test]
812 fn test_row_aware_span_cmp_tolerates_y_jitter() {
813 // Row 1 at y ≈ 100 with small per-cell jitter.
814 // Row 2 at y ≈ 86 (14pt leading below).
815 // A strict Y sort would interleave them because some row-1
816 // cells have lower Y than some row-2 cells.
817 #[derive(Debug, Clone, Copy)]
818 struct Cell {
819 y: f32,
820 x: f32,
821 id: &'static str,
822 }
823 let mut cells = [
824 Cell {
825 y: 100.5,
826 x: 50.0,
827 id: "r1-c1",
828 },
829 Cell {
830 y: 99.7,
831 x: 150.0,
832 id: "r1-c2",
833 },
834 Cell {
835 y: 100.2,
836 x: 250.0,
837 id: "r1-c3",
838 },
839 Cell {
840 y: 86.4,
841 x: 50.0,
842 id: "r2-c1",
843 },
844 Cell {
845 y: 85.8,
846 x: 150.0,
847 id: "r2-c2",
848 },
849 Cell {
850 y: 86.1,
851 x: 250.0,
852 id: "r2-c3",
853 },
854 ];
855 cells.sort_by(|a, b| row_aware_span_cmp(a.y, a.x, b.y, b.x));
856 let order: Vec<&str> = cells.iter().map(|c| c.id).collect();
857 assert_eq!(
858 order,
859 vec!["r1-c1", "r1-c2", "r1-c3", "r2-c1", "r2-c2", "r2-c3"],
860 "cells from the same row must stay contiguous and X-sorted"
861 );
862 }
863
864 /// Row-aware comparator must still put distinct-leading rows in
865 /// top-to-bottom reading order.
866 #[test]
867 fn test_row_aware_span_cmp_distinct_rows_descending() {
868 let mut rows = [
869 (100.0f32, 0.0f32, "top"),
870 (50.0, 0.0, "middle"),
871 (10.0, 0.0, "bottom"),
872 ];
873 rows.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
874 assert_eq!(rows[0].2, "top");
875 assert_eq!(rows[1].2, "middle");
876 assert_eq!(rows[2].2, "bottom");
877 }
878
879 /// The comparator is used by sort_by, which requires a valid total
880 /// order. Run a randomized stress test to confirm no transitivity
881 /// panics.
882 #[test]
883 fn test_row_aware_span_cmp_is_total_order() {
884 let mut v: Vec<(f32, f32)> = (0..200)
885 .map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32))
886 .collect();
887 v.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
888 }
889
890 /// #656/#657: the RTL variant keeps rows top-to-bottom but orders
891 /// X *descending* (right-to-left) within a row — a pure-RTL line's
892 /// logical reading order.
893 #[test]
894 fn test_row_aware_span_cmp_rtl_within_row_is_descending() {
895 // Same row (Y within band), laid out left-to-right by X.
896 let mut row = [
897 (100.0f32, 10.0f32, "leftmost"),
898 (100.0, 50.0, "mid"),
899 (100.0, 90.0, "rightmost"),
900 ];
901 row.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
902 // Rightmost (highest X) reads first in RTL.
903 assert_eq!(["rightmost", "mid", "leftmost"], [row[0].2, row[1].2, row[2].2]);
904 }
905
906 /// Rows still order top-to-bottom regardless of the within-row flip.
907 #[test]
908 fn test_row_aware_span_cmp_rtl_rows_top_to_bottom() {
909 let mut rows = [
910 (10.0f32, 0.0f32, "bottom"),
911 (100.0, 0.0, "top"),
912 (50.0, 0.0, "middle"),
913 ];
914 rows.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
915 assert_eq!(["top", "middle", "bottom"], [rows[0].2, rows[1].2, rows[2].2]);
916 }
917
918 /// Must be a valid total order for `sort_by` (no transitivity panic).
919 #[test]
920 fn test_row_aware_span_cmp_rtl_is_total_order() {
921 let mut v: Vec<(f32, f32)> = (0..200)
922 .map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32))
923 .collect();
924 v.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
925 }
926
927 /// Sort a large array with mixed NaN/normal values to stress-test.
928 #[test]
929 fn test_sort_stress_with_nan() {
930 let mut values: Vec<f32> = (0..100).map(|i| i as f32).collect();
931 // Insert NaN at various positions
932 for i in (0..100).step_by(7) {
933 values[i] = f32::NAN;
934 }
935 // Must not panic
936 values.sort_by(|a, b| safe_float_cmp(*a, *b));
937 }
938
939 #[test]
940 fn test_safe_prefix_ascii() {
941 assert_eq!(safe_prefix("hello", 3), "hel");
942 assert_eq!(safe_prefix("hello", 10), "hello");
943 assert_eq!(safe_prefix("", 5), "");
944 assert_eq!(safe_prefix("hi", 0), "");
945 }
946
947 #[test]
948 fn test_safe_prefix_multibyte() {
949 let text = "✚✳★✵"; // 4 × 3-byte chars = 12 bytes
950 assert_eq!(safe_prefix(text, 10), "✚✳★"); // rounds down from 10 to 9
951 assert_eq!(safe_prefix(text, 9), "✚✳★"); // exact boundary
952 assert_eq!(safe_prefix(text, 12), "✚✳★✵"); // full string
953 }
954
955 #[test]
956 fn test_safe_suffix_ascii() {
957 assert_eq!(safe_suffix("hello", 3), "llo");
958 assert_eq!(safe_suffix("hello", 10), "hello");
959 assert_eq!(safe_suffix("", 5), "");
960 assert_eq!(safe_suffix("hi", 0), "");
961 }
962
963 #[test]
964 fn test_safe_suffix_multibyte() {
965 let text = "AB✚✳★✵"; // 14 bytes: A(0) B(1) ✚(2..5) ✳(5..8) ★(8..11) ✵(11..14)
966 // 14 - 10 = 4, byte 4 is inside ✚ → rounds up to 5
967 assert_eq!(safe_suffix(text, 10), "✳★✵");
968 }
969 }
970}
971
972// Version info
973/// Library version
974pub const VERSION: &str = env!("CARGO_PKG_VERSION");
975
976/// Library name
977pub const NAME: &str = env!("CARGO_PKG_NAME");
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982
983 #[test]
984 fn test_version() {
985 // VERSION is populated from CARGO_PKG_VERSION at compile time
986 assert!(VERSION.starts_with("0."));
987 }
988
989 #[test]
990 fn test_name() {
991 assert_eq!(NAME, "pdf_oxide");
992 }
993}