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};