Skip to main content

agg_gui/lcd_coverage/
mask.rs

1use std::cell::RefCell;
2use std::collections::{HashMap, VecDeque};
3use std::sync::Arc;
4
5use agg_rust::basics::FillingRule;
6use agg_rust::color::Gray8;
7use agg_rust::conv_curve::ConvCurve;
8use agg_rust::conv_transform::ConvTransform;
9
10use agg_rust::path_storage::PathStorage;
11use agg_rust::pixfmt_gray::PixfmtGray8;
12use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
13use agg_rust::renderer_base::RendererBase;
14use agg_rust::renderer_scanline::render_scanlines_aa_solid;
15use agg_rust::rendering_buffer::RowAccessor;
16use agg_rust::scanline_u::ScanlineU8;
17use agg_rust::trans_affine::TransAffine;
18
19use super::filter::{apply_5_tap_filter, apply_gray_collapse};
20use crate::color::Color;
21use crate::draw_ctx::FillRule;
22use crate::text::{measure_text_metrics, shape_text, Font};
23
24/// Identity transform — exposed so call sites that don't otherwise
25/// depend on `agg_rust::trans_affine::TransAffine` can pass one.
26pub fn identity_xform() -> TransAffine {
27    TransAffine::new()
28}
29
30// ---------------------------------------------------------------------------
31// Cached LCD text raster
32// ---------------------------------------------------------------------------
33//
34// The mask is fully determined by `(text, font_ptr, font_size)` — colour is
35// applied at composite time, and placement coordinates are just translations
36// the caller handles.  Caching keeps `fill_text` roughly as fast as the old
37// grayscale path: AGG rasterisation runs once per unique text string, and
38// GL backends can further cache the uploaded texture keyed on the returned
39// `Arc`'s pointer identity (see `demo-gl`'s `arc_texture_cache` pattern).
40
41/// Result of [`rasterize_text_lcd_cached`].  Callers composite the mask
42/// at `(x - baseline_x_in_mask, y - baseline_y_in_mask)` where `(x, y)`
43/// is the target baseline position in local / screen coordinates.
44pub struct CachedLcdText {
45    /// 3-byte-per-pixel coverage mask, Y-up (row 0 = bottom).  Shared
46    /// `Arc` so GL backends can key a texture cache on its pointer
47    /// identity — one upload per unique raster result.
48    pub pixels: Arc<Vec<u8>>,
49    pub width: u32,
50    pub height: u32,
51    /// Mask-local x of the glyph origin (= padding inset).
52    pub baseline_x_in_mask: f64,
53    /// Mask-local Y-up y of the glyph baseline.
54    pub baseline_y_in_mask: f64,
55}
56
57const MASK_PAD: f64 = 2.0;
58
59#[derive(Clone, PartialEq, Eq, Hash)]
60struct LcdMaskKey {
61    text: String,
62    font_ptr: usize,
63    size_bits: u64,
64    /// Typography-style fingerprint — every parameter that `shape_text`
65    /// now applies must be part of the cache key, or a slider drag would
66    /// keep serving stale masks rendered in the previous style.  Bits
67    /// are read off the f64s so we inherit `Eq` / `Hash`.
68    width_bits: u64,
69    italic_bits: u64,
70    interval_bits: u64,
71    hint_y: bool,
72    faux_weight_bits: u64,
73    primary_weight_bits: u64,
74    gamma_bits: u64,
75    /// `true` for the grayscale (whole-pixel coverage) variant, `false`
76    /// for LCD subpixel. Same raster, different finalize — so the two
77    /// must not collide in the shared cache.
78    gray: bool,
79}
80
81struct LcdMaskEntry {
82    pixels: Arc<Vec<u8>>,
83    width: u32,
84    height: u32,
85    baseline_x_in_mask: f64,
86    baseline_y_in_mask: f64,
87}
88
89thread_local! {
90    static MASK_CACHE: RefCell<HashMap<LcdMaskKey, LcdMaskEntry>>
91        = RefCell::new(HashMap::new());
92    static MASK_LRU: RefCell<VecDeque<LcdMaskKey>>
93        = RefCell::new(VecDeque::new());
94}
95
96const MASK_CACHE_MAX: usize = 1024;
97
98/// Rasterise `text` in `font` at `size` into a 3-channel LCD coverage mask,
99/// caching the result so subsequent calls with the same `(text, font, size)`
100/// return the shared `Arc` without re-running AGG.
101pub fn rasterize_text_lcd_cached(font: &Arc<Font>, text: &str, size: f64) -> CachedLcdText {
102    rasterize_text_mask_cached(font, text, size, false)
103}
104
105/// Grayscale (whole-pixel coverage) sibling of [`rasterize_text_lcd_cached`].
106///
107/// Runs the identical AGG rasterization and caching, but box-averages the
108/// 3× horizontal gray buffer down to one coverage value per pixel and
109/// replicates it into all three channels (`R = G = B = coverage`). The
110/// result blits through the very same per-channel composite the LCD mask
111/// uses, so the GPU path is shared — but with equal channels there is no
112/// subpixel colour fringing. This is the anti-aliased text path for
113/// hi-DPI / touch displays where LCD subpixel order is unknown or the
114/// per-logical-pixel fringe would be visible (e.g. scaled desktops).
115pub fn rasterize_text_gray_cached(font: &Arc<Font>, text: &str, size: f64) -> CachedLcdText {
116    rasterize_text_mask_cached(font, text, size, true)
117}
118
119fn rasterize_text_mask_cached(
120    font: &Arc<Font>,
121    text: &str,
122    size: f64,
123    gray: bool,
124) -> CachedLcdText {
125    // Snapshot the current typography style once so the same values
126    // used for the cache key are also used to size the mask below.
127    let width_now = crate::font_settings::current_width();
128    let italic_now = crate::font_settings::current_faux_italic();
129    let interval_now = crate::font_settings::current_interval();
130    let hint_y_now = crate::font_settings::hinting_enabled();
131    let fweight_now = crate::font_settings::current_faux_weight();
132    let pweight_now = crate::font_settings::current_primary_weight();
133    let gamma_now = crate::font_settings::current_gamma();
134
135    let key = LcdMaskKey {
136        text: text.to_string(),
137        font_ptr: Arc::as_ptr(font) as *const () as usize,
138        size_bits: size.to_bits(),
139        width_bits: width_now.to_bits(),
140        italic_bits: italic_now.to_bits(),
141        interval_bits: interval_now.to_bits(),
142        hint_y: hint_y_now,
143        faux_weight_bits: fweight_now.to_bits(),
144        primary_weight_bits: pweight_now.to_bits(),
145        gamma_bits: gamma_now.to_bits(),
146        gray,
147    };
148    // Cache hit path — bump LRU, return shared Arc.
149    let hit = MASK_CACHE.with(|m| {
150        m.borrow().get(&key).map(|e| CachedLcdText {
151            pixels: Arc::clone(&e.pixels),
152            width: e.width,
153            height: e.height,
154            baseline_x_in_mask: e.baseline_x_in_mask,
155            baseline_y_in_mask: e.baseline_y_in_mask,
156        })
157    });
158    if let Some(got) = hit {
159        MASK_LRU.with(|lru| {
160            let mut lru = lru.borrow_mut();
161            // Move key to back (most recently used).
162            if let Some(pos) = lru.iter().position(|k| k == &key) {
163                lru.remove(pos);
164            }
165            lru.push_back(key);
166        });
167        return got;
168    }
169
170    // Cache miss — run the rasteriser.
171    let m = measure_text_metrics(font, text, size);
172    // Extra horizontal slack when Width != 1.0 (last glyph outline is
173    // scaled beyond its advance) or Faux Italic != 0 (shear lifts the
174    // top-right of each glyph past the advance column).  Without this
175    // a slider drag past 1.0/0 would crop glyph stems at the mask
176    // edges.
177    let width_slack = (width_now - 1.0).abs() * size;
178    let italic_slack = (italic_now.abs() / 3.0) * (m.ascent + m.descent);
179    let extra_pad = (width_slack + italic_slack).ceil();
180    let pad_x = MASK_PAD + extra_pad;
181    // Music and icon fonts deliberately overflow their ascender/descender
182    // (a SMuFL treble clef spans staff spaces far past the em box), so a
183    // mask sized from the font-wide metrics would crop such glyphs flat.
184    // Grow to the actual outline extents of the glyphs being drawn.
185    let mut ink_ascent = m.ascent;
186    let mut ink_descent = m.descent;
187    for ch in text.chars() {
188        if let Some((y_min, y_max)) = font.glyph_visual_bounds(ch, size) {
189            ink_ascent = ink_ascent.max(y_max);
190            ink_descent = ink_descent.max(-y_min);
191        }
192    }
193    let bw = (m.width + pad_x * 2.0).ceil().max(1.0) as u32;
194    let bh = (ink_ascent + ink_descent + MASK_PAD * 2.0).ceil().max(1.0) as u32;
195    let bx = pad_x;
196    // Snap the mask's internal baseline Y to a whole pixel **only when
197    // the user has hinting enabled** — the same checkbox that drives
198    // the per-glyph `gy` snap inside `shape_text`.  This keeps the
199    // two renderers aligned at integer pixels when the user opted in
200    // to hinting, and leaves both at their natural sub-pixel positions
201    // when they opted out (the small residual LCD/RGBA Y mismatch when
202    // hinting is OFF is intrinsic to LCD's composite-row-alignment
203    // requirement, not something we can paper over without forcing a
204    // permanent snap that the user explicitly rejected).
205    let by_unhinted = MASK_PAD + ink_descent;
206    let by = if hint_y_now {
207        by_unhinted.round()
208    } else {
209        by_unhinted
210    };
211    let mask = if gray {
212        rasterize_gray_mask(font, text, size, bx, by, bw, bh, &TransAffine::new())
213    } else {
214        rasterize_lcd_mask(font, text, size, bx, by, bw, bh, &TransAffine::new())
215    };
216    let pixels = Arc::new(mask.data);
217    let entry = LcdMaskEntry {
218        pixels: Arc::clone(&pixels),
219        width: bw,
220        height: bh,
221        baseline_x_in_mask: bx,
222        baseline_y_in_mask: by,
223    };
224
225    MASK_CACHE.with(|m| m.borrow_mut().insert(key.clone(), entry));
226    MASK_LRU.with(|lru| {
227        let mut lru = lru.borrow_mut();
228        lru.push_back(key.clone());
229        // LRU evict to cap — drop the oldest Arc strong refs so GL
230        // texture caches holding a Weak will see them expire and
231        // release their textures.
232        while lru.len() > MASK_CACHE_MAX {
233            if let Some(old) = lru.pop_front() {
234                MASK_CACHE.with(|m| m.borrow_mut().remove(&old));
235            }
236        }
237    });
238
239    CachedLcdText {
240        pixels,
241        width: bw,
242        height: bh,
243        baseline_x_in_mask: bx,
244        baseline_y_in_mask: by,
245    }
246}
247
248/// 3-byte-per-pixel LCD coverage mask.  Callers composite via
249/// [`composite_lcd_mask`].  The distinction from a normal RGBA image is
250/// crucial: the three channels are **independent coverage values**, not
251/// an RGB colour — they drive a per-channel blend where each subpixel
252/// mixes the source colour with the destination colour by its own amount.
253pub struct LcdMask {
254    pub data: Vec<u8>, // len = width * height * 3, stride = width * 3
255    pub width: u32,
256    pub height: u32,
257}
258
259/// Rasterize `text` at baseline `(x, y)` into a 3-channel coverage mask
260/// of size `mask_w × mask_h`.  `transform` is applied before the 3× X
261/// scale that puts the path into the high-resolution grayscale buffer.
262///
263/// The returned mask has **no colour**; at composite time `composite_lcd_mask`
264/// mixes the caller's desired text colour into the destination through the
265/// per-channel coverage.
266pub fn rasterize_lcd_mask(
267    font: &Font,
268    text: &str,
269    size: f64,
270    x: f64,
271    y: f64,
272    mask_w: u32,
273    mask_h: u32,
274    transform: &TransAffine,
275) -> LcdMask {
276    rasterize_lcd_mask_multi(font, &[(text, x, y)], size, mask_w, mask_h, transform)
277}
278
279/// Multi-span variant: raster several `(text, x, y)` tuples into a
280/// single mask.  Used by wrapped-text `Label` so every line shares one
281/// 3×-wide gray buffer and one filter pass.  The gray buffer is written
282/// cumulatively by AGG (glyphs in different pixels don't interact, so
283/// non-overlapping lines just occupy disjoint rows).
284///
285/// Now a thin wrapper over [`LcdMaskBuilder`] — kept as a free function
286/// because the cached text path keys on `(text, font, size)` and never
287/// needs to interleave non-text paths.  Generic callers should reach
288/// for the builder directly.
289pub fn rasterize_lcd_mask_multi(
290    font: &Font,
291    spans: &[(&str, f64, f64)],
292    size: f64,
293    mask_w: u32,
294    mask_h: u32,
295    transform: &TransAffine,
296) -> LcdMask {
297    let mut builder = LcdMaskBuilder::new(mask_w, mask_h);
298    builder.with_paths(transform, |add| {
299        for (text, x, y) in spans {
300            if text.is_empty() {
301                continue;
302            }
303            let (mut paths, _) = shape_text(font, text, size, *x, *y);
304            for path in paths.iter_mut() {
305                add(path);
306            }
307        }
308    });
309    builder.finalize()
310}
311
312/// Grayscale sibling of [`rasterize_lcd_mask`]: same 3× AGG rasterization,
313/// but the gray buffer is box-collapsed 3→1 per pixel and the coverage is
314/// replicated into all three channels. The returned mask has the identical
315/// 3-byte layout, so it composites through the same path as an LCD mask
316/// with no subpixel fringing.
317#[allow(clippy::too_many_arguments)]
318pub fn rasterize_gray_mask(
319    font: &Font,
320    text: &str,
321    size: f64,
322    x: f64,
323    y: f64,
324    mask_w: u32,
325    mask_h: u32,
326    transform: &TransAffine,
327) -> LcdMask {
328    let mut builder = LcdMaskBuilder::new(mask_w, mask_h);
329    builder.with_paths(transform, |add| {
330        if !text.is_empty() {
331            let (mut paths, _) = shape_text(font, text, size, x, y);
332            for path in paths.iter_mut() {
333                add(path);
334            }
335        }
336    });
337    builder.finalize_gray()
338}
339
340/// Convert a screen-space float clip rect `(x, y, w, h)` to the
341/// integer pixel clip box `(x1, y1, x2, y2)` (half-open) used by
342/// [`LcdBuffer::composite_mask`].  Floor on the left/bottom and ceil on
343/// the right/top so any pixel touched by the clip rect (even partially)
344/// is included — matches the AGG raster-clip convention.
345pub fn rect_to_pixel_clip(rect: (f64, f64, f64, f64)) -> (i32, i32, i32, i32) {
346    let (x, y, w, h) = rect;
347    (
348        x.floor() as i32,
349        y.floor() as i32,
350        (x + w).ceil() as i32,
351        (y + h).ceil() as i32,
352    )
353}
354
355// ── LcdMaskBuilder ──────────────────────────────────────────────────────────
356//
357// Lifts the inner "rasterize one or more AGG paths at 3× X resolution →
358// 5-tap low-pass filter → packed 3-byte LCD coverage mask" pipeline out
359// of the text-only entry points so any path source can drive it.  This
360// is the seam any new caller (rect fill, stroke, future widget paint)
361// hooks into when it needs LCD-aware coverage output.
362
363/// Accumulator for an [`LcdMask`].  Build the gray buffer with one or
364/// more `with_paths` calls (each opens an AGG rasterizer scope), then
365/// `finalize` to apply the 5-tap filter and produce the packed mask.
366pub struct LcdMaskBuilder {
367    gray: Vec<u8>,
368    gray_w: u32,
369    gray_h: u32,
370    mask_w: u32,
371    mask_h: u32,
372    /// Optional screen-space clip rect (in mask pixel coords, post-CTM).
373    /// Applied to the AGG renderer as a `clip_box_i` with X scaled by 3
374    /// before any path is added, so any rasterised coverage outside the
375    /// clip gets dropped at raster time (no need to also clip during
376    /// the filter pass — zero gray = zero mask).
377    clip: Option<(f64, f64, f64, f64)>,
378    fill_rule: FillRule,
379}
380
381impl LcdMaskBuilder {
382    /// Allocate a zeroed builder for an `mask_w × mask_h` output mask.
383    /// The internal gray buffer is `(3 × mask_w) × mask_h` bytes.
384    pub fn new(mask_w: u32, mask_h: u32) -> Self {
385        let gray_w = mask_w.saturating_mul(3);
386        let gray_h = mask_h;
387        let gray = vec![0u8; (gray_w as usize) * (gray_h as usize)];
388        Self {
389            gray,
390            gray_w,
391            gray_h,
392            mask_w,
393            mask_h,
394            clip: None,
395            fill_rule: FillRule::NonZero,
396        }
397    }
398
399    /// Set a clip rectangle in screen-space (mask pixel coords).  All
400    /// subsequent `with_paths` calls render only inside the clip;
401    /// pixels outside it stay zero in the gray buffer (and therefore
402    /// produce zero coverage in the final filtered mask).  Builder-style;
403    /// chain after `new`.
404    pub fn with_clip(mut self, clip: Option<(f64, f64, f64, f64)>) -> Self {
405        self.clip = clip;
406        self
407    }
408
409    /// Set the fill rule used by subsequent path rasterization.
410    pub fn with_fill_rule(mut self, fill_rule: FillRule) -> Self {
411        self.fill_rule = fill_rule;
412        self
413    }
414
415    /// Open an AGG rasterizer scope and let `f` add as many paths as
416    /// it likes via the supplied `&mut FnMut(&mut PathStorage)`.  All
417    /// paths share `transform`, with X supersampled by 3 inside the
418    /// scope.  Lifetimes prevent us from keeping the renderer alive
419    /// across separate method calls (it borrows `self.gray`), so the
420    /// closure pattern scopes the borrow precisely.
421    pub fn with_paths<F>(&mut self, transform: &TransAffine, f: F)
422    where
423        F: FnOnce(&mut dyn FnMut(&mut PathStorage)),
424    {
425        rasterize_paths_into_gray(
426            &mut self.gray,
427            self.gray_w,
428            self.gray_h,
429            transform,
430            self.clip,
431            self.fill_rule,
432            f,
433        );
434    }
435
436    /// Apply the 5-tap low-pass filter to the gray buffer and return
437    /// the packed mask.  Consumes the builder; callers usually composite
438    /// the result via [`LcdBuffer::composite_mask`] or
439    /// [`composite_lcd_mask`].
440    pub fn finalize(self) -> LcdMask {
441        if self.mask_w == 0 || self.mask_h == 0 {
442            return LcdMask {
443                data: Vec::new(),
444                width: self.mask_w,
445                height: self.mask_h,
446            };
447        }
448        let data = apply_5_tap_filter(&self.gray, self.gray_w, self.mask_w, self.mask_h);
449        LcdMask {
450            data,
451            width: self.mask_w,
452            height: self.mask_h,
453        }
454    }
455
456    /// Collapse the 3× gray buffer to a whole-pixel coverage mask: box-average
457    /// each triple of subpixels into one value and replicate it across R/G/B.
458    /// Produces the same packed 3-byte layout as [`finalize`], so the result
459    /// composites identically — but with equal channels, i.e. no chroma.
460    pub fn finalize_gray(self) -> LcdMask {
461        if self.mask_w == 0 || self.mask_h == 0 {
462            return LcdMask {
463                data: Vec::new(),
464                width: self.mask_w,
465                height: self.mask_h,
466            };
467        }
468        let data = apply_gray_collapse(&self.gray, self.gray_w, self.mask_w, self.mask_h);
469        LcdMask {
470            data,
471            width: self.mask_w,
472            height: self.mask_h,
473        }
474    }
475}
476
477/// Internal: run one AGG rasterizer scope writing into `gray` at 3× X
478/// scale.  The closure receives an `add` function that takes a mutable
479/// `PathStorage` and renders it with curve flattening + the X-scaled
480/// transform applied.  Optional `clip` (in mask pixel coords) is
481/// applied to the renderer with X scaled by 3 to match the gray
482/// buffer; rasterised coverage outside the clip is dropped at raster
483/// time.
484fn rasterize_paths_into_gray<F>(
485    gray: &mut [u8],
486    gray_w: u32,
487    gray_h: u32,
488    transform: &TransAffine,
489    clip: Option<(f64, f64, f64, f64)>,
490    fill_rule: FillRule,
491    f: F,
492) where
493    F: FnOnce(&mut dyn FnMut(&mut PathStorage)),
494{
495    if gray_w == 0 || gray_h == 0 {
496        return;
497    }
498    let stride = gray_w as i32;
499    let mut ra = RowAccessor::new();
500    unsafe {
501        ra.attach(gray.as_mut_ptr(), gray_w, gray_h, stride);
502    }
503    let pf = PixfmtGray8::new(&mut ra);
504    let mut rb = RendererBase::new(pf);
505    if let Some((cx, cy, cw, ch)) = clip {
506        // Clip box is in mask pixel coords.  The gray buffer is 3× X,
507        // so multiply X bounds by 3 to land on the right subpixels.
508        // `clip_box_i` is inclusive on both ends, so the right/top
509        // edges use `-1` after the ceil.
510        let x1 = (cx.floor() as i32).saturating_mul(3);
511        let y1 = cy.floor() as i32;
512        let x2 = ((cx + cw).ceil() as i32).saturating_mul(3) - 1;
513        let y2 = (cy + ch).ceil() as i32 - 1;
514        rb.clip_box_i(x1, y1, x2, y2);
515    }
516    let mut ras = RasterizerScanlineAa::new();
517    ras.filling_rule(to_agg_fill_rule(fill_rule));
518    let mut sl = ScanlineU8::new();
519
520    // Full coverage = 255.  AGG writes `gray_value * alpha / 255` per
521    // pixel; with value = 255 the output byte equals AGG's coverage
522    // estimate at that pixel — exactly what the 5-tap filter expects
523    // as input.
524    let cov_color = Gray8::new_opaque(255);
525
526    let mut xform = *transform;
527    xform.sx *= 3.0;
528    xform.shx *= 3.0;
529    xform.tx *= 3.0;
530    // shy, sy, ty unchanged — only X is supersampled.
531
532    let mut add = |path: &mut PathStorage| {
533        let mut curves = ConvCurve::new(path);
534        let mut tx = ConvTransform::new(&mut curves, xform);
535        ras.reset();
536        ras.add_path(&mut tx, 0);
537        render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &cov_color);
538    };
539    f(&mut add);
540}
541
542fn to_agg_fill_rule(rule: FillRule) -> FillingRule {
543    match rule {
544        FillRule::NonZero => FillingRule::NonZero,
545        FillRule::EvenOdd => FillingRule::EvenOdd,
546    }
547}
548
549/// Composite an [`LcdMask`] onto `dst_rgba` using per-channel Porter-Duff
550/// "over": each subpixel mixes `src_color` into the live destination by
551/// its own coverage.  The destination colour is whatever pixels are
552/// currently at the target rect — so this works over any background.
553///
554/// Both the mask and `dst_rgba` are **Y-up** (row 0 = bottom), matching
555/// `agg-gui`'s `Framebuffer` convention.  `(dst_x, dst_y)` is the mask's
556/// bottom-left in the destination's Y-up pixel grid; mask row `my` is
557/// written to destination row `dst_y + my`.
558pub fn composite_lcd_mask(
559    dst_rgba: &mut [u8],
560    dst_w: u32,
561    dst_h: u32,
562    mask: &LcdMask,
563    src: Color,
564    dst_x: i32,
565    dst_y: i32,
566) {
567    if mask.width == 0 || mask.height == 0 {
568        return;
569    }
570    let sa = src.a.clamp(0.0, 1.0);
571    let sr = src.r.clamp(0.0, 1.0);
572    let sg = src.g.clamp(0.0, 1.0);
573    let sb = src.b.clamp(0.0, 1.0);
574    let dst_w_i = dst_w as i32;
575    let dst_h_i = dst_h as i32;
576    let mw = mask.width as i32;
577    let mh = mask.height as i32;
578
579    for my in 0..mh {
580        // Both buffers Y-up: mask row my → dst row dst_y + my.
581        let dy = dst_y + my;
582        if dy < 0 || dy >= dst_h_i {
583            continue;
584        }
585        for mx in 0..mw {
586            let dx = dst_x + mx;
587            if dx < 0 || dx >= dst_w_i {
588                continue;
589            }
590            let mi = ((my * mw + mx) * 3) as usize;
591            // Effective per-channel src-over weight is `mask_cov × src.a`.
592            // Callers using a Color with alpha < 1 (e.g. placeholder text
593            // painted in a half-opacity "dim" colour) depend on this to
594            // get a partially-faded blit; without the alpha modulation
595            // the blit is full-opacity regardless of src.a.
596            let cr = (mask.data[mi] as f32 / 255.0) * sa;
597            let cg = (mask.data[mi + 1] as f32 / 255.0) * sa;
598            let cb = (mask.data[mi + 2] as f32 / 255.0) * sa;
599            if cr == 0.0 && cg == 0.0 && cb == 0.0 {
600                continue;
601            }
602
603            let di = ((dy * dst_w_i + dx) * 4) as usize;
604            let dr = dst_rgba[di] as f32 / 255.0;
605            let dg = dst_rgba[di + 1] as f32 / 255.0;
606            let db = dst_rgba[di + 2] as f32 / 255.0;
607
608            // Per-channel source-over in sRGB space.  Gamma-aware
609            // linearization is the correct next step (see the design
610            // doc); sRGB-direct is adequate for first-cut validation
611            // and matches what FreeType does in its non-linear mode.
612            let rr = sr * cr + dr * (1.0 - cr);
613            let rg = sg * cg + dg * (1.0 - cg);
614            let rbb = sb * cb + db * (1.0 - cb);
615
616            dst_rgba[di] = (rr * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
617            dst_rgba[di + 1] = (rg * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
618            dst_rgba[di + 2] = (rbb * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
619            // Alpha unchanged — mask composites onto the existing dst
620            // without introducing transparency.
621        }
622    }
623}