1
//! Glyph path and cell cache for CPU rendering.
2
//!
3
//! Two-level cache:
4
//! 1. **Path cache**: `PathStorage` objects keyed by (font, glyph, ppem).
5
//!    Avoids redundant path construction from font outlines.
6
//! 2. **Cell cache**: Rasterizer cells keyed by (font, glyph, ppem, scale, sub-pixel).
7
//!    Avoids the expensive path→cells conversion on every frame.
8
//!    Cells are computed at position (0,0) and offset at render time.
9

            
10
use std::collections::HashMap;
11

            
12
use agg_rust::basics::{VertexD, VertexSource, PATH_CMD_STOP};
13
use agg_rust::path_storage::PathStorage;
14
use agg_rust::rasterizer_cells_aa::CellAa;
15

            
16
use crate::font::parsed::{build_glyph_path, OwnedGlyph, ParsedFont};
17

            
18
/// A `VertexSource` view over an already-built slice of path vertices.
19
///
20
/// Replaces the upstream-removed `RasterizerScanlineAa::add_path_vertices_transformed`:
21
/// wrap this in a `ConvTransform` and feed it to `add_path` to rasterize cached glyph
22
/// vertices under a transform WITHOUT cloning the (shared, immutable) `PathStorage`.
23
pub(crate) struct SliceVertexSource<'a> {
24
    verts: &'a [VertexD],
25
    pos: usize,
26
}
27

            
28
impl<'a> SliceVertexSource<'a> {
29
12445
    pub(crate) const fn new(verts: &'a [VertexD]) -> Self {
30
12445
        Self { verts, pos: 0 }
31
12445
    }
32
}
33

            
34
impl VertexSource for SliceVertexSource<'_> {
35
12445
    fn rewind(&mut self, _path_id: u32) {
36
12445
        self.pos = 0;
37
12445
    }
38

            
39
436582
    fn vertex(&mut self, x: &mut f64, y: &mut f64) -> u32 {
40
436582
        match self.verts.get(self.pos) {
41
424137
            Some(v) => {
42
424137
                self.pos += 1;
43
424137
                *x = v.x;
44
424137
                *y = v.y;
45
424137
                v.cmd
46
            }
47
12445
            None => PATH_CMD_STOP,
48
        }
49
436582
    }
50
}
51

            
52
/// Cache key for a glyph path.
53
/// ppem = 0 means unhinted (font-unit path), ppem > 0 means hinted at that size.
54
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55
struct GlyphPathKey {
56
    font_hash: u64,
57
    glyph_id: u16,
58
    ppem: u16,
59
}
60

            
61
/// Cache key for pre-rasterized glyph cells.
62
/// Includes sub-pixel x/y fractional position quantized to 1/4 pixel.
63
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64
struct GlyphCellKey {
65
    font_hash: u64,
66
    glyph_id: u16,
67
    ppem: u16,
68
    /// Scale factor encoded as fixed-point (scale * 65536) for unhinted glyphs.
69
    /// 0 for hinted glyphs (already in pixel coords).
70
    scale_fixed: u32,
71
    /// Sub-pixel x position, quantized to 1/`x_subsamples`-of-a-pixel for
72
    /// the LCD path and to 1/4 pixel for the grayscale path.
73
    subpx_x: u8,
74
    /// Sub-pixel y position quantized to 1/4 pixel (0..3).
75
    subpx_y: u8,
76
    /// Horizontal sub-samples per pixel the cells were rasterized at: 1 for
77
    /// the grayscale path, 3 for RGB LCD. Part of the key because the two
78
    /// produce completely different cell geometry for the same glyph.
79
    x_subsamples: u8,
80
}
81

            
82
/// Result of a cache lookup: the path plus whether it's hinted (pixel coords) or not.
83
pub struct CachedGlyph<'a> {
84
    pub path: &'a PathStorage,
85
    pub is_hinted: bool,
86
}
87

            
88
impl core::fmt::Debug for CachedGlyph<'_> {
89
    // `path` is agg_rust's PathStorage (not Debug); show the rest.
90
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
91
        f.debug_struct("CachedGlyph")
92
            .field("is_hinted", &self.is_hinted)
93
            .finish_non_exhaustive()
94
    }
95
}
96

            
97
/// Pre-rasterized glyph cells at a canonical position.
98
/// Contains the rasterizer's cell output for a glyph at sub-pixel position (`subpx_x`, `subpx_y`).
99
/// To render at actual position (x, y), add integer pixel offset to each cell.
100
struct CachedCells {
101
    cells: Vec<CellAa>,
102
}
103

            
104
/// Entries per GENERATION before rotating. Two generations are live, so
105
/// the cache holds up to twice this — the totals match the single-map
106
/// caps these replaced (8192 paths, 16384 cells).
107
const MAX_PATH_ENTRIES: usize = 4096;
108
/// See [`MAX_PATH_ENTRIES`]. Cell entries are larger than paths.
109
const MAX_CELL_ENTRIES: usize = 8192;
110

            
111
/// Cache of built glyph paths and pre-rasterized cells.
112
///
113
/// GENERATIONAL, because the previous scheme had a cliff: on reaching the
114
/// cap it called `clear()` and threw away EVERYTHING, so one glyph over
115
/// the line re-hinted the entire visible page — a multi-millisecond stall
116
/// landing on whichever keystroke happened to cross it.
117
///
118
/// Now a full young map is demoted to `*_prev` (an O(1) move) and a fresh
119
/// one starts. A lookup that misses young but hits prev is promoted back,
120
/// so the live working set survives a rotation and only genuinely cold
121
/// entries are dropped — and they are dropped one map at a time, never
122
/// all of them.
123
pub struct GlyphCache {
124
    paths: HashMap<GlyphPathKey, Option<(PathStorage, bool)>>,
125
    /// Previous generation, see the type docs.
126
    paths_prev: HashMap<GlyphPathKey, Option<(PathStorage, bool)>>,
127
    cells: HashMap<GlyphCellKey, Option<CachedCells>>,
128
    /// Previous generation, see the type docs.
129
    cells_prev: HashMap<GlyphCellKey, Option<CachedCells>>,
130
    /// Pre-blended LCD tiles (uniform-background fast path). `None` entry =
131
    /// glyph has no cells. Flat cap with full drop — see `MAX_TILE_ENTRIES`.
132
    lcd_tiles: HashMap<LcdTileKey, Option<LcdGlyphTile>>,
133
}
134

            
135
impl core::fmt::Debug for GlyphCache {
136
    // Values hold agg_rust PathStorage / CellAa (not Debug); show entry counts.
137
1
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
138
1
        f.debug_struct("GlyphCache")
139
1
            .field("paths", &self.paths.len())
140
1
            .field("cells", &self.cells.len())
141
1
            .finish_non_exhaustive()
142
1
    }
143
}
144

            
145
/// Quantize a fractional pixel position to 1/4 pixel (0..3).
146
#[inline]
147
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
148
36845
fn quantize_subpx(frac: f32) -> u8 {
149
36845
    let f = frac - frac.floor();
150
36845
    (f * 4.0).min(3.0) as u8
151
36845
}
152

            
153
/// Horizontal sub-pixel buckets used by the LCD cell cache.
154
///
155
/// The obvious choice is 3 — one per RGB stripe — on the reasoning that a
156
/// 3x rasterizer cannot resolve finer. That reasoning is WRONG and the
157
/// reftests caught it: the uncached path translated the outline by a
158
/// FLOAT (`3.0 * px`), and the rasterizer carries sub-stripe precision
159
/// internally (24.8 fixed point), so exact fractional placement really did
160
/// render detail that 1/3-px bucketing throws away. Quantizing to thirds
161
/// moved four previously-passing text reftests into failure.
162
///
163
/// 16 buckets put the placement error at 1/16 px, an order of magnitude
164
/// below a stripe, at 16 cell sets per glyph instead of 3.
165
const LCD_SUBPX_BUCKETS: u8 = 16;
166

            
167
/// Quantize a fractional pixel position into one of [`LCD_SUBPX_BUCKETS`].
168
#[inline]
169
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
170
7090312
fn quantize_subpx_lcd(frac: f32) -> u8 {
171
7090312
    let f = frac - frac.floor();
172
7090312
    (f * f32::from(LCD_SUBPX_BUCKETS)).min(f32::from(LCD_SUBPX_BUCKETS - 1)) as u8
173
7090312
}
174

            
175
impl Default for GlyphCache {
176
1
    fn default() -> Self {
177
1
        Self::new()
178
1
    }
179
}
180

            
181
impl GlyphCache {
182
    #[must_use]
183
741
    pub fn new() -> Self {
184
741
        Self {
185
741
            paths: HashMap::new(),
186
741
            paths_prev: HashMap::new(),
187
741
            cells: HashMap::new(),
188
741
            cells_prev: HashMap::new(),
189
741
            lcd_tiles: HashMap::new(),
190
741
        }
191
741
    }
192

            
193
    /// Entry count of the glyph-path cache (for leak probes).
194
    /// Counts BOTH generations — a probe watching for unbounded growth
195
    /// must see everything the cache is holding.
196
11
    #[must_use] pub fn paths_len(&self) -> usize {
197
11
        self.paths.len() + self.paths_prev.len()
198
11
    }
199

            
200
    /// Entry count of the pre-rasterized cell cache (for leak probes).
201
    /// Counts BOTH generations, see [`Self::paths_len`].
202
13
    #[must_use] pub fn cells_len(&self) -> usize {
203
13
        self.cells.len() + self.cells_prev.len()
204
13
    }
205

            
206
    /// Entries held in the PREVIOUS generation of either cache.
207
    ///
208
    /// Exists so a test can observe [`Self::gc`] actually running: without
209
    /// it, "did GC happen" is indistinguishable from "there was nothing to
210
    /// collect", and a test asserting the latter passes whether or not the
211
    /// GC hook is wired up at all.
212
    #[must_use] pub fn prev_generation_len(&self) -> usize {
213
        self.paths_prev.len() + self.cells_prev.len()
