Skip to main content

pdfrum_doc/
geom.rs

1//! PDF rectangle arithmetic with the exact semantics the annotation and
2//! appearance code depends on.
3//!
4//! A PDF rectangle is `[left bottom right top]` in a y-**up** space, and none
5//! of the operations below normalize the way a general-purpose geometry
6//! library would. `kurbo::Rect` is the carrier — `(x0, y0, x1, y1)` reads as
7//! `(left, bottom, right, top)` — but its own accessors answer differently at
8//! every point that matters here, which is why this module exists rather than
9//! calling through to them:
10//!
11//! - [`width`] and [`height`] narrow to `f32`, because a `re` operator's
12//!   operands are written from them verbatim and PDF reals are single
13//!   precision. `kurbo`'s answer in `f64` would round to a different decimal.
14//!   Both are plain subtractions and **can be negative**, as `kurbo`'s are.
15//! - `Rect::contains` is half-open on the far edges and does not normalize;
16//!   the annotation hit test is inclusive on all four and does.
17//! - `Rect::union`, `Rect::intersect` and `Rect::inflate` are documented as
18//!   valid only for non-negative extents; the appearance generators feed them
19//!   inverted rectangles and rely on normalization happening first.
20//! - `Rect::is_zero_area` tests `area() == 0.0`; the emptiness this crate means
21//!   is `x0 >= x1 || y0 >= y1`, which is true of an inverted rectangle that has
22//!   area.
23//!
24//! The private half of the module is where those four divergences live. The
25//! public half is the vocabulary `pdfrum-form` and `pdfrum-tool` share with
26//! this crate: the `[left bottom right top]` constructor, the four narrowing
27//! edge accessors and the two extents, [`normalize`], and the two matrix
28//! operations an appearance stream is placed with.
29//!
30//! ```
31//! use pdfrum_doc::geom;
32//! use kurbo::Rect;
33//!
34//! // An inverted rectangle: right < left. The extents stay signed …
35//! let inverted = Rect::new(10.0, 0.0, 4.0, 8.0);
36//! assert_eq!(geom::width(inverted), -6.0);
37//! // … until something normalizes it.
38//! assert_eq!(geom::normalize(inverted), Rect::new(4.0, 0.0, 10.0, 8.0));
39//! assert_eq!(geom::width(geom::normalize(inverted)), 6.0);
40//! ```
41
42// PDF real numbers are single-precision, and `kurbo::Rect` carries doubles.
43// Narrowing at every edge accessor is the design: it is what keeps the
44// numbers this crate writes into a content stream identical to the ones the
45// file declared, rather than to doubles that merely round to them.
46#![allow(clippy::cast_possible_truncation)]
47
48use kurbo::{Affine, Point, Rect};
49
50/// The comparison epsilon the layout engine uses, as a `f64` literal so the
51/// comparison happens in double precision even for `f32` inputs.
52const EPSILON: f64 = 1e-4;
53
54/// Whether a value is within one epsilon of zero.
55#[must_use]
56pub(crate) fn is_float_zero(value: f32) -> bool {
57    let value = f64::from(value);
58    value < EPSILON && value > -EPSILON
59}
60
61/// Whether `a` exceeds `b` by more than one epsilon.
62#[must_use]
63pub(crate) fn is_float_bigger(a: f32, b: f32) -> bool {
64    a > b && !is_float_zero(a - b)
65}
66
67/// Whether `a` falls below `b` by more than one epsilon.
68#[must_use]
69pub(crate) fn is_float_smaller(a: f32, b: f32) -> bool {
70    a < b && !is_float_zero(a - b)
71}
72
73/// Builds a rectangle from PDF's `[left bottom right top]` ordering.
74///
75/// ```
76/// use pdfrum_doc::geom;
77///
78/// let letter = geom::rect(0.0, 0.0, 612.0, 792.0);
79/// assert_eq!(geom::width(letter), 612.0);
80/// ```
81#[must_use]
82pub fn rect(left: f32, bottom: f32, right: f32, top: f32) -> Rect {
83    Rect::new(
84        f64::from(left),
85        f64::from(bottom),
86        f64::from(right),
87        f64::from(top),
88    )
89}
90
91/// The rectangle's left edge.
92///
93/// ```
94/// use pdfrum_doc::geom;
95///
96/// assert_eq!(geom::left(geom::rect(4.0, 0.0, 10.0, 8.0)), 4.0);
97/// ```
98#[must_use]
99pub fn left(r: Rect) -> f32 {
100    r.x0 as f32
101}
102
103/// The rectangle's bottom edge.
104///
105/// ```
106/// use pdfrum_doc::geom;
107///
108/// assert_eq!(geom::bottom(geom::rect(4.0, 1.0, 10.0, 8.0)), 1.0);
109/// ```
110#[must_use]
111pub fn bottom(r: Rect) -> f32 {
112    r.y0 as f32
113}
114
115/// The rectangle's right edge.
116///
117/// ```
118/// use pdfrum_doc::geom;
119///
120/// assert_eq!(geom::right(geom::rect(4.0, 0.0, 10.0, 8.0)), 10.0);
121/// ```
122#[must_use]
123pub fn right(r: Rect) -> f32 {
124    r.x1 as f32
125}
126
127/// The rectangle's top edge.
128///
129/// ```
130/// use pdfrum_doc::geom;
131///
132/// assert_eq!(geom::top(geom::rect(4.0, 0.0, 10.0, 8.0)), 8.0);
133/// ```
134#[must_use]
135pub fn top(r: Rect) -> f32 {
136    r.y1 as f32
137}
138
139/// `right - left`, which is **negative** for an inverted rectangle.
140///
141/// ```
142/// use pdfrum_doc::geom;
143///
144/// assert_eq!(geom::width(geom::rect(4.0, 0.0, 10.0, 8.0)), 6.0);
145/// // Inverted: the subtraction is not corrected.
146/// assert_eq!(geom::width(geom::rect(10.0, 0.0, 4.0, 8.0)), -6.0);
147/// ```
148#[must_use]
149pub fn width(r: Rect) -> f32 {
150    right(r) - left(r)
151}
152
153/// `top - bottom`, which is **negative** for an inverted rectangle.
154///
155/// ```
156/// use pdfrum_doc::geom;
157///
158/// assert_eq!(geom::height(geom::rect(4.0, 0.0, 10.0, 8.0)), 8.0);
159/// assert_eq!(geom::height(geom::rect(4.0, 8.0, 10.0, 0.0)), -8.0);
160/// ```
161#[must_use]
162pub fn height(r: Rect) -> f32 {
163    top(r) - bottom(r)
164}
165
166/// Swaps whichever pairs of edges are out of order.
167///
168/// ```
169/// use pdfrum_doc::geom;
170///
171/// let inverted = geom::rect(10.0, 8.0, 4.0, 0.0);
172/// assert_eq!(geom::normalize(inverted), geom::rect(4.0, 0.0, 10.0, 8.0));
173/// ```
174#[must_use]
175pub fn normalize(r: Rect) -> Rect {
176    let (x0, x1) = if r.x0 > r.x1 {
177        (r.x1, r.x0)
178    } else {
179        (r.x0, r.x1)
180    };
181    let (y0, y1) = if r.y0 > r.y1 {
182        (r.y1, r.y0)
183    } else {
184        (r.y0, r.y1)
185    };
186    Rect::new(x0, y0, x1, y1)
187}
188
189/// Whether the rectangle encloses nothing — tested **without** normalizing,
190/// so an inverted rectangle is empty even though it has area.
191#[must_use]
192pub(crate) fn is_empty(r: Rect) -> bool {
193    r.x0 >= r.x1 || r.y0 >= r.y1
194}
195
196/// Grows the rectangle by `x` horizontally and `y` vertically, normalizing
197/// first. Negative amounts shrink it (see [`deflate`]).
198#[must_use]
199pub(crate) fn inflate(r: Rect, x: f32, y: f32) -> Rect {
200    let r = normalize(r);
201    let (x, y) = (f64::from(x), f64::from(y));
202    Rect::new(r.x0 - x, r.y0 - y, r.x1 + x, r.y1 + y)
203}
204
205/// Shrinks the rectangle, normalizing first. Exactly `inflate(-x, -y)`.
206#[must_use]
207pub(crate) fn deflate(r: Rect, x: f32, y: f32) -> Rect {
208    inflate(r, -x, -y)
209}
210
211/// The smallest rectangle containing both, each normalized first.
212#[must_use]
213pub(crate) fn union(a: Rect, b: Rect) -> Rect {
214    let (a, b) = (normalize(a), normalize(b));
215    Rect::new(
216        a.x0.min(b.x0),
217        a.y0.min(b.y0),
218        a.x1.max(b.x1),
219        a.y1.max(b.y1),
220    )
221}
222
223/// Whether the point falls inside, **inclusive on all four edges**, after
224/// normalizing a copy of the rectangle.
225///
226/// Only the tests ask: `nav::link`'s hit test is the one that used to, and it
227/// is itself `#[cfg(test)]` now.
228#[cfg(test)]
229#[must_use]
230pub(crate) fn contains(r: Rect, p: Point) -> bool {
231    let r = normalize(r);
232    p.x >= r.x0 && p.x <= r.x1 && p.y >= r.y0 && p.y <= r.y1
233}
234
235/// The largest centred square that fits: half-extent `min(w, h) / 2` about
236/// the centre.
237#[must_use]
238pub(crate) fn center_square(r: Rect) -> Rect {
239    let half = f64::from(width(r).abs().min(height(r).abs()) / 2.0);
240    let (cx, cy) = (f64::midpoint(r.x0, r.x1), f64::midpoint(r.y0, r.y1));
241    Rect::new(cx - half, cy - half, cx + half, cy + half)
242}
243
244/// Scales the half-extents about the centre.
245#[must_use]
246pub(crate) fn scale_from_center(r: Rect, scale: f32) -> Rect {
247    let scale = f64::from(scale);
248    let (cx, cy) = (f64::midpoint(r.x0, r.x1), f64::midpoint(r.y0, r.y1));
249    let (hw, hh) = ((r.x1 - r.x0) / 2.0 * scale, (r.y1 - r.y0) / 2.0 * scale);
250    Rect::new(cx - hw, cy - hh, cx + hw, cy + hh)
251}
252
253/// Moves the rectangle by `(dx, dy)` without normalizing.
254#[must_use]
255pub(crate) fn translate(r: Rect, dx: f32, dy: f32) -> Rect {
256    let (dx, dy) = (f64::from(dx), f64::from(dy));
257    Rect::new(r.x0 + dx, r.y0 + dy, r.x1 + dx, r.y1 + dy)
258}
259
260/// The scale-and-translate that maps `src` onto `dest`.
261///
262/// A degenerate axis (source extent under `0.001`) contributes a scale of 1
263/// rather than a division by nearly zero, which is how an appearance stream
264/// with a zero-width `/BBox` still lands somewhere sensible.
265///
266/// ```
267/// use pdfrum_doc::geom;
268///
269/// // The transform that places an appearance stream's `/BBox` into an
270/// // annotation's `/Rect`.
271/// let bbox = geom::rect(0.0, 0.0, 10.0, 20.0);
272/// let rect = geom::rect(100.0, 200.0, 120.0, 240.0);
273/// let placed = geom::transform_rect(geom::match_rect(rect, bbox), bbox);
274/// assert_eq!(placed, rect);
275/// ```
276#[must_use]
277pub fn match_rect(dest: Rect, src: Rect) -> Affine {
278    let a = if (src.x0 - src.x1).abs() < 0.001 {
279        1.0
280    } else {
281        (dest.x0 - dest.x1) / (src.x0 - src.x1)
282    };
283    let d = if (src.y0 - src.y1).abs() < 0.001 {
284        1.0
285    } else {
286        (dest.y0 - dest.y1) / (src.y0 - src.y1)
287    };
288    Affine::new([a, 0.0, 0.0, d, dest.x0 - src.x0 * a, dest.y0 - src.y0 * d])
289}
290
291/// Maps all four corners and returns their bounding box.
292///
293/// ```
294/// use pdfrum_doc::geom;
295///
296/// use kurbo::Affine;
297///
298/// // A quarter turn maps the corners; the answer is their bounding box.
299/// let turned = geom::transform_rect(Affine::rotate(std::f64::consts::FRAC_PI_2),
300///     geom::rect(0.0, 0.0, 10.0, 20.0));
301/// assert!((geom::width(turned) - 20.0).abs() < 1e-4);
302/// assert!((geom::height(turned) - 10.0).abs() < 1e-4);
303/// ```
304#[must_use]
305pub fn transform_rect(m: Affine, r: Rect) -> Rect {
306    let corners = [
307        m * Point::new(r.x0, r.y0),
308        m * Point::new(r.x1, r.y0),
309        m * Point::new(r.x0, r.y1),
310        m * Point::new(r.x1, r.y1),
311    ];
312    let mut out = Rect::new(corners[0].x, corners[0].y, corners[0].x, corners[0].y);
313    for c in &corners[1..] {
314        out = Rect::new(
315            out.x0.min(c.x),
316            out.y0.min(c.y),
317            out.x1.max(c.x),
318            out.y1.max(c.y),
319        );
320    }
321    out
322}
323
324/// A widget annotation's `/MK /R`, normalized to one of the four quadrants.
325///
326/// ISO 32000-1 Table 189 defines `/R` as "the number of degrees by which the
327/// widget annotation shall be rotated counterclockwise relative to the page",
328/// and says it "shall be a multiple of 90". Neither clause is a guarantee
329/// about real files, so [`WidgetRotation::from_degrees`] rules on both: a
330/// negative or out-of-range angle is the *same* quadrant it names modulo a
331/// full turn, and an angle that is not a multiple of 90 names no quadrant at
332/// all and is upright.
333///
334/// This is the one normalization both readers of the key share — the
335/// appearance builder, which needs the box, and `pdfrum-form`'s routing,
336/// which needs the map. They used to fold it separately and disagreed.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
338pub enum WidgetRotation {
339    /// Upright.
340    #[default]
341    None,
342    /// A quarter turn counterclockwise.
343    Quarter,
344    /// A half turn.
345    Half,
346    /// Three quarters counterclockwise.
347    ThreeQuarter,
348}
349
350impl WidgetRotation {
351    /// The quadrant `/MK /R n` names, or [`WidgetRotation::None`] when it
352    /// names none.
353    ///
354    /// The fold is `rem_euclid(360)`, so `-90` is `270` — a quarter turn
355    /// *clockwise* is three quarters counterclockwise, which is what a
356    /// counterclockwise angle of `-90` means. A value that is not a multiple
357    /// of 90 is upright.
358    ///
359    /// `[oracle-bug]` A negative multiple of 90 keeps its sign through the
360    /// fold, so `/R -90` draws and hit-tests as a three-quarter turn — the
361    /// specification's "counterclockwise" reading, and pdf.js's.
362    ///
363    /// ```
364    /// use pdfrum_doc::geom::WidgetRotation;
365    ///
366    /// assert_eq!(WidgetRotation::from_degrees(90), WidgetRotation::Quarter);
367    /// // `-90` counterclockwise is three quarters, and folds there.
368    /// assert_eq!(WidgetRotation::from_degrees(-90), WidgetRotation::ThreeQuarter);
369    /// // Not a multiple of 90: no quadrant at all.
370    /// assert_eq!(WidgetRotation::from_degrees(45), WidgetRotation::None);
371    /// ```
372    // [oracle-bug] PDFium folds with `abs(GetRotation() % 360)`
373    // (fpdfsdk/cpdfsdk_widget.cpp:1029 in GetRotatedRect, :1049 in
374    // GetMatrix), sending -90 to 90 — the wrong direction. The two agree on
375    // the *box*, since 90 and 270 swap the same axes, but GetMatrix builds
376    // CFX_Matrix(0, 1, -1, 0, fWidth, 0) for 90 and CFX_Matrix(0, -1, 1, 0,
377    // 0, fHeight) for 270 (:1053-1062), so a /R -90 widget lands a half turn
378    // away from where the file asked. pdf.js is the tiebreaker and
379    // normalizes the way this does — `angle %= 360; if (angle < 0) { angle
380    // += 360; }`, then `if (angle % 90 === 0)` before it is kept
381    // (src/core/annotation.js, WidgetAnnotation.setRotation).
382    //
383    // The non-multiple of 90 is not a divergence: the `default:` arm on both
384    // of the oracle's switches falls through to the 0/180 case, and pdf.js's
385    // `angle % 90 === 0` gate leaves this.rotation at its initialized 0.
386    #[must_use]
387    pub fn from_degrees(degrees: i64) -> WidgetRotation {
388        match degrees.rem_euclid(360) {
389            90 => WidgetRotation::Quarter,
390            180 => WidgetRotation::Half,
391            270 => WidgetRotation::ThreeQuarter,
392            _ => WidgetRotation::None,
393        }
394    }
395
396    /// Whether this rotation exchanges the widget's width and height.
397    ///
398    /// ```
399    /// use pdfrum_doc::geom::WidgetRotation;
400    ///
401    /// assert!(WidgetRotation::Quarter.swaps_axes());
402    /// assert!(!WidgetRotation::Half.swaps_axes());
403    /// ```
404    #[must_use]
405    pub fn swaps_axes(self) -> bool {
406        matches!(self, WidgetRotation::Quarter | WidgetRotation::ThreeQuarter)
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::WidgetRotation;
413    use super::{
414        contains, deflate, height, inflate, is_empty, is_float_bigger, is_float_smaller,
415        is_float_zero, match_rect, normalize, union, width,
416    };
417    use kurbo::{Point, Rect};
418
419    #[test]
420    fn is_empty_does_not_normalize_but_deflate_does() {
421        let inverted = Rect::new(10.0, 0.0, 4.0, 8.0);
422        assert!(is_empty(inverted));
423        assert!((width(inverted) - -6.0).abs() < f32::EPSILON);
424        assert!((height(inverted) - 8.0).abs() < f32::EPSILON);
425        // Normalized to (4, 0, 10, 8), then shrunk by one on each side.
426        assert_eq!(deflate(inverted, 1.0, 1.0), Rect::new(5.0, 1.0, 9.0, 7.0));
427        assert_eq!(inflate(inverted, 1.0, 0.0), Rect::new(3.0, 0.0, 11.0, 8.0));
428    }
429
430    #[test]
431    fn contains_is_inclusive_on_every_edge() {
432        let r = Rect::new(0.0, 0.0, 10.0, 10.0);
433        for p in [
434            Point::new(0.0, 0.0),
435            Point::new(10.0, 10.0),
436            Point::new(0.0, 5.0),
437            Point::new(5.0, 10.0),
438        ] {
439            assert!(contains(r, p), "{p:?}");
440        }
441        assert!(!contains(r, Point::new(-0.001, 5.0)));
442        // An inverted rectangle is normalized for the test.
443        assert!(contains(
444            Rect::new(10.0, 10.0, 0.0, 0.0),
445            Point::new(5.0, 5.0)
446        ));
447    }
448
449    #[test]
450    fn union_normalizes_both_operands() {
451        let a = Rect::new(4.0, 5.0, 2.0, 3.0);
452        let b = Rect::new(4.0, 3.0, 6.0, 5.0);
453        assert_eq!(union(a, b), Rect::new(2.0, 3.0, 6.0, 5.0));
454        assert_eq!(normalize(a), Rect::new(2.0, 3.0, 4.0, 5.0));
455    }
456
457    #[test]
458    fn epsilon_comparisons_use_double_precision() {
459        assert!(is_float_zero(5e-5));
460        assert!(!is_float_zero(2e-4));
461        assert!(is_float_bigger(1.0, 0.5));
462        assert!(!is_float_bigger(1.0, 1.0 - 5e-5));
463        assert!(is_float_smaller(0.5, 1.0));
464        assert!(!is_float_smaller(1.0 - 5e-5, 1.0));
465    }
466
467    #[test]
468    fn a_negative_quarter_turn_is_three_quarters_counterclockwise() {
469        // ISO 32000-1 table 189: `/R` counts degrees *counterclockwise*, so
470        // `-90` is a quarter turn clockwise, which is `270`. PDFium's
471        // `abs()` answers `90` — see the `[oracle-bug]` note on
472        // `from_degrees`.
473        assert_eq!(
474            WidgetRotation::from_degrees(-90),
475            WidgetRotation::ThreeQuarter
476        );
477        assert_eq!(WidgetRotation::from_degrees(-270), WidgetRotation::Quarter);
478        assert_eq!(WidgetRotation::from_degrees(-180), WidgetRotation::Half);
479        assert_eq!(WidgetRotation::from_degrees(-360), WidgetRotation::None);
480    }
481
482    #[test]
483    fn a_rotation_that_is_not_a_quarter_turn_is_upright() {
484        // Both readers agree here: PDFium's `default:` falls through to the
485        // 0/180 case and pdf.js's `angle % 90 === 0` gate leaves the angle
486        // at zero.
487        for degrees in [45, -45, 1, 359, 91, 100_000] {
488            assert_eq!(
489                WidgetRotation::from_degrees(degrees),
490                WidgetRotation::None,
491                "{degrees}"
492            );
493        }
494    }
495
496    #[test]
497    fn a_full_turn_folds_away() {
498        assert_eq!(WidgetRotation::from_degrees(450), WidgetRotation::Quarter);
499        assert_eq!(
500            WidgetRotation::from_degrees(-450),
501            WidgetRotation::ThreeQuarter
502        );
503        assert_eq!(WidgetRotation::from_degrees(720), WidgetRotation::None);
504    }
505
506    #[test]
507    fn only_the_odd_quadrants_swap_the_axes() {
508        assert!(!WidgetRotation::None.swaps_axes());
509        assert!(WidgetRotation::Quarter.swaps_axes());
510        assert!(!WidgetRotation::Half.swaps_axes());
511        assert!(WidgetRotation::ThreeQuarter.swaps_axes());
512    }
513
514    #[test]
515    fn match_rect_keeps_a_degenerate_axis_at_unit_scale() {
516        let m = match_rect(
517            Rect::new(0.0, 0.0, 20.0, 10.0),
518            Rect::new(0.0, 0.0, 10.0, 0.0),
519        );
520        let c = m.as_coeffs();
521        assert!((c[0] - 2.0).abs() < 1e-9);
522        assert!((c[3] - 1.0).abs() < 1e-9);
523    }
524}