Skip to main content

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    /// Row band descending, then `x` ascending. No baseline tiebreak.
409    ///
410    /// Callers that sort on a *synthetic* key — one derived from a span's
411    /// position rather than read from it — want this rather than
412    /// `row_aware_span_cmp`, because a baseline tiebreak applied to a made-up
413    /// value decides an order from bookkeeping instead of from the page.
414    pub fn row_band_then_x(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
415        // Non-finite Y (NaN/±Inf) cannot be quantized into an i32 band —
416        // `as i32` saturates, collapsing distinct non-finite values into
417        // the same band and reordering them unpredictably against finite
418        // spans. Fall back to `safe_float_cmp` so non-finite values follow
419        // the same NaN-last / total-order policy used everywhere else.
420        if !a_y.is_finite() || !b_y.is_finite() {
421            return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(a_x, b_x));
422        }
423        let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
424        let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
425        // Larger Y = higher on page → descending band order.
426        band_b.cmp(&band_a).then_with(|| safe_float_cmp(a_x, b_x))
427    }
428
429    /// Reading order for two spans: row band descending, then `x` ascending,
430    /// then the baseline descending.
431    ///
432    /// Without the third key, two spans sharing a band and an `x` compare
433    /// `Equal` and `sort_by`'s stability settles them — which is the XY-cut
434    /// leaf's incoming order, not reading order. Two OCR words from different
435    /// columns drawn at the same x, 0.15 pt apart, came out as
436    /// `who con- who lodge. was waiting`, a fragment injected mid-sentence.
437    ///
438    /// `(band desc, x asc, y desc)` is a lexicographic composition of total
439    /// orders and changes nothing wherever `x` differs.
440    ///
441    /// The baseline key is only meaningful when `a_y`/`b_y` are baselines the
442    /// page actually draws. A caller passing a synthetic key must use
443    /// `row_band_then_x` and apply its own tiebreak on the real geometry:
444    /// feeding a promoted label's `anchor + 1.0` in here let a bookkeeping
445    /// offset outrank a real baseline and put a wrapped table cell's
446    /// continuation line ahead of the line it continues.
447    pub fn row_aware_span_cmp(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
448        row_band_then_x(a_y, a_x, b_y, b_x).then_with(|| safe_float_cmp(b_y, a_y))
449    }
450
451    /// Writing-axis quadrant, then row band, then `x`, with no baseline
452    /// tiebreak.
453    ///
454    /// Row banding compares baselines along page-y and orders within a band
455    /// along page-x. Both only mean something for runs that share a writing
456    /// axis. ISO 32000-1:2008 §9.4.4: "Both the glyph's shape and its
457    /// displacement (horizontal or vertical) shall be interpreted in text
458    /// space", so a run at 90° to the body advances along a different page
459    /// axis — its `bbox.width` is an extent the body's x arithmetic cannot
460    /// compare against.
461    ///
462    /// Without this, a rotated marginal stamp whose baseline happened to fall
463    /// inside a body line's 3 pt band sorted to the front of that band on x
464    /// (its origin is near the page edge) and was emitted *inside* the
465    /// sentence, with no separator because the gap test computed
466    /// `72 − (32 + 343.30)` between two perpendicular runs.
467    ///
468    /// Quadrants rather than raw angles, so jitter around a right angle does
469    /// not split a group. Pages whose content is *dominantly* rotated are
470    /// rewritten into their reading frame upstream, which zeroes
471    /// `rotation_degrees`; there this key is constant and changes nothing. It
472    /// separates only a minority run that disagrees with its neighbours.
473    ///
474    /// For callers ordering on a row key rather than on each span's own
475    /// baseline. Giving two spans the same row key is the point of such a key,
476    /// but it also makes them compare equal on any tiebreak read from that key,
477    /// which silently hands the order back to whatever sequence the spans
478    /// arrived in — a space-only run drawn 0.2 pt under a heading then came out
479    /// ahead of the heading and broke it in two. Pair this with a tiebreak on
480    /// the baseline the page actually draws.
481    pub fn row_band_then_x_axis(
482        a_rot: f32,
483        a_y: f32,
484        a_x: f32,
485        b_rot: f32,
486        b_y: f32,
487        b_x: f32,
488    ) -> Ordering {
489        quadrant_key(a_rot)
490            .cmp(&quadrant_key(b_rot))
491            .then_with(|| row_band_then_x(a_y, a_x, b_y, b_x))
492    }
493
494    /// Writing-axis bucket for a run's rotation: 0/90/180/270, or a distinct
495    /// bucket for anything that is not within half a degree of a right angle.
496    #[inline]
497    fn quadrant_key(rot: f32) -> i32 {
498        if !rot.is_finite() {
499            return i32::MAX;
500        }
501        let norm = rot.rem_euclid(360.0);
502        for (q, angle) in [(0, 0.0), (1, 90.0), (2, 180.0), (3, 270.0)] {
503            if (norm - angle).abs() <= 0.5 || (norm - (angle + 360.0)).abs() <= 0.5 {
504                return q;
505            }
506        }
507        // Off-axis runs get their own bucket, ordered by angle so the result
508        // stays a total order.
509        4 + (norm as i32)
510    }
511
512    /// Dominant text-matrix rotation of a page's spans, if any.
513    ///
514    /// Returns the snapped rotation (`90` / `180` / `-90`) shared by at
515    /// least half of the page's non-whitespace spans, or `None` when the
516    /// page is predominantly upright (or empty). The half-or-more majority
517    /// mirrors the existing vertical-CJK (tategaki) vote: at most one
518    /// rotation group can dominate, and a marginal stamp or figure label
519    /// can never hijack the page frame. Rotations are grouped with the same
520    /// 0.5° tolerance `order_rotated_blocks` uses, so free-angle (skewed)
521    /// text never forms a quadrant group.
522    pub(crate) fn dominant_rotation(spans: &[crate::layout::TextSpan]) -> Option<f32> {
523        let mut groups: Vec<(f32, usize)> = Vec::new();
524        let mut total = 0usize;
525        for s in spans {
526            if s.text.trim().is_empty() {
527                continue;
528            }
529            total += 1;
530            if s.rotation_degrees == 0.0 {
531                continue;
532            }
533            match groups
534                .iter_mut()
535                .find(|(k, _)| (*k - s.rotation_degrees).abs() < 0.5)
536            {
537                Some(g) => g.1 += 1,
538                None => groups.push((s.rotation_degrees, 1)),
539            }
540        }
541        groups
542            .into_iter()
543            .max_by_key(|&(_, n)| n)
544            .filter(|&(_, n)| n * 2 >= total && total > 0)
545            .map(|(deg, _)| deg)
546    }
547
548    /// Right-to-left variant of [`row_aware_span_cmp`] (issues #656/#657).
549    ///
550    /// Identical row banding (lines top-to-bottom), but orders spans
551    /// **right-to-left within a row** (X descending). A pure-RTL line's
552    /// logical reading order *is* its rightmost-first geometric order, so
553    /// sorting word-spans by descending X reconstructs logical order
554    /// directly from page geometry — independent of whether the producer
555    /// stored the run in visual or logical order. Used by the tagged
556    /// struct-tree assemblers, which otherwise have no span-order pass for
557    /// RTL (the untagged `reverse_rtl_visual_order_runs` is never reached
558    /// on tagged pages).
559    ///
560    /// Retained as a tested geometric utility: the tagged RTL assembler now
561    /// orders pure-RTL spans via `document::PdfDocument::order_pure_rtl_spans`
562    /// (font-relative line grouping), which subsumes the fixed-band comparator,
563    /// so this has no production caller at present.
564    #[inline]
565    #[allow(dead_code)]
566    pub fn row_aware_span_cmp_rtl(a_y: f32, a_x: f32, b_y: f32, b_x: f32) -> Ordering {
567        if !a_y.is_finite() || !b_y.is_finite() {
568            return safe_float_cmp(b_y, a_y).then_with(|| safe_float_cmp(b_x, a_x));
569        }
570        let band_a = (a_y / ROW_BAND_TOLERANCE_PT).round() as i32;
571        let band_b = (b_y / ROW_BAND_TOLERANCE_PT).round() as i32;
572        match band_b.cmp(&band_a) {
573            Ordering::Equal => safe_float_cmp(b_x, a_x).then_with(|| safe_float_cmp(b_y, a_y)),
574            other => other,
575        }
576    }
577
578    /// Sort spans into tategaki (vertical-writing) reading order:
579    /// right-to-left across columns, top-to-bottom within each column (PDF
580    /// user-space Y increases upward, so top-first means Y descending).
581    ///
582    /// Columns are found by single-linkage clustering of X-centers: order
583    /// the centers right-to-left, then start a new column whenever the gap
584    /// to the previous center exceeds `tol` (the median span width —
585    /// tategaki CJK body text is functionally monospaced, so this
586    /// approximates the column pitch: wide enough to keep one column
587    /// together, narrow enough to separate the next).
588    ///
589    /// Comparing raw X-centers against a `|a - b| <= tol` tolerance
590    /// *inside* a sort comparator is not transitive — a chain of spans
591    /// each within `tol` of its neighbor can span far more than `tol`
592    /// overall, so "same column" isn't an equivalence relation and
593    /// `sort_by` can panic with "does not correctly implement a total
594    /// order". Clustering into columns first and sorting by `(column, Y)`
595    /// avoids this: every comparison is between two discrete, precomputed
596    /// keys, which is transitive by construction. It's also more accurate
597    /// than quantizing each X-center into a fixed-size band independently
598    /// (e.g. `round(x / tol)`) — banding can split two spans that are only
599    /// a couple points apart into different buckets if they straddle a
600    /// bucket boundary, even though they're well within `tol` of each
601    /// other; single-linkage clustering only looks at the gap between
602    /// neighbors, so it has no such boundary effect.
603    pub fn sort_vertical_tategaki<T>(
604        items: Vec<T>,
605        get_bbox: impl Fn(&T) -> &crate::geometry::Rect,
606    ) -> Vec<T> {
607        if items.len() < 2 {
608            return items;
609        }
610
611        let mut widths: Vec<f32> = items.iter().map(|it| get_bbox(it).width.max(1.0)).collect();
612        widths.sort_by(|a, b| safe_float_cmp(*a, *b));
613        let tol = widths[widths.len() / 2].max(1.0);
614
615        let centers: Vec<f32> = items
616            .iter()
617            .map(|it| {
618                let b = get_bbox(it);
619                b.x + b.width * 0.5
620            })
621            .collect();
622        let ys: Vec<f32> = items.iter().map(|it| get_bbox(it).y).collect();
623
624        // Right-to-left pass assigning column ids. Stable sort keeps ties
625        // in input order, so clustering is deterministic.
626        let mut order: Vec<usize> = (0..items.len()).collect();
627        order.sort_by(|&a, &b| safe_float_cmp(centers[b], centers[a]));
628
629        let mut column = vec![0u32; items.len()];
630        let mut current = 0u32;
631        let mut prev = centers[order[0]];
632        for &idx in &order[1..] {
633            let center = centers[idx];
634            // A NaN gap (either end non-finite) never chains, so a
635            // non-finite center always starts its own column.
636            let gap = prev - center;
637            if gap.is_nan() || gap > tol {
638                current += 1;
639            }
640            column[idx] = current;
641            prev = center;
642        }
643
644        // Column ascending (columns were numbered right-to-left above),
645        // then top-to-bottom within a column. Both keys are total orders.
646        order.sort_by(|&a, &b| {
647            column[a]
648                .cmp(&column[b])
649                .then_with(|| safe_float_cmp(ys[b], ys[a]))
650        });
651
652        let mut slots: Vec<Option<T>> = items.into_iter().map(Some).collect();
653        order
654            .into_iter()
655            .map(|i| slots[i].take().expect("each index appears once"))
656            .collect()
657    }
658
659    /// Safely compare two floating point numbers, handling NaN cases.
660    ///
661    /// NaN values are treated as equal to each other and greater than all other values.
662    /// This ensures that sorting operations never panic due to NaN comparisons.
663    ///
664    /// # Examples
665    ///
666    /// ```ignore
667    /// # use std::cmp::Ordering;
668    /// # use pdf_oxide::utils::safe_float_cmp;
669    /// assert_eq!(safe_float_cmp(1.0, 2.0), Ordering::Less);
670    /// assert_eq!(safe_float_cmp(2.0, 1.0), Ordering::Greater);
671    /// assert_eq!(safe_float_cmp(1.0, 1.0), Ordering::Equal);
672    ///
673    /// // NaN handling
674    /// assert_eq!(safe_float_cmp(f32::NAN, f32::NAN), Ordering::Equal);
675    /// assert_eq!(safe_float_cmp(f32::NAN, 1.0), Ordering::Greater);
676    /// assert_eq!(safe_float_cmp(1.0, f32::NAN), Ordering::Less);
677    /// ```
678    #[inline]
679    pub fn safe_float_cmp(a: f32, b: f32) -> Ordering {
680        match (a.is_nan(), b.is_nan()) {
681            (true, true) => Ordering::Equal,
682            (true, false) => Ordering::Greater, // NaN > all numbers
683            (false, true) => Ordering::Less,    // all numbers < NaN
684            (false, false) => {
685                // Both are normal numbers, safe to unwrap
686                a.partial_cmp(&b).unwrap()
687            },
688        }
689    }
690
691    /// Sort `items` into row-band reading order, computing each element's band
692    /// key once instead of re-quantizing on every `row_aware_span_cmp`
693    /// comparison.
694    ///
695    /// When all `y`/`x` are finite this is a cached-key stable sort with the
696    /// same order as `sort_by(row_aware_span_cmp)` (band descending, then `x`
697    /// ascending — `f32::total_cmp` equals `safe_float_cmp` for finite values,
698    /// and both are stable on ties). Otherwise it falls back to the comparator
699    /// so the NaN/±∞ policy is unchanged.
700    pub fn sort_by_row_band<T>(
701        items: &mut [T],
702        get_y: impl Fn(&T) -> f32,
703        get_x: impl Fn(&T) -> f32,
704    ) {
705        let all_finite = items
706            .iter()
707            .all(|it| get_y(it).is_finite() && get_x(it).is_finite());
708        if !all_finite {
709            items.sort_by(|a, b| row_aware_span_cmp(get_y(a), get_x(a), get_y(b), get_x(b)));
710            return;
711        }
712        // Cached-key stable sort. `total_cmp` matches `safe_float_cmp` for the
713        // finite values we gated on above.
714        items.sort_by_cached_key(|it| {
715            let band = (get_y(it) / ROW_BAND_TOLERANCE_PT).round() as i32;
716            // Reverse band → larger Y (higher on page) first, matching the
717            // comparator's `band_b.cmp(&band_a)`.
718            (std::cmp::Reverse(band), F32Ord(get_x(it)), std::cmp::Reverse(F32Ord(get_y(it))))
719        });
720    }
721
722    /// Give every span the baseline of the row it is printed on, so a row-band
723    /// comparator sees one row per printed line.
724    ///
725    /// Quantizing each baseline onto a fixed grid decides row membership by
726    /// which side of an arbitrary boundary a baseline lands on, and that is
727    /// wrong wherever a row mixes font sizes. A timetable sets its times at
728    /// 5 pt and its band names at 8 pt on the same rows; the name's baseline
729    /// sits 3.3 pt below its own time's and only 2.0 pt above the next one's,
730    /// so the name bands with the row *below* the one it is printed on.
731    ///
732    /// Neither edge of the box settles it alone. Producers align mixed sizes
733    /// sometimes on the baseline and sometimes on the cap top — this very page
734    /// does both — so two runs are taken to be aligned when *either* their
735    /// baselines or their tops agree, whichever agrees better. ISO 32000-1:2008
736    /// §9.4.4 computes the glyph displacement along the writing axis and sets
737    /// the component for the other axis to 0: a horizontal run does not move
738    /// vertically as it is painted, so both edges are fixed by the font and
739    /// either may be the one the producer aligned on.
740    ///
741    /// The page's dominant text size defines the row grid. Rows are seeded
742    /// from spans at that size, in descending baseline order; every remaining
743    /// span then joins the row it aligns with *best*, rather than the first
744    /// row within tolerance — a name centred between two rows is close to
745    /// both, and only the better match is the row it is printed on. A span
746    /// that aligns with no row seeds one of its own.
747    ///
748    /// Rows are formed per writing-axis quadrant, so a rotated run never joins
749    /// a horizontal row.
750    pub fn snap_baselines_to_rows(
751        all_spans: &[crate::layout::TextSpan],
752        indices: &[usize],
753    ) -> Vec<f32> {
754        // Baseline and top of a span. A degenerate box falls back to the font
755        // size so it still gets a row rather than becoming one.
756        let edges = |i: usize| -> (f32, f32) {
757            let b = &all_spans[i].bbox;
758            let h = if b.height.is_finite() && b.height > 0.0 {
759                b.height
760            } else {
761                all_spans[i].font_size.max(1.0)
762            };
763            (b.y, b.y + h)
764        };
765        let quadrant = |i: usize| -> i32 {
766            let r = all_spans[i].rotation_degrees;
767            if !r.is_finite() {
768                return 0;
769            }
770            (r / 90.0).round().rem_euclid(4.0) as i32
771        };
772        // How far apart two runs are, taking the better-agreeing edge — but
773        // only between runs of comparable height.
774        //
775        // Reading the better-agreeing edge is what lets a superscript, a drop
776        // capital or a run whose box carries a descender join the line it
777        // belongs to: at similar heights, agreement on either edge implies
778        // agreement on the other. That implication fails once one run is much
779        // taller than the other. A 19 pt centred title spanning three lines of
780        // an 8 pt stamp beside it had a top edge 0.4 pt from the stamp's first
781        // line and a baseline 11 pt away, so the better-agreeing edge put the
782        // title *inside* the stamp's opening phrase and pushed the phrase's
783        // second half onto the following line: `Prescribed by Treasury` /
784        // title / `Department Treasury Dept. Cir. 1076`.
785        //
786        // Sharing a row means sharing a baseline. Above twice the height the
787        // top edge stops being evidence of that and only the baseline counts.
788        const COMPARABLE_HEIGHT_RATIO: f32 = 2.0;
789        let distance = |a: usize, b: usize| -> f32 {
790            let (a_base, a_top) = edges(a);
791            let (b_base, b_top) = edges(b);
792            let by_baseline = (a_base - b_base).abs();
793            let (short, tall) = {
794                let (ha, hb) = (a_top - a_base, b_top - b_base);
795                (ha.min(hb), ha.max(hb))
796            };
797            if short > 0.0 && tall > short * COMPARABLE_HEIGHT_RATIO {
798                return by_baseline;
799            }
800            by_baseline.min((a_top - b_top).abs())
801        };
802
803        let mut snapped: Vec<f32> = indices.iter().map(|&i| all_spans[i].bbox.y).collect();
804        if indices.is_empty() {
805            return snapped;
806        }
807
808        // The dominant text size, to 0.5 pt. Seeding rows from one size keeps
809        // the grid regular; mixing every size in would let a run centred
810        // between two rows define a row of its own between them.
811        let mut tally: std::collections::HashMap<i32, usize> = std::collections::HashMap::new();
812        for &i in indices {
813            let fs = all_spans[i].font_size;
814            if fs.is_finite() && fs > 0.0 {
815                *tally.entry((fs * 2.0).round() as i32).or_insert(0) += 1;
816            }
817        }
818        let modal = tally
819            .into_iter()
820            .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
821            .map(|(k, _)| k);
822
823        // Positions within `indices`, topmost baseline first, so a row is
824        // always seeded by its upper edge.
825        let mut order: Vec<usize> = (0..indices.len()).collect();
826        order.sort_by(|&a, &b| {
827            safe_float_cmp(all_spans[indices[b]].bbox.y, all_spans[indices[a]].bbox.y)
828        });
829
830        // Two runs cannot share a line and also share the space on it.
831        //
832        // Row membership is decided from vertical evidence alone, which is
833        // right for runs printed side by side and wrong for runs printed on
834        // top of each other. A page footer stamped over an earlier footer sits
835        // within a fraction of a point of it — 0.145 pt between the cap tops of
836        // a 7 pt and a 9 pt run — so every vertical test accepts the pair, the
837        // row is then ordered by left edge, and the two footers come back
838        // shuffled into one another: `The Molecular Probes The Molecular
839        // Probes(R) Handbook: (TM) Handbook: A Guide to ...`.
840        //
841        // Horizontal extent settles it, and nothing else does. ISO 32000-1:2008
842        // §9.4.4 advances the text position along the writing axis by each
843        // glyph's displacement, so a run occupies one unbroken interval on that
844        // axis; two runs whose intervals overlap substantially cannot both be
845        // reading matter on one line, and one is drawn over the other.
846        //
847        // Substantially, because extractor boxes overreach to the right on
848        // trailing whitespace and stretched advances, and adjacent runs on a
849        // real line touch or overlap slightly through kerning. The bar is a
850        // quarter of the shorter run and at least two points; the stamped
851        // footers above overlap by 69.7 pt, which is 95% of the shorter one.
852        const MIN_OVERLAP_PT: f32 = 2.0;
853        const OVERLAP_FRACTION: f32 = 0.25;
854        let x_extent = |i: usize| -> (f32, f32) {
855            let b = &all_spans[i].bbox;
856            let w = if b.width.is_finite() && b.width > 0.0 {
857                b.width
858            } else {
859                0.0
860            };
861            (b.x, b.x + w)
862        };
863        let occupies_the_same_space = |i: usize, j: usize| -> bool {
864            // A blank run competes for no reading space. Producers emit
865            // space-only runs freely, and one drawn a fraction of a point under
866            // a heading at the same left edge belongs to that heading's row —
867            // separating it there would undo the rule that keeps a two-line
868            // section title whole.
869            if all_spans[i].text.trim().is_empty() || all_spans[j].text.trim().is_empty() {
870                return false;
871            }
872            let ((li, ri), (lj, rj)) = (x_extent(i), x_extent(j));
873            if !(li.is_finite() && ri.is_finite() && lj.is_finite() && rj.is_finite()) {
874                return false;
875            }
876            let overlap = ri.min(rj) - li.max(lj);
877            if overlap <= 0.0 {
878                return false;
879            }
880            let shorter = (ri - li).min(rj - lj).max(0.0);
881            overlap > MIN_OVERLAP_PT.max(shorter * OVERLAP_FRACTION)
882        };
883
884        // A row is remembered by the span that seeded it, and by everything
885        // assigned to it — a candidate has to clear the space of every member,
886        // not just the seed's, because the run it collides with may have joined
887        // the row later.
888        let mut rows: Vec<usize> = Vec::new();
889        let mut members: Vec<Vec<usize>> = Vec::new();
890        let mut row_of: Vec<Option<usize>> = vec![None; indices.len()];
891        // Seeded rows indexed by their seed's baseline, ascending, so the
892        // nearest-row search reads a window instead of every row on the page.
893        //
894        // `distance` is the smaller of the baseline gap and the top-edge gap,
895        // and a top-edge gap differs from the baseline gap by at most the two
896        // runs' heights, so `d <= ROW_BAND_TOLERANCE_PT` implies
897        // `|baseline_i - baseline_seed| <= ROW_BAND_TOLERANCE_PT + h_i + h_max`.
898        // Every row that could win is inside that window; the ones outside it
899        // could only lose, and a loss and an absence take the same branch.
900        let mut rows_by_baseline: Vec<(f32, usize)> = Vec::new();
901        let h_max = indices
902            .iter()
903            .map(|&i| {
904                let (b, t) = edges(i);
905                (t - b).abs()
906            })
907            .filter(|h| h.is_finite())
908            .fold(0.0f32, f32::max);
909        let is_modal = |i: usize| -> bool {
910            modal.is_some_and(|m| ((all_spans[i].font_size * 2.0).round() as i32) == m)
911        };
912
913        // Two passes over the same order: the dominant size lays down the
914        // grid, then everything else attaches to it.
915        for modal_pass in [true, false] {
916            for &pos in &order {
917                let i = indices[pos];
918                if row_of[pos].is_some() || is_modal(i) != modal_pass {
919                    continue;
920                }
921                if !all_spans[i].bbox.y.is_finite() {
922                    continue;
923                }
924                let q = quadrant(i);
925                let (base_i, top_i) = edges(i);
926                let window = ROW_BAND_TOLERANCE_PT + (top_i - base_i).abs() + h_max;
927                let (lo_b, hi_b) = (base_i - window, base_i + window);
928                let from = rows_by_baseline.partition_point(|&(b, _)| b < lo_b);
929                let to = rows_by_baseline.partition_point(|&(b, _)| b <= hi_b);
930                let mut candidates: Vec<usize> =
931                    rows_by_baseline[from..to].iter().map(|&(_, r)| r).collect();
932                // Row order, so a tie still resolves to the row seeded first.
933                candidates.sort_unstable();
934                let best = candidates
935                    .into_iter()
936                    .filter(|&r| quadrant(rows[r]) == q)
937                    .map(|r| (distance(i, rows[r]), r, rows[r]))
938                    .min_by(|a, b| safe_float_cmp(a.0, b.0));
939                // The space test is applied to the row that wins on distance,
940                // not to every row that might have. Scanning each candidate's
941                // members for every span is quadratic in the spans on a page
942                // and doubled the time to convert a 725-page book; a run is
943                // only ever placed on its nearest row, so that is the only one
944                // whose space it can be competing for. A run vetoed there opens
945                // a row of its own, which is what it needs.
946                match best {
947                    Some((d, r, seed))
948                        if d <= ROW_BAND_TOLERANCE_PT
949                            && !members[r].iter().any(|&m| occupies_the_same_space(i, m)) =>
950                    {
951                        row_of[pos] = Some(seed);
952                        members[r].push(i);
953                    },
954                    _ => {
955                        let at = rows_by_baseline.partition_point(|&(b, _)| b <= base_i);
956                        rows_by_baseline.insert(at, (base_i, rows.len()));
957                        rows.push(i);
958                        members.push(vec![i]);
959                        row_of[pos] = Some(i);
960                    },
961                }
962            }
963        }
964
965        for (pos, seed) in row_of.iter().enumerate() {
966            if let Some(seed) = seed {
967                snapped[pos] = all_spans[*seed].bbox.y;
968            }
969        }
970        snapped
971    }
972
973    /// Total-order wrapper over `f32` for use as a sort key. For finite values
974    /// `total_cmp` is identical to `safe_float_cmp` / `partial_cmp`.
975    #[derive(Clone, Copy, PartialEq)]
976    struct F32Ord(f32);
977    impl Eq for F32Ord {}
978    impl PartialOrd for F32Ord {
979        fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
980            Some(self.cmp(other))
981        }
982    }
983    impl Ord for F32Ord {
984        fn cmp(&self, other: &Self) -> Ordering {
985            self.0.total_cmp(&other.0)
986        }
987    }
988
989    #[cfg(test)]
990    mod tests {
991        use super::*;
992
993        /// Build a span at an explicit baseline/height for the row-snapping
994        /// tests below.
995        fn row_span(y: f32, height: f32, text: &str) -> crate::layout::TextSpan {
996            row_span_at(0.0, y, height, text)
997        }
998
999        /// As [`row_span`], at an explicit left edge — for cases where the
1000        /// horizontal extents matter and must not overlap.
1001        fn row_span_at(x: f32, y: f32, height: f32, text: &str) -> crate::layout::TextSpan {
1002            crate::layout::TextSpan {
1003                text: text.to_string(),
1004                bbox: crate::geometry::Rect::new(x, y, 10.0, height),
1005                font_size: height,
1006                ..Default::default()
1007            }
1008        }
1009
1010        /// A government form's masthead, at the geometry the file draws. A
1011        /// 19 pt centred title spans all three lines of an 8 pt stamp beside
1012        /// it, so its top edge lands 0.4 pt from the stamp's first line while
1013        /// its baseline sits 11.5 pt away.
1014        ///
1015        /// Taking the better-agreeing edge put the title on the stamp's
1016        /// opening row, which sorted it between `Prescribed by Treasury` and
1017        /// `Department` — splitting one phrase and gluing its second half to
1018        /// the line below.
1019        #[test]
1020        fn test_tall_centred_run_does_not_join_a_short_row_beside_it() {
1021            let spans = vec![
1022                row_span(723.0, 8.2, "Prescribed by Treasury"),
1023                row_span(716.0, 8.3, "Department"),
1024                row_span(709.1, 8.2, "Treasury Dept. Cir. 1076"),
1025                row_span(711.5, 19.3, "DIRECT DEPOSIT SIGN-UP FORM"),
1026            ];
1027            let idx: Vec<usize> = (0..spans.len()).collect();
1028            let rows = snap_baselines_to_rows(&spans, &idx);
1029            assert_ne!(
1030                rows[3], rows[0],
1031                "a run 2.4x the height of the line beside it does not share its row"
1032            );
1033        }
1034
1035        /// The counter-case that keeps the narrowing honest. Two runs of
1036        /// comparable height whose tops agree exactly and whose baselines do
1037        /// not — a superscript, a drop capital, a box carrying a descender —
1038        /// must still snap together. This is the case the better-agreeing-edge
1039        /// rule exists for, and it passes with or without the height guard.
1040        #[test]
1041        fn comparable_runs_still_snap_on_their_better_edge() {
1042            // Side by side, as a superscript actually sits: sharing a line
1043            // means sharing neither ink nor the space it occupies.
1044            let spans = vec![
1045                row_span_at(0.0, 700.0, 10.0, "body"),
1046                row_span_at(12.0, 703.0, 7.0, "sup"),
1047            ];
1048            let idx: Vec<usize> = (0..spans.len()).collect();
1049            let rows = snap_baselines_to_rows(&spans, &idx);
1050            assert_eq!(
1051                rows[0], rows[1],
1052                "runs of similar height still join on whichever edge agrees"
1053            );
1054        }
1055
1056        /// The cached-key sort must produce the identical permutation to
1057        /// `sort_by(row_aware_span_cmp)` on finite inputs.
1058        #[test]
1059        fn test_sort_by_row_band_matches_comparator() {
1060            // Deterministic pseudo-random spans (no rng in tests).
1061            let raw: Vec<(f32, f32)> = (0..500)
1062                .map(|i| {
1063                    let y = ((i * 37 % 113) as f32) * 1.3;
1064                    let x = ((i * 71 % 97) as f32) * 2.1;
1065                    (y, x)
1066                })
1067                .collect();
1068            let mut a = raw.clone();
1069            let mut b = raw.clone();
1070            sort_by_row_band(&mut a, |t| t.0, |t| t.1);
1071            b.sort_by(|p, q| row_aware_span_cmp(p.0, p.1, q.0, q.1));
1072            assert_eq!(a, b, "cached-key sort must match the comparator permutation");
1073        }
1074
1075        #[test]
1076        fn test_safe_float_cmp_normal() {
1077            assert_eq!(safe_float_cmp(1.0, 2.0), Ordering::Less);
1078            assert_eq!(safe_float_cmp(2.0, 1.0), Ordering::Greater);
1079            assert_eq!(safe_float_cmp(1.5, 1.5), Ordering::Equal);
1080        }
1081
1082        #[test]
1083        fn test_safe_float_cmp_nan() {
1084            assert_eq!(safe_float_cmp(f32::NAN, f32::NAN), Ordering::Equal);
1085            assert_eq!(safe_float_cmp(f32::NAN, 0.0), Ordering::Greater);
1086            assert_eq!(safe_float_cmp(0.0, f32::NAN), Ordering::Less);
1087        }
1088
1089        fn tategaki_rect(x: f32, y: f32, w: f32) -> crate::geometry::Rect {
1090            crate::geometry::Rect::new(x, y, w, 12.0)
1091        }
1092
1093        /// Two well-separated columns: rightmost column first, top-to-bottom
1094        /// within each (the ordering the pre-fix comparator also produced
1095        /// for the well-behaved case — this must not regress).
1096        #[test]
1097        fn test_sort_vertical_tategaki_two_columns() {
1098            let items = vec![
1099                ("D", tategaki_rect(300.0, 700.0, 12.0)),
1100                ("F", tategaki_rect(300.0, 676.0, 12.0)),
1101                ("B", tategaki_rect(500.0, 688.0, 12.0)),
1102                ("C", tategaki_rect(500.0, 676.0, 12.0)),
1103                ("A", tategaki_rect(500.0, 700.0, 12.0)),
1104                ("E", tategaki_rect(300.0, 688.0, 12.0)),
1105            ];
1106            let sorted = sort_vertical_tategaki(items, |it| &it.1);
1107            let order: String = sorted.iter().map(|it| it.0).collect();
1108            assert_eq!(order, "ABCDEF");
1109        }
1110
1111        /// A chain of X-centers each within `tol` of its neighbor but
1112        /// spanning far more than `tol` overall made the old pairwise
1113        /// `|a - b| <= tol` comparator non-transitive (A<B, B<C, C<A),
1114        /// which panicked `sort_by` on Rust 1.81+. Single-linkage
1115        /// clustering must read the whole chain as one column, top to
1116        /// bottom, without panicking.
1117        #[test]
1118        fn test_sort_vertical_tategaki_chained_centers() {
1119            // Centers step by 8pt across 64 spans (630pt total span) — every
1120            // adjacent pair is "same column" under a naive tolerance check,
1121            // but the first and last are 500+pt apart.
1122            let items: Vec<(usize, crate::geometry::Rect)> = (0..64)
1123                .map(|i| (i, tategaki_rect(i as f32 * 8.0, ((i * 37) % 64) as f32 * 7.0, 10.0)))
1124                .collect();
1125            let sorted = sort_vertical_tategaki(items, |it| &it.1);
1126            assert_eq!(sorted.len(), 64);
1127            assert!(
1128                sorted.windows(2).all(|w| w[0].1.y >= w[1].1.y),
1129                "one chained column must read top-to-bottom"
1130            );
1131        }
1132
1133        /// Two spans only 2pt apart (well within `tol`) must land in the
1134        /// same column even when their absolute X-centers straddle what
1135        /// would be a fixed quantization-bucket boundary (e.g. `tol`
1136        /// multiples of 100 straddling x=250). Single-linkage clustering
1137        /// only looks at the gap between neighbors, so it has no such
1138        /// boundary effect — unlike banding each center independently via
1139        /// `round(x / tol)`.
1140        #[test]
1141        fn test_sort_vertical_tategaki_no_boundary_straddle_effect() {
1142            let items = vec![
1143                ("near", tategaki_rect(249.0, 700.0, 100.0)),
1144                ("straddle", tategaki_rect(251.0, 690.0, 100.0)),
1145                ("far", tategaki_rect(10.0, 680.0, 100.0)),
1146            ];
1147            let sorted = sort_vertical_tategaki(items, |it| &it.1);
1148            // "near" and "straddle" are 2pt apart (tol = 100) so they must
1149            // share a column and sort top-to-bottom relative to each other,
1150            // both ahead of the genuinely distant "far" column.
1151            let order: Vec<&str> = sorted.iter().map(|it| it.0).collect();
1152            assert_eq!(order, vec!["near", "straddle", "far"]);
1153        }
1154
1155        /// Non-finite coordinates must not panic the sort, and every item
1156        /// must survive the permutation exactly once.
1157        #[test]
1158        fn test_sort_vertical_tategaki_non_finite() {
1159            let mut items: Vec<(usize, crate::geometry::Rect)> = (0..32)
1160                .map(|i| (i, tategaki_rect((i % 8) as f32 * 40.0, i as f32 * 5.0, 12.0)))
1161                .collect();
1162            items[3].1.x = f32::NAN;
1163            items[11].1.y = f32::NAN;
1164            items[17].1.width = f32::NAN;
1165            items[23].1.x = f32::INFINITY;
1166            let sorted = sort_vertical_tategaki(items, |it| &it.1);
1167            let mut ids: Vec<usize> = sorted.iter().map(|it| it.0).collect();
1168            ids.sort_unstable();
1169            assert_eq!(ids, (0..32).collect::<Vec<_>>());
1170        }
1171
1172        #[test]
1173        fn test_safe_float_cmp_infinity() {
1174            assert_eq!(safe_float_cmp(f32::INFINITY, f32::INFINITY), Ordering::Equal);
1175            assert_eq!(safe_float_cmp(f32::INFINITY, 1.0), Ordering::Greater);
1176            assert_eq!(safe_float_cmp(f32::NEG_INFINITY, f32::INFINITY), Ordering::Less);
1177        }
1178
1179        /// Verify that sort_by using safe_float_cmp never panics with NaN values.
1180        /// This is a regression test for the "total order" panic that affected 42
1181        /// PDFs across 5 test datasets (issue found in v0.3.11-pre).
1182        #[test]
1183        fn test_sort_with_nan_does_not_panic() {
1184            let mut values = [3.0_f32, f32::NAN, 1.0, f32::NAN, 2.0, f32::NAN, 0.5];
1185            values.sort_by(|a, b| safe_float_cmp(*a, *b));
1186            // NaN values should sort to the end (NaN > all numbers)
1187            assert!(values[0..4].iter().all(|v| !v.is_nan()));
1188            assert!(values[4..].iter().all(|v| v.is_nan()));
1189        }
1190
1191        /// Verify transitivity: if a < b and b < c then a < c.
1192        /// The previous `partial_cmp().unwrap_or(Equal)` pattern violated this
1193        /// when NaN was involved, causing Rust's sort to panic.
1194        #[test]
1195        fn test_safe_float_cmp_transitivity() {
1196            let a = 1.0_f32;
1197            let b = 2.0_f32;
1198            let nan = f32::NAN;
1199
1200            // a < b
1201            assert_eq!(safe_float_cmp(a, b), Ordering::Less);
1202            // b < NaN
1203            assert_eq!(safe_float_cmp(b, nan), Ordering::Less);
1204            // Therefore a < NaN (transitivity)
1205            assert_eq!(safe_float_cmp(a, nan), Ordering::Less);
1206        }
1207
1208        /// Cells in the same tabular row with slightly-different Y values
1209        /// must stay together and be ordered by X, not interleaved with
1210        /// cells from other rows.
1211        #[test]
1212        fn test_row_aware_span_cmp_tolerates_y_jitter() {
1213            // Row 1 at y ≈ 100 with small per-cell jitter.
1214            // Row 2 at y ≈ 86 (14pt leading below).
1215            // A strict Y sort would interleave them because some row-1
1216            // cells have lower Y than some row-2 cells.
1217            #[derive(Debug, Clone, Copy)]
1218            struct Cell {
1219                y: f32,
1220                x: f32,
1221                id: &'static str,
1222            }
1223            let mut cells = [
1224                Cell {
1225                    y: 100.5,
1226                    x: 50.0,
1227                    id: "r1-c1",
1228                },
1229                Cell {
1230                    y: 99.7,
1231                    x: 150.0,
1232                    id: "r1-c2",
1233                },
1234                Cell {
1235                    y: 100.2,
1236                    x: 250.0,
1237                    id: "r1-c3",
1238                },
1239                Cell {
1240                    y: 86.4,
1241                    x: 50.0,
1242                    id: "r2-c1",
1243                },
1244                Cell {
1245                    y: 85.8,
1246                    x: 150.0,
1247                    id: "r2-c2",
1248                },
1249                Cell {
1250                    y: 86.1,
1251                    x: 250.0,
1252                    id: "r2-c3",
1253                },
1254            ];
1255            cells.sort_by(|a, b| row_aware_span_cmp(a.y, a.x, b.y, b.x));
1256            let order: Vec<&str> = cells.iter().map(|c| c.id).collect();
1257            assert_eq!(
1258                order,
1259                vec!["r1-c1", "r1-c2", "r1-c3", "r2-c1", "r2-c2", "r2-c3"],
1260                "cells from the same row must stay contiguous and X-sorted"
1261            );
1262        }
1263
1264        /// Row-aware comparator must still put distinct-leading rows in
1265        /// top-to-bottom reading order.
1266        #[test]
1267        fn test_row_aware_span_cmp_distinct_rows_descending() {
1268            let mut rows = [
1269                (100.0f32, 0.0f32, "top"),
1270                (50.0, 0.0, "middle"),
1271                (10.0, 0.0, "bottom"),
1272            ];
1273            rows.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
1274            assert_eq!(rows[0].2, "top");
1275            assert_eq!(rows[1].2, "middle");
1276            assert_eq!(rows[2].2, "bottom");
1277        }
1278
1279        /// The comparator is used by sort_by, which requires a valid total
1280        /// order. Run a randomized stress test to confirm no transitivity
1281        /// panics.
1282        #[test]
1283        fn test_row_aware_span_cmp_is_total_order() {
1284            let mut v: Vec<(f32, f32)> = (0..200)
1285                .map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32))
1286                .collect();
1287            v.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
1288        }
1289
1290        /// #656/#657: the RTL variant keeps rows top-to-bottom but orders
1291        /// X *descending* (right-to-left) within a row — a pure-RTL line's
1292        /// logical reading order.
1293        /// Two spans in one band at the same x still order by baseline.
1294        #[test]
1295        fn test_sub_band_baseline_difference_still_decides() {
1296            assert_eq!(
1297                row_aware_span_cmp(98.36, 232.08, 98.21, 232.08),
1298                Ordering::Less,
1299                "one band, one x: the baseline must decide, or sort stability does"
1300            );
1301            assert_eq!(row_aware_span_cmp(98.21, 232.08, 98.36, 232.08), Ordering::Greater);
1302        }
1303
1304        /// The banding still does its job: within a band, x decides whatever
1305        /// the baselines are doing. This is the case banding exists for and the
1306        /// tiebreak must not disturb it.
1307        #[test]
1308        fn x_still_decides_within_a_band() {
1309            assert_eq!(row_aware_span_cmp(98.36, 100.0, 98.21, 200.0), Ordering::Less);
1310            assert_eq!(row_aware_span_cmp(98.21, 100.0, 98.36, 200.0), Ordering::Less);
1311        }
1312
1313        /// `row_band_then_x` deliberately stops before the baseline, so a
1314        /// caller sorting on a synthetic key can apply its own tiebreak on the
1315        /// real geometry. The two comparators must differ in exactly this way,
1316        /// or the split has no effect and the hazard comes back.
1317        #[test]
1318        fn test_band_and_x_comparator_leaves_a_same_x_tie_open() {
1319            assert_eq!(row_band_then_x(98.21, 232.08, 98.36, 232.08), Ordering::Equal);
1320            assert_eq!(row_aware_span_cmp(98.21, 232.08, 98.36, 232.08), Ordering::Greater);
1321            // Wherever x differs the two agree, so swapping one for the other
1322            // moves nothing except the tie.
1323            assert_eq!(
1324                row_band_then_x(98.36, 100.0, 98.21, 200.0),
1325                row_aware_span_cmp(98.36, 100.0, 98.21, 200.0)
1326            );
1327        }
1328
1329        /// Two spans in one row share a row key — that is what the key is
1330        /// for — so any tiebreak read back from it compares them equal and
1331        /// hands their order to the sequence they arrived in. A space-only run
1332        /// drawn a fifth of a point under a heading, at the same left edge and
1333        /// emitted first, then sorted ahead of the heading's own text and broke
1334        /// a two-line section title into body text plus its last word.
1335        ///
1336        /// The row key settles the band and `x`; the baseline the page draws
1337        /// settles what is left.
1338        #[test]
1339        fn test_shared_row_key_leaves_the_baseline_to_decide() {
1340            use crate::layout::TextSpan;
1341            let span = |y: f32, text: &str| TextSpan {
1342                text: text.to_string(),
1343                bbox: crate::geometry::Rect::new(36.0, y, 100.0, 12.0),
1344                font_size: 12.0,
1345                ..Default::default()
1346            };
1347            // Emitted in the order a producer drew them: the space first.
1348            let spans = vec![span(745.73, " "), span(745.93, "Section Title")];
1349            let idx: Vec<usize> = (0..spans.len()).collect();
1350            let key = snap_baselines_to_rows(&spans, &idx);
1351            assert_eq!(
1352                key[0], key[1],
1353                "the two runs are one row, so the hazard this guards is real"
1354            );
1355
1356            // Ordering on the key alone cannot separate them.
1357            assert_eq!(row_band_then_x_axis(0.0, key[0], 36.0, 0.0, key[1], 36.0), Ordering::Equal);
1358            // Adding the drawn baseline does, and puts the heading first.
1359            let ordered = row_band_then_x_axis(0.0, key[0], 36.0, 0.0, key[1], 36.0)
1360                .then_with(|| safe_float_cmp(spans[1].bbox.y, spans[0].bbox.y));
1361            assert_eq!(
1362                ordered,
1363                Ordering::Greater,
1364                "the run drawn higher on the page must be read first"
1365            );
1366        }
1367
1368        /// A different band still wins over x.
1369        #[test]
1370        fn test_different_band_still_wins_over_x() {
1371            assert_eq!(row_aware_span_cmp(120.0, 400.0, 98.0, 50.0), Ordering::Less);
1372        }
1373
1374        /// Identical geometry is genuinely equal — the comparator must not
1375        /// invent an order where there is no evidence for one.
1376        #[test]
1377        fn identical_geometry_is_equal() {
1378            assert_eq!(row_aware_span_cmp(98.36, 232.08, 98.36, 232.08), Ordering::Equal);
1379        }
1380
1381        /// And the cached-key sort must agree with the comparator, or the two
1382        /// orderings diverge wherever both are used on the same data.
1383        #[test]
1384        fn test_cached_key_sort_agrees_with_the_comparator() {
1385            let data = [(98.21_f32, 232.08_f32), (98.36, 232.08), (98.30, 100.0)];
1386            let mut by_key = data.to_vec();
1387            sort_by_row_band(&mut by_key, |it| it.0, |it| it.1);
1388            let mut by_cmp = data.to_vec();
1389            by_cmp.sort_by(|a, b| row_aware_span_cmp(a.0, a.1, b.0, b.1));
1390            assert_eq!(by_key, by_cmp);
1391        }
1392
1393        #[test]
1394        fn test_row_aware_span_cmp_rtl_within_row_is_descending() {
1395            // Same row (Y within band), laid out left-to-right by X.
1396            let mut row = [
1397                (100.0f32, 10.0f32, "leftmost"),
1398                (100.0, 50.0, "mid"),
1399                (100.0, 90.0, "rightmost"),
1400            ];
1401            row.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
1402            // Rightmost (highest X) reads first in RTL.
1403            assert_eq!(["rightmost", "mid", "leftmost"], [row[0].2, row[1].2, row[2].2]);
1404        }
1405
1406        /// Rows still order top-to-bottom regardless of the within-row flip.
1407        #[test]
1408        fn test_row_aware_span_cmp_rtl_rows_top_to_bottom() {
1409            let mut rows = [
1410                (10.0f32, 0.0f32, "bottom"),
1411                (100.0, 0.0, "top"),
1412                (50.0, 0.0, "middle"),
1413            ];
1414            rows.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
1415            assert_eq!(["top", "middle", "bottom"], [rows[0].2, rows[1].2, rows[2].2]);
1416        }
1417
1418        /// Must be a valid total order for `sort_by` (no transitivity panic).
1419        #[test]
1420        fn test_row_aware_span_cmp_rtl_is_total_order() {
1421            let mut v: Vec<(f32, f32)> = (0..200)
1422                .map(|i| ((i as f32) * 0.73, ((i * 17) % 500) as f32))
1423                .collect();
1424            v.sort_by(|a, b| row_aware_span_cmp_rtl(a.0, a.1, b.0, b.1));
1425        }
1426
1427        /// Sort a large array with mixed NaN/normal values to stress-test.
1428        #[test]
1429        fn test_sort_stress_with_nan() {
1430            let mut values: Vec<f32> = (0..100).map(|i| i as f32).collect();
1431            // Insert NaN at various positions
1432            for i in (0..100).step_by(7) {
1433                values[i] = f32::NAN;
1434            }
1435            // Must not panic
1436            values.sort_by(|a, b| safe_float_cmp(*a, *b));
1437        }
1438
1439        #[test]
1440        fn test_safe_prefix_ascii() {
1441            assert_eq!(safe_prefix("hello", 3), "hel");
1442            assert_eq!(safe_prefix("hello", 10), "hello");
1443            assert_eq!(safe_prefix("", 5), "");
1444            assert_eq!(safe_prefix("hi", 0), "");
1445        }
1446
1447        #[test]
1448        fn test_safe_prefix_multibyte() {
1449            let text = "✚✳★✵"; // 4 × 3-byte chars = 12 bytes
1450            assert_eq!(safe_prefix(text, 10), "✚✳★"); // rounds down from 10 to 9
1451            assert_eq!(safe_prefix(text, 9), "✚✳★"); // exact boundary
1452            assert_eq!(safe_prefix(text, 12), "✚✳★✵"); // full string
1453        }
1454
1455        #[test]
1456        fn test_safe_suffix_ascii() {
1457            assert_eq!(safe_suffix("hello", 3), "llo");
1458            assert_eq!(safe_suffix("hello", 10), "hello");
1459            assert_eq!(safe_suffix("", 5), "");
1460            assert_eq!(safe_suffix("hi", 0), "");
1461        }
1462
1463        #[test]
1464        fn test_safe_suffix_multibyte() {
1465            let text = "AB✚✳★✵"; // 14 bytes: A(0) B(1) ✚(2..5) ✳(5..8) ★(8..11) ✵(11..14)
1466                                 // 14 - 10 = 4, byte 4 is inside ✚ → rounds up to 5
1467            assert_eq!(safe_suffix(text, 10), "✳★✵");
1468        }
1469    }
1470}
1471
1472// Version info
1473/// Library version
1474pub const VERSION: &str = env!("CARGO_PKG_VERSION");
1475
1476/// Library name
1477pub const NAME: &str = env!("CARGO_PKG_NAME");
1478
1479#[cfg(test)]
1480mod tests {
1481    use super::*;
1482
1483    #[test]
1484    fn test_version() {
1485        // VERSION is populated from CARGO_PKG_VERSION at compile time
1486        assert!(VERSION.starts_with("0."));
1487    }
1488
1489    #[test]
1490    fn test_name() {
1491        assert_eq!(NAME, "pdf_oxide");
1492    }
1493}