214
    }
215

            
216
    /// Get a cached path, or build it on cache miss.
217
    /// Returns `None` if the glyph has no outline (e.g. space character).
218
8291706
    pub fn get_or_build(
219
8291706
        &mut self,
220
8291706
        font_hash: u64,
221
8291706
        glyph_id: u16,
222
8291706
        glyph_data: &OwnedGlyph,
223
8291706
        parsed_font: &ParsedFont,
224
8291706
        ppem: u16,
225
8291706
    ) -> Option<CachedGlyph<'_>> {
226
8291706
        let key = GlyphPathKey { font_hash, glyph_id, ppem };
227
        // Promote from the previous generation before considering a
228
        // rotation, so a live glyph is never rebuilt just because it aged
229
        // into the older map.
230
8291706
        if !self.paths.contains_key(&key) {
231
13454
            if let Some(v) = self.paths_prev.remove(&key) {
232
1
                self.paths.insert(key, v);
233
13453
            } else if self.paths.len() >= MAX_PATH_ENTRIES {
234
2
                self.paths_prev = core::mem::take(&mut self.paths);
235
13451
            }
236
8278252
        }
237
8291706
        let entry = self
238
8291706
            .paths
239
8291706
            .entry(key)
240
8291706
            .or_insert_with(|| {
241
                // Only fires on a MISS, so this span is the true cost of
242
                // running the hinting interpreter for a glyph — as opposed
243
                // to `glyph_lcd_outline`, which is paid on every frame.
244
13453
                let _p = crate::probe::Probe::span("glyph_path_build");
245
                // Try hinted path first if ppem > 0
246
13453
                if ppem > 0 {
247
5257
                    if let Some(path) = build_hinted_path(glyph_id, glyph_data, parsed_font, ppem) {
248
4892
                        return Some((path, true));
249
365
                    }
250
8196
                }
251
                // Fall back to unhinted path
252
8561
                build_glyph_path(glyph_data).map(|p| (p, false))
253
13453
            });
254
8291706
        entry.as_ref().map(|(path, is_hinted)| CachedGlyph {
255
7079510
            path,
256
7079510
            is_hinted: *is_hinted,
257
7079510
        })
258
8291706
    }
259

            
260
    /// Promote `key` from the previous cell generation if it is there, and
261
    /// otherwise rotate when the young map is full. Mirrors what
262
    /// `get_or_build` does inline for paths.
263
6844334
    fn promote_or_rotate_cells(&mut self, key: &GlyphCellKey) {
264
6844334
        if self.cells.contains_key(key) {
265
6815092
            return;
266
29242
        }
267
29242
        if let Some(v) = self.cells_prev.remove(key) {
268
            self.cells.insert(*key, v);
269
29242
        } else if self.cells.len() >= MAX_CELL_ENTRIES {
270
2
            self.cells_prev = core::mem::take(&mut self.cells);
271
29240
        }
272
6844334
    }
273

            
274
    /// Drop the previous generation of both caches.
275
    ///
276
    /// Intended to be called AFTER a frame is presented, never during one:
277
    /// freeing thousands of `PathStorage`/cell vectors is exactly the kind
278
    /// of work that must not land on a keystroke. Rotation alone already
279
    /// bounds the cache, so this is an opportunity to return memory early,
280
    /// not a correctness requirement.
281
1
    pub fn gc(&mut self) {
282
1
        self.paths_prev = HashMap::new();
283
1
        self.cells_prev = HashMap::new();
284
1
    }
285

            
286
    /// Get cached rasterizer cells for a glyph, or build them from the path.
287
    ///
288
    /// - `glyph_x`, `glyph_y`: final pixel position (used for sub-pixel quantization)
289
    /// - `scale`: font-unit→pixel scale (0.0 for hinted glyphs)
290
    /// - `is_hinted`: whether the path is in pixel coords (hinted) or font units
291
    /// - `hint_correction`: `effective_px / ppem` for hinted glyphs (1.0 otherwise).
292
    ///   A hinted outline is built at the *integer* ppem; when the requested
293
    ///   effective size (`font_size * dpi`) is fractional this rescales it back to
294
    ///   the true target size so hinted glyphs match their unhinted neighbours and
295
    ///   animate smoothly instead of snapping between integer ppems. When the
296
    ///   effective size is already integral this is 1.0 and the hinted glyph keeps
297
    ///   its pixel-grid-snapped placement.
298
    ///
299
    /// Returns the cached cells and the integer pixel offset to apply.
300
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
301
16412
    pub fn get_or_build_cells(
302
16412
        &mut self,
303
16412
        font_hash: u64,
304
16412
        glyph_id: u16,
305
16412
        ppem: u16,
306
16412
        glyph_x: f32,
307
16412
        glyph_y: f32,
308
16412
        scale: f32,
309
16412
        is_hinted: bool,
310
16412
        hint_correction: f32,
311
16412
    ) -> Option<(&[CellAa], i32, i32)> {
312
        // Hinted outline built at integer ppem needs rescaling only when the
313
        // effective size is fractional (hint_correction != 1). Otherwise it stays
314
        // pixel-grid-snapped (rounded placement) as hinting intends.
315
16412
        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
316
16412
        let grid_snapped = is_hinted && !rescale_hinted;
317

            
318
        // Sub-pixel HORIZONTAL positioning (default ON): even a grid-snapped
319
        // (hinted-at-integer-ppem) glyph places its ORIGIN at a 1/4-pixel X
320
        // bucket, so advances accumulate smoothly and the run lands where
321
        // CoreText (fractional-x) puts it, instead of each origin rounding to a
322
        // whole pixel. The grid-fitted OUTLINE is unchanged — only where we drop
323
        // it horizontally shifts — so vertical stems stay crisp. The Y baseline
324
        // stays grid-snapped (`subpx_y == 0` for grid_snapped). With
325
        // `AZ_TEXT_SUBPIXEL=0` the grid_snapped case reverts to integer X
326
        // (sub-pixel 0, rounded origin), the previous behaviour.
327
16412
        let subpx_x_snap = grid_snapped && !text_subpixel_enabled();
328
16412
        let subpx_x = if subpx_x_snap { 0 } else { quantize_subpx(glyph_x) };
329
16412
        let subpx_y = if grid_snapped { 0 } else { quantize_subpx(glyph_y) };
330
16412
        debug_assert!((0.0..65536.0).contains(&scale), "scale out of range for fixed-point: {scale}");
331
16412
        let scale_fixed = if is_hinted {
332
9
            if rescale_hinted { (hint_correction * 65536.0) as u32 } else { 0 }
333
        } else {
334
16403
            (scale * 65536.0) as u32
335
        };
336

            
337
16412
        let cell_key = GlyphCellKey {
338
16412
            font_hash, glyph_id, ppem, scale_fixed, subpx_x, subpx_y,
339
16412
            x_subsamples: 1,
340
16412
        };
341

            
342
        // Integer pixel offset — the cells are at sub-pixel origin, offset by int
343
        // part. `int_x + subpx_x*0.25` must reconstruct `glyph_x`, so the floor
344
        // pairs with the quantized fraction; only the integer-X-snap case rounds.
345
16412
        let int_x = if subpx_x_snap { glyph_x.round() as i32 } else { glyph_x.floor() as i32 };
346
16412
        let int_y = if grid_snapped { glyph_y.round() as i32 } else { glyph_y.floor() as i32 };
347

            
348
16412
        self.promote_or_rotate_cells(&cell_key);
349
16412
        if !self.cells.contains_key(&cell_key) {
350
            // Build cells from cached path
351
16402
            let path_key = GlyphPathKey { font_hash, glyph_id, ppem };
352
16402
            let path_entry = self.paths.get(&path_key);
353
16402
            let cached_cells = path_entry.and_then(|entry| {
354
                use agg_rust::trans_affine::TransAffine;
355
                use agg_rust::basics::FillingRule;
356
                use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
357
6
                let (path, _) = entry.as_ref()?;
358
6
                let frac_x = f64::from(subpx_x) * 0.25;
359
6
                let frac_y = f64::from(subpx_y) * 0.25;
360

            
361
6
                let mut ras = RasterizerScanlineAa::new();
362
6
                ras.filling_rule(FillingRule::NonZero);
363

            
364
6
                let transform = if is_hinted {
365
                    if rescale_hinted {
366
                        let mut t = TransAffine::new_scaling_uniform(f64::from(hint_correction));
367
                        t.multiply(&TransAffine::new_translation(frac_x, frac_y));
368
                        t
369
                    } else {
370
                        TransAffine::new_translation(frac_x, frac_y)
371
                    }
372
                } else {
373
6
                    let mut t = TransAffine::new_scaling_uniform(f64::from(scale));
374
6
                    t.multiply(&TransAffine::new_translation(frac_x, frac_y));
375
6
                    t
376
                };
377

            
378
                // Feed the cached glyph vertices through the transform via ConvTransform
379
                // (upstream removed add_path_vertices_transformed), then flatten the
380
                // quadratic curve3 segments adaptively via ConvCurve. Without the
381
                // ConvCurve stage the rasterizer walks curve3 verbs as straight
382
                // lines THROUGH the control points, which turns every bowl into a
383
                // chiseled polygon — invisible at UI sizes, obvious "blocky" curves
384
                // at 24px+ (Lorem-ipsum headline comparison vs Chrome).
385
6
                let mut src = agg_rust::conv_curve::ConvCurve::new(
386
6
                    agg_rust::conv_transform::ConvTransform::new(
387
6
                        SliceVertexSource::new(path.vertices()),
388
6
                        transform,
389
                    ),
390
                );
391
6
                ras.add_path(&mut src, 0);
392
6
                let cells = ras.outline_cells_sorted();
393
6
                if cells.is_empty() { None } else { Some(CachedCells { cells }) }
394
6
            });
395
16402
            self.cells.insert(cell_key, cached_cells);
396
10
        }
397

            
398
16412
        let entry = self.cells.get(&cell_key)?;
399
16412
        entry.as_ref().map(|cc| (cc.cells.as_slice(), int_x, int_y))
400
16412
    }
