Skip to main content

pdf_interpret/
util.rs

1//! A number of utility methods.
2
3use kurbo::{Affine, BezPath, PathEl, Rect};
4use log::warn;
5use pdf_syntax::page::{Page, Rotation};
6use siphasher::sip128::{Hasher128, SipHasher13};
7use std::hash::Hash;
8use std::ops::Sub;
9
10pub(crate) trait OptionLog {
11    fn warn_none(self, f: &str) -> Self;
12}
13
14impl<T> OptionLog for Option<T> {
15    #[inline]
16    fn warn_none(self, f: &str) -> Self {
17        self.or_else(|| {
18            warn!("{f}");
19
20            None
21        })
22    }
23}
24
25const SCALAR_NEARLY_ZERO: f32 = 1.0 / (1 << 8) as f32;
26
27/// A number of useful methods for f32 numbers.
28pub trait Float32Ext: Sized + Sub<f32, Output = f32> + Copy + PartialOrd<f32> {
29    /// Whether the number is approximately 0.
30    fn is_nearly_zero(&self) -> bool {
31        self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO)
32    }
33
34    /// Whether the number is nearly equal to another number.
35    fn is_nearly_equal(&self, other: f32) -> bool {
36        (*self - other).is_nearly_zero()
37    }
38
39    /// Whether the number is nearly equal to another number.
40    fn is_nearly_less_or_equal(&self, other: f32) -> bool {
41        (*self - other).is_nearly_zero() || *self < other
42    }
43
44    /// Whether the number is nearly equal to another number.
45    fn is_nearly_greater_or_equal(&self, other: f32) -> bool {
46        (*self - other).is_nearly_zero() || *self > other
47    }
48
49    /// Whether the number is approximately 0, with a given tolerance.
50    fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool;
51}
52
53impl Float32Ext for f32 {
54    fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool {
55        debug_assert!(tolerance >= 0.0, "tolerance must be non-negative");
56
57        self.abs() <= tolerance
58    }
59}
60
61/// A number of useful methods for f64 numbers.
62pub trait Float64Ext: Sized + Sub<f64, Output = f64> + Copy + PartialOrd<f64> {
63    /// Whether the number is approximately 0.
64    fn is_nearly_zero(&self) -> bool {
65        self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO as f64)
66    }
67
68    /// Whether the number is nearly equal to another number.
69    fn is_nearly_equal(&self, other: f64) -> bool {
70        (*self - other).is_nearly_zero()
71    }
72
73    /// Whether the number is nearly equal to another number.
74    fn is_nearly_less_or_equal(&self, other: f64) -> bool {
75        (*self - other).is_nearly_zero() || *self < other
76    }
77
78    /// Whether the number is nearly equal to another number.
79    fn is_nearly_greater_or_equal(&self, other: f64) -> bool {
80        (*self - other).is_nearly_zero() || *self > other
81    }
82
83    /// Whether the number is approximately 0, with a given tolerance.
84    fn is_nearly_zero_within_tolerance(&self, tolerance: f64) -> bool;
85}
86
87impl Float64Ext for f64 {
88    fn is_nearly_zero_within_tolerance(&self, tolerance: f64) -> bool {
89        debug_assert!(tolerance >= 0.0, "tolerance must be non-negative");
90
91        self.abs() <= tolerance
92    }
93}
94
95pub(crate) trait PointExt: Sized {
96    fn x(&self) -> f32;
97    fn y(&self) -> f32;
98
99    fn nearly_same(&self, other: Self) -> bool {
100        self.x().is_nearly_equal(other.x()) && self.y().is_nearly_equal(other.y())
101    }
102}
103
104impl PointExt for kurbo::Point {
105    fn x(&self) -> f32 {
106        self.x as f32
107    }
108
109    fn y(&self) -> f32 {
110        self.y as f32
111    }
112}
113
114/// Calculate a 128-bit siphash of a value.
115pub(crate) fn hash128<T: Hash + ?Sized>(value: &T) -> u128 {
116    let mut state = SipHasher13::new();
117    value.hash(&mut state);
118    state.finish128().as_u128()
119}
120
121pub(crate) trait BezPathExt {
122    fn fast_bounding_box(&self) -> Rect;
123}
124
125impl BezPathExt for BezPath {
126    fn fast_bounding_box(&self) -> Rect {
127        let mut min_x = f64::INFINITY;
128        let mut min_y = f64::INFINITY;
129        let mut max_x = f64::NEG_INFINITY;
130        let mut max_y = f64::NEG_INFINITY;
131
132        let mut include = |x: f64, y: f64| {
133            min_x = min_x.min(x);
134            min_y = min_y.min(y);
135            max_x = max_x.max(x);
136            max_y = max_y.max(y);
137        };
138
139        for el in self.elements() {
140            match *el {
141                PathEl::MoveTo(p) | PathEl::LineTo(p) => include(p.x, p.y),
142                PathEl::QuadTo(p1, p2) => {
143                    include(p1.x, p1.y);
144                    include(p2.x, p2.y);
145                }
146                PathEl::CurveTo(p1, p2, p3) => {
147                    include(p1.x, p1.y);
148                    include(p2.x, p2.y);
149                    include(p3.x, p3.y);
150                }
151                PathEl::ClosePath => {}
152            }
153        }
154
155        if min_x > max_x {
156            Rect::ZERO
157        } else {
158            Rect::new(min_x, min_y, max_x, max_y)
159        }
160    }
161}
162
163/// Extension methods for rectangles.
164pub trait RectExt {
165    /// Convert the rectangle to a `kurbo` rectangle.
166    fn to_kurbo(&self) -> Rect;
167}
168
169impl RectExt for pdf_syntax::object::Rect {
170    fn to_kurbo(&self) -> Rect {
171        Rect::new(self.x0, self.y0, self.x1, self.y1)
172    }
173}
174
175// Note: Keep in sync with `pdf-interpret-write`.
176/// Extension methods for PDF pages.
177pub trait PageExt {
178    /// Return the initial transform that should be applied when rendering. This accounts for a
179    /// number of factors, such as the mismatch between PDF's y-up and most renderers' y-down
180    /// coordinate system, the rotation of the page and the offset of the crop box.
181    fn initial_transform(&self, invert_y: bool) -> Affine;
182}
183
184impl PageExt for Page<'_> {
185    fn initial_transform(&self, invert_y: bool) -> Affine {
186        // Use the raw CropBox origin for the coordinate-system translation.
187        // MuPDF maps (CropBox.x0, CropBox.y0) → canvas (0, 0).  For normal
188        // PDFs (CropBox ⊆ MediaBox) intersected_crop_box and crop_box share
189        // the same origin, so there is no change.  For unusual documents where
190        // CropBox extends beyond MediaBox (e.g. gen-802: CropBox=[0,0,684,864]
191        // but MediaBox=[36,36,648,828]) the intersected origin was (36,36),
192        // producing a 75-pixel content offset vs MuPDF. (#544 follow-up)
193        let crop_box = self.crop_box();
194        let (_, base_height) = self.base_dimensions();
195        let (width, height) = self.render_dimensions();
196
197        let horizontal_t =
198            Affine::rotate(90.0_f64.to_radians()) * Affine::translate((0.0, -width as f64));
199        let flipped_horizontal_t =
200            Affine::translate((0.0, height as f64)) * Affine::rotate(-90.0_f64.to_radians());
201
202        let rotation_transform = match self.rotation() {
203            Rotation::None => Affine::IDENTITY,
204            Rotation::Horizontal => {
205                if invert_y {
206                    horizontal_t
207                } else {
208                    flipped_horizontal_t
209                }
210            }
211            Rotation::Flipped => {
212                Affine::scale(-1.0) * Affine::translate((-width as f64, -height as f64))
213            }
214            Rotation::FlippedHorizontal => {
215                if invert_y {
216                    flipped_horizontal_t
217                } else {
218                    horizontal_t
219                }
220            }
221        };
222
223        let inversion_transform = if invert_y {
224            Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, base_height as f64])
225        } else {
226            Affine::IDENTITY
227        };
228
229        rotation_transform * inversion_transform * Affine::translate((-crop_box.x0, -crop_box.y0))
230    }
231}