agg_gui/lcd_coverage/mask.rs
1use std::cell::RefCell;
2use std::collections::{HashMap, VecDeque};
3use std::sync::Arc;
4
5use agg_rust::basics::{is_vertex, 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/// Device-space (mask-pixel) bounding box of `path` under `transform`,
356/// as `(min_x, min_y, max_x, max_y)`; `None` when the path has no
357/// coordinate-bearing vertices.
358///
359/// Only real vertices (`is_vertex`) contribute — `end_poly` / `stop`
360/// markers carry stale coords. Curves are **not** flattened here: a
361/// Bézier/arc lies inside the convex hull of its control points, so the
362/// control-point bbox is a conservative superset of the flattened curve
363/// the rasteriser will actually draw. That keeps this O(vertices) and
364/// still guarantees we never clip painted pixels.
365fn transformed_path_bbox(
366 path: &PathStorage,
367 transform: &TransAffine,
368) -> Option<(f64, f64, f64, f64)> {
369 let mut min_x = f64::INFINITY;
370 let mut min_y = f64::INFINITY;
371 let mut max_x = f64::NEG_INFINITY;
372 let mut max_y = f64::NEG_INFINITY;
373 let mut any = false;
374 for i in 0..path.total_vertices() {
375 let mut x = 0.0;
376 let mut y = 0.0;
377 let cmd = path.vertex_idx(i, &mut x, &mut y);
378 if !is_vertex(cmd) {
379 continue;
380 }
381 transform.transform(&mut x, &mut y);
382 min_x = min_x.min(x);
383 min_y = min_y.min(y);
384 max_x = max_x.max(x);
385 max_y = max_y.max(y);
386 any = true;
387 }
388 if any {
389 Some((min_x, min_y, max_x, max_y))
390 } else {
391 None
392 }
393}
394
395/// Build an LCD coverage mask sized to the transformed path's bounding box
396/// instead of the whole `buffer_w × buffer_h` target, returning the mask
397/// plus its bottom-left origin `(bbox_x, bbox_y)` in buffer pixel coords.
398///
399/// This is the core of the `fill_path` optimization: a single small fill
400/// (e.g. a strip-background rect) used to allocate + rasterize + 5-tap
401/// filter a full-buffer-sized mask, making per-fill cost O(buffer). Here
402/// the mask covers only the bbox, so cost is O(bbox). The result is
403/// composited at `(bbox_x, bbox_y)` — pass those to
404/// [`LcdBuffer::composite_mask`] / [`LcdBuffer::composite_mask_with_color`].
405///
406/// Returns `None` when the padded, clipped bbox is empty (fully off-buffer
407/// or fully clipped away) — the caller then paints nothing, matching the
408/// old code which composited an all-zero mask.
409///
410/// **Equivalence to the full-buffer path (why this is pixel-identical):**
411/// the bbox is padded by 2px on every side. The 5-tap filter reaches ±2
412/// subpixels horizontally and AGG AA can touch one pixel past a fractional
413/// edge, so 2px of zero padding around the path means the filter reads the
414/// same neighbourhood it would in a full buffer. `bbox_x`/`bbox_y` are
415/// whole pixels, so shifting the path into mask-local space moves it by a
416/// multiple of 3 subpixels — the filter kernel is translation-invariant at
417/// that granularity, giving byte-identical coverage. At buffer/clip edges
418/// both the bounded and full-buffer masks read zero beyond the boundary.
419pub fn build_bounded_mask(
420 buffer_w: u32,
421 buffer_h: u32,
422 path: &mut PathStorage,
423 transform: &TransAffine,
424 clip: Option<(f64, f64, f64, f64)>,
425 fill_rule: FillRule,
426) -> Option<(LcdMask, i32, i32)> {
427 if buffer_w == 0 || buffer_h == 0 {
428 return None;
429 }
430 let (min_x, min_y, max_x, max_y) = transformed_path_bbox(path, transform)?;
431
432 // Pad for 5-tap horizontal reach (±2 subpixels) + AA edge fraction.
433 const PAD: f64 = 2.0;
434 let mut x1 = (min_x - PAD).floor() as i32;
435 let mut y1 = (min_y - PAD).floor() as i32;
436 let mut x2 = (max_x + PAD).ceil() as i32;
437 let mut y2 = (max_y + PAD).ceil() as i32;
438
439 // Intersect with the clip rect (buffer pixel coords) if present. The
440 // composite-time clip still runs, but shrinking the mask here is what
441 // makes a clipped fill cheap.
442 if let Some(c) = clip {
443 let (cx1, cy1, cx2, cy2) = rect_to_pixel_clip(c);
444 x1 = x1.max(cx1);
445 y1 = y1.max(cy1);
446 x2 = x2.min(cx2);
447 y2 = y2.min(cy2);
448 }
449 // Intersect with the buffer rect.
450 x1 = x1.max(0);
451 y1 = y1.max(0);
452 x2 = x2.min(buffer_w as i32);
453 y2 = y2.min(buffer_h as i32);
454 if x1 >= x2 || y1 >= y2 {
455 return None;
456 }
457
458 let bbox_x = x1;
459 let bbox_y = y1;
460 let bbox_w = (x2 - x1) as u32;
461 let bbox_h = (y2 - y1) as u32;
462
463 // Compose the caller's transform with a translation into mask-local
464 // space: apply `transform` first, then shift by (-bbox_x, -bbox_y).
465 // `multiply` in this codebase is post-multiply (self * m = "self then
466 // m"), so `transform.multiply(translation)` translates after the CTM.
467 let mut local = *transform;
468 local.multiply(&TransAffine::new_translation(
469 -bbox_x as f64,
470 -bbox_y as f64,
471 ));
472 // The builder's clip is in mask-local pixel coords — shift it the same.
473 let local_clip = clip.map(|(cx, cy, cw, ch)| (cx - bbox_x as f64, cy - bbox_y as f64, cw, ch));
474
475 let mut builder = LcdMaskBuilder::new(bbox_w, bbox_h)
476 .with_clip(local_clip)
477 .with_fill_rule(fill_rule);
478 builder.with_paths(&local, |add| {
479 add(path);
480 });
481 Some((builder.finalize(), bbox_x, bbox_y))
482}
483
484// ── LcdMaskBuilder ──────────────────────────────────────────────────────────
485//
486// Lifts the inner "rasterize one or more AGG paths at 3× X resolution →
487// 5-tap low-pass filter → packed 3-byte LCD coverage mask" pipeline out
488// of the text-only entry points so any path source can drive it. This
489// is the seam any new caller (rect fill, stroke, future widget paint)
490// hooks into when it needs LCD-aware coverage output.
491
492/// Accumulator for an [`LcdMask`]. Build the gray buffer with one or
493/// more `with_paths` calls (each opens an AGG rasterizer scope), then
494/// `finalize` to apply the 5-tap filter and produce the packed mask.
495pub struct LcdMaskBuilder {
496 gray: Vec<u8>,
497 gray_w: u32,
498 gray_h: u32,
499 mask_w: u32,
500 mask_h: u32,
501 /// Optional screen-space clip rect (in mask pixel coords, post-CTM).
502 /// Applied to the AGG renderer as a `clip_box_i` with X scaled by 3
503 /// before any path is added, so any rasterised coverage outside the
504 /// clip gets dropped at raster time (no need to also clip during
505 /// the filter pass — zero gray = zero mask).
506 clip: Option<(f64, f64, f64, f64)>,
507 fill_rule: FillRule,
508}
509
510impl LcdMaskBuilder {
511 /// Allocate a zeroed builder for an `mask_w × mask_h` output mask.
512 /// The internal gray buffer is `(3 × mask_w) × mask_h` bytes.
513 pub fn new(mask_w: u32, mask_h: u32) -> Self {
514 let gray_w = mask_w.saturating_mul(3);
515 let gray_h = mask_h;
516 let gray = vec![0u8; (gray_w as usize) * (gray_h as usize)];
517 Self {
518 gray,
519 gray_w,
520 gray_h,
521 mask_w,
522 mask_h,
523 clip: None,
524 fill_rule: FillRule::NonZero,
525 }
526 }
527
528 /// Set a clip rectangle in screen-space (mask pixel coords). All
529 /// subsequent `with_paths` calls render only inside the clip;
530 /// pixels outside it stay zero in the gray buffer (and therefore
531 /// produce zero coverage in the final filtered mask). Builder-style;
532 /// chain after `new`.
533 pub fn with_clip(mut self, clip: Option<(f64, f64, f64, f64)>) -> Self {
534 self.clip = clip;
535 self
536 }
537
538 /// Set the fill rule used by subsequent path rasterization.
539 pub fn with_fill_rule(mut self, fill_rule: FillRule) -> Self {
540 self.fill_rule = fill_rule;
541 self
542 }
543
544 /// Open an AGG rasterizer scope and let `f` add as many paths as
545 /// it likes via the supplied `&mut FnMut(&mut PathStorage)`. All
546 /// paths share `transform`, with X supersampled by 3 inside the
547 /// scope. Lifetimes prevent us from keeping the renderer alive
548 /// across separate method calls (it borrows `self.gray`), so the
549 /// closure pattern scopes the borrow precisely.
550 pub fn with_paths<F>(&mut self, transform: &TransAffine, f: F)
551 where
552 F: FnOnce(&mut dyn FnMut(&mut PathStorage)),
553 {
554 rasterize_paths_into_gray(
555 &mut self.gray,
556 self.gray_w,
557 self.gray_h,
558 transform,
559 self.clip,
560 self.fill_rule,
561 f,
562 );
563 }
564
565 /// Apply the 5-tap low-pass filter to the gray buffer and return
566 /// the packed mask. Consumes the builder; callers usually composite
567 /// the result via [`LcdBuffer::composite_mask`] or
568 /// [`composite_lcd_mask`].
569 pub fn finalize(self) -> LcdMask {
570 if self.mask_w == 0 || self.mask_h == 0 {
571 return LcdMask {
572 data: Vec::new(),
573 width: self.mask_w,
574 height: self.mask_h,
575 };
576 }
577 let data = apply_5_tap_filter(&self.gray, self.gray_w, self.mask_w, self.mask_h);
578 LcdMask {
579 data,
580 width: self.mask_w,
581 height: self.mask_h,
582 }
583 }
584
585 /// Collapse the 3× gray buffer to a whole-pixel coverage mask: box-average
586 /// each triple of subpixels into one value and replicate it across R/G/B.
587 /// Produces the same packed 3-byte layout as [`finalize`], so the result
588 /// composites identically — but with equal channels, i.e. no chroma.
589 pub fn finalize_gray(self) -> LcdMask {
590 if self.mask_w == 0 || self.mask_h == 0 {
591 return LcdMask {
592 data: Vec::new(),
593 width: self.mask_w,
594 height: self.mask_h,
595 };
596 }
597 let data = apply_gray_collapse(&self.gray, self.gray_w, self.mask_w, self.mask_h);
598 LcdMask {
599 data,
600 width: self.mask_w,
601 height: self.mask_h,
602 }
603 }
604}
605
606/// Internal: run one AGG rasterizer scope writing into `gray` at 3× X
607/// scale. The closure receives an `add` function that takes a mutable
608/// `PathStorage` and renders it with curve flattening + the X-scaled
609/// transform applied. Optional `clip` (in mask pixel coords) is
610/// applied to the renderer with X scaled by 3 to match the gray
611/// buffer; rasterised coverage outside the clip is dropped at raster
612/// time.
613fn rasterize_paths_into_gray<F>(
614 gray: &mut [u8],
615 gray_w: u32,
616 gray_h: u32,
617 transform: &TransAffine,
618 clip: Option<(f64, f64, f64, f64)>,
619 fill_rule: FillRule,
620 f: F,
621) where
622 F: FnOnce(&mut dyn FnMut(&mut PathStorage)),
623{
624 if gray_w == 0 || gray_h == 0 {
625 return;
626 }
627 let stride = gray_w as i32;
628 let mut ra = RowAccessor::new();
629 unsafe {
630 ra.attach(gray.as_mut_ptr(), gray_w, gray_h, stride);
631 }
632 let pf = PixfmtGray8::new(&mut ra);
633 let mut rb = RendererBase::new(pf);
634 if let Some((cx, cy, cw, ch)) = clip {
635 // Clip box is in mask pixel coords. The gray buffer is 3× X,
636 // so multiply X bounds by 3 to land on the right subpixels.
637 // `clip_box_i` is inclusive on both ends, so the right/top
638 // edges use `-1` after the ceil.
639 let x1 = (cx.floor() as i32).saturating_mul(3);
640 let y1 = cy.floor() as i32;
641 let x2 = ((cx + cw).ceil() as i32).saturating_mul(3) - 1;
642 let y2 = (cy + ch).ceil() as i32 - 1;
643 rb.clip_box_i(x1, y1, x2, y2);
644 }
645 let mut ras = RasterizerScanlineAa::new();
646 ras.filling_rule(to_agg_fill_rule(fill_rule));
647 let mut sl = ScanlineU8::new();
648
649 // Full coverage = 255. AGG writes `gray_value * alpha / 255` per
650 // pixel; with value = 255 the output byte equals AGG's coverage
651 // estimate at that pixel — exactly what the 5-tap filter expects
652 // as input.
653 let cov_color = Gray8::new_opaque(255);
654
655 let mut xform = *transform;
656 xform.sx *= 3.0;
657 xform.shx *= 3.0;
658 xform.tx *= 3.0;
659 // shy, sy, ty unchanged — only X is supersampled.
660
661 let mut add = |path: &mut PathStorage| {
662 let mut curves = ConvCurve::new(path);
663 let mut tx = ConvTransform::new(&mut curves, xform);
664 ras.reset();
665 ras.add_path(&mut tx, 0);
666 render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &cov_color);
667 };
668 f(&mut add);
669}
670
671fn to_agg_fill_rule(rule: FillRule) -> FillingRule {
672 match rule {
673 FillRule::NonZero => FillingRule::NonZero,
674 FillRule::EvenOdd => FillingRule::EvenOdd,
675 }
676}
677
678/// Composite an [`LcdMask`] onto `dst_rgba` using per-channel Porter-Duff
679/// "over": each subpixel mixes `src_color` into the live destination by
680/// its own coverage. The destination colour is whatever pixels are
681/// currently at the target rect — so this works over any background.
682///
683/// Both the mask and `dst_rgba` are **Y-up** (row 0 = bottom), matching
684/// `agg-gui`'s `Framebuffer` convention. `(dst_x, dst_y)` is the mask's
685/// bottom-left in the destination's Y-up pixel grid; mask row `my` is
686/// written to destination row `dst_y + my`.
687pub fn composite_lcd_mask(
688 dst_rgba: &mut [u8],
689 dst_w: u32,
690 dst_h: u32,
691 mask: &LcdMask,
692 src: Color,
693 dst_x: i32,
694 dst_y: i32,
695) {
696 if mask.width == 0 || mask.height == 0 {
697 return;
698 }
699 let sa = src.a.clamp(0.0, 1.0);
700 let sr = src.r.clamp(0.0, 1.0);
701 let sg = src.g.clamp(0.0, 1.0);
702 let sb = src.b.clamp(0.0, 1.0);
703 let dst_w_i = dst_w as i32;
704 let dst_h_i = dst_h as i32;
705 let mw = mask.width as i32;
706 let mh = mask.height as i32;
707
708 for my in 0..mh {
709 // Both buffers Y-up: mask row my → dst row dst_y + my.
710 let dy = dst_y + my;
711 if dy < 0 || dy >= dst_h_i {
712 continue;
713 }
714 for mx in 0..mw {
715 let dx = dst_x + mx;
716 if dx < 0 || dx >= dst_w_i {
717 continue;
718 }
719 let mi = ((my * mw + mx) * 3) as usize;
720 // Effective per-channel src-over weight is `mask_cov × src.a`.
721 // Callers using a Color with alpha < 1 (e.g. placeholder text
722 // painted in a half-opacity "dim" colour) depend on this to
723 // get a partially-faded blit; without the alpha modulation
724 // the blit is full-opacity regardless of src.a.
725 let cr = (mask.data[mi] as f32 / 255.0) * sa;
726 let cg = (mask.data[mi + 1] as f32 / 255.0) * sa;
727 let cb = (mask.data[mi + 2] as f32 / 255.0) * sa;
728 if cr == 0.0 && cg == 0.0 && cb == 0.0 {
729 continue;
730 }
731
732 let di = ((dy * dst_w_i + dx) * 4) as usize;
733 let dr = dst_rgba[di] as f32 / 255.0;
734 let dg = dst_rgba[di + 1] as f32 / 255.0;
735 let db = dst_rgba[di + 2] as f32 / 255.0;
736
737 // Per-channel source-over in sRGB space. Gamma-aware
738 // linearization is the correct next step (see the design
739 // doc); sRGB-direct is adequate for first-cut validation
740 // and matches what FreeType does in its non-linear mode.
741 let rr = sr * cr + dr * (1.0 - cr);
742 let rg = sg * cg + dg * (1.0 - cg);
743 let rbb = sb * cb + db * (1.0 - cb);
744
745 dst_rgba[di] = (rr * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
746 dst_rgba[di + 1] = (rg * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
747 dst_rgba[di + 2] = (rbb * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
748 // Alpha unchanged — mask composites onto the existing dst
749 // without introducing transparency.
750 }
751 }
752}