Skip to main content

pdfrum_page/shading/
axial.rs

1//! Type 2: axial shadings (ISO 32000-1 §8.7.4.5.3).
2//!
3//! A linear gradient between two points. The parametric position of a point
4//! is its projection onto the axis, normalized by the axis's squared length —
5//! which is why a **zero-length axis** divides by zero. The C++ leaves that
6//! unguarded and then casts the resulting infinity or NaN to an integer,
7//! which is undefined behaviour; we treat it as "every pixel is out of range
8//! at the start end", the branch x86 actually lands in, and record a
9//! diagnostic.
10
11use super::{read_domain, read_extend};
12use crate::names;
13use kurbo::Point;
14use pdfrum_object::{Dict, Resolve};
15
16/// A type 2 shading's geometry.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct Axial {
19    /// The axis's start point.
20    pub start: Point,
21    /// The axis's end point.
22    pub end: Point,
23    /// `/Domain`'s low bound.
24    pub t_min: f32,
25    /// `/Domain`'s high bound.
26    pub t_max: f32,
27    /// Whether the gradient continues past the start point.
28    pub extend_start: bool,
29    /// Whether it continues past the end point.
30    pub extend_end: bool,
31}
32
33impl Axial {
34    /// Load from a shading dictionary.
35    ///
36    /// `/Coords` is required, but its **length is not checked**: a short
37    /// array simply reads zeros for what it does not state.
38    pub(super) fn load(dict: &Dict, r: &impl Resolve) -> Option<Self> {
39        let coords = dict.array(names::COORDS, r)?;
40        let at = |i: usize| f64::from(coords.number_at_or_zero(i));
41        let (t_min, t_max) = read_domain(dict, r);
42        let (extend_start, extend_end) = read_extend(dict, r);
43        Some(Self {
44            start: Point::new(at(0), at(1)),
45            end: Point::new(at(2), at(3)),
46            t_min,
47            t_max,
48            extend_start,
49            extend_end,
50        })
51    }
52
53    /// The squared axis length, which is the normalizing divisor.
54    ///
55    /// Zero for a degenerate axis, which is the case the caller must handle.
56    #[must_use]
57    pub fn axis_len_squared(&self) -> f64 {
58        let dx = self.end.x - self.start.x;
59        let dy = self.end.y - self.start.y;
60        dx * dx + dy * dy
61    }
62
63    /// The parametric position of a point in the shading's own space.
64    ///
65    /// `None` for a degenerate axis, where the C++ divides by zero. A caller
66    /// treating that as "out of range at the start" reproduces what the
67    /// undefined cast produces in practice.
68    #[must_use]
69    pub fn position(&self, p: Point) -> Option<f32> {
70        let len_sq = self.axis_len_squared();
71        if len_sq == 0.0 {
72            return None;
73        }
74        let dx = self.end.x - self.start.x;
75        let dy = self.end.y - self.start.y;
76        let scale = ((p.x - self.start.x) * dx + (p.y - self.start.y) * dy) / len_sq;
77        #[expect(
78            clippy::cast_possible_truncation,
79            reason = "the shading LUT indexes with f32 throughout, matching the C++"
80        )]
81        Some(scale as f32)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    // Test fixtures quote the oracle's own vectors, compare floats exactly
88    // where the behaviour being pinned is exact, and index arrays whose
89    // length the fixture itself fixes.
90    #![allow(
91        clippy::unreadable_literal,
92        clippy::float_cmp,
93        clippy::indexing_slicing,
94        clippy::cast_precision_loss,
95        clippy::cast_possible_truncation,
96        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
97    )]
98
99    use super::Axial;
100    use kurbo::Point;
101
102    fn horizontal() -> Axial {
103        Axial {
104            start: Point::new(0.0, 0.0),
105            end: Point::new(10.0, 0.0),
106            t_min: 0.0,
107            t_max: 1.0,
108            extend_start: false,
109            extend_end: false,
110        }
111    }
112
113    #[test]
114    fn the_position_is_the_normalized_projection() {
115        let a = horizontal();
116        assert!(
117            a.position(Point::new(0.0, 0.0))
118                .is_some_and(|v| v.abs() < 1e-6)
119        );
120        assert!(
121            a.position(Point::new(5.0, 99.0))
122                .is_some_and(|v| (v - 0.5).abs() < 1e-6),
123            "the off-axis component does not matter"
124        );
125        assert!(
126            a.position(Point::new(10.0, 0.0))
127                .is_some_and(|v| (v - 1.0).abs() < 1e-6)
128        );
129        // Beyond the ends the position simply keeps going.
130        assert!(a.position(Point::new(20.0, 0.0)).is_some_and(|v| v > 1.9));
131        assert!(a.position(Point::new(-10.0, 0.0)).is_some_and(|v| v < -0.9));
132    }
133
134    #[test]
135    fn a_degenerate_axis_has_no_position() {
136        let a = Axial {
137            start: Point::new(3.0, 4.0),
138            end: Point::new(3.0, 4.0),
139            ..horizontal()
140        };
141        assert_eq!(a.axis_len_squared(), 0.0);
142        assert!(a.position(Point::new(0.0, 0.0)).is_none());
143    }
144}