401

            
402
    /// Cached rasterizer cells for the **RGB LCD** path — the same idea as
403
    /// [`Self::get_or_build_cells`], but rasterized at 3 horizontal
404
    /// sub-samples per pixel so the cells address individual R/G/B stripes.
405
    ///
406
    /// WHY THIS EXISTS: the cell cache was built for the grayscale path, and
407
    /// the LCD path — added later, and on by default — never used it. It
408
    /// re-flattened and re-rasterized every glyph on screen on every frame,
409
    /// which measured 40 ms of a 46 ms frame for a 30-paragraph document
410
    /// (`layout/tests/frame_perf.rs`), against 0.8 ms for the whole layout.
411
    /// Grayscale, which does use the cache, renders the same document in
412
    /// 18 ms. This closes that gap.
413
    ///
414
    /// Returned offsets are `(cells, int_x, int_y)` where `int_x` is in
415
    /// WHOLE PIXELS — the caller multiplies by 3 when adding the cells,
416
    /// because the cells' own x axis is already in stripe units.
417
    ///
418
    /// Horizontal sub-pixel positioning buckets at 1/3 px rather than the
419
    /// grayscale path's 1/4 px: 1/3 px is exactly what a 3× rasterizer can
420
    /// resolve, so nothing visible is given up, and 3 buckets per glyph keeps
421
    /// the cache small. The baseline is always grid-snapped (crisp vertical),
422
    /// matching what the uncached LCD path did.
423
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
424
6827922
    pub fn get_or_build_cells_lcd(
425
6827922
        &mut self,
426
6827922
        font_hash: u64,
427
6827922
        glyph_id: u16,
428
6827922
        ppem: u16,
429
6827922
        glyph_x: f32,
430
6827922
        glyph_y: f32,
431
6827922
        scale: f32,
432
6827922
        is_hinted: bool,
433
6827922
        hint_correction: f32,
434
6827922
    ) -> Option<(&[CellAa], i32, i32)> {
435
6827922
        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
436
6827922
        let subpx = text_subpixel_enabled();
437

            
438
        // Mirrors the uncached LCD path exactly: fractional x when sub-pixel
439
        // positioning is on, rounded x when it is off, always-rounded y.
440
6827922
        let (int_x, subpx_x) = if subpx {
441
6827922
            (glyph_x.floor() as i32, quantize_subpx_lcd(glyph_x))
442
        } else {
443
            (glyph_x.round() as i32, 0)
444
        };
445
6827922
        let int_y = glyph_y.round() as i32;
446

            
447
6827922
        debug_assert!((0.0..65536.0).contains(&scale), "scale out of range for fixed-point: {scale}");
448
6827922
        let scale_fixed = if is_hinted {
449
6825460
            if rescale_hinted { (hint_correction * 65536.0) as u32 } else { 0 }
450
        } else {
451
2462
            (scale * 65536.0) as u32
452
        };
453

            
454
6827922
        let cell_key = GlyphCellKey {
455
6827922
            font_hash,
456
6827922
            glyph_id,
457
6827922
            ppem,
458
6827922
            scale_fixed,
459
6827922
            subpx_x,
460
6827922
            subpx_y: 0,
461
6827922
            x_subsamples: 3,
462
6827922
        };
463

            
464
6827922
        self.promote_or_rotate_cells(&cell_key);
465
6827922
        if !self.cells.contains_key(&cell_key) {
466
12840
            let path_key = GlyphPathKey { font_hash, glyph_id, ppem };
467
12840
            let cached_cells = self.paths.get(&path_key).and_then(|entry| {
468
                use agg_rust::basics::FillingRule;
469
                use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
470
                use agg_rust::trans_affine::TransAffine;
471
12840
                let (path, _) = entry.as_ref()?;
472

            
473
                // Path units -> pixels, same rule as the uncached path: a
474
                // hinted outline at integer ppem is already pixel-space,
475
                // a fractional effective size rescales by hint_correction,
476
                // an unhinted outline is in font units.
477
12439
                let path_scale = if is_hinted {
478
12330
                    if rescale_hinted { f64::from(hint_correction) } else { 1.0 }
479
                } else {
480
109
                    f64::from(scale)
481
                };
482

            
483
                // Triple the x axis, then shift by the sub-pixel bucket.
484
                // The bucket is a fraction of a PIXEL, and the axis is in
485
                // stripes, so it converts as `3 * k / BUCKETS`.
486
12439
                let frac_stripes =
487
12439
                    3.0 * f64::from(subpx_x) / f64::from(LCD_SUBPX_BUCKETS);
488
12439
                let mut t = TransAffine::new_scaling(3.0 * path_scale, path_scale);
489
12439
                t.multiply(&TransAffine::new_translation(frac_stripes, 0.0));
490

            
491
12439
                let mut ras = RasterizerScanlineAa::new();
492
12439
                ras.filling_rule(FillingRule::NonZero);
493
                // ConvCurve flattens the quadratic curve3 verbs adaptively;
494
                // without it the rasterizer draws straight lines THROUGH the
495
                // control points and every bowl renders as a chiseled polygon.
496
12439
                let mut src = agg_rust::conv_curve::ConvCurve::new(
497
12439
                    agg_rust::conv_transform::ConvTransform::new(
498
12439
                        SliceVertexSource::new(path.vertices()),
499
12439
                        t,
500
                    ),
501
                );
502
12439
                ras.add_path(&mut src, 0);
503
12439
                let cells = ras.outline_cells_sorted();
504
12439
                if cells.is_empty() { None } else { Some(CachedCells { cells }) }
505
12840
            });
506
12840
            self.cells.insert(cell_key, cached_cells);
507
6815082
        }
508

            
509
6827922
        let entry = self.cells.get(&cell_key)?;
510
6827922
        entry.as_ref().map(|cc| (cc.cells.as_slice(), int_x, int_y))
511
6827922
    }
