rasterrocket_render/path/adjust.rs
1//! Stroke-adjust hint application.
2//!
3//! Mirrors `SplashXPathAdjust` and `SplashXPath::strokeAdjust()` from
4//! `splash/SplashXPath.cc`.
5//!
6//! ## What stroke adjustment does
7//!
8//! PDF stroke adjustment snaps near-axis-aligned path segments to integer
9//! pixel boundaries so that adjacent stroked rectangles share a pixel edge
10//! rather than leaving a half-pixel gap. This is controlled by the PDF
11//! `strokeAdjust` graphics state parameter.
12//!
13//! Each [`XPathAdjust`] describes three "snap windows" (around x0, xm, x1)
14//! with ±0.01 tolerance. Any transformed coordinate that falls **strictly
15//! inside** a window is replaced by the corresponding snapped target.
16//!
17//! ## Open-interval semantics
18//!
19//! The snap windows are **open** intervals: a coordinate exactly at a boundary
20//! value (e.g. `v == adj0 - 0.01`) is **not** snapped. See [`stroke_adjust`].
21
22use crate::types::splash_round;
23
24/// An axis-aligned stroke-adjust hint, derived from a `StrokeAdjustHint`
25/// (see `crate::path::stroke`) after path transformation.
26///
27/// Matches `SplashXPathAdjust` in `splash/SplashXPath.cc`.
28///
29/// ## Snap-window fields
30///
31/// The three snap windows are open intervals; a coordinate `v` is replaced by
32/// the target only when `v ∈ (xa, xb)` (strictly inside):
33///
34/// | Fields | Meaning |
35/// |--------|---------|
36/// | `x0a`, `x0b`, `x0` | Left snap window: coordinate in `(x0a, x0b)` snaps to `x0`. |
37/// | `xma`, `xmb`, `xm` | Mid snap window: coordinate in `(xma, xmb)` snaps to `xm`. |
38/// | `x1a`, `x1b`, `x1` | Right snap window: coordinate in `(x1a, x1b)` snaps to `x1`. |
39#[derive(Clone, Debug)]
40pub struct XPathAdjust {
41 /// First path-point index (inclusive) in the range to adjust.
42 pub first_pt: usize,
43 /// Last path-point index (inclusive) in the range to adjust.
44 pub last_pt: usize,
45 /// `true` → adjust the x-coordinate; `false` → adjust the y-coordinate.
46 pub vert: bool,
47 /// Left snap window lower bound (`adj0 - 0.01`).
48 pub x0a: f64,
49 /// Left snap window upper bound (`adj0 + 0.01`).
50 pub x0b: f64,
51 /// Left snap target (rounded `adj0`).
52 pub x0: f64,
53 /// Mid snap window lower bound (`mid - 0.01`).
54 pub xma: f64,
55 /// Mid snap window upper bound (`mid + 0.01`).
56 pub xmb: f64,
57 /// Mid snap target (midpoint of rounded endpoints).
58 pub xm: f64,
59 /// Right snap window lower bound (`adj1 - 0.01`).
60 pub x1a: f64,
61 /// Right snap window upper bound (`adj1 + 0.01`).
62 pub x1b: f64,
63 /// Right snap target (`rounded_adj1 - 0.01`).
64 pub x1: f64,
65}
66
67impl XPathAdjust {
68 /// Construct an adjust record for two endpoint values `adj0 ≤ adj1`.
69 ///
70 /// # Precondition
71 ///
72 /// `adj0 <= adj1`. The caller in `build_adjusts` in `xpath.rs` guarantees
73 /// this by passing `min`/`max`-reduced values.
74 ///
75 /// `adjust_lines` and `line_pos_i` implement the same special-case logic as
76 /// in `SplashXPath` constructor: when the two rounded endpoints coincide and
77 /// `adjust_lines` is true, the span is expanded to
78 /// `[line_pos_i, line_pos_i + 1]`.
79 #[must_use]
80 pub fn new(
81 first_pt: usize,
82 last_pt: usize,
83 vert: bool,
84 adj0: f64,
85 adj1: f64,
86 adjust_lines: bool,
87 line_pos_i: i32,
88 ) -> Self {
89 debug_assert!(adj0 <= adj1, "adj0 must be <= adj1");
90
91 let mid = adj0.midpoint(adj1);
92 let mut r0 = f64::from(splash_round(adj0));
93 let mut r1 = f64::from(splash_round(adj1));
94 // Both `r0` and `r1` are exact f64 representations of i32 values
95 // (from `splash_round`, which returns i32). Every i32 is representable
96 // exactly as f64, so bit-exact comparison correctly detects when both
97 // endpoints round to the same integer — no floating-point imprecision
98 // can produce a false positive or false negative here.
99 if r0.to_bits() == r1.to_bits() {
100 if adjust_lines {
101 r0 = f64::from(line_pos_i);
102 r1 = f64::from(line_pos_i) + 1.0;
103 } else {
104 r1 += 1.0;
105 }
106 }
107 Self {
108 first_pt,
109 last_pt,
110 vert,
111 x0a: adj0 - 0.01,
112 x0b: adj0 + 0.01,
113 x0: r0,
114 xma: mid - 0.01,
115 xmb: mid + 0.01,
116 xm: r0.midpoint(r1),
117 x1a: adj1 - 0.01,
118 x1b: adj1 + 0.01,
119 x1: r1 - 0.01,
120 }
121 }
122}
123
124/// Apply a stroke-adjust hint to a single point `(x, y)`.
125///
126/// If `adj.vert` is true, the x-coordinate is examined; otherwise the
127/// y-coordinate. If the relevant coordinate falls **strictly inside** any of
128/// the three snap windows (open intervals), it is replaced by the corresponding
129/// snapped target.
130///
131/// ## Open-interval semantics
132///
133/// Windows are open intervals; boundary values are **not** snapped. For
134/// example, a coordinate equal to exactly `adj.x0a` (= `adj0 - 0.01`) is
135/// outside the window `(x0a, x0b)` and will not be snapped.
136///
137/// Matches `SplashXPath::strokeAdjust()` in `SplashXPath.cc`.
138#[inline]
139pub fn stroke_adjust(adj: &XPathAdjust, x: &mut f64, y: &mut f64) {
140 let v = if adj.vert { x } else { y };
141 if *v > adj.x0a && *v < adj.x0b {
142 *v = adj.x0;
143 } else if *v > adj.xma && *v < adj.xmb {
144 *v = adj.xm;
145 } else if *v > adj.x1a && *v < adj.x1b {
146 *v = adj.x1;
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn snaps_to_x0() {
156 let adj = XPathAdjust::new(0, 1, true, 1.0, 3.0, false, 0);
157 let mut x = 1.005;
158 let mut y = 0.0;
159 stroke_adjust(&adj, &mut x, &mut y);
160 assert!((x - 1.0).abs() < 1e-10, "x={x}");
161 }
162
163 #[test]
164 fn snaps_to_xm() {
165 let adj = XPathAdjust::new(0, 1, true, 1.0, 3.0, false, 0);
166 let mut x = 2.0; // midpoint of [1,3]
167 let mut y = 0.0;
168 stroke_adjust(&adj, &mut x, &mut y);
169 assert!((x - 2.0).abs() < 1e-10, "x={x}"); // xm = (1+3)/2 = 2.0
170 }
171
172 #[test]
173 fn snaps_to_x1() {
174 let adj = XPathAdjust::new(0, 1, true, 1.0, 3.0, false, 0);
175 let mut x = 3.005;
176 let mut y = 0.0;
177 stroke_adjust(&adj, &mut x, &mut y);
178 // x1 = 3.0 - 0.01 = 2.99
179 assert!((x - 2.99).abs() < 1e-10, "x={x}");
180 }
181
182 #[test]
183 fn no_snap_outside_windows() {
184 let adj = XPathAdjust::new(0, 1, true, 1.0, 3.0, false, 0);
185 let mut x = 5.0;
186 let mut y = 0.0;
187 stroke_adjust(&adj, &mut x, &mut y);
188 assert!((x - 5.0).abs() < 1e-10);
189 }
190
191 #[test]
192 fn horizontal_adjusts_y() {
193 let adj = XPathAdjust::new(0, 1, false, 2.0, 4.0, false, 0);
194 let mut x = 0.0;
195 let mut y = 2.005;
196 stroke_adjust(&adj, &mut x, &mut y);
197 assert!((y - 2.0).abs() < 1e-10, "y={y}");
198 assert!((x - 0.0).abs() < 1e-10); // x unchanged
199 }
200
201 #[test]
202 fn snaps_only_within_open_interval() {
203 // A coordinate exactly on the window boundary must NOT be snapped,
204 // because the windows are open intervals: v ∈ (x0a, x0b), not [x0a, x0b].
205 let adj = XPathAdjust::new(0, 1, true, 1.0, 3.0, false, 0);
206 // x0a = adj0 - 0.01 = 0.99 — exactly on the boundary, must not snap.
207 let boundary = adj.x0a;
208 let mut x = boundary;
209 let mut y = 0.0;
210 stroke_adjust(&adj, &mut x, &mut y);
211 assert!(
212 (x - boundary).abs() < 1e-15,
213 "coordinate exactly at x0a boundary must not be snapped, got x={x}"
214 );
215
216 // Similarly for x0b = adj0 + 0.01 = 1.01.
217 let mut x = adj.x0b;
218 let boundary_b = adj.x0b;
219 stroke_adjust(&adj, &mut x, &mut y);
220 assert!(
221 (x - boundary_b).abs() < 1e-15,
222 "coordinate exactly at x0b boundary must not be snapped, got x={x}"
223 );
224 }
225}