Skip to main content

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}