Skip to main content

agg_gui/
lcd_coverage.rs

1//! LCD subpixel text as a **per-channel coverage mask** that composites
2//! onto arbitrary backgrounds — no bg pre-fill, no destination-color
3//! knowledge required at rasterization time.
4//!
5//! # Why this replaces the pre-fill approach
6//!
7//! The older `PixfmtRgba32Lcd` path baked the caller's background colour
8//! into the rasterised output via a per-channel src-over against the
9//! pre-filled framebuffer.  That coupled the LCD glyphs to one specific
10//! destination and forced us to know that destination everywhere text is
11//! drawn — driving the walk / sample / push / pop complexity.
12//!
13//! Instead, we keep the **three subpixel coverage values independent**:
14//! the output of the rasteriser is three 8-bit channels per pixel
15//! `(cov_r, cov_g, cov_b)` describing how much of each subpixel the glyph
16//! covered.  At composite time a per-channel Porter-Duff `over` blend
17//! mixes the TEXT COLOUR into the live destination:
18//!
19//! ```text
20//! dst.r = src.r * cov.r + dst.r * (1 - cov.r)
21//! dst.g = src.g * cov.g + dst.g * (1 - cov.g)
22//! dst.b = src.b * cov.b + dst.b * (1 - cov.b)
23//! ```
24//!
25//! The coverage mask is the same regardless of where it lands; the blend
26//! naturally produces the correct LCD chroma against any background.
27//!
28//! See `lcd-subpixel-compositing.md` at the repository root for the full
29//! derivation.
30//!
31//! # Pipeline
32//!
33//! ```text
34//! shape_text (rustybuzz kerning + fallback chain — unchanged)
35//!   │
36//! per-glyph PathStorage → ConvTransform(scale_x_3) → PixfmtGray8
37//!   (8-bit grayscale coverage at 3× horizontal resolution)
38//!   │
39//! 5-tap low-pass filter per output channel
40//!   │
41//! packed (cov_r, cov_g, cov_b) 3-byte mask
42//! ```
43
44use agg_rust::path_storage::PathStorage;
45use agg_rust::trans_affine::TransAffine;
46
47use crate::color::Color;
48use crate::draw_ctx::FillRule;
49
50// ---------------------------------------------------------------------------
51// LcdBuffer — opaque 3-byte-per-pixel RGB render target
52// ---------------------------------------------------------------------------
53//
54// Analogue of `Framebuffer` for widgets that opt into
55// [`crate::widget::BackbufferMode::LcdCoverage`].  Every fill into an
56// `LcdBuffer` goes through the 3× horizontal supersample + 5-tap filter
57// pipeline and composites per-channel via Porter-Duff src-over.  The
58// buffer has no alpha channel — it's intended to be fully covered by
59// opaque fills and blitted as an opaque RGB texture.
60
61/// LCD coverage buffer, row 0 = bottom (matches `Framebuffer` convention).
62///
63/// **Two planes, 3 bytes per pixel each:**
64///
65/// - `color`: per-channel **premultiplied** RGB colour accumulated from
66///   every paint so far.  `(R_color, G_color, B_color)` where each byte
67///   is `channel_color * channel_alpha`.
68/// - `alpha`: per-channel alpha/coverage accumulated from every paint so
69///   far.  `(R_alpha, G_alpha, B_alpha)` where each byte is the combined
70///   opacity of that subpixel column (0 = untouched, 255 = fully opaque).
71///
72/// **Why per-channel alpha?**  LCD subpixel rendering produces a distinct
73/// coverage value per R/G/B channel, so a single per-pixel alpha can't
74/// represent the output correctly at glyph edges and fractional image
75/// boundaries.  Splitting alpha per-channel gives each subpixel its own
76/// Porter-Duff state: paints accumulate independently through the same
77/// premultiplied src-over math you'd use for a normal RGBA surface, just
78/// three streams instead of one.  A cached `LcdBuffer` with partial
79/// coverage can be composited onto any destination without the "black
80/// rect where unpainted" failure mode that killed the first-cut design.
81pub struct LcdBuffer {
82    color: Vec<u8>,
83    alpha: Vec<u8>,
84    width: u32,
85    height: u32,
86}
87
88impl LcdBuffer {
89    /// Allocate a fully-transparent buffer (color zero, alpha zero
90    /// everywhere).  "Transparent" here means the per-channel alpha is
91    /// 0, so composite-onto-destination leaves the destination
92    /// unchanged wherever no paint has landed yet.
93    pub fn new(width: u32, height: u32) -> Self {
94        // Safety net: refuse to honour an obviously-pathological size
95        // rather than let the allocator try for gigabytes.  Returning a
96        // 1×1 buffer means the caller's text doesn't render this
97        // frame, but the app keeps running and the offending widget's
98        // bounds get clamped naturally on the next layout pass.  A
99        // debug build prints the caller info; release silently clamps.
100        const MAX_BYTES: usize = 512 * 1024 * 1024; // 512 MB per plane
101        let bytes = (width as usize)
102            .saturating_mul(height as usize)
103            .saturating_mul(3);
104        if bytes > MAX_BYTES {
105            #[cfg(debug_assertions)]
106            eprintln!(
107                "[LcdBuffer] clamped pathological size ({}, {}); \
108                 widget bounds likely skipped a size cap",
109                width, height,
110            );
111            return Self {
112                color: vec![0u8; 3],
113                alpha: vec![0u8; 3],
114                width: 1,
115                height: 1,
116            };
117        }
118        Self {
119            color: vec![0u8; bytes],
120            alpha: vec![0u8; bytes],
121            width,
122            height,
123        }
124    }
125
126    #[inline]
127    pub fn width(&self) -> u32 {
128        self.width
129    }
130    #[inline]
131    pub fn height(&self) -> u32 {
132        self.height
133    }
134
135    #[inline]
136    pub fn color_plane(&self) -> &[u8] {
137        &self.color
138    }
139    #[inline]
140    pub fn alpha_plane(&self) -> &[u8] {
141        &self.alpha
142    }
143    #[inline]
144    pub fn color_plane_mut(&mut self) -> &mut [u8] {
145        &mut self.color
146    }
147    #[inline]
148    pub fn alpha_plane_mut(&mut self) -> &mut [u8] {
149        &mut self.alpha
150    }
151
152    /// Both planes mutably in one borrow — for inner loops that update
153    /// a pixel's colour and alpha together (image blit, manual composite).
154    #[inline]
155    pub fn planes_mut(&mut self) -> (&mut [u8], &mut [u8]) {
156        (&mut self.color, &mut self.alpha)
157    }
158
159    /// Consume the buffer, returning the owned `(color, alpha)` planes
160    /// as a pair — used when moving the painted pixels into `Arc`s for
161    /// a widget's backbuffer cache or for GPU texture upload.
162    pub fn into_planes(self) -> (Vec<u8>, Vec<u8>) {
163        (self.color, self.alpha)
164    }
165
166    /// Top-row-first copy of the colour plane, suitable for a plain
167    /// RGB8 upload or CPU blit.  Row 0 of the output is the VISUAL
168    /// top of the buffer (Y-up → Y-down flip).
169    pub fn color_plane_flipped(&self) -> Vec<u8> {
170        flip_plane(&self.color, self.width, self.height)
171    }
172
173    /// Top-row-first copy of the alpha plane.
174    pub fn alpha_plane_flipped(&self) -> Vec<u8> {
175        flip_plane(&self.alpha, self.width, self.height)
176    }
177
178    /// Copy a **top-down** row range of both planes into caller-provided
179    /// top-down destination planes, in place — the strip-update companion to
180    /// [`Self::color_plane_flipped`] / [`Self::alpha_plane_flipped`].
181    ///
182    /// Row space: `row_start..row_end` are TOP-DOWN row indices (row 0 = visual
183    /// top), half-open. `dst_color` / `dst_alpha` are full-size top-down planes
184    /// (`len == width * height * 3`). For each top-down row `r` in the range we
185    /// copy `width * 3` bytes from the Y-up source row `height - 1 - r` into the
186    /// same top-down row `r` of the destination — the same Y-up→top-down flip
187    /// [`flip_plane`] performs, but restricted to a band of rows so a dirty-strip
188    /// edit never rewrites the untouched majority of the buffer.
189    ///
190    /// Rows outside the range are left untouched (they already hold the
191    /// previously-flipped content the caller is reusing via `Arc::make_mut`).
192    pub fn copy_rows_flipped_into(
193        &self,
194        row_start: u32,
195        row_end: u32,
196        dst_color: &mut [u8],
197        dst_alpha: &mut [u8],
198    ) {
199        let w = self.width as usize;
200        let h = self.height as usize;
201        let row_bytes = w * 3;
202        debug_assert_eq!(dst_color.len(), row_bytes * h, "dst_color must be full-size");
203        debug_assert_eq!(dst_alpha.len(), row_bytes * h, "dst_alpha must be full-size");
204        let start = row_start.min(self.height) as usize;
205        let end = row_end.min(self.height) as usize;
206        for r in start..end {
207            // Top-down dst row `r` pulls from Y-up src row `h - 1 - r`.
208            let src_y = h - 1 - r;
209            let so = src_y * row_bytes;
210            let dof = r * row_bytes;
211            dst_color[dof..dof + row_bytes].copy_from_slice(&self.color[so..so + row_bytes]);
212            dst_alpha[dof..dof + row_bytes].copy_from_slice(&self.alpha[so..so + row_bytes]);
213        }
214    }
215
216    /// Collapse both planes into a single top-row-first straight-alpha
217    /// RGBA8 image suitable for the existing blit pipeline (one texture,
218    /// standard `SRC_ALPHA, ONE_MINUS_SRC_ALPHA` blend).
219    ///
220    /// The per-channel alphas get collapsed to a single per-pixel alpha
221    /// via `max(R_alpha, G_alpha, B_alpha)`; RGB is recovered by dividing
222    /// the premult colour by that max alpha (straight-alpha form).  This
223    /// conversion is **lossy** when the three subpixel alphas diverge
224    /// (the whole point of the per-channel representation is lost under
225    /// collapse).  It's correct for typical monochrome-text cases where
226    /// all three alphas agree, and degrades gracefully otherwise —
227    /// Phase 5.2's two-plane blit path preserves the full per-channel
228    /// information through upload and shader.
229    pub fn to_rgba8_top_down_collapsed(&self) -> Vec<u8> {
230        let w = self.width as usize;
231        let h = self.height as usize;
232        let mut out = vec![0u8; w * h * 4];
233        for y in 0..h {
234            let src_y = h - 1 - y;
235            for x in 0..w {
236                let si = (src_y * w + x) * 3;
237                let di = (y * w + x) * 4;
238                let ra = self.alpha[si];
239                let ga = self.alpha[si + 1];
240                let ba = self.alpha[si + 2];
241                let a = ra.max(ga).max(ba);
242                if a == 0 {
243                    continue;
244                } // fully transparent → keep RGBA zero
245                let af = a as f32 / 255.0;
246                let rc = self.color[si] as f32 / 255.0;
247                let gc = self.color[si + 1] as f32 / 255.0;
248                let bc = self.color[si + 2] as f32 / 255.0;
249                out[di] = ((rc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
250                out[di + 1] = ((gc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
251                out[di + 2] = ((bc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
252                out[di + 3] = a;
253            }
254        }
255        out
256    }
257
258    // ── Paint primitives ────────────────────────────────────────────────────
259    //
260    // These are the foundation operations every higher layer (LcdGfxCtx,
261    // path-fill helpers, image blit) eventually composes into.  They write
262    // directly into the 3-byte-per-pixel coverage store with no intermediate
263    // allocation.
264
265    /// Fill the entire buffer with a solid colour.  Every subpixel gets
266    /// the same premultiplied colour contribution and the same alpha —
267    /// a flat clear has no per-subpixel differentiation, so the three
268    /// alpha channels are all set to `color.a` and the three colour
269    /// channels to `color.rgb * color.a`.
270    pub fn clear(&mut self, color: Color) {
271        let a = color.a.clamp(0.0, 1.0);
272        let r_c = ((color.r.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
273        let g_c = ((color.g.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
274        let b_c = ((color.b.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
275        let a_byte = (a * 255.0 + 0.5) as u8;
276        for px in self.color.chunks_exact_mut(3) {
277            px[0] = r_c;
278            px[1] = g_c;
279            px[2] = b_c;
280        }
281        for px in self.alpha.chunks_exact_mut(3) {
282            px[0] = a_byte;
283            px[1] = a_byte;
284            px[2] = a_byte;
285        }
286    }
287
288    /// Fill an AGG path through the LCD pipeline: rasterize at 3× X
289    /// resolution → 5-tap filter → per-channel src-over composite into
290    /// this buffer.  `transform` is applied to `path` before the 3× X
291    /// scale (typically the caller's CTM); the path's coordinates are
292    /// in the buffer's pixel space (Y-up, origin = bottom-left).
293    /// Optional `clip` is a screen-space rect (post-CTM, in mask pixel
294    /// coords) — pixels outside it are unaffected.
295    ///
296    /// First non-text primitive on the buffer.  Future fill / stroke /
297    /// image-blit entry points either call this directly (for solid
298    /// fills / outlines) or open their own `LcdMaskBuilder` scope when
299    /// they need to batch many paths into one mask.
300    ///
301    /// The coverage mask is sized to the transformed path's bounding box,
302    /// not the whole buffer, via [`build_bounded_mask`].  A single small
303    /// fill (e.g. a 1400×22 strip-background rect on a 1400×1492 band
304    /// buffer) used to allocate + rasterize + 5-tap filter a full-buffer
305    /// mask, making per-fill cost O(buffer) — measured at ~40 ms for that
306    /// strip.  Bounding to the bbox makes it O(bbox); the output is
307    /// byte-identical (see `build_bounded_mask` for the equivalence
308    /// argument and `fill_path_bbox_tests` for the pixel checks).
309    pub fn fill_path(
310        &mut self,
311        path: &mut PathStorage,
312        color: Color,
313        transform: &TransAffine,
314        clip: Option<(f64, f64, f64, f64)>,
315        fill_rule: FillRule,
316    ) {
317        let Some((mask, bbox_x, bbox_y)) =
318            build_bounded_mask(self.width, self.height, path, transform, clip, fill_rule)
319        else {
320            return;
321        };
322        // Convert clip → integer pixel rect for composite-time enforcement.
323        // The gray-buffer raster clip should already have zeroed coverage
324        // outside, but the 5-tap filter can leak ±2 subpixels at clip
325        // edges; composite-time clip catches that.
326        let clip_i = clip.map(rect_to_pixel_clip);
327        self.composite_mask(&mask, color, bbox_x, bbox_y, clip_i);
328    }
329
330    /// Composite an [`LcdMask`] into this buffer using per-channel
331    /// **premultiplied** Porter-Duff src-over.  Each subpixel column's
332    /// effective alpha is `src.a × mask.channel_coverage`, and colour +
333    /// alpha both accumulate under the standard premult src-over:
334    ///
335    /// ```text
336    /// eff_a_c        = src.a * mask.c
337    /// buf.color_c   := src.c * eff_a_c + buf.color_c * (1 - eff_a_c)
338    /// buf.alpha_c   := eff_a_c         + buf.alpha_c * (1 - eff_a_c)
339    /// ```
340    ///
341    /// `(dst_x, dst_y)` is the mask's bottom-left in this buffer's Y-up
342    /// pixel grid; mask row `my` writes to buffer row `dst_y + my`.
343    /// Optional `clip` (in this buffer's integer pixel coords:
344    /// `(x1, y1, x2, y2)`, half-open) suppresses writes outside its
345    /// bounds — used by widgets that paint inside a clipping parent.
346    pub fn composite_mask(
347        &mut self,
348        mask: &LcdMask,
349        src: Color,
350        dst_x: i32,
351        dst_y: i32,
352        clip: Option<(i32, i32, i32, i32)>,
353    ) {
354        if mask.width == 0 || mask.height == 0 {
355            return;
356        }
357        let sa = src.a.clamp(0.0, 1.0);
358        let sr = src.r.clamp(0.0, 1.0);
359        let sg = src.g.clamp(0.0, 1.0);
360        let sb = src.b.clamp(0.0, 1.0);
361        let dst_w_i = self.width as i32;
362        let dst_h_i = self.height as i32;
363        let dst_w_u = self.width as usize;
364        let mw = mask.width as i32;
365        let mh = mask.height as i32;
366        let (cx1, cy1, cx2, cy2) = match clip {
367            Some((cx1, cy1, cx2, cy2)) => {
368                (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
369            }
370            None => (0, 0, dst_w_i, dst_h_i),
371        };
372        if cx1 >= cx2 || cy1 >= cy2 {
373            return;
374        }
375
376        for my in 0..mh {
377            let dy = dst_y + my;
378            if dy < cy1 || dy >= cy2 {
379                continue;
380            }
381            let dy_u = dy as usize;
382            for mx in 0..mw {
383                let dx = dst_x + mx;
384                if dx < cx1 || dx >= cx2 {
385                    continue;
386                }
387                let mi = ((my * mw + mx) * 3) as usize;
388                // Per-channel effective alpha = src colour alpha × mask coverage.
389                let ea_r = sa * (mask.data[mi] as f32 / 255.0);
390                let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
391                let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
392                if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
393                    continue;
394                }
395
396                let di = (dy_u * dst_w_u + (dx as usize)) * 3;
397                // Read existing premult colour + per-channel alpha.
398                let bc_r = self.color[di] as f32 / 255.0;
399                let bc_g = self.color[di + 1] as f32 / 255.0;
400                let bc_b = self.color[di + 2] as f32 / 255.0;
401                let ba_r = self.alpha[di] as f32 / 255.0;
402                let ba_g = self.alpha[di + 1] as f32 / 255.0;
403                let ba_b = self.alpha[di + 2] as f32 / 255.0;
404                // Premult src-over per channel.  `src.c × eff_a` is the
405                // premultiplied source colour contribution; it adds to
406                // the buffer's existing premult colour, weighted by
407                // (1 - eff_a).  Alpha stream does the same Porter-Duff
408                // composite independently per channel.
409                let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
410                let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
411                let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
412                let ra_r = ea_r + ba_r * (1.0 - ea_r);
413                let ra_g = ea_g + ba_g * (1.0 - ea_g);
414                let ra_b = ea_b + ba_b * (1.0 - ea_b);
415
416                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
417                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
418                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
419                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
420                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
421                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
422            }
423        }
424    }
425
426    /// Composite an [`LcdMask`] using a per-pixel source colour callback.
427    ///
428    /// The callback receives destination pixel coordinates in this buffer's
429    /// Y-up pixel space.  This keeps the LCD coverage pipeline shared for
430    /// solid and gradient fills while allowing colour to vary across the mask.
431    pub fn composite_mask_with_color<F>(
432        &mut self,
433        mask: &LcdMask,
434        dst_x: i32,
435        dst_y: i32,
436        clip: Option<(i32, i32, i32, i32)>,
437        mut color_at: F,
438    ) where
439        F: FnMut(i32, i32) -> Color,
440    {
441        if mask.width == 0 || mask.height == 0 {
442            return;
443        }
444        let dst_w_i = self.width as i32;
445        let dst_h_i = self.height as i32;
446        let dst_w_u = self.width as usize;
447        let mw = mask.width as i32;
448        let mh = mask.height as i32;
449        let (cx1, cy1, cx2, cy2) = match clip {
450            Some((cx1, cy1, cx2, cy2)) => {
451                (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
452            }
453            None => (0, 0, dst_w_i, dst_h_i),
454        };
455        if cx1 >= cx2 || cy1 >= cy2 {
456            return;
457        }
458
459        for my in 0..mh {
460            let dy = dst_y + my;
461            if dy < cy1 || dy >= cy2 {
462                continue;
463            }
464            let dy_u = dy as usize;
465            for mx in 0..mw {
466                let dx = dst_x + mx;
467                if dx < cx1 || dx >= cx2 {
468                    continue;
469                }
470                let mi = ((my * mw + mx) * 3) as usize;
471                let src = color_at(dx, dy);
472                let sa = src.a.clamp(0.0, 1.0);
473                let sr = src.r.clamp(0.0, 1.0);
474                let sg = src.g.clamp(0.0, 1.0);
475                let sb = src.b.clamp(0.0, 1.0);
476                let ea_r = sa * (mask.data[mi] as f32 / 255.0);
477                let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
478                let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
479                if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
480                    continue;
481                }
482
483                let di = (dy_u * dst_w_u + (dx as usize)) * 3;
484                let bc_r = self.color[di] as f32 / 255.0;
485                let bc_g = self.color[di + 1] as f32 / 255.0;
486                let bc_b = self.color[di + 2] as f32 / 255.0;
487                let ba_r = self.alpha[di] as f32 / 255.0;
488                let ba_g = self.alpha[di + 1] as f32 / 255.0;
489                let ba_b = self.alpha[di + 2] as f32 / 255.0;
490
491                let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
492                let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
493                let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
494                let ra_r = ea_r + ba_r * (1.0 - ea_r);
495                let ra_g = ea_g + ba_g * (1.0 - ea_g);
496                let ra_b = ea_b + ba_b * (1.0 - ea_b);
497
498                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
499                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
500                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
501                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
502                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
503                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
504            }
505        }
506    }
507
508    /// Composite `src` onto this buffer at offset `(dst_x, dst_y)` via
509    /// **per-channel premultiplied src-over** — the buffer-level
510    /// analogue of [`Self::composite_mask`].  Each of the three
511    /// subpixel columns applies `src.ch_alpha` as its own
512    /// Porter-Duff weight:
513    ///
514    /// ```text
515    /// buf.color_c := src.color_c + buf.color_c * (1 - src.alpha_c)
516    /// buf.alpha_c := src.alpha_c + buf.alpha_c * (1 - src.alpha_c)
517    /// ```
518    ///
519    /// Untouched source pixels (alpha zero on every channel) don't
520    /// change the buffer at all — exactly the semantic that makes a
521    /// popped layer leave unpainted areas alone, no seed trick needed.
522    pub fn composite_buffer(
523        &mut self,
524        src: &LcdBuffer,
525        dst_x: i32,
526        dst_y: i32,
527        clip: Option<(i32, i32, i32, i32)>,
528    ) {
529        if src.width == 0 || src.height == 0 {
530            return;
531        }
532        let dst_w_i = self.width as i32;
533        let dst_h_i = self.height as i32;
534        let dst_w_u = self.width as usize;
535        let src_w_u = src.width as usize;
536        let sw = src.width as i32;
537        let sh = src.height as i32;
538        let (cx1, cy1, cx2, cy2) = match clip {
539            Some((x1, y1, x2, y2)) => (x1.max(0), y1.max(0), x2.min(dst_w_i), y2.min(dst_h_i)),
540            None => (0, 0, dst_w_i, dst_h_i),
541        };
542        if cx1 >= cx2 || cy1 >= cy2 {
543            return;
544        }
545
546        for sy in 0..sh {
547            let dy = dst_y + sy;
548            if dy < cy1 || dy >= cy2 {
549                continue;
550            }
551            let dy_u = dy as usize;
552            let sy_u = sy as usize;
553            for sx in 0..sw {
554                let dx = dst_x + sx;
555                if dx < cx1 || dx >= cx2 {
556                    continue;
557                }
558                let si = (sy_u * src_w_u + sx as usize) * 3;
559                let di = (dy_u * dst_w_u + dx as usize) * 3;
560
561                let sa_r = src.alpha[si] as f32 / 255.0;
562                let sa_g = src.alpha[si + 1] as f32 / 255.0;
563                let sa_b = src.alpha[si + 2] as f32 / 255.0;
564                if sa_r == 0.0 && sa_g == 0.0 && sa_b == 0.0 {
565                    continue;
566                }
567
568                let sc_r = src.color[si] as f32 / 255.0;
569                let sc_g = src.color[si + 1] as f32 / 255.0;
570                let sc_b = src.color[si + 2] as f32 / 255.0;
571
572                let bc_r = self.color[di] as f32 / 255.0;
573                let bc_g = self.color[di + 1] as f32 / 255.0;
574                let bc_b = self.color[di + 2] as f32 / 255.0;
575                let ba_r = self.alpha[di] as f32 / 255.0;
576                let ba_g = self.alpha[di + 1] as f32 / 255.0;
577                let ba_b = self.alpha[di + 2] as f32 / 255.0;
578
579                // src is already premultiplied, so `sc + bc*(1-sa)` is the
580                // plain Porter-Duff expression — no additional modulation.
581                let rc_r = sc_r + bc_r * (1.0 - sa_r);
582                let rc_g = sc_g + bc_g * (1.0 - sa_g);
583                let rc_b = sc_b + bc_b * (1.0 - sa_b);
584                let ra_r = sa_r + ba_r * (1.0 - sa_r);
585                let ra_g = sa_g + ba_g * (1.0 - sa_g);
586                let ra_b = sa_b + ba_b * (1.0 - sa_b);
587
588                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
589                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
590                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
591                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
592                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
593                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
594            }
595        }
596    }
597}
598
599// ── helpers ───────────────────────────────────────────────────────────────
600
601/// Y-flip a 3-byte/pixel plane (Y-up row 0 = bottom → top-row-first).
602fn flip_plane(src: &[u8], width: u32, height: u32) -> Vec<u8> {
603    let row_bytes = (width * 3) as usize;
604    let mut out = vec![0u8; src.len()];
605    for y in 0..height as usize {
606        let dst_y = height as usize - 1 - y;
607        out[dst_y * row_bytes..(dst_y + 1) * row_bytes]
608            .copy_from_slice(&src[y * row_bytes..(y + 1) * row_bytes]);
609    }
610    out
611}
612
613mod filter;
614mod mask;
615#[cfg(test)]
616mod fill_path_bbox_tests;
617#[cfg(test)]
618mod tests;
619
620pub use mask::{
621    build_bounded_mask, composite_lcd_mask, identity_xform, rasterize_gray_mask, rasterize_lcd_mask,
622    rasterize_lcd_mask_multi, rasterize_text_gray_cached, rasterize_text_lcd_cached,
623    rect_to_pixel_clip, CachedLcdText, LcdMask, LcdMaskBuilder,
624};