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