pdfrum_page/shading/radial.rs
1//! Type 3: radial shadings (ISO 32000-1 §8.7.4.5.4).
2//!
3//! A gradient between two circles. Finding a pixel's parametric position
4//! means solving a quadratic, and the C++'s solver has **three branches with
5//! different amounts of guarding** — a fact worth stating plainly, because
6//! two of them are visibly under-guarded and both are ported as they are:
7//!
8//! | Branch | Condition | Guards |
9//! |---|---|---|
10//! | 1 | `b` ≈ 0 | **none at all**: no discriminant check, no root selection, no radius check, and no `a == 0` guard, so `sqrt(-c/a)` may be NaN |
11//! | 2 | `a` ≈ 0 | linear, `-c/b`; **no radius check** |
12//! | 3 | otherwise | discriminant check, root selection by direction, and a negative-radius check |
13//!
14//! The "decreasing" test that picks between the roots truncates a hypotenuse
15//! to an integer before comparing it, which is its own small quirk.
16
17// The quadratic's coefficients are `a`, `b` and `c`, and the deltas `dx`,
18// `dy`, `dr`; these are the names ISO 32000-1 §8.7.4.5.4 and every derivation
19// of it use.
20#![expect(
21 clippy::many_single_char_names,
22 reason = "quadratic coefficients are single-letter by convention"
23)]
24
25use super::{read_domain, read_extend};
26use crate::names;
27use kurbo::Point;
28use pdfrum_object::{Dict, Resolve};
29
30/// A type 3 shading's geometry.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct Radial {
33 /// The first circle's centre.
34 pub start: Point,
35 /// The first circle's radius.
36 pub start_radius: f32,
37 /// The second circle's centre.
38 pub end: Point,
39 /// The second circle's radius.
40 pub end_radius: f32,
41 /// `/Domain`'s low bound.
42 pub t_min: f32,
43 /// `/Domain`'s high bound.
44 pub t_max: f32,
45 /// Whether the gradient continues past the first circle.
46 pub extend_start: bool,
47 /// Whether it continues past the second.
48 pub extend_end: bool,
49}
50
51/// What the quadratic solver produced for one point.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub enum RadialPosition {
54 /// A parametric position along the gradient.
55 At(f32),
56 /// The pixel is not covered: the discriminant was negative, or the
57 /// interpolated radius would be.
58 Uncovered,
59}
60
61impl Radial {
62 /// Load from a shading dictionary. `/Coords` is six numbers, again with
63 /// no length check.
64 pub(super) fn load(dict: &Dict, r: &impl Resolve) -> Option<Self> {
65 let coords = dict.array(names::COORDS, r)?;
66 let at = |i: usize| coords.number_at_or_zero(i);
67 let (t_min, t_max) = read_domain(dict, r);
68 let (extend_start, extend_end) = read_extend(dict, r);
69 Some(Self {
70 start: Point::new(f64::from(at(0)), f64::from(at(1))),
71 start_radius: at(2),
72 end: Point::new(f64::from(at(3)), f64::from(at(4))),
73 end_radius: at(5),
74 t_min,
75 t_max,
76 extend_start,
77 extend_end,
78 })
79 }
80
81 /// Whether the circles shrink as the gradient advances, which decides
82 /// which root of the quadratic to prefer.
83 ///
84 /// The centre distance is **truncated to an integer** before the
85 /// comparison, so a shading whose centres are 3.9 units apart with
86 /// `dr == -3.5` counts as decreasing while one with `dr == -3.0` does
87 /// not. Reproduced.
88 #[must_use]
89 pub fn is_decreasing(&self) -> bool {
90 let dx = self.end.x - self.start.x;
91 let dy = self.end.y - self.start.y;
92 let dr = self.end_radius - self.start_radius;
93 #[expect(
94 clippy::cast_possible_truncation,
95 reason = "the integer truncation of the hypotenuse is the quirk being ported"
96 )]
97 let hypot = dx.hypot(dy) as i32;
98 dr < 0.0 && f64::from(hypot) < f64::from(-dr)
99 }
100
101 /// The parametric position of a point, or that it is uncovered.
102 #[must_use]
103 #[expect(
104 clippy::cast_possible_truncation,
105 reason = "the C++ solves this quadratic in f32 throughout, and the \
106 branch selection is sensitive to that precision"
107 )]
108 pub fn position(&self, p: Point) -> RadialPosition {
109 let dx = (self.end.x - self.start.x) as f32;
110 let dy = (self.end.y - self.start.y) as f32;
111 let dr = self.end_radius - self.start_radius;
112 let a = dx * dx + dy * dy - dr * dr;
113 let a_is_zero = is_float_zero(a);
114
115 let pdx = (p.x - self.start.x) as f32;
116 let pdy = (p.y - self.start.y) as f32;
117 let b = -2.0 * (pdx * dx + pdy * dy + self.start_radius * dr);
118 let c = pdx * pdx + pdy * pdy - self.start_radius * self.start_radius;
119
120 // Branch 1: no guards whatsoever, so this may well be NaN.
121 if is_float_zero(b) {
122 return RadialPosition::At((-c / a).sqrt());
123 }
124 // Branch 2: linear, and with no radius check.
125 if a_is_zero {
126 return RadialPosition::At(-c / b);
127 }
128 // Branch 3: the fully guarded one.
129 let disc = b * b - 4.0 * a * c;
130 if disc < 0.0 {
131 return RadialPosition::Uncovered;
132 }
133 let root = disc.sqrt();
134 let (mut s1, mut s2) = ((-b - root) / (2.0 * a), (-b + root) / (2.0 * a));
135 if a <= 0.0 {
136 // Ensure `s1 <= s2`.
137 std::mem::swap(&mut s1, &mut s2);
138 }
139 let s = if self.is_decreasing() {
140 if s1 >= 0.0 || self.extend_start {
141 s1
142 } else {
143 s2
144 }
145 } else if s2 <= 1.0 || self.extend_end {
146 s2
147 } else {
148 s1
149 };
150 // A negative interpolated radius means the pixel is outside both
151 // circles' sweep.
152 if self.start_radius + s * dr < 0.0 {
153 return RadialPosition::Uncovered;
154 }
155 RadialPosition::At(s)
156 }
157}
158
159/// Whether `v` is zero to the shading tolerance: `|v| < 1e-4`.
160///
161/// A **fixed 1e-4 tolerance**, not a machine epsilon — roughly 840 times
162/// wider than `f32::EPSILON`. `a` is `dx² + dy² - dr²`, a catastrophic
163/// cancellation whenever the start point sits on the end circle, and the two
164/// tolerances then disagree about which branch runs: the linear `a == 0` one,
165/// which has no negative-radius skip, or the quadratic one, which does.
166/// `radial_shading_point_at_border` lands in exactly that gap at `a ≈ 2.4e-7`.
167///
168/// The comparison widens to `f64` before the test, so a `f32` operand is not
169/// rounded against a `f32` bound.
170// The oracle's `FXSYS_IsFloatZero` (`fx_system.h:36`),
171// `(f) < 0.0001 && (f) > -0.0001`: its operand is a `float` but the literals
172// are `double`, so the comparison happens in `double` there too.
173fn is_float_zero(v: f32) -> bool {
174 f64::from(v).abs() < FLOAT_ZERO
175}
176
177/// The `FXSYS_IsFloatZero` tolerance.
178const FLOAT_ZERO: f64 = 1e-4;
179
180#[cfg(test)]
181mod tests {
182 // Test fixtures quote the oracle's own vectors, compare floats exactly
183 // where the behaviour being pinned is exact, and index arrays whose
184 // length the fixture itself fixes.
185 #![allow(
186 clippy::unreadable_literal,
187 clippy::float_cmp,
188 clippy::indexing_slicing,
189 clippy::cast_precision_loss,
190 clippy::cast_possible_truncation,
191 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
192 )]
193
194 use super::{Radial, RadialPosition};
195 use kurbo::Point;
196
197 fn concentric() -> Radial {
198 Radial {
199 start: Point::new(0.0, 0.0),
200 start_radius: 0.0,
201 end: Point::new(0.0, 0.0),
202 end_radius: 10.0,
203 t_min: 0.0,
204 t_max: 1.0,
205 extend_start: false,
206 extend_end: false,
207 }
208 }
209
210 #[test]
211 fn a_concentric_gradient_maps_radius_to_position() {
212 let s = concentric();
213 // At the centre `b` is zero, so branch 1 runs.
214 let RadialPosition::At(v) = s.position(Point::new(0.0, 0.0)) else {
215 panic!("the centre should have a position");
216 };
217 assert!(v.abs() < 1e-6, "got {v}");
218 // Halfway out.
219 let RadialPosition::At(v) = s.position(Point::new(5.0, 0.0)) else {
220 panic!("expected a position");
221 };
222 assert!((v - 0.5).abs() < 1e-5, "got {v}");
223 }
224
225 #[test]
226 fn the_linear_branch_runs_when_a_is_zero() {
227 // Equal radii and coincident centres make `a` zero for a translated
228 // gradient: dx² + dy² == dr².
229 let s = Radial {
230 start: Point::new(0.0, 0.0),
231 start_radius: 0.0,
232 end: Point::new(3.0, 4.0),
233 end_radius: 5.0,
234 ..concentric()
235 };
236 // `a = 9 + 16 - 25 = 0`.
237 let RadialPosition::At(v) = s.position(Point::new(1.0, 1.0)) else {
238 panic!("expected a position");
239 };
240 assert!(v.is_finite(), "got {v}");
241 }
242
243 #[test]
244 fn an_uncovered_pixel_is_reported_rather_than_clamped() {
245 // Two separated circles leave points outside their sweep uncovered.
246 let s = Radial {
247 start: Point::new(0.0, 0.0),
248 start_radius: 1.0,
249 end: Point::new(100.0, 0.0),
250 end_radius: 1.0,
251 ..concentric()
252 };
253 assert_eq!(
254 s.position(Point::new(50.0, 500.0)),
255 RadialPosition::Uncovered
256 );
257 }
258
259 #[test]
260 fn the_decreasing_test_truncates_the_centre_distance() {
261 // Centres 3.9 apart with dr = −3.5: the truncated hypotenuse is 3,
262 // which is below 3.5, so the shading counts as decreasing.
263 let s = Radial {
264 start: Point::new(0.0, 0.0),
265 start_radius: 4.0,
266 end: Point::new(3.9, 0.0),
267 end_radius: 0.5,
268 ..concentric()
269 };
270 assert!(s.is_decreasing());
271 // With dr = −3.0 the truncated 3 is not below 3, so it is not.
272 let s = Radial {
273 end_radius: 1.0,
274 ..s
275 };
276 assert!(!s.is_decreasing());
277 }
278}