Skip to main content

rasterrocket_render/stroke/
mod.rs

1//! Stroke rasterization.
2//!
3//! Public entry point is [`stroke`]; the helpers (`stroke_narrow`,
4//! `stroke_wide`, and the path-shaping functions in the private `path`
5//! submodule) are crate-internal implementation details.
6//!
7//! Algorithmic provenance: the splitting of stroking into flatten → dash →
8//! narrow/wide → outline-expansion mirrors `Splash::stroke` and friends in
9//! `splash/Splash.cc`; refer there for the spec interpretation if a stroke
10//! edge case looks wrong.
11
12mod path;
13use path::{flatten_path, make_dashed_path, make_stroke_path};
14
15use crate::bitmap::Bitmap;
16use crate::clip::{Clip, ClipResult};
17use crate::fill::fill;
18use crate::path::Path;
19use crate::pipe::{PipeSrc, PipeState};
20use crate::types::{LineCap, LineJoin, splash_floor};
21use crate::xpath::XPath;
22use color::Pixel;
23
24// ── Public API ────────────────────────────────────────────────────────────────
25
26/// Parameters governing how a stroke is rendered.
27///
28/// All fields mirror the correspondingly named fields on the C++ `SplashState`.
29pub struct StrokeParams<'a> {
30    /// Stroke line width in user space.
31    pub line_width: f64,
32    /// Line cap style (Butt / Round / Projecting).
33    pub line_cap: LineCap,
34    /// Line join style (Miter / Round / Bevel).
35    pub line_join: LineJoin,
36    /// Miter limit (dimensionless ratio).
37    pub miter_limit: f64,
38    /// Flatness for curve flattening (maximum chord deviation, device pixels).
39    pub flatness: f64,
40    /// Whether stroke adjustment is enabled.
41    pub stroke_adjust: bool,
42    /// Dash array; empty slice means solid line.
43    pub line_dash: &'a [f64],
44    /// Phase offset into the dash array.
45    pub line_dash_phase: f64,
46    /// Whether vector anti-aliasing is requested.
47    pub vector_antialias: bool,
48}
49
50/// Top-level stroke entry point.
51///
52/// Mirrors `Splash::stroke`.
53///
54/// Steps:
55/// 1. Flatten curves.
56/// 2. Apply dashing if a dash array is set.
57/// 3. Choose hairline (zero line-width) or wide rendering.
58pub fn stroke<P: Pixel>(
59    bitmap: &mut Bitmap<P>,
60    clip: &Clip,
61    path: &Path,
62    pipe: &PipeState<'_>,
63    src: &PipeSrc<'_>,
64    matrix: &[f64; 6],
65    params: &StrokeParams<'_>,
66) {
67    if path.pts.is_empty() {
68        return;
69    }
70
71    let mut path2 = flatten_path(path, matrix, params.flatness);
72
73    if !params.line_dash.is_empty() {
74        path2 = make_dashed_path(&path2, params.line_dash, params.line_dash_phase);
75        if path2.pts.is_empty() {
76            return;
77        }
78    }
79
80    // Compute the approximate device-space line width by transforming a unit
81    // square and taking half the maximum diagonal length. This mirrors the C++
82    // computation in `Splash::stroke` that decides narrow vs wide.
83    // (We skip the minLineWidth check as instructed.)
84    if params.line_width == 0.0 {
85        stroke_narrow::<P>(bitmap, clip, &path2, pipe, src, matrix, params.flatness);
86    } else {
87        stroke_wide::<P>(bitmap, clip, &path2, pipe, src, matrix, params);
88    }
89}
90
91/// Draw zero-width (hairline) strokes: one pixel per scanline intersection.
92///
93/// Each segment of the flattened [`XPath`] is walked scanline-by-scanline,
94/// drawing a single span (possibly one pixel wide) per row.
95fn stroke_narrow<P: Pixel>(
96    bitmap: &mut Bitmap<P>,
97    clip: &Clip,
98    path: &Path,
99    pipe: &PipeState<'_>,
100    src: &PipeSrc<'_>,
101    matrix: &[f64; 6],
102    flatness: f64,
103) {
104    // Build a non-closing XPath (no implicit close for hairlines).
105    let xpath = XPath::new(path, matrix, flatness, false);
106
107    for seg in &xpath.segs {
108        // Orient so that y0 <= y1.
109        let (sx0, sy0, sx1, sy1) = if seg.y0 <= seg.y1 {
110            (seg.x0, seg.y0, seg.x1, seg.y1)
111        } else {
112            (seg.x1, seg.y1, seg.x0, seg.y0)
113        };
114
115        let y0 = splash_floor(sy0);
116        let y1 = splash_floor(sy1);
117        let x0 = splash_floor(sx0);
118        let x1 = splash_floor(sx1);
119
120        let (xl, xr) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
121        let clip_res = clip.test_rect(xl, y0, xr, y1);
122        if clip_res == ClipResult::AllOutside {
123            continue;
124        }
125
126        if y0 == y1 {
127            // Horizontal or near-horizontal: draw one span.
128            let (span_x0, span_x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
129            draw_narrow_span::<P>(bitmap, clip, pipe, src, span_x0, span_x1, y0, clip_res);
130        } else {
131            // Sloped segment: walk scanlines.
132            let dxdy = seg.dxdy;
133
134            // Clip the y range to the clip rectangle.
135            let (mut cy0, mut cx0) = (y0, x0);
136            let (mut cy1, mut cx1) = (y1, x1);
137
138            if cy0 < clip.y_min_i {
139                cy0 = clip.y_min_i;
140                cx0 = splash_floor((clip.y_min - sy0).mul_add(dxdy, sx0));
141            }
142            if cy1 > clip.y_max_i {
143                cy1 = clip.y_max_i;
144                cx1 = splash_floor((clip.y_max - sy0).mul_add(dxdy, sx0));
145            }
146
147            // Hoist the shared initialisation out of both branches.
148            let mut xa = cx0;
149            let left_to_right = cx0 <= cx1;
150            for y in cy0..=cy1 {
151                let xb = if y < cy1 {
152                    splash_floor((f64::from(y) + 1.0 - sy0).mul_add(dxdy, sx0))
153                } else if left_to_right {
154                    cx1 + 1
155                } else {
156                    cx1 - 1
157                };
158                let (span_x0, span_x1) = if left_to_right {
159                    if xa == xb { (xa, xa) } else { (xa, xb - 1) }
160                } else if xa == xb {
161                    (xa, xa)
162                } else {
163                    (xb + 1, xa)
164                };
165                draw_narrow_span::<P>(bitmap, clip, pipe, src, span_x0, span_x1, y, clip_res);
166                xa = xb;
167            }
168        }
169    }
170}
171
172/// Wide stroke: expand the path into a filled outline and fill it.
173fn stroke_wide<P: Pixel>(
174    bitmap: &mut Bitmap<P>,
175    clip: &Clip,
176    path: &Path,
177    pipe: &PipeState<'_>,
178    src: &PipeSrc<'_>,
179    matrix: &[f64; 6],
180    params: &StrokeParams<'_>,
181) {
182    let outline = make_stroke_path(path, params.line_width, params);
183    fill::<P>(
184        bitmap,
185        clip,
186        &outline,
187        pipe,
188        src,
189        matrix,
190        params.flatness,
191        params.vector_antialias,
192    );
193}
194
195// ── Private helpers ───────────────────────────────────────────────────────────
196
197/// Draw a span (possibly a single pixel), clamped to the bitmap and clipped.
198///
199/// Used by [`stroke_narrow`] to draw each hairline pixel-run.
200#[expect(
201    clippy::too_many_arguments,
202    reason = "all parameters are required; splitting would add indirection"
203)]
204fn draw_narrow_span<P: Pixel>(
205    bitmap: &mut Bitmap<P>,
206    clip: &Clip,
207    pipe: &PipeState<'_>,
208    src: &PipeSrc<'_>,
209    x0: i32,
210    x1: i32,
211    y: i32,
212    clip_res: ClipResult,
213) {
214    if y < 0 {
215        return;
216    }
217    #[expect(clippy::cast_sign_loss, reason = "y >= 0 checked above")]
218    if (y as u32) >= bitmap.height {
219        return;
220    }
221    // Clamp x to bitmap.
222    #[expect(
223        clippy::cast_possible_wrap,
224        reason = "bitmap width fits in i32 in practice"
225    )]
226    let width_i = bitmap.width as i32;
227
228    let (sx0, sx1) = if clip_res == ClipResult::AllInside {
229        (x0.max(0), x1.min(width_i - 1))
230    } else {
231        (x0.max(clip.x_min_i), x1.min(clip.x_max_i))
232    };
233
234    if sx0 > sx1 {
235        return;
236    }
237
238    if clip_res == ClipResult::AllInside {
239        draw_span_unchecked::<P>(bitmap, pipe, src, sx0, sx1, y);
240    } else {
241        // Per-pixel clip test for partial regions.
242        let mut run_start: Option<i32> = None;
243        for x in sx0..=sx1 {
244            if clip.test(x, y) {
245                if run_start.is_none() {
246                    run_start = Some(x);
247                }
248            } else if let Some(rs) = run_start.take() {
249                draw_span_unchecked::<P>(bitmap, pipe, src, rs, x - 1, y);
250            }
251        }
252        if let Some(rs) = run_start {
253            draw_span_unchecked::<P>(bitmap, pipe, src, rs, sx1, y);
254        }
255    }
256}
257
258/// Write one span of pixels directly to the bitmap (no clip check).
259fn draw_span_unchecked<P: Pixel>(
260    bitmap: &mut Bitmap<P>,
261    pipe: &PipeState<'_>,
262    src: &PipeSrc<'_>,
263    x0: i32,
264    x1: i32,
265    y: i32,
266) {
267    debug_assert!(x0 <= x1);
268    debug_assert!(y >= 0);
269    #[expect(clippy::cast_sign_loss, reason = "y >= 0")]
270    let y_u = y as u32;
271    #[expect(clippy::cast_sign_loss, reason = "x0 >= 0 after clamping")]
272    let byte_off = x0 as usize * P::BYTES;
273    #[expect(clippy::cast_sign_loss, reason = "x1 >= x0 >= 0")]
274    let byte_end = (x1 as usize + 1) * P::BYTES;
275    #[expect(clippy::cast_sign_loss, reason = "x0 >= 0, x1 >= x0")]
276    let alpha_range = x0 as usize..=x1 as usize;
277
278    let (row, alpha) = bitmap.row_and_alpha_mut(y_u);
279    let dst_pixels = &mut row[byte_off..byte_end];
280    let dst_alpha = alpha.map(|a| &mut a[alpha_range]);
281
282    crate::pipe::render_span::<P>(pipe, src, dst_pixels, dst_alpha, None, x0, x1, y);
283}
284
285// ── Tests ─────────────────────────────────────────────────────────────────────
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::bitmap::Bitmap;
291    use crate::path::PathBuilder;
292    use crate::pipe::PipeSrc;
293    use crate::testutil::{identity_matrix, make_clip, simple_pipe};
294    use color::Rgb8;
295
296    fn default_params<'a>() -> StrokeParams<'a> {
297        StrokeParams {
298            line_width: 0.0,
299            line_cap: LineCap::Butt,
300            line_join: LineJoin::Miter,
301            miter_limit: 10.0,
302            flatness: 1.0,
303            stroke_adjust: false,
304            line_dash: &[],
305            line_dash_phase: 0.0,
306            vector_antialias: false,
307        }
308    }
309
310    /// `stroke_narrow` should paint pixels along a diagonal line segment.
311    #[test]
312    fn stroke_narrow_draws_diagonal() {
313        let mut bmp: Bitmap<Rgb8> = Bitmap::new(16, 16, 4, false);
314        let clip = make_clip(16, 16);
315        let pipe = simple_pipe();
316        let color = [255u8, 0, 0];
317        let src = PipeSrc::Solid(&color);
318
319        // Build a diagonal path from (1,1) to (8,8).
320        let mut b = PathBuilder::new();
321        b.move_to(1.0, 1.0).unwrap();
322        b.line_to(8.0, 8.0).unwrap();
323        let path = b.build();
324
325        // Flatten (it's already flat) and draw hairline.
326        let flat = flatten_path(&path, &identity_matrix(), 1.0);
327        stroke_narrow::<Rgb8>(&mut bmp, &clip, &flat, &pipe, &src, &identity_matrix(), 1.0);
328
329        // At least one pixel on the diagonal should be painted.
330        let mut any_painted = false;
331        for i in 1u32..9 {
332            if bmp.row(i)[i as usize].r == 255 {
333                any_painted = true;
334                break;
335            }
336        }
337        assert!(
338            any_painted,
339            "stroke_narrow should paint at least one diagonal pixel"
340        );
341    }
342
343    /// `make_stroke_path` must return a non-empty outline for a non-degenerate
344    /// segment with positive line width.
345    #[test]
346    fn make_stroke_path_non_degenerate() {
347        // A simple horizontal segment.
348        let mut b = PathBuilder::new();
349        b.move_to(0.0, 0.0).unwrap();
350        b.line_to(10.0, 0.0).unwrap();
351        let path = b.build();
352
353        let params = StrokeParams {
354            line_width: 2.0,
355            ..default_params()
356        };
357        let outline = make_stroke_path(&path, 2.0, &params);
358        assert!(
359            !outline.pts.is_empty(),
360            "make_stroke_path should return a non-empty path for a non-degenerate segment"
361        );
362        // The outline should have at least 4 points (a stroke rectangle).
363        assert!(
364            outline.pts.len() >= 4,
365            "stroke outline should have at least 4 points, got {}",
366            outline.pts.len()
367        );
368    }
369
370    /// `make_dashed_path` must respect the dash array: segments alternate on/off.
371    #[test]
372    fn make_dashed_path_respects_dash_array() {
373        // A horizontal line of length 20, dash array [4, 2] → on 4, off 2, ...
374        let mut b = PathBuilder::new();
375        b.move_to(0.0, 0.0).unwrap();
376        b.line_to(20.0, 0.0).unwrap();
377        let path = b.build();
378
379        let dash = [4.0_f64, 2.0];
380        let dashed = make_dashed_path(&path, &dash, 0.0);
381
382        // Result should be non-empty.
383        assert!(
384            !dashed.pts.is_empty(),
385            "dashed path should not be empty for a long segment"
386        );
387
388        // All point x coordinates must be in [0, 20].
389        for pt in &dashed.pts {
390            assert!(
391                pt.x >= -1e-9 && pt.x <= 20.0 + 1e-9,
392                "dashed point x={} out of [0, 20]",
393                pt.x
394            );
395        }
396
397        // Should have multiple subpaths (at least 2 FIRST-flagged points).
398        let first_count = dashed.flags.iter().filter(|f| f.is_first()).count();
399        assert!(
400            first_count >= 2,
401            "dashed path should have at least 2 subpaths (on segments), got {first_count}"
402        );
403    }
404
405    /// Zero dash array should return an empty path (Acrobat behaviour).
406    #[test]
407    fn make_dashed_path_zero_dash_is_empty() {
408        let mut b = PathBuilder::new();
409        b.move_to(0.0, 0.0).unwrap();
410        b.line_to(10.0, 0.0).unwrap();
411        let path = b.build();
412
413        let dash = [0.0_f64];
414        let dashed = make_dashed_path(&path, &dash, 0.0);
415        assert!(
416            dashed.pts.is_empty(),
417            "zero dash array should produce empty path"
418        );
419    }
420
421    /// `flatten_path` must convert a curve to only straight segments (no CURVE flags).
422    #[test]
423    fn flatten_path_removes_curves() {
424        let mut b = PathBuilder::new();
425        b.move_to(0.0, 0.0).unwrap();
426        b.curve_to(1.0, 2.0, 3.0, 4.0, 4.0, 0.0).unwrap();
427        let path = b.build();
428
429        // The original path has CURVE flags.
430        assert!(path.flags.iter().any(|f| f.is_curve()));
431
432        let flat = flatten_path(&path, &identity_matrix(), 1.0);
433        // The flattened path must have no CURVE flags.
434        assert!(
435            flat.flags.iter().all(|f| !f.is_curve()),
436            "flatten_path must remove all CURVE flags"
437        );
438        // The flattened path should have more than 2 points (the curve was subdivided).
439        assert!(
440            flat.pts.len() >= 2,
441            "flattened curve should have at least 2 points"
442        );
443    }
444}