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!(
203            dst_color.len(),
204            row_bytes * h,
205            "dst_color must be full-size"
206        );
207        debug_assert_eq!(
208            dst_alpha.len(),
209            row_bytes * h,
210            "dst_alpha must be full-size"
211        );
212        let start = row_start.min(self.height) as usize;
213        let end = row_end.min(self.height) as usize;
214        for r in start..end {
215            // Top-down dst row `r` pulls from Y-up src row `h - 1 - r`.
216            let src_y = h - 1 - r;
217            let so = src_y * row_bytes;
218            let dof = r * row_bytes;
219            dst_color[dof..dof + row_bytes].copy_from_slice(&self.color[so..so + row_bytes]);
220            dst_alpha[dof..dof + row_bytes].copy_from_slice(&self.alpha[so..so + row_bytes]);
221        }
222    }
223
224    /// Collapse both planes into a single top-row-first straight-alpha
225    /// RGBA8 image suitable for the existing blit pipeline (one texture,
226    /// standard `SRC_ALPHA, ONE_MINUS_SRC_ALPHA` blend).
227    ///
228    /// Per pixel this is [`collapse_lcd_pixel`] — Rec.709 luminance-weighted
229    /// alpha, lifted so the unpremultiply never clamps; see that function for
230    /// the derivation and for why `max` was wrong.
231    ///
232    /// **Relationship to the `lcb_flatten` shader in `demo-wgpu`.**  Both
233    /// collapse to the same weighted alpha, but only this CPU path applies the
234    /// lift, and the difference is not an inconsistency: the shader emits a
235    /// **premultiplied** sample (`vec4(c, aa)`) and never divides by alpha, so
236    /// it has no clamp to protect against.  This path has to hand back
237    /// **straight** alpha for the standard `SRC_ALPHA, ONE_MINUS_SRC_ALPHA`
238    /// blit, and that division is what needs the lift.
239    ///
240    /// The conversion is still **lossy** in chroma when the three
241    /// subpixel alphas diverge — intentionally so, since a flattened
242    /// sample has only one alpha to spend.  The two-plane blit path
243    /// preserves the full per-channel information where it can.
244    pub fn to_rgba8_top_down_collapsed(&self) -> Vec<u8> {
245        let w = self.width as usize;
246        let h = self.height as usize;
247        let mut out = vec![0u8; w * h * 4];
248        for y in 0..h {
249            let src_y = h - 1 - y;
250            for x in 0..w {
251                let si = (src_y * w + x) * 3;
252                let di = (y * w + x) * 4;
253                let px = collapse_lcd_pixel(
254                    [self.color[si], self.color[si + 1], self.color[si + 2]],
255                    [self.alpha[si], self.alpha[si + 1], self.alpha[si + 2]],
256                );
257                out[di..di + 4].copy_from_slice(&px);
258            }
259        }
260        out
261    }
262
263    // ── Paint primitives ────────────────────────────────────────────────────
264    //
265    // These are the foundation operations every higher layer (LcdGfxCtx,
266    // path-fill helpers, image blit) eventually composes into.  They write
267    // directly into the 3-byte-per-pixel coverage store with no intermediate
268    // allocation.
269
270    /// Fill the entire buffer with a solid colour.  Every subpixel gets
271    /// the same premultiplied colour contribution and the same alpha —
272    /// a flat clear has no per-subpixel differentiation, so the three
273    /// alpha channels are all set to `color.a` and the three colour
274    /// channels to `color.rgb * color.a`.
275    pub fn clear(&mut self, color: Color) {
276        let a = color.a.clamp(0.0, 1.0);
277        let r_c = ((color.r.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
278        let g_c = ((color.g.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
279        let b_c = ((color.b.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
280        let a_byte = (a * 255.0 + 0.5) as u8;
281        for px in self.color.chunks_exact_mut(3) {
282            px[0] = r_c;
283            px[1] = g_c;
284            px[2] = b_c;
285        }
286        for px in self.alpha.chunks_exact_mut(3) {
287            px[0] = a_byte;
288            px[1] = a_byte;
289            px[2] = a_byte;
290        }
291    }
292
293    /// Fill an AGG path through the LCD pipeline: rasterize at 3× X
294    /// resolution → 5-tap filter → per-channel src-over composite into
295    /// this buffer.  `transform` is applied to `path` before the 3× X
296    /// scale (typically the caller's CTM); the path's coordinates are
297    /// in the buffer's pixel space (Y-up, origin = bottom-left).
298    /// Optional `clip` is a screen-space rect (post-CTM, in mask pixel
299    /// coords) — pixels outside it are unaffected.
300    ///
301    /// First non-text primitive on the buffer.  Future fill / stroke /
302    /// image-blit entry points either call this directly (for solid
303    /// fills / outlines) or open their own `LcdMaskBuilder` scope when
304    /// they need to batch many paths into one mask.
305    ///
306    /// The coverage mask is sized to the transformed path's bounding box,
307    /// not the whole buffer, via [`build_bounded_mask`].  A single small
308    /// fill (e.g. a 1400×22 strip-background rect on a 1400×1492 band
309    /// buffer) used to allocate + rasterize + 5-tap filter a full-buffer
310    /// mask, making per-fill cost O(buffer) — measured at ~40 ms for that
311    /// strip.  Bounding to the bbox makes it O(bbox); the output is
312    /// byte-identical (see `build_bounded_mask` for the equivalence
313    /// argument and `fill_path_bbox_tests` for the pixel checks).
314    pub fn fill_path(
315        &mut self,
316        path: &mut PathStorage,
317        color: Color,
318        transform: &TransAffine,
319        clip: Option<(f64, f64, f64, f64)>,
320        fill_rule: FillRule,
321    ) {
322        let Some((mask, bbox_x, bbox_y)) =
323            build_bounded_mask(self.width, self.height, path, transform, clip, fill_rule)
324        else {
325            return;
326        };
327        // Convert clip → integer pixel rect for composite-time enforcement.
328        // The gray-buffer raster clip should already have zeroed coverage
329        // outside, but the 5-tap filter can leak ±2 subpixels at clip
330        // edges; composite-time clip catches that.
331        let clip_i = clip.map(rect_to_pixel_clip);
332        self.composite_mask(&mask, color, bbox_x, bbox_y, clip_i);
333    }
334
335    /// Composite an [`LcdMask`] into this buffer using per-channel
336    /// **premultiplied** Porter-Duff src-over.  Each subpixel column's
337    /// effective alpha is `src.a × mask.channel_coverage`, and colour +
338    /// alpha both accumulate under the standard premult src-over:
339    ///
340    /// ```text
341    /// eff_a_c        = src.a * mask.c
342    /// buf.color_c   := src.c * eff_a_c + buf.color_c * (1 - eff_a_c)
343    /// buf.alpha_c   := eff_a_c         + buf.alpha_c * (1 - eff_a_c)
344    /// ```
345    ///
346    /// `(dst_x, dst_y)` is the mask's bottom-left in this buffer's Y-up
347    /// pixel grid; mask row `my` writes to buffer row `dst_y + my`.
348    /// Optional `clip` (in this buffer's integer pixel coords:
349    /// `(x1, y1, x2, y2)`, half-open) suppresses writes outside its
350    /// bounds — used by widgets that paint inside a clipping parent.
351    pub fn composite_mask(
352        &mut self,
353        mask: &LcdMask,
354        src: Color,
355        dst_x: i32,
356        dst_y: i32,
357        clip: Option<(i32, i32, i32, i32)>,
358    ) {
359        if mask.width == 0 || mask.height == 0 {
360            return;
361        }
362        let sa = src.a.clamp(0.0, 1.0);
363        let sr = src.r.clamp(0.0, 1.0);
364        let sg = src.g.clamp(0.0, 1.0);
365        let sb = src.b.clamp(0.0, 1.0);
366        let dst_w_i = self.width as i32;
367        let dst_h_i = self.height as i32;
368        let dst_w_u = self.width as usize;
369        let mw = mask.width as i32;
370        let mh = mask.height as i32;
371        let (cx1, cy1, cx2, cy2) = match clip {
372            Some((cx1, cy1, cx2, cy2)) => {
373                (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
374            }
375            None => (0, 0, dst_w_i, dst_h_i),
376        };
377        if cx1 >= cx2 || cy1 >= cy2 {
378            return;
379        }
380
381        for my in 0..mh {
382            let dy = dst_y + my;
383            if dy < cy1 || dy >= cy2 {
384                continue;
385            }
386            let dy_u = dy as usize;
387            for mx in 0..mw {
388                let dx = dst_x + mx;
389                if dx < cx1 || dx >= cx2 {
390                    continue;
391                }
392                let mi = ((my * mw + mx) * 3) as usize;
393                // Per-channel effective alpha = src colour alpha × mask coverage.
394                let ea_r = sa * (mask.data[mi] as f32 / 255.0);
395                let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
396                let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
397                if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
398                    continue;
399                }
400
401                let di = (dy_u * dst_w_u + (dx as usize)) * 3;
402                // Read existing premult colour + per-channel alpha.
403                let bc_r = self.color[di] as f32 / 255.0;
404                let bc_g = self.color[di + 1] as f32 / 255.0;
405                let bc_b = self.color[di + 2] as f32 / 255.0;
406                let ba_r = self.alpha[di] as f32 / 255.0;
407                let ba_g = self.alpha[di + 1] as f32 / 255.0;
408                let ba_b = self.alpha[di + 2] as f32 / 255.0;
409                // Premult src-over per channel.  `src.c × eff_a` is the
410                // premultiplied source colour contribution; it adds to
411                // the buffer's existing premult colour, weighted by
412                // (1 - eff_a).  Alpha stream does the same Porter-Duff
413                // composite independently per channel.
414                let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
415                let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
416                let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
417                let ra_r = ea_r + ba_r * (1.0 - ea_r);
418                let ra_g = ea_g + ba_g * (1.0 - ea_g);
419                let ra_b = ea_b + ba_b * (1.0 - ea_b);
420
421                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
422                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
423                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
424                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
425                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
426                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
427            }
428        }
429    }
430
431    /// Composite an [`LcdMask`] using a per-pixel source colour callback.
432    ///
433    /// The callback receives destination pixel coordinates in this buffer's
434    /// Y-up pixel space.  This keeps the LCD coverage pipeline shared for
435    /// solid and gradient fills while allowing colour to vary across the mask.
436    pub fn composite_mask_with_color<F>(
437        &mut self,
438        mask: &LcdMask,
439        dst_x: i32,
440        dst_y: i32,
441        clip: Option<(i32, i32, i32, i32)>,
442        mut color_at: F,
443    ) where
444        F: FnMut(i32, i32) -> Color,
445    {
446        if mask.width == 0 || mask.height == 0 {
447            return;
448        }
449        let dst_w_i = self.width as i32;
450        let dst_h_i = self.height as i32;
451        let dst_w_u = self.width as usize;
452        let mw = mask.width as i32;
453        let mh = mask.height as i32;
454        let (cx1, cy1, cx2, cy2) = match clip {
455            Some((cx1, cy1, cx2, cy2)) => {
456                (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
457            }
458            None => (0, 0, dst_w_i, dst_h_i),
459        };
460        if cx1 >= cx2 || cy1 >= cy2 {
461            return;
462        }
463
464        for my in 0..mh {
465            let dy = dst_y + my;
466            if dy < cy1 || dy >= cy2 {
467                continue;
468            }
469            let dy_u = dy as usize;
470            for mx in 0..mw {
471                let dx = dst_x + mx;
472                if dx < cx1 || dx >= cx2 {
473                    continue;
474                }
475                let mi = ((my * mw + mx) * 3) as usize;
476                let src = color_at(dx, dy);
477                let sa = src.a.clamp(0.0, 1.0);
478                let sr = src.r.clamp(0.0, 1.0);
479                let sg = src.g.clamp(0.0, 1.0);
480                let sb = src.b.clamp(0.0, 1.0);
481                let ea_r = sa * (mask.data[mi] as f32 / 255.0);
482                let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
483                let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
484                if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
485                    continue;
486                }
487
488                let di = (dy_u * dst_w_u + (dx as usize)) * 3;
489                let bc_r = self.color[di] as f32 / 255.0;
490                let bc_g = self.color[di + 1] as f32 / 255.0;
491                let bc_b = self.color[di + 2] as f32 / 255.0;
492                let ba_r = self.alpha[di] as f32 / 255.0;
493                let ba_g = self.alpha[di + 1] as f32 / 255.0;
494                let ba_b = self.alpha[di + 2] as f32 / 255.0;
495
496                let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
497                let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
498                let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
499                let ra_r = ea_r + ba_r * (1.0 - ea_r);
500                let ra_g = ea_g + ba_g * (1.0 - ea_g);
501                let ra_b = ea_b + ba_b * (1.0 - ea_b);
502
503                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
504                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
505                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
506                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
507                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
508                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
509            }
510        }
511    }
512
513    /// Composite `src` onto this buffer at offset `(dst_x, dst_y)` via
514    /// **per-channel premultiplied src-over** — the buffer-level
515    /// analogue of [`Self::composite_mask`].  Each of the three
516    /// subpixel columns applies `src.ch_alpha` as its own
517    /// Porter-Duff weight:
518    ///
519    /// ```text
520    /// buf.color_c := src.color_c + buf.color_c * (1 - src.alpha_c)
521    /// buf.alpha_c := src.alpha_c + buf.alpha_c * (1 - src.alpha_c)
522    /// ```
523    ///
524    /// Untouched source pixels (alpha zero on every channel) don't
525    /// change the buffer at all — exactly the semantic that makes a
526    /// popped layer leave unpainted areas alone, no seed trick needed.
527    pub fn composite_buffer(
528        &mut self,
529        src: &LcdBuffer,
530        dst_x: i32,
531        dst_y: i32,
532        clip: Option<(i32, i32, i32, i32)>,
533    ) {
534        if src.width == 0 || src.height == 0 {
535            return;
536        }
537        let dst_w_i = self.width as i32;
538        let dst_h_i = self.height as i32;
539        let dst_w_u = self.width as usize;
540        let src_w_u = src.width as usize;
541        let sw = src.width as i32;
542        let sh = src.height as i32;
543        let (cx1, cy1, cx2, cy2) = match clip {
544            Some((x1, y1, x2, y2)) => (x1.max(0), y1.max(0), x2.min(dst_w_i), y2.min(dst_h_i)),
545            None => (0, 0, dst_w_i, dst_h_i),
546        };
547        if cx1 >= cx2 || cy1 >= cy2 {
548            return;
549        }
550
551        for sy in 0..sh {
552            let dy = dst_y + sy;
553            if dy < cy1 || dy >= cy2 {
554                continue;
555            }
556            let dy_u = dy as usize;
557            let sy_u = sy as usize;
558            for sx in 0..sw {
559                let dx = dst_x + sx;
560                if dx < cx1 || dx >= cx2 {
561                    continue;
562                }
563                let si = (sy_u * src_w_u + sx as usize) * 3;
564                let di = (dy_u * dst_w_u + dx as usize) * 3;
565
566                let sa_r = src.alpha[si] as f32 / 255.0;
567                let sa_g = src.alpha[si + 1] as f32 / 255.0;
568                let sa_b = src.alpha[si + 2] as f32 / 255.0;
569                if sa_r == 0.0 && sa_g == 0.0 && sa_b == 0.0 {
570                    continue;
571                }
572
573                let sc_r = src.color[si] as f32 / 255.0;
574                let sc_g = src.color[si + 1] as f32 / 255.0;
575                let sc_b = src.color[si + 2] as f32 / 255.0;
576
577                let bc_r = self.color[di] as f32 / 255.0;
578                let bc_g = self.color[di + 1] as f32 / 255.0;
579                let bc_b = self.color[di + 2] as f32 / 255.0;
580                let ba_r = self.alpha[di] as f32 / 255.0;
581                let ba_g = self.alpha[di + 1] as f32 / 255.0;
582                let ba_b = self.alpha[di + 2] as f32 / 255.0;
583
584                // src is already premultiplied, so `sc + bc*(1-sa)` is the
585                // plain Porter-Duff expression — no additional modulation.
586                let rc_r = sc_r + bc_r * (1.0 - sa_r);
587                let rc_g = sc_g + bc_g * (1.0 - sa_g);
588                let rc_b = sc_b + bc_b * (1.0 - sa_b);
589                let ra_r = sa_r + ba_r * (1.0 - sa_r);
590                let ra_g = sa_g + ba_g * (1.0 - sa_g);
591                let ra_b = sa_b + ba_b * (1.0 - sa_b);
592
593                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
594                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
595                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
596                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
597                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
598                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
599            }
600        }
601    }
602}
603
604// ── helpers ───────────────────────────────────────────────────────────────
605
606/// Collapse one pixel's per-channel (premultiplied colour, alpha) triple into a
607/// single **straight-alpha** RGBA8 pixel.
608///
609/// Shared by [`LcdBuffer::to_rgba8_top_down_collapsed`] and the default
610/// [`crate::draw_ctx::DrawCtx::draw_lcd_backbuffer_arc`] so the two CPU collapse
611/// sites cannot drift apart — they did, and the drift is what let the
612/// "LCD text is bolder" bug survive in one path after being fixed in the other.
613///
614/// The collapsed alpha is the **Rec.709 luminance-weighted mean** of the three
615/// channel alphas, lifted so that no channel's unpremultiply can clamp:
616///
617/// ```text
618/// aa = max( 0.2126*a_r + 0.7152*a_g + 0.0722*a_b,  max_i color_i )
619/// ```
620///
621/// **Why the weighted mean.**  The per-channel composite over a uniform
622/// destination `d` gives `out_i = c_i + d*(1 - a_i)`; a single-alpha flatten
623/// gives `out_i = c_i + d*(1 - aa)`.  The Rec.709-weighted sum of the encoded
624/// channels is preserved exactly when `aa` is the Rec.709-weighted mean of the
625/// `a_i`, so perceived luminance over a neutral background stays correct.
626/// `max` over-weights coverage on the two channels below the max and biases
627/// every unequal-alpha pixel dark — the "LCD text is bolder" bug.
628///
629/// **Why the lift.**  Straight colour is `c_i / aa`, so a channel whose own
630/// alpha exceeds the weighted mean (`c_i > aa`, which happens for LIGHT text on
631/// a dark theme, where `c_i ≈ a_i`) would overshoot 1.0 and get clamped,
632/// silently eating that channel's ink on exactly the near-white glyph edges
633/// that carry it.  Lifting `aa` to at least `max_i c_i` makes the clamp
634/// unreachable.  The trade: where the lift raises `aa` above the weighted mean
635/// the residual luminance error becomes `d * (aa - weighted)`, which scales
636/// with the DESTINATION rather than with the source colour — so it vanishes on
637/// black, stays small on a dark theme, and is far cheaper than the clamp loss
638/// it replaces.  Dark-on-light is untouched: premultiplied colours are tiny
639/// there, so the lift never binds and the collapse stays luminance-exact.
640///
641/// Returns a fully transparent pixel when nothing survives the collapse.
642#[inline]
643pub fn collapse_lcd_pixel(color: [u8; 3], alpha: [u8; 3]) -> [u8; 4] {
644    let weighted = 0.2126 * alpha[0] as f32 + 0.7152 * alpha[1] as f32 + 0.0722 * alpha[2] as f32;
645    let lift = color[0].max(color[1]).max(color[2]) as f32;
646    let a = (weighted.max(lift) + 0.5).clamp(0.0, 255.0) as u8;
647    if a == 0 {
648        // Nothing to encode: the lift guarantees `a >= max_i color_i`, so a zero
649        // alpha here means every premultiplied colour byte is zero too.  The
650        // pixel can only be carrying sub-half-byte coverage of a near-black
651        // source, whose contribution rounds away in any case.
652        return [0, 0, 0, 0];
653    }
654    let af = a as f32;
655    [
656        ((color[0] as f32 / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
657        ((color[1] as f32 / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
658        ((color[2] as f32 / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
659        a,
660    ]
661}
662
663/// Y-flip a 3-byte/pixel plane (Y-up row 0 = bottom → top-row-first).
664fn flip_plane(src: &[u8], width: u32, height: u32) -> Vec<u8> {
665    let row_bytes = (width * 3) as usize;
666    let mut out = vec![0u8; src.len()];
667    for y in 0..height as usize {
668        let dst_y = height as usize - 1 - y;
669        out[dst_y * row_bytes..(dst_y + 1) * row_bytes]
670            .copy_from_slice(&src[y * row_bytes..(y + 1) * row_bytes]);
671    }
672    out
673}
674
675#[cfg(test)]
676mod fill_path_bbox_tests;
677mod filter;
678#[cfg(test)]
679mod ink_diag_tests;
680mod mask;
681#[cfg(test)]
682mod tests;
683
684pub use mask::{
685    build_bounded_mask, composite_lcd_mask, identity_xform, rasterize_gray_mask,
686    rasterize_lcd_mask, rasterize_lcd_mask_multi, rasterize_text_gray_cached,
687    rasterize_text_lcd_cached, rect_to_pixel_clip, CachedLcdText, LcdMask, LcdMaskBuilder,
688};