Skip to main content

xberg_native_pdf/
lib.rs

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