512
}
513

            
514
/// Build a hinted glyph path using TrueType bytecode hinting.
515
///
516
/// The returned path is in pixel coordinates (1 unit = 1 pixel at the given ppem).
517
/// Returns `None` if the glyph has no raw hinting data or hinting fails.
518
/// Read a glyph's left side bearing (font units) straight from the `hmtx`
519
/// table. Mirrors the `FreeType` `TT_Get_HMetrics` lookup used to place phantom
520
/// point pp1 at `xMin - lsb`. Returns `None` if hmtx is unavailable.
521
4900
fn glyph_lsb(parsed_font: &ParsedFont, glyph_id: u16) -> Option<i16> {
522
4900
    let (off, len) = parsed_font.hmtx_range;
523
4900
    if len == 0 {
524
        return None;
525
4900
    }
526
4900
    let bytes = parsed_font.original_bytes.as_ref()?;
527
4899
    let hmtx = bytes.as_ref().get(off..off + len)?;
528
4899
    let num = usize::from(parsed_font.hhea_table.num_h_metrics);
529
4899
    if num == 0 {
530
1
        return None;
531
4898
    }
532
4898
    let gid = usize::from(glyph_id);
533
    // longHorMetric[i] = { advanceWidth: u16, lsb: i16 } (4 bytes) for i < num;
534
    // trailing leftSideBearing: i16 array for the remaining glyphs.
535
4898
    let lsb_off = if gid < num {
536
4709
        gid * 4 + 2
537
    } else {
538
189
        num * 4 + (gid - num) * 2
539
    };
540
4898
    let b = hmtx.get(lsb_off..lsb_off + 2)?;
541
4896
    Some(i16::from_be_bytes([b[0], b[1]]))
542
4900
}
543

            
544
5263
fn build_hinted_path(
545
5263
    glyph_id: u16,
546
5263
    glyph: &OwnedGlyph,
547
5263
    parsed_font: &ParsedFont,
548
5263
    ppem: u16,
549
5263
) -> Option<PathStorage> {
550
    use allsorts::hinting::f26dot6::F26Dot6;
551
5263
    let raw_points = glyph.raw_points.as_ref()?;
552
4992
    let raw_on_curve = glyph.raw_on_curve.as_ref()?;
553
4992
    let raw_contour_ends = glyph.raw_contour_ends.as_ref()?;
554
4992
    let instructions = glyph.instructions.as_ref()?;
555

            
556
4992
    if raw_points.is_empty() || raw_contour_ends.is_empty() {
557
1
        return None;
558
4991
    }
559

            
560
4991
    let hint_mutex = parsed_font.hint_instance.as_ref()?;
561
4894
    let mut hint = hint_mutex.lock().ok()?;
562

            
563
4894
    let upem = parsed_font.font_metrics.units_per_em;
564
4894
    if upem == 0 {
565
1
        return None;
566
4893
    }
567

            
568
    // Set up hinting for this ppem (scales CVT, runs prep)
569
4893
    if hint.set_ppem(ppem, f64::from(ppem)).is_err() {
570
        return None;
571
4893
    }
572

            
573
    // Scale raw points from font units to F26Dot6
574
4893
    let scale = allsorts::hinting::f26dot6::compute_scale(ppem, upem);
575

            
576
4893
    let points_f26dot6: Vec<(i32, i32)> = raw_points
577
4893
        .iter()
578
111056
        .map(|&(x, y)| {
579
111056
            let sx = F26Dot6::from_funits(i32::from(x), scale);
580
111056
            let sy = F26Dot6::from_funits(i32::from(y), scale);
581
111056
            (sx.to_bits(), sy.to_bits())
582
111056
        })
583
4893
        .collect();
584

            
585
    // Scale advance width to F26Dot6 for phantom points
586
4893
    let adv_f26dot6 = F26Dot6::from_funits(i32::from(glyph.horz_advance), scale).to_bits();
587

            
588
    // Phantom point pp1.x = (xMin - lsb) scaled (FreeType tt_loader_set_pp).
589
    // Threading the real lsb makes left-side-bearing grid-fitting match FreeType
590
    // for fonts where lsb != xMin; when lsb is unavailable, fall back to xMin
591
    // (i.e. lsb == xMin => pp1.x = 0, the previous hardcoded behaviour).
592
4893
    let x_min = i32::from(glyph.bounding_box.min_x);
593
4893
    let lsb = glyph_lsb(parsed_font, glyph_id).map_or(x_min, i32::from);
594
4893
    let pp1_x_f26dot6 = F26Dot6::from_funits(x_min - lsb, scale).to_bits();
595

            
596
    // Run hinting and capture the POST-hinting on-curve flags. FLIPPT/FLIPRGON/
597
    // FLIPRGOFF can flip a point between on-curve and off-curve during the glyph
598
    // program; the contour builder must use the updated flags, not the original
599
    // raw_on_curve, or it treats a flipped control point as a line endpoint (and
600
    // vice versa), kinking the outline.
601
4893
    let Ok((hinted, hinted_on_curve)) = hint.hint_glyph_with_flags_pp1(
602
4893
        &points_f26dot6,
603
4893
        raw_on_curve,
604
4893
        raw_contour_ends,
605
4893
        instructions,
606
4893
        adv_f26dot6,
607
4893
        pp1_x_f26dot6,
608
4893
    ) else {
609
        return None;
610
    };
611
4893
    drop(hint);
612

            
613
    // Optional "light" hinting (CoreText / DirectWrite grayscale style): keep the
614
    // grid-fitted Y (baseline, x-height and horizontal stems snap crisply to the
615
    // pixel grid) but restore the UNHINTED fractional X, so vertical stems stay
616
    // sub-pixel-positioned and anti-alias to soft gray instead of snapping to a
617
    // hard full-black 1px column (Windows-style full grid-fit). This is what
618
    // converges our CPU text onto CoreText — full bytecode hinting over-snaps X,
619
    // rendering stems thinner + darker than CoreText's. On by default; set
620
    // AZ_HINT_LIGHT=0 to force Windows-style full grid-fit. The interpreter still
621
    // runs in full (its Y output + FLIP'd on-curve flags are used), so no hinting
622
    // correctness is lost — only the X axis is left sub-pixel for grayscale AA.
623
4893
    if hint_light_enabled() {
624
4893
        let light: Vec<(i32, i32)> = hinted
625
4893
            .iter()
626
4893
            .enumerate()
627
111056
            .map(|(i, &(hx, hy))| (points_f26dot6.get(i).map_or(hx, |p| p.0), hy))
628
4893
            .collect();
629
4893
        return build_path_from_contours(&light, &hinted_on_curve, raw_contour_ends);
630
    }
631

            
632
    // Build path from hinted points using TrueType quadratic contour conventions
633
    build_path_from_contours(&hinted, &hinted_on_curve, raw_contour_ends)
634
5263
}
635

            
636
/// Whether to apply CoreText-style "light" hinting (grid-fit Y only, fractional X).
637
/// ON by default (matches CoreText / modern browser grayscale rendering); set
638
/// `AZ_HINT_LIGHT=0` (or `false`) to force Windows-style full grid-fit. Read once.
639
866484
pub(crate) fn hint_light_enabled() -> bool {
640
    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
641
866484
    *V.get_or_init(|| {
642
20
        std::env::var("AZ_HINT_LIGHT")
643
20
            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
644
20
            .unwrap_or(true)
645
20
    })
646
866484
}
647

            
648
/// Whether to place each glyph ORIGIN at a sub-pixel HORIZONTAL position (a
649
/// 1/4-pixel X bucket) instead of snapping it to a whole pixel.
650
///
651
/// ON by default. This is the horizontal half of the light-hinting philosophy
652
/// (crisp vertical, soft/sub-pixel horizontal): the grid-fitted glyph *outline*
653
/// still snaps its stems and baseline to the pixel grid, but the pen advances
654
/// accumulate at fractional precision so a run of glyphs lands where CoreText
655
/// (which positions glyphs at fractional x) puts them, rather than each origin
656
/// rounding to an integer pixel and drifting the whole line. Set
657
/// `AZ_TEXT_SUBPIXEL=0` (or `false`) to force integer X placement. Read once.
658
7090324
pub(crate) fn text_subpixel_enabled() -> bool {
659
    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
660
7090324
    *V.get_or_init(|| {
661
20
        std::env::var("AZ_TEXT_SUBPIXEL")
662
20
            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
663
20
            .unwrap_or(true)
664
20
    })
665
7090324
}
666

            
667
/// Build an agg `PathStorage` from TrueType contour data (points in `F26Dot6`).
668
///
669
/// Matches allsorts' `visit_simple_glyph_outline` algorithm exactly:
670
/// - On-curve points are endpoints of line/curve segments
671
/// - Off-curve points are quadratic Bézier control points
672
/// - Two consecutive off-curve points have an implicit on-curve midpoint
673
/// - Y is negated for screen coordinates (font Y-up → screen Y-down)
674
/// - The origin point is NOT revisited in the loop; `close()` handles the final segment
675
4911
#[must_use] pub fn build_path_from_contours(
676
4911
    points: &[(i32, i32)],
677
4911
    on_curve: &[bool],
678
4911
    contour_ends: &[u16],
679
4911
) -> Option<PathStorage> {
680
    use agg_rust::basics::PATH_FLAGS_NONE;
681

            
682
4911
    let mut path = PathStorage::new();
683
4911
    let mut has_ops = false;
684
4911
    let mut contour_start = 0usize;
685

            
686
15208
    for &end_idx in contour_ends {
687
10297
        let end = end_idx as usize;
688
10297
        if end >= points.len() || contour_start > end {
689
7
            contour_start = end + 1;
690
7
            continue;
691
10290
        }
692

            
693
10290
        let pts = &points[contour_start..=end];
694
10290
        let flags = &on_curve[contour_start..=end];
695
10290
        let n = pts.len();
696
10290
        if n < 2 {
697
28
            contour_start = end + 1;
698
28
            continue;
699
10262
        }
700

            
701
        // Helper: get point as (f64, f64) with Y negated
702
133650
        let px = |i: usize| -> (f64, f64) {
703
133650
            (f64::from(f26_to_px(pts[i].0)), f64::from(-f26_to_px(pts[i].1)))
704
133650
        };
705
22604
        let mid = |a: (f64, f64), b: (f64, f64)| -> (f64, f64) {
706
22593
            ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5)
707
22593
        };
708

            
709
        // Determine origin and processing range (matching allsorts' calculate_origin)
710
10262
        let (origin, start, until) = if flags[0] {
711
10224
            (px(0), 1usize, n)
712
38
        } else if flags[n - 1] {
713
37
            (px(n - 1), 0usize, n - 1)
714
        } else {
715
1
            (mid(px(0), px(n - 1)), 0usize, n)
716
        };
717

            
718
10262
        path.move_to(origin.0, origin.1);
719
10262
        has_ops = true;
720

            
721
10262
        let mut i = start;
722
94708
        while i < until {
723
84446
            if flags[i] {
724
43085
                // On-curve: line segment
725
43085
                let to = px(i);
726
43085
                path.line_to(to.0, to.1);
727
43085
                i += 1;
728
43085
            } else {
729
                // Off-curve control point
730
41361
                let ctrl = px(i);
731
41361
                let next = i + 1;
732
41361
                if next < until {
733
38941
                    if flags[next] {
734
16349
                        // Next is on-curve: quad to it, consume both
735
16349
                        let to = px(next);
736
16349
                        path.curve3(ctrl.0, ctrl.1, to.0, to.1);
737
16349
                        i = next + 1;
738
22592
                    } else {
739
22592
                        // Next is also off-curve: quad to implicit midpoint
740
22592
                        let m = mid(ctrl, px(next));
741
22592
                        path.curve3(ctrl.0, ctrl.1, m.0, m.1);
742
22592
                        i = next;
743
22592
                    }
744
2420
                } else {
745
2420
                    // End of range: curve back to origin
746
2420
                    path.curve3(ctrl.0, ctrl.1, origin.0, origin.1);
747
2420
                    i = next;
748
2420
                }
749
            }
750
        }
751
10262
        path.close_polygon(PATH_FLAGS_NONE);
752

            
753
10262
        contour_start = end + 1;
754
    }
755

            
756
4911
    if !has_ops {
757
8
        return None;
758
4903
    }
759
4903
    Some(path)
760
4911
}
761

            
762
/// Convert `F26Dot6` value to pixel coordinate (f32).
763
#[inline]
764
#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
765
267339
fn f26_to_px(v: i32) -> f32 {
766
267339
    v as f32 / 64.0
767
267339
}
768

            
769
#[cfg(test)]
770
#[allow(
771
    // exact float comparisons are intentional: power-of-two / midpoint
772
    // arithmetic with exactly representable results
773
    clippy::float_cmp,
774
    clippy::cast_precision_loss,
775
    clippy::cast_possible_truncation,
776
    clippy::cast_lossless
