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