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 /// Collapse both planes into a single top-row-first straight-alpha
179 /// RGBA8 image suitable for the existing blit pipeline (one texture,
180 /// standard `SRC_ALPHA, ONE_MINUS_SRC_ALPHA` blend).
181 ///
182 /// The per-channel alphas get collapsed to a single per-pixel alpha
183 /// via `max(R_alpha, G_alpha, B_alpha)`; RGB is recovered by dividing
184 /// the premult colour by that max alpha (straight-alpha form). This
185 /// conversion is **lossy** when the three subpixel alphas diverge
186 /// (the whole point of the per-channel representation is lost under
187 /// collapse). It's correct for typical monochrome-text cases where
188 /// all three alphas agree, and degrades gracefully otherwise —
189 /// Phase 5.2's two-plane blit path preserves the full per-channel
190 /// information through upload and shader.
191 pub fn to_rgba8_top_down_collapsed(&self) -> Vec<u8> {
192 let w = self.width as usize;
193 let h = self.height as usize;
194 let mut out = vec![0u8; w * h * 4];
195 for y in 0..h {
196 let src_y = h - 1 - y;
197 for x in 0..w {
198 let si = (src_y * w + x) * 3;
199 let di = (y * w + x) * 4;
200 let ra = self.alpha[si];
201 let ga = self.alpha[si + 1];
202 let ba = self.alpha[si + 2];
203 let a = ra.max(ga).max(ba);
204 if a == 0 {
205 continue;
206 } // fully transparent → keep RGBA zero
207 let af = a as f32 / 255.0;
208 let rc = self.color[si] as f32 / 255.0;
209 let gc = self.color[si + 1] as f32 / 255.0;
210 let bc = self.color[si + 2] as f32 / 255.0;
211 out[di] = ((rc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
212 out[di + 1] = ((gc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
213 out[di + 2] = ((bc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
214 out[di + 3] = a;
215 }
216 }
217 out
218 }
219
220 // ── Paint primitives ────────────────────────────────────────────────────
221 //
222 // These are the foundation operations every higher layer (LcdGfxCtx,
223 // path-fill helpers, image blit) eventually composes into. They write
224 // directly into the 3-byte-per-pixel coverage store with no intermediate
225 // allocation.
226
227 /// Fill the entire buffer with a solid colour. Every subpixel gets
228 /// the same premultiplied colour contribution and the same alpha —
229 /// a flat clear has no per-subpixel differentiation, so the three
230 /// alpha channels are all set to `color.a` and the three colour
231 /// channels to `color.rgb * color.a`.
232 pub fn clear(&mut self, color: Color) {
233 let a = color.a.clamp(0.0, 1.0);
234 let r_c = ((color.r.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
235 let g_c = ((color.g.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
236 let b_c = ((color.b.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
237 let a_byte = (a * 255.0 + 0.5) as u8;
238 for px in self.color.chunks_exact_mut(3) {
239 px[0] = r_c;
240 px[1] = g_c;
241 px[2] = b_c;
242 }
243 for px in self.alpha.chunks_exact_mut(3) {
244 px[0] = a_byte;
245 px[1] = a_byte;
246 px[2] = a_byte;
247 }
248 }
249
250 /// Fill an AGG path through the LCD pipeline: rasterize at 3× X
251 /// resolution → 5-tap filter → per-channel src-over composite into
252 /// this buffer. `transform` is applied to `path` before the 3× X
253 /// scale (typically the caller's CTM); the path's coordinates are
254 /// in the buffer's pixel space (Y-up, origin = bottom-left).
255 /// Optional `clip` is a screen-space rect (post-CTM, in mask pixel
256 /// coords) — pixels outside it are unaffected.
257 ///
258 /// First non-text primitive on the buffer. Future fill / stroke /
259 /// image-blit entry points either call this directly (for solid
260 /// fills / outlines) or open their own `LcdMaskBuilder` scope when
261 /// they need to batch many paths into one mask.
262 ///
263 /// First-cut implementation: rasterizes at the buffer's full size.
264 /// A later optimization can compute the path's bbox and size the
265 /// scratch tightly — measurable win for small paths in large
266 /// buffers, but architecturally identical and not required for
267 /// correctness.
268 pub fn fill_path(
269 &mut self,
270 path: &mut PathStorage,
271 color: Color,
272 transform: &TransAffine,
273 clip: Option<(f64, f64, f64, f64)>,
274 fill_rule: FillRule,
275 ) {
276 if self.width == 0 || self.height == 0 {
277 return;
278 }
279 let mut builder = LcdMaskBuilder::new(self.width, self.height)
280 .with_clip(clip)
281 .with_fill_rule(fill_rule);
282 builder.with_paths(transform, |add| {
283 add(path);
284 });
285 let mask = builder.finalize();
286 // Convert clip → integer pixel rect for composite-time enforcement.
287 // The gray-buffer raster clip should already have zeroed coverage
288 // outside, but the 5-tap filter can leak ±2 subpixels at clip
289 // edges; composite-time clip catches that.
290 let clip_i = clip.map(rect_to_pixel_clip);
291 self.composite_mask(&mask, color, 0, 0, clip_i);
292 }
293
294 /// Composite an [`LcdMask`] into this buffer using per-channel
295 /// **premultiplied** Porter-Duff src-over. Each subpixel column's
296 /// effective alpha is `src.a × mask.channel_coverage`, and colour +
297 /// alpha both accumulate under the standard premult src-over:
298 ///
299 /// ```text
300 /// eff_a_c = src.a * mask.c
301 /// buf.color_c := src.c * eff_a_c + buf.color_c * (1 - eff_a_c)
302 /// buf.alpha_c := eff_a_c + buf.alpha_c * (1 - eff_a_c)
303 /// ```
304 ///
305 /// `(dst_x, dst_y)` is the mask's bottom-left in this buffer's Y-up
306 /// pixel grid; mask row `my` writes to buffer row `dst_y + my`.
307 /// Optional `clip` (in this buffer's integer pixel coords:
308 /// `(x1, y1, x2, y2)`, half-open) suppresses writes outside its
309 /// bounds — used by widgets that paint inside a clipping parent.
310 pub fn composite_mask(
311 &mut self,
312 mask: &LcdMask,
313 src: Color,
314 dst_x: i32,
315 dst_y: i32,
316 clip: Option<(i32, i32, i32, i32)>,
317 ) {
318 if mask.width == 0 || mask.height == 0 {
319 return;
320 }
321 let sa = src.a.clamp(0.0, 1.0);
322 let sr = src.r.clamp(0.0, 1.0);
323 let sg = src.g.clamp(0.0, 1.0);
324 let sb = src.b.clamp(0.0, 1.0);
325 let dst_w_i = self.width as i32;
326 let dst_h_i = self.height as i32;
327 let dst_w_u = self.width as usize;
328 let mw = mask.width as i32;
329 let mh = mask.height as i32;
330 let (cx1, cy1, cx2, cy2) = match clip {
331 Some((cx1, cy1, cx2, cy2)) => {
332 (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
333 }
334 None => (0, 0, dst_w_i, dst_h_i),
335 };
336 if cx1 >= cx2 || cy1 >= cy2 {
337 return;
338 }
339
340 for my in 0..mh {
341 let dy = dst_y + my;
342 if dy < cy1 || dy >= cy2 {
343 continue;
344 }
345 let dy_u = dy as usize;
346 for mx in 0..mw {
347 let dx = dst_x + mx;
348 if dx < cx1 || dx >= cx2 {
349 continue;
350 }
351 let mi = ((my * mw + mx) * 3) as usize;
352 // Per-channel effective alpha = src colour alpha × mask coverage.
353 let ea_r = sa * (mask.data[mi] as f32 / 255.0);
354 let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
355 let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
356 if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
357 continue;
358 }
359
360 let di = (dy_u * dst_w_u + (dx as usize)) * 3;
361 // Read existing premult colour + per-channel alpha.
362 let bc_r = self.color[di] as f32 / 255.0;
363 let bc_g = self.color[di + 1] as f32 / 255.0;
364 let bc_b = self.color[di + 2] as f32 / 255.0;
365 let ba_r = self.alpha[di] as f32 / 255.0;
366 let ba_g = self.alpha[di + 1] as f32 / 255.0;
367 let ba_b = self.alpha[di + 2] as f32 / 255.0;
368 // Premult src-over per channel. `src.c × eff_a` is the
369 // premultiplied source colour contribution; it adds to
370 // the buffer's existing premult colour, weighted by
371 // (1 - eff_a). Alpha stream does the same Porter-Duff
372 // composite independently per channel.
373 let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
374 let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
375 let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
376 let ra_r = ea_r + ba_r * (1.0 - ea_r);
377 let ra_g = ea_g + ba_g * (1.0 - ea_g);
378 let ra_b = ea_b + ba_b * (1.0 - ea_b);
379
380 self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
381 self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
382 self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
383 self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
384 self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
385 self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
386 }
387 }
388 }
389
390 /// Composite an [`LcdMask`] using a per-pixel source colour callback.
391 ///
392 /// The callback receives destination pixel coordinates in this buffer's
393 /// Y-up pixel space. This keeps the LCD coverage pipeline shared for
394 /// solid and gradient fills while allowing colour to vary across the mask.
395 pub fn composite_mask_with_color<F>(
396 &mut self,
397 mask: &LcdMask,
398 dst_x: i32,
399 dst_y: i32,
400 clip: Option<(i32, i32, i32, i32)>,
401 mut color_at: F,
402 ) where
403 F: FnMut(i32, i32) -> Color,
404 {
405 if mask.width == 0 || mask.height == 0 {
406 return;
407 }
408 let dst_w_i = self.width as i32;
409 let dst_h_i = self.height as i32;
410 let dst_w_u = self.width as usize;
411 let mw = mask.width as i32;
412 let mh = mask.height as i32;
413 let (cx1, cy1, cx2, cy2) = match clip {
414 Some((cx1, cy1, cx2, cy2)) => {
415 (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
416 }
417 None => (0, 0, dst_w_i, dst_h_i),
418 };
419 if cx1 >= cx2 || cy1 >= cy2 {
420 return;
421 }
422
423 for my in 0..mh {
424 let dy = dst_y + my;
425 if dy < cy1 || dy >= cy2 {
426 continue;
427 }
428 let dy_u = dy as usize;
429 for mx in 0..mw {
430 let dx = dst_x + mx;
431 if dx < cx1 || dx >= cx2 {
432 continue;
433 }
434 let mi = ((my * mw + mx) * 3) as usize;
435 let src = color_at(dx, dy);
436 let sa = src.a.clamp(0.0, 1.0);
437 let sr = src.r.clamp(0.0, 1.0);
438 let sg = src.g.clamp(0.0, 1.0);
439 let sb = src.b.clamp(0.0, 1.0);
440 let ea_r = sa * (mask.data[mi] as f32 / 255.0);
441 let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
442 let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
443 if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
444 continue;
445 }
446
447 let di = (dy_u * dst_w_u + (dx as usize)) * 3;
448 let bc_r = self.color[di] as f32 / 255.0;
449 let bc_g = self.color[di + 1] as f32 / 255.0;
450 let bc_b = self.color[di + 2] as f32 / 255.0;
451 let ba_r = self.alpha[di] as f32 / 255.0;
452 let ba_g = self.alpha[di + 1] as f32 / 255.0;
453 let ba_b = self.alpha[di + 2] as f32 / 255.0;
454
455 let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
456 let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
457 let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
458 let ra_r = ea_r + ba_r * (1.0 - ea_r);
459 let ra_g = ea_g + ba_g * (1.0 - ea_g);
460 let ra_b = ea_b + ba_b * (1.0 - ea_b);
461
462 self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
463 self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
464 self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
465 self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
466 self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
467 self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
468 }
469 }
470 }
471
472 /// Composite `src` onto this buffer at offset `(dst_x, dst_y)` via
473 /// **per-channel premultiplied src-over** — the buffer-level
474 /// analogue of [`Self::composite_mask`]. Each of the three
475 /// subpixel columns applies `src.ch_alpha` as its own
476 /// Porter-Duff weight:
477 ///
478 /// ```text
479 /// buf.color_c := src.color_c + buf.color_c * (1 - src.alpha_c)
480 /// buf.alpha_c := src.alpha_c + buf.alpha_c * (1 - src.alpha_c)
481 /// ```
482 ///
483 /// Untouched source pixels (alpha zero on every channel) don't
484 /// change the buffer at all — exactly the semantic that makes a
485 /// popped layer leave unpainted areas alone, no seed trick needed.
486 pub fn composite_buffer(
487 &mut self,
488 src: &LcdBuffer,
489 dst_x: i32,
490 dst_y: i32,
491 clip: Option<(i32, i32, i32, i32)>,
492 ) {
493 if src.width == 0 || src.height == 0 {
494 return;
495 }
496 let dst_w_i = self.width as i32;
497 let dst_h_i = self.height as i32;
498 let dst_w_u = self.width as usize;
499 let src_w_u = src.width as usize;
500 let sw = src.width as i32;
501 let sh = src.height as i32;
502 let (cx1, cy1, cx2, cy2) = match clip {
503 Some((x1, y1, x2, y2)) => (x1.max(0), y1.max(0), x2.min(dst_w_i), y2.min(dst_h_i)),
504 None => (0, 0, dst_w_i, dst_h_i),
505 };
506 if cx1 >= cx2 || cy1 >= cy2 {
507 return;
508 }
509
510 for sy in 0..sh {
511 let dy = dst_y + sy;
512 if dy < cy1 || dy >= cy2 {
513 continue;
514 }
515 let dy_u = dy as usize;
516 let sy_u = sy as usize;
517 for sx in 0..sw {
518 let dx = dst_x + sx;
519 if dx < cx1 || dx >= cx2 {
520 continue;
521 }
522 let si = (sy_u * src_w_u + sx as usize) * 3;
523 let di = (dy_u * dst_w_u + dx as usize) * 3;
524
525 let sa_r = src.alpha[si] as f32 / 255.0;
526 let sa_g = src.alpha[si + 1] as f32 / 255.0;
527 let sa_b = src.alpha[si + 2] as f32 / 255.0;
528 if sa_r == 0.0 && sa_g == 0.0 && sa_b == 0.0 {
529 continue;
530 }
531
532 let sc_r = src.color[si] as f32 / 255.0;
533 let sc_g = src.color[si + 1] as f32 / 255.0;
534 let sc_b = src.color[si + 2] as f32 / 255.0;
535
536 let bc_r = self.color[di] as f32 / 255.0;
537 let bc_g = self.color[di + 1] as f32 / 255.0;
538 let bc_b = self.color[di + 2] as f32 / 255.0;
539 let ba_r = self.alpha[di] as f32 / 255.0;
540 let ba_g = self.alpha[di + 1] as f32 / 255.0;
541 let ba_b = self.alpha[di + 2] as f32 / 255.0;
542
543 // src is already premultiplied, so `sc + bc*(1-sa)` is the
544 // plain Porter-Duff expression — no additional modulation.
545 let rc_r = sc_r + bc_r * (1.0 - sa_r);
546 let rc_g = sc_g + bc_g * (1.0 - sa_g);
547 let rc_b = sc_b + bc_b * (1.0 - sa_b);
548 let ra_r = sa_r + ba_r * (1.0 - sa_r);
549 let ra_g = sa_g + ba_g * (1.0 - sa_g);
550 let ra_b = sa_b + ba_b * (1.0 - sa_b);
551
552 self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
553 self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
554 self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
555 self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
556 self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
557 self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
558 }
559 }
560 }
561}
562
563// ── helpers ───────────────────────────────────────────────────────────────
564
565/// Y-flip a 3-byte/pixel plane (Y-up row 0 = bottom → top-row-first).
566fn flip_plane(src: &[u8], width: u32, height: u32) -> Vec<u8> {
567 let row_bytes = (width * 3) as usize;
568 let mut out = vec![0u8; src.len()];
569 for y in 0..height as usize {
570 let dst_y = height as usize - 1 - y;
571 out[dst_y * row_bytes..(dst_y + 1) * row_bytes]
572 .copy_from_slice(&src[y * row_bytes..(y + 1) * row_bytes]);
573 }
574 out
575}
576
577mod mask;
578#[cfg(test)]
579mod tests;
580
581pub use mask::{
582 composite_lcd_mask, identity_xform, rasterize_lcd_mask, rasterize_lcd_mask_multi,
583 rasterize_text_lcd_cached, rect_to_pixel_clip, CachedLcdText, LcdMask, LcdMaskBuilder,
584};