777
)]
778
mod autotest_generated {
779
    use std::{
780
        panic::{catch_unwind, AssertUnwindSafe},
781
        sync::Arc,
782
    };
783

            
784
    use agg_rust::basics::{
785
        PATH_CMD_CURVE3, PATH_CMD_END_POLY, PATH_CMD_LINE_TO, PATH_CMD_MOVE_TO, PATH_FLAGS_CLOSE,
786
    };
787
    use azul_core::resources::OwnedGlyphBoundingBox;
788

            
789
    use super::*;
790

            
791
    // ---------------------------------------------------------------------
792
    // helpers
793
    // ---------------------------------------------------------------------
794

            
795
    /// A real system/repo font, or `None` — font-dependent tests skip rather
796
    /// than guess. Source bytes are retained so `glyph_lsb` can read `hmtx`.
797
    fn test_font() -> Option<ParsedFont> {
798
        let candidates = [
799
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
800
            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
801
            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
802
            "C:/Windows/Fonts/arial.ttf",
803
            concat!(
804
                env!("CARGO_MANIFEST_DIR"),
805
                "/../examples/assets/fonts/SourceSerifPro-Regular.ttf"
806
            ),
807
        ];
808
        for path in candidates {
809
            let Ok(bytes) = std::fs::read(path) else {
810
                continue;
811
            };
812
            let arc = Arc::new(rust_fontconfig::FontBytes::Owned(Arc::from(
813
                bytes.as_slice(),
814
            )));
815
            if let Some(font) =
816
                ParsedFont::from_bytes(&bytes, 0, &mut Vec::new()).map(|f| f.with_source_bytes(arc))
817
            {
818
                return Some(font);
819
            }
820
        }
821
        None
822
    }
823

            
824
    /// A glyph with no outline at all (the "space character" shape the
825
    /// `get_or_build` doc mentions) — buildable without a font.
826
    fn empty_glyph() -> OwnedGlyph {
827
        OwnedGlyph {
828
            bounding_box: OwnedGlyphBoundingBox {
829
                max_x: 0,
830
                max_y: 0,
831
                min_x: 0,
832
                min_y: 0,
833
            },
834
            horz_advance: 0,
835
            outline: Vec::new(),
836
            phantom_points: None,
837
            raw_points: None,
838
            raw_on_curve: None,
839
            raw_contour_ends: None,
840
            instructions: None,
841
        }
842
    }
843

            
844
    /// `('A', decoded glyph)` from `font`, if the font has one with an outline.
845
    fn glyph_a(font: &ParsedFont) -> Option<(u16, Arc<OwnedGlyph>)> {
846
        let gid = font.lookup_glyph_index('A' as u32)?;
847
        let glyph = font.get_or_decode_glyph(gid)?;
848
        Some((gid, glyph))
849
    }
850

            
851
    // ---------------------------------------------------------------------
852
    // quantize_subpx — numeric
853
    // ---------------------------------------------------------------------
854

            
855
    #[test]
856
    fn quantize_subpx_zero_and_quarter_buckets() {
857
        assert_eq!(quantize_subpx(0.0), 0);
858
        assert_eq!(quantize_subpx(0.24), 0);
859
        assert_eq!(quantize_subpx(0.25), 1);
860
        assert_eq!(quantize_subpx(0.5), 2);
861
        assert_eq!(quantize_subpx(0.75), 3);
862
        // 0.999 * 4 == 3.996, clamped by `.min(3.0)` — must not reach bucket 4.
863
        assert_eq!(quantize_subpx(0.999), 3);
864
        // Only the fractional part matters: whole pixels drop out.
865
        assert_eq!(quantize_subpx(1.0), 0);
866
        assert_eq!(quantize_subpx(2.25), 1);
867
        assert_eq!(quantize_subpx(1024.5), 2);
868
    }
869

            
870
    #[test]
871
    fn quantize_subpx_negative_inputs_use_floor_not_truncation() {
872
        // frac - frac.floor() is always in [0, 1), so a negative x lands in the
873
        // bucket of its positive fractional remainder (-0.25 => 0.75 => 3).
874
        assert_eq!(quantize_subpx(-0.25), 3);
875
        assert_eq!(quantize_subpx(-0.5), 2);
876
        assert_eq!(quantize_subpx(-0.75), 1);
877
        assert_eq!(quantize_subpx(-1.0), 0);
878
        assert_eq!(quantize_subpx(-0.0), 0);
879
        assert_eq!(quantize_subpx(-7.25), 3);
880
    }
881

            
882
    #[test]
883
    fn quantize_subpx_nan_and_infinities_are_defined() {
884
        // NaN.floor() == NaN, NaN - NaN == NaN, and f32::min ignores NaN, so the
885
        // clamp yields 3.0. Defined and non-panicking, if arguably surprising:
886
        // a NaN position buckets as if it were 3/4 of a pixel.
887
        assert_eq!(quantize_subpx(f32::NAN), 3);
888
        assert_eq!(quantize_subpx(f32::INFINITY), 3);
889
        assert_eq!(quantize_subpx(f32::NEG_INFINITY), 3);
890
    }
891

            
892
    #[test]
893
    fn quantize_subpx_never_exceeds_three() {
894
        let extremes = [
895
            f32::MAX,
896
            f32::MIN,
897
            f32::MIN_POSITIVE,
898
            -f32::MIN_POSITIVE,
899
            f32::EPSILON,
900
            1e30,
901
            -1e30,
902
            16_777_216.0, // 2^24: f32 loses the fractional bit entirely
903
            -16_777_217.0,
904
            f32::NAN,
905
            f32::INFINITY,
906
            f32::NEG_INFINITY,
907
        ];
908
        for v in extremes {
909
            assert!(
910
                quantize_subpx(v) <= 3,
911
                "quantize_subpx({v}) escaped the 0..=3 bucket range"
912
            );
913
        }
914
        for i in -2000..2000 {
915
            let v = f64_to_f32(f64::from(i) * 0.017);
916
            assert!(quantize_subpx(v) <= 3, "quantize_subpx({v}) > 3");
917
        }
918
    }
919

            
920
    #[allow(clippy::cast_possible_truncation)]
921
    fn f64_to_f32(v: f64) -> f32 {
922
        v as f32
923
    }
924

            
925
    // ---------------------------------------------------------------------
926
    // f26_to_px — numeric
927
    // ---------------------------------------------------------------------
928

            
929
    #[test]
930
    fn f26_to_px_zero_and_exact_fractions() {
931
        assert_eq!(f26_to_px(0), 0.0);
932
        assert_eq!(f26_to_px(64), 1.0);
933
        assert_eq!(f26_to_px(-64), -1.0);
934
        assert_eq!(f26_to_px(32), 0.5);
935
        assert_eq!(f26_to_px(16), 0.25);
936
        assert_eq!(f26_to_px(1), 1.0 / 64.0);
937
        assert_eq!(f26_to_px(-1), -1.0 / 64.0);
938
    }
939

            
940
    #[test]
941
    fn f26_to_px_min_max_stay_finite() {
942
        // i32::MAX rounds up to 2^31 in f32; both ends are exactly ±2^25 px.
943
        assert_eq!(f26_to_px(i32::MAX), 33_554_432.0);
944
        assert_eq!(f26_to_px(i32::MIN), -33_554_432.0);
945
        assert!(f26_to_px(i32::MAX).is_finite());
946
        assert!(f26_to_px(i32::MIN).is_finite());
947
    }
948

            
949
    #[test]
950
    fn f26_to_px_round_trips_for_exactly_representable_inputs() {
951
        // /64 is a power-of-two scaling: exact (no rounding) for |v| <= 2^24.
952
        for v in [
953
            0,
954
            1,
955
            -1,
956
            63,
957
            64,
958
            -64,
959
            4096,
960
            -4096,
961
            8_388_607,
962
            -8_388_607,
963
            16_777_216,
964
            -16_777_216,
965
        ] {
966
            let px = f26_to_px(v);
967
            assert_eq!(px * 64.0, v as f32, "f26_to_px({v}) did not round-trip");
968
        }
969
    }
970

            
971
    #[test]
972
    fn f26_to_px_is_monotonic() {
973
        let ladder = [
974
            i32::MIN,
975
            -1_000_000,
976
            -64,
977
            -1,
978
            0,
979
            1,
980
            64,
981
            1_000_000,
982
            i32::MAX,
983
        ];
984
        for w in ladder.windows(2) {
985
            assert!(
986
                f26_to_px(w[0]) <= f26_to_px(w[1]),
987
                "f26_to_px is not monotonic between {} and {}",
988
                w[0],
989
                w[1]
990
            );
991
        }
992
    }
993

            
994
    // ---------------------------------------------------------------------
995
    // build_path_from_contours — structure / round-trip of the TrueType rules
996
    // ---------------------------------------------------------------------
997

            
998
    #[test]
999
    fn build_path_from_contours_empty_inputs_return_none() {
        assert!(build_path_from_contours(&[], &[], &[]).is_none());
        // Points but no contours => nothing emitted.
        assert!(build_path_from_contours(&[(0, 0), (64, 0)], &[true, true], &[]).is_none());
        // Contour end but no points => out-of-range end, skipped.
        assert!(build_path_from_contours(&[], &[], &[0]).is_none());
    }
    #[test]
    fn build_path_from_contours_single_point_contour_is_skipped() {
        // n < 2 => degenerate contour, no path ops at all.
        assert!(build_path_from_contours(&[(0, 0)], &[true], &[0]).is_none());
    }
    #[test]
    fn build_path_from_contours_out_of_range_contour_end_is_skipped_not_indexed() {
        let pts = [(0, 0), (64, 64)];
        let oc = [true, true];
        assert!(build_path_from_contours(&pts, &oc, &[10]).is_none());
        // u16::MAX end: `end + 1` must not overflow the usize cursor either.
        assert!(build_path_from_contours(&pts, &oc, &[u16::MAX]).is_none());
        // Skipping a bogus end still advances the cursor to `end + 1`, so every
        // later contour falls into the `contour_start > end` branch too. The
        // whole glyph fails closed (None) instead of indexing out of bounds.
        assert!(
            build_path_from_contours(&pts, &oc, &[99, 1]).is_none(),
            "a bogus contour end poisons the cursor for the rest of the glyph"
        );
    }
    #[test]
    fn build_path_from_contours_line_contour_emits_move_line_close() {
        let path = build_path_from_contours(&[(0, 0), (128, 64)], &[true, true], &[1])
            .expect("two on-curve points form a contour");
        let v = path.vertices();
        assert_eq!(v.len(), 3, "expected move_to + line_to + close");
        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
        assert_eq!(v[0].x, 0.0);
        assert_eq!(v[0].y, 0.0);
        assert_eq!(v[1].cmd, PATH_CMD_LINE_TO);
        assert_eq!(v[1].x, 2.0, "128 F26Dot6 units == 2 px");
        assert_eq!(v[1].y, -1.0, "Y must be negated for screen coords");
        assert_eq!(v[2].cmd, PATH_CMD_END_POLY | PATH_FLAGS_CLOSE);
    }
    #[test]
    fn build_path_from_contours_offcurve_point_becomes_curve3() {
        let path = build_path_from_contours(
            &[(0, 0), (64, 64), (128, 0)],
            &[true, false, true],
            &[2],
        )
        .expect("on/off/on contour");
        let v = path.vertices();
        assert_eq!(v.len(), 4, "move_to + curve3(ctrl,to) + close");
        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
        assert_eq!(v[1].cmd, PATH_CMD_CURVE3);
        assert_eq!((v[1].x, v[1].y), (1.0, -1.0), "control point");
        assert_eq!(v[2].cmd, PATH_CMD_CURVE3);
        assert_eq!((v[2].x, v[2].y), (2.0, 0.0), "curve endpoint");
        assert_eq!(v[3].cmd, PATH_CMD_END_POLY | PATH_FLAGS_CLOSE);
    }
    #[test]
    fn build_path_from_contours_two_offcurve_points_insert_implicit_midpoint() {
        let path = build_path_from_contours(
            &[(0, 0), (64, 64), (128, 64), (192, 0)],
            &[true, false, false, true],
            &[3],
        )
        .expect("on/off/off/on contour");
        let v = path.vertices();
        assert_eq!(v.len(), 6, "move_to + 2 curve3 + close");
        // First quad ends at the implicit midpoint of the two control points:
        // mid((1,-1), (2,-1)) == (1.5, -1).
        assert_eq!((v[1].x, v[1].y), (1.0, -1.0));
        assert_eq!((v[2].x, v[2].y), (1.5, -1.0), "implicit on-curve midpoint");
        assert_eq!((v[3].x, v[3].y), (2.0, -1.0));
        assert_eq!((v[4].x, v[4].y), (3.0, 0.0));
    }
    #[test]
    fn build_path_from_contours_all_offcurve_closes_back_to_the_synthetic_origin() {
        // No on-curve point anywhere: origin is the midpoint of first & last,
        // and the final curve must return to it.
        let path = build_path_from_contours(
            &[(0, 0), (64, 64), (128, 0)],
            &[false, false, false],
            &[2],
        )
        .expect("all-off-curve contour");
        let v = path.vertices();
        assert_eq!(v.len(), 8, "move_to + 3 curve3 + close");
        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
        assert_eq!((v[0].x, v[0].y), (1.0, 0.0), "origin = mid(first, last)");
        let last_pt = v[6];
        assert_eq!(
            (last_pt.x, last_pt.y),
            (v[0].x, v[0].y),
            "final curve3 must land back on the origin"
        );
        assert_eq!(v[7].cmd, PATH_CMD_END_POLY | PATH_FLAGS_CLOSE);
    }
    #[test]
    fn build_path_from_contours_leading_offcurve_uses_trailing_oncurve_as_origin() {
        // flags[0] off, flags[n-1] on => origin is the LAST point, range [0, n-1).
        let path = build_path_from_contours(&[(64, 64), (128, 0)], &[false, true], &[1])
            .expect("off/on contour");
        let v = path.vertices();
        assert_eq!(v.len(), 4);
        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
        assert_eq!((v[0].x, v[0].y), (2.0, 0.0), "origin is the on-curve point");
        assert_eq!(v[1].cmd, PATH_CMD_CURVE3);
        assert_eq!((v[2].x, v[2].y), (2.0, 0.0), "curve returns to the origin");
    }
    #[test]
    fn build_path_from_contours_multiple_contours_emit_multiple_subpaths() {
        let pts = [(0, 0), (64, 0), (0, 64), (64, 64)];
        let oc = [true; 4];
        let path = build_path_from_contours(&pts, &oc, &[1, 3]).expect("two contours");
        let v = path.vertices();
        assert_eq!(v.len(), 6, "two × (move_to + line_to + close)");
        let moves = v.iter().filter(|x| x.cmd == PATH_CMD_MOVE_TO).count();
        assert_eq!(moves, 2, "each contour must start its own subpath");
    }
    #[test]
    fn build_path_from_contours_non_monotonic_contour_ends_do_not_panic() {
        let pts = [(0, 0), (64, 0), (0, 64), (64, 64)];
        let oc = [true; 4];
        // Descending ends: the second contour has contour_start > end => skipped.
        let path = build_path_from_contours(&pts, &oc, &[1, 0]).expect("first contour builds");
        assert_eq!(path.vertices().len(), 3);
        // Duplicate ends: likewise skipped, not re-emitted.
        let dup = build_path_from_contours(&pts, &oc, &[1, 1]).expect("first contour builds");
        assert_eq!(dup.vertices().len(), 3);
    }
    #[test]
    fn build_path_from_contours_extra_on_curve_flags_are_harmless() {
        // on_curve longer than points: the extra flags must simply go unread.
        let path = build_path_from_contours(&[(0, 0), (64, 0)], &[true; 8], &[1])
            .expect("contour builds with a too-long flag slice");
        assert_eq!(path.vertices().len(), 3);
    }
    #[test]
    #[should_panic(expected = "out of range")]
    fn build_path_from_contours_short_on_curve_slice_panics() {
        // BUG (documented, not weakened): the bounds check only compares the
        // contour end against `points.len()`, then slices `on_curve` with the
        // same range. A caller passing a shorter `on_curve` than `points` gets
        // an index-out-of-bounds panic out of a `pub fn` that otherwise
        // signals failure by returning `None`.
        let _ = build_path_from_contours(&[(0, 0), (64, 0)], &[true], &[1]);
    }
    #[test]
    fn build_path_from_contours_extreme_coordinates_stay_finite() {
        let pts = [(i32::MIN, i32::MAX), (i32::MAX, i32::MIN), (0, 0)];
        let oc = [true, false, true];
        let path = build_path_from_contours(&pts, &oc, &[2]).expect("extreme contour still builds");
        for v in path.vertices() {
            assert!(
                v.x.is_finite() && v.y.is_finite(),
                "F26Dot6 extremes produced a non-finite vertex: ({}, {})",
                v.x,
                v.y
            );
        }
    }
    // ---------------------------------------------------------------------
    // env-backed predicates
    // ---------------------------------------------------------------------
    #[test]
    fn hint_light_enabled_is_stable_across_calls() {
        // OnceLock-backed: whatever the env says, every call must agree.
        let first = hint_light_enabled();
        assert_eq!(first, hint_light_enabled());
        assert_eq!(first, hint_light_enabled());
    }
    #[test]
    fn text_subpixel_enabled_is_stable_across_calls() {
        let first = text_subpixel_enabled();
        assert_eq!(first, text_subpixel_enabled());
        assert_eq!(first, text_subpixel_enabled());
    }
    // ---------------------------------------------------------------------
    // GlyphCache::new / paths_len / cells_len
    // ---------------------------------------------------------------------
    #[test]
    fn new_cache_is_empty_and_default_matches_new() {
        let cache = GlyphCache::new();
        assert_eq!(cache.paths_len(), 0);
        assert_eq!(cache.cells_len(), 0);
        let def = GlyphCache::default();
        assert_eq!(def.paths_len(), 0);
        assert_eq!(def.cells_len(), 0);
    }
    #[test]
    fn debug_impl_reports_entry_counts_without_touching_agg_types() {
        let cache = GlyphCache::new();
        let s = format!("{cache:?}");
        assert!(s.contains("GlyphCache"), "{s}");
        assert!(s.contains("paths"), "{s}");
        assert!(s.contains("cells"), "{s}");
    }
    // ---------------------------------------------------------------------
    // get_or_build_cells — numeric (needs no font: a path-cache miss is a
    // legitimate, reachable state)
    // ---------------------------------------------------------------------
    #[test]
    fn get_or_build_cells_without_a_cached_path_returns_none_and_negative_caches() {
        let mut cache = GlyphCache::new();
        let got = cache
            .get_or_build_cells(1, 2, 16, 0.0, 0.0, 1.0, false, 1.0)
            .map(|(cells, x, y)| (cells.len(), x, y));
        assert_eq!(got, None, "no path cached => no cells");
        assert_eq!(cache.cells_len(), 1, "the miss must be negative-cached");
        // Idempotent: a repeat lookup does not add a second entry.
        let again = cache
            .get_or_build_cells(1, 2, 16, 0.0, 0.0, 1.0, false, 1.0)
            .map(|(cells, _, _)| cells.len());
        assert_eq!(again, None);
        assert_eq!(cache.cells_len(), 1);
    }
    #[test]
    fn get_or_build_cells_keys_on_the_quarter_pixel_bucket_not_the_raw_position() {
        let mut cache = GlyphCache::new();
        // All of these have a fractional part < 0.25 => same sub-pixel bucket.
        for x in [0.0_f32, 0.1, 0.2, 5.24, 100.0] {
            let _ = cache.get_or_build_cells(7, 3, 16, x, 0.0, 1.0, false, 1.0);
        }
        assert_eq!(cache.cells_len(), 1, "same bucket must reuse one entry");
        // A different bucket (0.5 => bucket 2) is a different key.
        let _ = cache.get_or_build_cells(7, 3, 16, 0.5, 0.0, 1.0, false, 1.0);
        assert_eq!(cache.cells_len(), 2);
        // A different scale is a different key too (scale_fixed is in the key).
        let _ = cache.get_or_build_cells(7, 3, 16, 0.5, 0.0, 2.0, false, 1.0);
        assert_eq!(cache.cells_len(), 3);
    }
    #[test]
    fn get_or_build_cells_extreme_arguments_do_not_panic() {
        let mut cache = GlyphCache::new();
        // is_hinted + scale 0.0 keeps the fixed-point debug_assert satisfied
        // while pushing every *other* argument to its limit.
        for x in [
            0.0_f32,
            f32::MAX,
            f32::MIN,
            f32::NAN,
            f32::INFINITY,
            f32::NEG_INFINITY,
            -0.75,
        ] {
            let got = cache
                .get_or_build_cells(u64::MAX, u16::MAX, u16::MAX, x, x, 0.0, true, 1.0)
                .map(|(cells, _, _)| cells.len());
            assert_eq!(got, None, "empty path cache must yield None for x = {x}");
        }
        // Zero everywhere.
        let zeroed = cache
            .get_or_build_cells(0, 0, 0, 0.0, 0.0, 0.0, false, 0.0)
            .map(|(cells, _, _)| cells.len());
        assert_eq!(zeroed, None);
        // Largest in-range scale (the debug_assert's exclusive upper bound - 1).
        let big = cache
            .get_or_build_cells(0, 0, 0, 0.0, 0.0, 65_535.0, false, 1.0)
            .map(|(cells, _, _)| cells.len());
        assert_eq!(big, None);
    }
    #[test]
    fn get_or_build_cells_nan_hint_correction_falls_back_to_grid_snapped() {
        // (NaN - 1.0).abs() > 1e-4 is FALSE, so a NaN hint_correction is treated
        // exactly like the no-rescale case (scale_fixed = 0) rather than being
        // cast to a garbage fixed-point key. Same key => no extra entry.
        let mut cache = GlyphCache::new();
        let _ = cache.get_or_build_cells(9, 1, 12, 3.5, 4.5, 0.0, true, 1.0);
        assert_eq!(cache.cells_len(), 1);
        let _ = cache.get_or_build_cells(9, 1, 12, 3.5, 4.5, 0.0, true, f32::NAN);
        assert_eq!(
            cache.cells_len(),
            1,
            "NaN hint_correction must collapse onto the grid-snapped key"
        );
    }
    #[test]
    fn get_or_build_cells_negative_scale_is_debug_asserted() {
        let mut cache = GlyphCache::new();
        let res = catch_unwind(AssertUnwindSafe(|| {
            cache
                .get_or_build_cells(1, 1, 16, 0.0, 0.0, -1.0, false, 1.0)
                .map(|(cells, _, _)| cells.len())
        }));
        if cfg!(debug_assertions) {
            assert!(
                res.is_err(),
                "a negative scale must trip the fixed-point range debug_assert"
            );
        } else {
            // Release: the f32 -> u32 cast saturates to 0 instead of wrapping.
            assert_eq!(res.ok().flatten(), None);
        }
    }
    #[test]
    fn get_or_build_cells_rotates_generations_at_the_entry_limit() {
        let mut cache = GlyphCache::new();
        for i in 0..MAX_CELL_ENTRIES as u64 {
            let _ = cache.get_or_build_cells(i, 0, 16, 0.0, 0.0, 1.0, false, 1.0);
        }
        assert_eq!(cache.cells_len(), MAX_CELL_ENTRIES);
        // One past the limit rotates rather than clearing.
        let _ = cache.get_or_build_cells(u64::MAX, 0, 16, 0.0, 0.0, 1.0, false, 1.0);
        assert_eq!(
            cache.cells_len(),
            MAX_CELL_ENTRIES + 1,
            "rotation must DEMOTE the old generation, not delete it"
        );
        for i in 0..MAX_CELL_ENTRIES as u64 {
            let _ = cache.get_or_build_cells(1_000_000 + i, 0, 16, 0.0, 0.0, 1.0, false, 1.0);
        }
        assert!(
            cache.cells_len() <= 2 * MAX_CELL_ENTRIES,
            "cell cache must not grow unbounded, got {}",
            cache.cells_len()
        );
        // `gc()` is the post-present hook: it drops the older generation.
        cache.gc();
        assert!(
            cache.cells_len() <= MAX_CELL_ENTRIES,
            "gc() must release the previous generation"
        );
    }
    // ---------------------------------------------------------------------
    // get_or_build — needs a ParsedFont (skipped when no font is available)
    // ---------------------------------------------------------------------
    #[test]
    fn get_or_build_outlineless_glyph_returns_none_and_caches_the_miss() {
        let Some(font) = test_font() else {
            return; // no font on this machine: skip rather than guess
        };
        let mut cache = GlyphCache::new();
        let glyph = empty_glyph();
        let got = cache
            .get_or_build(1, 0, &glyph, &font, 0)
            .map(|c| c.is_hinted);
        assert_eq!(got, None, "a glyph with no outline must yield None");
        assert_eq!(cache.paths_len(), 1, "the miss must be negative-cached");
    }
    #[test]
    fn get_or_build_extreme_ids_and_ppem_do_not_panic() {
        let Some(font) = test_font() else {
            return;
        };
        let mut cache = GlyphCache::new();
        let glyph = empty_glyph();
        for (hash, gid, ppem) in [
            (0_u64, 0_u16, 0_u16),
            (u64::MAX, u16::MAX, u16::MAX),
            (u64::MAX, 0, 1),
            (0, u16::MAX, u16::MAX),
        ] {
            let got = cache
                .get_or_build(hash, gid, &glyph, &font, ppem)
                .map(|c| c.is_hinted);
            assert_eq!(got, None, "outline-less glyph at ppem {ppem}");
        }
        assert_eq!(cache.paths_len(), 4, "each (hash, gid, ppem) is its own key");
    }
    #[test]
    fn get_or_build_is_idempotent_and_ppem_is_part_of_the_key() {
        let Some(font) = test_font() else {
            return;
        };
        let Some((gid, glyph)) = glyph_a(&font) else {
            return;
        };
        let mut cache = GlyphCache::new();
        // ppem == 0 => unhinted path, in font units.
        let first = cache
            .get_or_build(font.hash, gid, &glyph, &font, 0)
            .map(|c| (c.is_hinted, c.path.total_vertices()));
        let Some((is_hinted, verts)) = first else {
            return; // 'A' has no outline in this font: nothing to assert
        };
        assert!(!is_hinted, "ppem == 0 must not produce a hinted path");
        assert!(verts > 0, "an outlined glyph must emit vertices");
        assert_eq!(cache.paths_len(), 1);
        // Second lookup is a cache hit: same result, no new entry.
        let second = cache
            .get_or_build(font.hash, gid, &glyph, &font, 0)
            .map(|c| (c.is_hinted, c.path.total_vertices()));
        assert_eq!(second, Some((is_hinted, verts)));
        assert_eq!(cache.paths_len(), 1, "a hit must not insert a second entry");
        // A different ppem is a different key.
        let _ = cache.get_or_build(font.hash, gid, &glyph, &font, 16);
        assert_eq!(cache.paths_len(), 2);
    }
    #[test]
    fn get_or_build_rotates_generations_at_the_entry_limit() {
        let Some(font) = test_font() else {
            return;
        };
        let mut cache = GlyphCache::new();
        let glyph = empty_glyph(); // no outline => cheap negative entries
        for i in 0..MAX_PATH_ENTRIES as u64 {
            let _ = cache.get_or_build(i, 0, &glyph, &font, 0);
        }
        assert_eq!(cache.paths_len(), MAX_PATH_ENTRIES);
        // One past the limit rotates: the full young map becomes `prev` and
        // a fresh one starts. Bounded, but NOT emptied.
        let _ = cache.get_or_build(u64::MAX, 0, &glyph, &font, 0);
        assert_eq!(
            cache.paths_len(),
            MAX_PATH_ENTRIES + 1,
            "rotation must DEMOTE the old generation, not delete it — clearing \
             wholesale re-hints the entire visible page on whichever keystroke \
             happens to cross the limit"
        );
        // The point of keeping it: an entry that was live before the
        // rotation is served from `prev` and promoted, not rebuilt.
        let _ = cache.get_or_build(0, 0, &glyph, &font, 0);
        assert!(
            cache.paths_len() <= 2 * MAX_PATH_ENTRIES,
            "two generations is the whole budget; a third would be unbounded"
        );
        // Filling a second generation must evict the FIRST, never both.
        for i in 0..MAX_PATH_ENTRIES as u64 {
            let _ = cache.get_or_build(1_000_000 + i, 0, &glyph, &font, 0);
        }
        assert!(
            cache.paths_len() <= 2 * MAX_PATH_ENTRIES,
            "path cache must not grow unbounded, got {}",
            cache.paths_len()
        );
    }
    // ---------------------------------------------------------------------
    // glyph_lsb — numeric / bounds
    // ---------------------------------------------------------------------
    #[test]
    fn glyph_lsb_without_source_bytes_is_none() {
        let Some(font) = test_font() else {
            return;
        };
        let mut stripped = font;
        stripped.original_bytes = None;
        assert_eq!(
            glyph_lsb(&stripped, 0),
            None,
            "no retained font bytes => no hmtx to read"
        );
    }
    #[test]
    fn glyph_lsb_zero_h_metrics_is_none() {
        let Some(mut font) = test_font() else {
            return;
        };
        font.hhea_table.num_h_metrics = 0;
        assert_eq!(glyph_lsb(&font, 0), None, "num_h_metrics == 0 must bail out");
    }
    #[test]
    fn glyph_lsb_reads_gid_zero_and_rejects_out_of_range_gids() {
        let Some(font) = test_font() else {
            return;
        };
        let (_, len) = font.hmtx_range;
        let num = usize::from(font.hhea_table.num_h_metrics);
        if len > 0 && num > 0 && font.original_bytes.is_some() {
            assert!(
                glyph_lsb(&font, 0).is_some(),
                "gid 0 sits inside hmtx and must read back"
            );
        }
        // A gid whose lsb offset lands past the table must be bounds-rejected,
        // not indexed. (Mirrors the impl's offset arithmetic to know it is out
        // of range for THIS font rather than assuming a glyph count.)
        let gid = usize::from(u16::MAX);
        let lsb_off = if gid < num {
            gid * 4 + 2
        } else {
            num * 4 + (gid - num) * 2
        };
        if lsb_off + 2 > len {
            assert_eq!(
                glyph_lsb(&font, u16::MAX),
                None,
                "an out-of-table gid must return None, not panic"
            );
        }
        // Sweep the boundary around num_h_metrics (long metrics -> trailing
        // lsb-only array) — none of these may panic.
        for gid in [0_u16, 1, u16::MAX] {
            let _ = glyph_lsb(&font, gid);
        }
    }
    // ---------------------------------------------------------------------
    // build_hinted_path — guard clauses (the interpreter path itself is only
    // smoke-tested at a realistic ppem)
    // ---------------------------------------------------------------------
    #[test]
    fn build_hinted_path_without_raw_hinting_data_is_none() {
        let Some(font) = test_font() else {
            return;
        };
        let glyph = empty_glyph(); // raw_points / instructions all None
        assert!(build_hinted_path(0, &glyph, &font, 16).is_none());
        assert!(
            build_hinted_path(u16::MAX, &glyph, &font, u16::MAX).is_none(),
            "missing raw data must short-circuit before any hinting arithmetic"
        );
    }
    #[test]
    fn build_hinted_path_with_empty_contours_is_none() {
        let Some(font) = test_font() else {
            return;
        };
        let mut glyph = empty_glyph();
        glyph.raw_points = Some(Vec::new());
        glyph.raw_on_curve = Some(Vec::new());
        glyph.raw_contour_ends = Some(Vec::new());
        glyph.instructions = Some(Vec::new());
        assert!(
            build_hinted_path(0, &glyph, &font, 16).is_none(),
            "an empty point/contour list must bail out, not hint an empty glyph"
        );
    }
    #[test]
    fn build_hinted_path_without_a_hint_instance_is_none() {
        let Some(mut font) = test_font() else {
            return;
        };
        let Some((gid, glyph)) = glyph_a(&font) else {
            return;
        };
        if glyph.raw_points.is_none() || glyph.instructions.is_none() {
            return; // CFF / composite glyph: nothing to hint, test not applicable
        }
        font.hint_instance = None;
        assert!(
            build_hinted_path(gid, &glyph, &font, 16).is_none(),
            "no interpreter => no hinted path"
        );
    }
    #[test]
    fn build_hinted_path_with_zero_upem_is_none() {
        let Some(mut font) = test_font() else {
            return;
        };
        let Some((gid, glyph)) = glyph_a(&font) else {
            return;
        };
        if font.hint_instance.is_none() || glyph.raw_points.is_none() {
            return; // the upem guard sits behind the hint-instance guard
        }
        font.font_metrics.units_per_em = 0;
        assert!(
            build_hinted_path(gid, &glyph, &font, 16).is_none(),
            "upem == 0 must bail out before the divide-by-upem scale"
        );
    }
    #[test]
    fn build_hinted_path_at_a_realistic_ppem_produces_a_finite_path() {
        let Some(font) = test_font() else {
            return;
        };
        let Some((gid, glyph)) = glyph_a(&font) else {
            return;
        };
        let Some(path) = build_hinted_path(gid, &glyph, &font, 16) else {
            return; // unhinted font (CFF / no instructions): nothing to assert
        };
        assert!(path.total_vertices() > 0, "hinted path must have vertices");
        for v in path.vertices() {
            assert!(
                v.x.is_finite() && v.y.is_finite(),
                "hinting produced a non-finite vertex: ({}, {})",
                v.x,
                v.y
            );
        }
    }
}
// ============================================================================
// Pre-blended LCD glyph tiles (uniform-background fast path)
// ============================================================================
/// A pre-blended LCD glyph: the FINAL RGBA pixels of one glyph composited
/// against a known solid opaque background through the exact colorimetric
/// pipeline (`PixfmtRgba32LcdLinear` + the FIR LUT). Where the display-list
/// generator PROVED the run sits on that background (`Text.uniform_bg`),
/// painting the glyph is an opaque row copy — the per-pixel linear blend
/// (~6 ms of every big.md repaint) runs once per (glyph, color, bg) ever.
#[derive(Debug, Clone)]
pub struct LcdGlyphTile {
    pub w: u32,
    pub h: u32,
    /// Offset from the glyph's (`int_x`, `int_y`) anchor to the tile's top-left.
    pub dx: i32,
    pub dy: i32,
    pub rgba: Vec<u8>,
}
#[derive(Clone, PartialEq, Eq, Hash)]
struct LcdTileKey {
    font_hash: u64,
    glyph_id: u16,
    ppem: u16,
    scale_fixed: u32,
    subpx_x: u8,
    color: (u8, u8, u8, u8),
    bg: (u8, u8, u8),
}
/// Entries per generation; a body-text document uses a few hundred
/// distinct (glyph, subpixel, color, bg) combinations at ~1 KB each.
const MAX_TILE_ENTRIES: usize = 4096;
impl GlyphCache {
    /// Pre-blended tile for one glyph on a uniform opaque background.
    /// `None` = the glyph produced no cells (whitespace) — nothing to paint.
    #[allow(clippy::too_many_arguments)]
262390
    pub fn get_or_build_lcd_tile(
262390
        &mut self,
262390
        font_hash: u64,
262390
        glyph_id: u16,
262390
        ppem: u16,
262390
        glyph_x: f32,
262390
        glyph_y: f32,
262390
        scale: f32,
262390
        is_hinted: bool,
262390
        hint_correction: f32,
262390
        color: azul_css::props::basic::color::ColorU,
262390
        bg: azul_css::props::basic::color::ColorU,
262390
        lut: &agg_rust::pixfmt_lcd::LcdDistributionLut,
262390
        params: agg_rust::pixfmt_lcd::LcdBlendParams,
262390
    ) -> Option<(LcdGlyphTile, i32, i32)> {
262390
        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
262390
        let subpx = text_subpixel_enabled();
262390
        let (int_x, subpx_x) = if subpx {
262390
            (glyph_x.floor() as i32, quantize_subpx_lcd(glyph_x))
        } else {
            (glyph_x.round() as i32, 0)
        };
262390
        let int_y = glyph_y.round() as i32;
262390
        let scale_fixed = if is_hinted {
260028
            if rescale_hinted { (hint_correction * 65536.0) as u32 } else { 0 }
        } else {
2362
            (scale * 65536.0) as u32
        };
262390
        let key = LcdTileKey {
262390
            font_hash,
262390
            glyph_id,
262390
            ppem,
262390
            scale_fixed,
262390
            subpx_x,
262390
            color: (color.r, color.g, color.b, color.a),
262390
            bg: (bg.r, bg.g, bg.b),
262390
        };
262390
        if let Some(hit) = self.lcd_tiles.get(&key) {
253910
            return hit.clone().map(|t| (t, int_x, int_y));
8480
        }
8480
        if self.lcd_tiles.len() >= MAX_TILE_ENTRIES {
            self.lcd_tiles.clear(); // simple full-drop; rebuild is cheap
8480
        }
        // Cells for THIS glyph (cached themselves), cloned so we can borrow
        // self mutably for the insert below.
8480
        let cells: Vec<CellAa> = self
8480
            .get_or_build_cells_lcd(
8480
                font_hash, glyph_id, ppem, glyph_x, glyph_y, scale, is_hinted,
8480
                hint_correction,
            )
8480
            .map(|(c, _, _)| c.to_vec())?;
6149
        if cells.is_empty() {
            self.lcd_tiles.insert(key, None);
            return None;
6149
        }
        // Cell bbox: x in STRIPE coords (3 per pixel), y in pixels.
6149
        let (mut min_sx, mut max_sx, mut min_y, mut max_y) =
6149
            (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
483032
        for c in &cells {
476883
            min_sx = min_sx.min(c.x);
476883
            max_sx = max_sx.max(c.x);
476883
            min_y = min_y.min(c.y);
476883
            max_y = max_y.max(c.y);
476883
        }
        // Snap the stripe range outward to whole pixels, then pad 1 px on
        // each horizontal side: the 5-tap FIR distributes a stripe's energy
        // up to 2 STRIPES sideways, so a tile cut at the cell bbox would
        // lose the sub-pixel fringe the slow path paints into the dst.
6149
        let min_px = min_sx.div_euclid(3) - 1;
6149
        let max_px = max_sx.div_euclid(3) + 1;
6149
        let w = (max_px - min_px + 1).max(1) as u32;
6149
        let h = (max_y - min_y + 1).max(1) as u32;
6149
        if w > 512 || h > 512 {
            // Degenerate/huge glyph: no tile (caller falls back).
            self.lcd_tiles.insert(key, None);
            return None;
6149
        }
        // bg-filled tile, then the exact same sweep the slow path runs.
6149
        let mut rgba = vec![0u8; (w * h * 4) as usize];
1209436
        for px in rgba.chunks_exact_mut(4) {
1209436
            px[0] = bg.r;
1209436
            px[1] = bg.g;
1209436
            px[2] = bg.b;
1209436
            px[3] = 255;
1209436
        }
        {
            use agg_rust::basics::FillingRule;
            use agg_rust::pixfmt_lcd::PixfmtRgba32LcdLinear;
            use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
            use agg_rust::renderer_base::RendererBase;
            use agg_rust::renderer_scanline::render_scanlines_aa_solid;
            use agg_rust::rendering_buffer::RowAccessor;
            use agg_rust::scanline_u::ScanlineU8;
6149
            let mut ras = RasterizerScanlineAa::new();
6149
            ras.filling_rule(FillingRule::NonZero);
6149
            ras.add_cells_offset(&cells, -min_px * 3, -min_y);
6149
            let stride = (w * 4) as i32;
6149
            let mut ra =
6149
                unsafe { RowAccessor::new_with_buf(rgba.as_mut_ptr(), w, h, stride) };
6149
            let pf = PixfmtRgba32LcdLinear::new(&mut ra, lut, params);
6149
            let mut rb = RendererBase::new(pf);
6149
            let mut sl = ScanlineU8::new();
6149
            let agg_color = agg_rust::color::Rgba8::new(
6149
                u32::from(color.r),
6149
                u32::from(color.g),
6149
                u32::from(color.b),
6149
                u32::from(color.a),
            );
6149
            render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
        }
6149
        let tile = LcdGlyphTile { w, h, dx: min_px, dy: min_y, rgba };
6149
        self.lcd_tiles.insert(key, Some(tile.clone()));
6149
        Some((tile, int_x, int_y))
262390
    }
}