Skip to main content

azul_layout/
glyph_cache.rs

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
10use std::collections::HashMap;
11
12use agg_rust::basics::{VertexD, VertexSource, PATH_CMD_STOP};
13use agg_rust::path_storage::PathStorage;
14use agg_rust::rasterizer_cells_aa::CellAa;
15
16use 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`.
23pub(crate) struct SliceVertexSource<'a> {
24    verts: &'a [VertexD],
25    pos: usize,
26}
27
28impl<'a> SliceVertexSource<'a> {
29    pub(crate) const fn new(verts: &'a [VertexD]) -> Self {
30        Self { verts, pos: 0 }
31    }
32}
33
34impl VertexSource for SliceVertexSource<'_> {
35    fn rewind(&mut self, _path_id: u32) {
36        self.pos = 0;
37    }
38
39    fn vertex(&mut self, x: &mut f64, y: &mut f64) -> u32 {
40        match self.verts.get(self.pos) {
41            Some(v) => {
42                self.pos += 1;
43                *x = v.x;
44                *y = v.y;
45                v.cmd
46            }
47            None => PATH_CMD_STOP,
48        }
49    }
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)]
55struct 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)]
64struct 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/4 pixel (0..3).
72    subpx_x: u8,
73    /// Sub-pixel y position quantized to 1/4 pixel (0..3).
74    subpx_y: u8,
75}
76
77/// Result of a cache lookup: the path plus whether it's hinted (pixel coords) or not.
78pub struct CachedGlyph<'a> {
79    pub path: &'a PathStorage,
80    pub is_hinted: bool,
81}
82
83impl core::fmt::Debug for CachedGlyph<'_> {
84    // `path` is agg_rust's PathStorage (not Debug); show the rest.
85    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
86        f.debug_struct("CachedGlyph")
87            .field("is_hinted", &self.is_hinted)
88            .finish_non_exhaustive()
89    }
90}
91
92/// Pre-rasterized glyph cells at a canonical position.
93/// Contains the rasterizer's cell output for a glyph at sub-pixel position (`subpx_x`, `subpx_y`).
94/// To render at actual position (x, y), add integer pixel offset to each cell.
95struct CachedCells {
96    cells: Vec<CellAa>,
97}
98
99/// Maximum number of glyph path entries before eviction.
100/// ~8K glyphs covers most Latin + CJK pages without unbounded growth.
101const MAX_PATH_ENTRIES: usize = 8192;
102/// Maximum number of cell cache entries before eviction.
103/// Cell entries are larger than paths, so a lower limit is appropriate.
104const MAX_CELL_ENTRIES: usize = 16384;
105
106/// Cache of built glyph paths and pre-rasterized cells.
107pub struct GlyphCache {
108    paths: HashMap<GlyphPathKey, Option<(PathStorage, bool)>>,
109    cells: HashMap<GlyphCellKey, Option<CachedCells>>,
110}
111
112impl core::fmt::Debug for GlyphCache {
113    // Values hold agg_rust PathStorage / CellAa (not Debug); show entry counts.
114    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
115        f.debug_struct("GlyphCache")
116            .field("paths", &self.paths.len())
117            .field("cells", &self.cells.len())
118            .finish_non_exhaustive()
119    }
120}
121
122/// Quantize a fractional pixel position to 1/4 pixel (0..3).
123#[inline]
124#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
125fn quantize_subpx(frac: f32) -> u8 {
126    let f = frac - frac.floor();
127    (f * 4.0).min(3.0) as u8
128}
129
130impl Default for GlyphCache {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl GlyphCache {
137    #[must_use]
138    pub fn new() -> Self {
139        Self {
140            paths: HashMap::new(),
141            cells: HashMap::new(),
142        }
143    }
144
145    /// Entry count of the glyph-path cache (for leak probes).
146    #[must_use] pub fn paths_len(&self) -> usize { self.paths.len() }
147
148    /// Entry count of the pre-rasterized cell cache (for leak probes).
149    #[must_use] pub fn cells_len(&self) -> usize { self.cells.len() }
150
151    /// Get a cached path, or build it on cache miss.
152    /// Returns `None` if the glyph has no outline (e.g. space character).
153    pub fn get_or_build(
154        &mut self,
155        font_hash: u64,
156        glyph_id: u16,
157        glyph_data: &OwnedGlyph,
158        parsed_font: &ParsedFont,
159        ppem: u16,
160    ) -> Option<CachedGlyph<'_>> {
161        if self.paths.len() >= MAX_PATH_ENTRIES {
162            self.paths.clear();
163        }
164        let key = GlyphPathKey { font_hash, glyph_id, ppem };
165        let entry = self
166            .paths
167            .entry(key)
168            .or_insert_with(|| {
169                // Try hinted path first if ppem > 0
170                if ppem > 0 {
171                    if let Some(path) = build_hinted_path(glyph_id, glyph_data, parsed_font, ppem) {
172                        return Some((path, true));
173                    }
174                }
175                // Fall back to unhinted path
176                build_glyph_path(glyph_data).map(|p| (p, false))
177            });
178        entry.as_ref().map(|(path, is_hinted)| CachedGlyph {
179            path,
180            is_hinted: *is_hinted,
181        })
182    }
183
184    /// Get cached rasterizer cells for a glyph, or build them from the path.
185    ///
186    /// - `glyph_x`, `glyph_y`: final pixel position (used for sub-pixel quantization)
187    /// - `scale`: font-unit→pixel scale (0.0 for hinted glyphs)
188    /// - `is_hinted`: whether the path is in pixel coords (hinted) or font units
189    /// - `hint_correction`: `effective_px / ppem` for hinted glyphs (1.0 otherwise).
190    ///   A hinted outline is built at the *integer* ppem; when the requested
191    ///   effective size (`font_size * dpi`) is fractional this rescales it back to
192    ///   the true target size so hinted glyphs match their unhinted neighbours and
193    ///   animate smoothly instead of snapping between integer ppems. When the
194    ///   effective size is already integral this is 1.0 and the hinted glyph keeps
195    ///   its pixel-grid-snapped placement.
196    ///
197    /// Returns the cached cells and the integer pixel offset to apply.
198    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
199    pub fn get_or_build_cells(
200        &mut self,
201        font_hash: u64,
202        glyph_id: u16,
203        ppem: u16,
204        glyph_x: f32,
205        glyph_y: f32,
206        scale: f32,
207        is_hinted: bool,
208        hint_correction: f32,
209    ) -> Option<(&[CellAa], i32, i32)> {
210        if self.cells.len() >= MAX_CELL_ENTRIES {
211            self.cells.clear();
212        }
213        // Hinted outline built at integer ppem needs rescaling only when the
214        // effective size is fractional (hint_correction != 1). Otherwise it stays
215        // pixel-grid-snapped (rounded placement) as hinting intends.
216        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
217        let grid_snapped = is_hinted && !rescale_hinted;
218
219        // Sub-pixel HORIZONTAL positioning (default ON): even a grid-snapped
220        // (hinted-at-integer-ppem) glyph places its ORIGIN at a 1/4-pixel X
221        // bucket, so advances accumulate smoothly and the run lands where
222        // CoreText (fractional-x) puts it, instead of each origin rounding to a
223        // whole pixel. The grid-fitted OUTLINE is unchanged — only where we drop
224        // it horizontally shifts — so vertical stems stay crisp. The Y baseline
225        // stays grid-snapped (`subpx_y == 0` for grid_snapped). With
226        // `AZ_TEXT_SUBPIXEL=0` the grid_snapped case reverts to integer X
227        // (sub-pixel 0, rounded origin), the previous behaviour.
228        let subpx_x_snap = grid_snapped && !text_subpixel_enabled();
229        let subpx_x = if subpx_x_snap { 0 } else { quantize_subpx(glyph_x) };
230        let subpx_y = if grid_snapped { 0 } else { quantize_subpx(glyph_y) };
231        debug_assert!((0.0..65536.0).contains(&scale), "scale out of range for fixed-point: {scale}");
232        let scale_fixed = if is_hinted {
233            if rescale_hinted { (hint_correction * 65536.0) as u32 } else { 0 }
234        } else {
235            (scale * 65536.0) as u32
236        };
237
238        let cell_key = GlyphCellKey {
239            font_hash, glyph_id, ppem, scale_fixed, subpx_x, subpx_y,
240        };
241
242        // Integer pixel offset — the cells are at sub-pixel origin, offset by int
243        // part. `int_x + subpx_x*0.25` must reconstruct `glyph_x`, so the floor
244        // pairs with the quantized fraction; only the integer-X-snap case rounds.
245        let int_x = if subpx_x_snap { glyph_x.round() as i32 } else { glyph_x.floor() as i32 };
246        let int_y = if grid_snapped { glyph_y.round() as i32 } else { glyph_y.floor() as i32 };
247
248        if !self.cells.contains_key(&cell_key) {
249            // Build cells from cached path
250            let path_key = GlyphPathKey { font_hash, glyph_id, ppem };
251            let path_entry = self.paths.get(&path_key);
252            let cached_cells = path_entry.and_then(|entry| {
253                use agg_rust::trans_affine::TransAffine;
254                use agg_rust::basics::FillingRule;
255                use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
256                let (path, _) = entry.as_ref()?;
257                let frac_x = f64::from(subpx_x) * 0.25;
258                let frac_y = f64::from(subpx_y) * 0.25;
259
260                let mut ras = RasterizerScanlineAa::new();
261                ras.filling_rule(FillingRule::NonZero);
262
263                let transform = if is_hinted {
264                    if rescale_hinted {
265                        let mut t = TransAffine::new_scaling_uniform(f64::from(hint_correction));
266                        t.multiply(&TransAffine::new_translation(frac_x, frac_y));
267                        t
268                    } else {
269                        TransAffine::new_translation(frac_x, frac_y)
270                    }
271                } else {
272                    let mut t = TransAffine::new_scaling_uniform(f64::from(scale));
273                    t.multiply(&TransAffine::new_translation(frac_x, frac_y));
274                    t
275                };
276
277                // Feed the cached glyph vertices through the transform via ConvTransform
278                // (upstream removed add_path_vertices_transformed).
279                let mut src = agg_rust::conv_transform::ConvTransform::new(
280                    SliceVertexSource::new(path.vertices()),
281                    transform,
282                );
283                ras.add_path(&mut src, 0);
284                let cells = ras.outline_cells();
285                if cells.is_empty() { None } else { Some(CachedCells { cells }) }
286            });
287            self.cells.insert(cell_key, cached_cells);
288        }
289
290        let entry = self.cells.get(&cell_key)?;
291        entry.as_ref().map(|cc| (cc.cells.as_slice(), int_x, int_y))
292    }
293}
294
295/// Build a hinted glyph path using TrueType bytecode hinting.
296///
297/// The returned path is in pixel coordinates (1 unit = 1 pixel at the given ppem).
298/// Returns `None` if the glyph has no raw hinting data or hinting fails.
299/// Read a glyph's left side bearing (font units) straight from the `hmtx`
300/// table. Mirrors the `FreeType` `TT_Get_HMetrics` lookup used to place phantom
301/// point pp1 at `xMin - lsb`. Returns `None` if hmtx is unavailable.
302fn glyph_lsb(parsed_font: &ParsedFont, glyph_id: u16) -> Option<i16> {
303    let (off, len) = parsed_font.hmtx_range;
304    if len == 0 {
305        return None;
306    }
307    let bytes = parsed_font.original_bytes.as_ref()?;
308    let hmtx = bytes.as_ref().get(off..off + len)?;
309    let num = usize::from(parsed_font.hhea_table.num_h_metrics);
310    if num == 0 {
311        return None;
312    }
313    let gid = usize::from(glyph_id);
314    // longHorMetric[i] = { advanceWidth: u16, lsb: i16 } (4 bytes) for i < num;
315    // trailing leftSideBearing: i16 array for the remaining glyphs.
316    let lsb_off = if gid < num {
317        gid * 4 + 2
318    } else {
319        num * 4 + (gid - num) * 2
320    };
321    let b = hmtx.get(lsb_off..lsb_off + 2)?;
322    Some(i16::from_be_bytes([b[0], b[1]]))
323}
324
325fn build_hinted_path(
326    glyph_id: u16,
327    glyph: &OwnedGlyph,
328    parsed_font: &ParsedFont,
329    ppem: u16,
330) -> Option<PathStorage> {
331    use allsorts::hinting::f26dot6::F26Dot6;
332    let raw_points = glyph.raw_points.as_ref()?;
333    let raw_on_curve = glyph.raw_on_curve.as_ref()?;
334    let raw_contour_ends = glyph.raw_contour_ends.as_ref()?;
335    let instructions = glyph.instructions.as_ref()?;
336
337    if raw_points.is_empty() || raw_contour_ends.is_empty() {
338        return None;
339    }
340
341    let hint_mutex = parsed_font.hint_instance.as_ref()?;
342    let mut hint = hint_mutex.lock().ok()?;
343
344    let upem = parsed_font.font_metrics.units_per_em;
345    if upem == 0 {
346        return None;
347    }
348
349    // Set up hinting for this ppem (scales CVT, runs prep)
350    if hint.set_ppem(ppem, f64::from(ppem)).is_err() {
351        return None;
352    }
353
354    // Scale raw points from font units to F26Dot6
355    let scale = allsorts::hinting::f26dot6::compute_scale(ppem, upem);
356
357    let points_f26dot6: Vec<(i32, i32)> = raw_points
358        .iter()
359        .map(|&(x, y)| {
360            let sx = F26Dot6::from_funits(i32::from(x), scale);
361            let sy = F26Dot6::from_funits(i32::from(y), scale);
362            (sx.to_bits(), sy.to_bits())
363        })
364        .collect();
365
366    // Scale advance width to F26Dot6 for phantom points
367    let adv_f26dot6 = F26Dot6::from_funits(i32::from(glyph.horz_advance), scale).to_bits();
368
369    // Phantom point pp1.x = (xMin - lsb) scaled (FreeType tt_loader_set_pp).
370    // Threading the real lsb makes left-side-bearing grid-fitting match FreeType
371    // for fonts where lsb != xMin; when lsb is unavailable, fall back to xMin
372    // (i.e. lsb == xMin => pp1.x = 0, the previous hardcoded behaviour).
373    let x_min = i32::from(glyph.bounding_box.min_x);
374    let lsb = glyph_lsb(parsed_font, glyph_id).map_or(x_min, i32::from);
375    let pp1_x_f26dot6 = F26Dot6::from_funits(x_min - lsb, scale).to_bits();
376
377    // Run hinting and capture the POST-hinting on-curve flags. FLIPPT/FLIPRGON/
378    // FLIPRGOFF can flip a point between on-curve and off-curve during the glyph
379    // program; the contour builder must use the updated flags, not the original
380    // raw_on_curve, or it treats a flipped control point as a line endpoint (and
381    // vice versa), kinking the outline.
382    let Ok((hinted, hinted_on_curve)) = hint.hint_glyph_with_flags_pp1(
383        &points_f26dot6,
384        raw_on_curve,
385        raw_contour_ends,
386        instructions,
387        adv_f26dot6,
388        pp1_x_f26dot6,
389    ) else {
390        return None;
391    };
392    drop(hint);
393
394    // Optional "light" hinting (CoreText / DirectWrite grayscale style): keep the
395    // grid-fitted Y (baseline, x-height and horizontal stems snap crisply to the
396    // pixel grid) but restore the UNHINTED fractional X, so vertical stems stay
397    // sub-pixel-positioned and anti-alias to soft gray instead of snapping to a
398    // hard full-black 1px column (Windows-style full grid-fit). This is what
399    // converges our CPU text onto CoreText — full bytecode hinting over-snaps X,
400    // rendering stems thinner + darker than CoreText's. On by default; set
401    // AZ_HINT_LIGHT=0 to force Windows-style full grid-fit. The interpreter still
402    // runs in full (its Y output + FLIP'd on-curve flags are used), so no hinting
403    // correctness is lost — only the X axis is left sub-pixel for grayscale AA.
404    if hint_light_enabled() {
405        let light: Vec<(i32, i32)> = hinted
406            .iter()
407            .enumerate()
408            .map(|(i, &(hx, hy))| (points_f26dot6.get(i).map_or(hx, |p| p.0), hy))
409            .collect();
410        return build_path_from_contours(&light, &hinted_on_curve, raw_contour_ends);
411    }
412
413    // Build path from hinted points using TrueType quadratic contour conventions
414    build_path_from_contours(&hinted, &hinted_on_curve, raw_contour_ends)
415}
416
417/// Whether to apply CoreText-style "light" hinting (grid-fit Y only, fractional X).
418/// ON by default (matches CoreText / modern browser grayscale rendering); set
419/// `AZ_HINT_LIGHT=0` (or `false`) to force Windows-style full grid-fit. Read once.
420fn hint_light_enabled() -> bool {
421    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
422    *V.get_or_init(|| {
423        std::env::var("AZ_HINT_LIGHT")
424            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
425            .unwrap_or(true)
426    })
427}
428
429/// Whether to place each glyph ORIGIN at a sub-pixel HORIZONTAL position (a
430/// 1/4-pixel X bucket) instead of snapping it to a whole pixel.
431///
432/// ON by default. This is the horizontal half of the light-hinting philosophy
433/// (crisp vertical, soft/sub-pixel horizontal): the grid-fitted glyph *outline*
434/// still snaps its stems and baseline to the pixel grid, but the pen advances
435/// accumulate at fractional precision so a run of glyphs lands where CoreText
436/// (which positions glyphs at fractional x) puts them, rather than each origin
437/// rounding to an integer pixel and drifting the whole line. Set
438/// `AZ_TEXT_SUBPIXEL=0` (or `false`) to force integer X placement. Read once.
439pub(crate) fn text_subpixel_enabled() -> bool {
440    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
441    *V.get_or_init(|| {
442        std::env::var("AZ_TEXT_SUBPIXEL")
443            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
444            .unwrap_or(true)
445    })
446}
447
448/// Build an agg `PathStorage` from TrueType contour data (points in `F26Dot6`).
449///
450/// Matches allsorts' `visit_simple_glyph_outline` algorithm exactly:
451/// - On-curve points are endpoints of line/curve segments
452/// - Off-curve points are quadratic Bézier control points
453/// - Two consecutive off-curve points have an implicit on-curve midpoint
454/// - Y is negated for screen coordinates (font Y-up → screen Y-down)
455/// - The origin point is NOT revisited in the loop; `close()` handles the final segment
456#[must_use] pub fn build_path_from_contours(
457    points: &[(i32, i32)],
458    on_curve: &[bool],
459    contour_ends: &[u16],
460) -> Option<PathStorage> {
461    use agg_rust::basics::PATH_FLAGS_NONE;
462
463    let mut path = PathStorage::new();
464    let mut has_ops = false;
465    let mut contour_start = 0usize;
466
467    for &end_idx in contour_ends {
468        let end = end_idx as usize;
469        if end >= points.len() || contour_start > end {
470            contour_start = end + 1;
471            continue;
472        }
473
474        let pts = &points[contour_start..=end];
475        let flags = &on_curve[contour_start..=end];
476        let n = pts.len();
477        if n < 2 {
478            contour_start = end + 1;
479            continue;
480        }
481
482        // Helper: get point as (f64, f64) with Y negated
483        let px = |i: usize| -> (f64, f64) {
484            (f64::from(f26_to_px(pts[i].0)), f64::from(-f26_to_px(pts[i].1)))
485        };
486        let mid = |a: (f64, f64), b: (f64, f64)| -> (f64, f64) {
487            ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5)
488        };
489
490        // Determine origin and processing range (matching allsorts' calculate_origin)
491        let (origin, start, until) = if flags[0] {
492            (px(0), 1usize, n)
493        } else if flags[n - 1] {
494            (px(n - 1), 0usize, n - 1)
495        } else {
496            (mid(px(0), px(n - 1)), 0usize, n)
497        };
498
499        path.move_to(origin.0, origin.1);
500        has_ops = true;
501
502        let mut i = start;
503        while i < until {
504            if flags[i] {
505                // On-curve: line segment
506                let to = px(i);
507                path.line_to(to.0, to.1);
508                i += 1;
509            } else {
510                // Off-curve control point
511                let ctrl = px(i);
512                let next = i + 1;
513                if next < until {
514                    if flags[next] {
515                        // Next is on-curve: quad to it, consume both
516                        let to = px(next);
517                        path.curve3(ctrl.0, ctrl.1, to.0, to.1);
518                        i = next + 1;
519                    } else {
520                        // Next is also off-curve: quad to implicit midpoint
521                        let m = mid(ctrl, px(next));
522                        path.curve3(ctrl.0, ctrl.1, m.0, m.1);
523                        i = next;
524                    }
525                } else {
526                    // End of range: curve back to origin
527                    path.curve3(ctrl.0, ctrl.1, origin.0, origin.1);
528                    i = next;
529                }
530            }
531        }
532        path.close_polygon(PATH_FLAGS_NONE);
533
534        contour_start = end + 1;
535    }
536
537    if !has_ops {
538        return None;
539    }
540    Some(path)
541}
542
543/// Convert `F26Dot6` value to pixel coordinate (f32).
544#[inline]
545#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
546fn f26_to_px(v: i32) -> f32 {
547    v as f32 / 64.0
548}
549
550#[cfg(test)]
551#[allow(
552    // exact float comparisons are intentional: power-of-two / midpoint
553    // arithmetic with exactly representable results
554    clippy::float_cmp,
555    clippy::cast_precision_loss,
556    clippy::cast_possible_truncation,
557    clippy::cast_lossless
558)]
559mod autotest_generated {
560    use std::{
561        panic::{catch_unwind, AssertUnwindSafe},
562        sync::Arc,
563    };
564
565    use agg_rust::basics::{
566        PATH_CMD_CURVE3, PATH_CMD_END_POLY, PATH_CMD_LINE_TO, PATH_CMD_MOVE_TO, PATH_FLAGS_CLOSE,
567    };
568    use azul_core::resources::OwnedGlyphBoundingBox;
569
570    use super::*;
571
572    // ---------------------------------------------------------------------
573    // helpers
574    // ---------------------------------------------------------------------
575
576    /// A real system/repo font, or `None` — font-dependent tests skip rather
577    /// than guess. Source bytes are retained so `glyph_lsb` can read `hmtx`.
578    fn test_font() -> Option<ParsedFont> {
579        let candidates = [
580            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
581            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
582            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
583            "C:/Windows/Fonts/arial.ttf",
584            concat!(
585                env!("CARGO_MANIFEST_DIR"),
586                "/../examples/assets/fonts/SourceSerifPro-Regular.ttf"
587            ),
588        ];
589        for path in candidates {
590            let Ok(bytes) = std::fs::read(path) else {
591                continue;
592            };
593            let arc = Arc::new(rust_fontconfig::FontBytes::Owned(Arc::from(
594                bytes.as_slice(),
595            )));
596            if let Some(font) =
597                ParsedFont::from_bytes(&bytes, 0, &mut Vec::new()).map(|f| f.with_source_bytes(arc))
598            {
599                return Some(font);
600            }
601        }
602        None
603    }
604
605    /// A glyph with no outline at all (the "space character" shape the
606    /// `get_or_build` doc mentions) — buildable without a font.
607    fn empty_glyph() -> OwnedGlyph {
608        OwnedGlyph {
609            bounding_box: OwnedGlyphBoundingBox {
610                max_x: 0,
611                max_y: 0,
612                min_x: 0,
613                min_y: 0,
614            },
615            horz_advance: 0,
616            outline: Vec::new(),
617            phantom_points: None,
618            raw_points: None,
619            raw_on_curve: None,
620            raw_contour_ends: None,
621            instructions: None,
622        }
623    }
624
625    /// `('A', decoded glyph)` from `font`, if the font has one with an outline.
626    fn glyph_a(font: &ParsedFont) -> Option<(u16, Arc<OwnedGlyph>)> {
627        let gid = font.lookup_glyph_index('A' as u32)?;
628        let glyph = font.get_or_decode_glyph(gid)?;
629        Some((gid, glyph))
630    }
631
632    // ---------------------------------------------------------------------
633    // quantize_subpx — numeric
634    // ---------------------------------------------------------------------
635
636    #[test]
637    fn quantize_subpx_zero_and_quarter_buckets() {
638        assert_eq!(quantize_subpx(0.0), 0);
639        assert_eq!(quantize_subpx(0.24), 0);
640        assert_eq!(quantize_subpx(0.25), 1);
641        assert_eq!(quantize_subpx(0.5), 2);
642        assert_eq!(quantize_subpx(0.75), 3);
643        // 0.999 * 4 == 3.996, clamped by `.min(3.0)` — must not reach bucket 4.
644        assert_eq!(quantize_subpx(0.999), 3);
645        // Only the fractional part matters: whole pixels drop out.
646        assert_eq!(quantize_subpx(1.0), 0);
647        assert_eq!(quantize_subpx(2.25), 1);
648        assert_eq!(quantize_subpx(1024.5), 2);
649    }
650
651    #[test]
652    fn quantize_subpx_negative_inputs_use_floor_not_truncation() {
653        // frac - frac.floor() is always in [0, 1), so a negative x lands in the
654        // bucket of its positive fractional remainder (-0.25 => 0.75 => 3).
655        assert_eq!(quantize_subpx(-0.25), 3);
656        assert_eq!(quantize_subpx(-0.5), 2);
657        assert_eq!(quantize_subpx(-0.75), 1);
658        assert_eq!(quantize_subpx(-1.0), 0);
659        assert_eq!(quantize_subpx(-0.0), 0);
660        assert_eq!(quantize_subpx(-7.25), 3);
661    }
662
663    #[test]
664    fn quantize_subpx_nan_and_infinities_are_defined() {
665        // NaN.floor() == NaN, NaN - NaN == NaN, and f32::min ignores NaN, so the
666        // clamp yields 3.0. Defined and non-panicking, if arguably surprising:
667        // a NaN position buckets as if it were 3/4 of a pixel.
668        assert_eq!(quantize_subpx(f32::NAN), 3);
669        assert_eq!(quantize_subpx(f32::INFINITY), 3);
670        assert_eq!(quantize_subpx(f32::NEG_INFINITY), 3);
671    }
672
673    #[test]
674    fn quantize_subpx_never_exceeds_three() {
675        let extremes = [
676            f32::MAX,
677            f32::MIN,
678            f32::MIN_POSITIVE,
679            -f32::MIN_POSITIVE,
680            f32::EPSILON,
681            1e30,
682            -1e30,
683            16_777_216.0, // 2^24: f32 loses the fractional bit entirely
684            -16_777_217.0,
685            f32::NAN,
686            f32::INFINITY,
687            f32::NEG_INFINITY,
688        ];
689        for v in extremes {
690            assert!(
691                quantize_subpx(v) <= 3,
692                "quantize_subpx({v}) escaped the 0..=3 bucket range"
693            );
694        }
695        for i in -2000..2000 {
696            let v = f64_to_f32(f64::from(i) * 0.017);
697            assert!(quantize_subpx(v) <= 3, "quantize_subpx({v}) > 3");
698        }
699    }
700
701    #[allow(clippy::cast_possible_truncation)]
702    fn f64_to_f32(v: f64) -> f32 {
703        v as f32
704    }
705
706    // ---------------------------------------------------------------------
707    // f26_to_px — numeric
708    // ---------------------------------------------------------------------
709
710    #[test]
711    fn f26_to_px_zero_and_exact_fractions() {
712        assert_eq!(f26_to_px(0), 0.0);
713        assert_eq!(f26_to_px(64), 1.0);
714        assert_eq!(f26_to_px(-64), -1.0);
715        assert_eq!(f26_to_px(32), 0.5);
716        assert_eq!(f26_to_px(16), 0.25);
717        assert_eq!(f26_to_px(1), 1.0 / 64.0);
718        assert_eq!(f26_to_px(-1), -1.0 / 64.0);
719    }
720
721    #[test]
722    fn f26_to_px_min_max_stay_finite() {
723        // i32::MAX rounds up to 2^31 in f32; both ends are exactly ±2^25 px.
724        assert_eq!(f26_to_px(i32::MAX), 33_554_432.0);
725        assert_eq!(f26_to_px(i32::MIN), -33_554_432.0);
726        assert!(f26_to_px(i32::MAX).is_finite());
727        assert!(f26_to_px(i32::MIN).is_finite());
728    }
729
730    #[test]
731    fn f26_to_px_round_trips_for_exactly_representable_inputs() {
732        // /64 is a power-of-two scaling: exact (no rounding) for |v| <= 2^24.
733        for v in [
734            0,
735            1,
736            -1,
737            63,
738            64,
739            -64,
740            4096,
741            -4096,
742            8_388_607,
743            -8_388_607,
744            16_777_216,
745            -16_777_216,
746        ] {
747            let px = f26_to_px(v);
748            assert_eq!(px * 64.0, v as f32, "f26_to_px({v}) did not round-trip");
749        }
750    }
751
752    #[test]
753    fn f26_to_px_is_monotonic() {
754        let ladder = [
755            i32::MIN,
756            -1_000_000,
757            -64,
758            -1,
759            0,
760            1,
761            64,
762            1_000_000,
763            i32::MAX,
764        ];
765        for w in ladder.windows(2) {
766            assert!(
767                f26_to_px(w[0]) <= f26_to_px(w[1]),
768                "f26_to_px is not monotonic between {} and {}",
769                w[0],
770                w[1]
771            );
772        }
773    }
774
775    // ---------------------------------------------------------------------
776    // build_path_from_contours — structure / round-trip of the TrueType rules
777    // ---------------------------------------------------------------------
778
779    #[test]
780    fn build_path_from_contours_empty_inputs_return_none() {
781        assert!(build_path_from_contours(&[], &[], &[]).is_none());
782        // Points but no contours => nothing emitted.
783        assert!(build_path_from_contours(&[(0, 0), (64, 0)], &[true, true], &[]).is_none());
784        // Contour end but no points => out-of-range end, skipped.
785        assert!(build_path_from_contours(&[], &[], &[0]).is_none());
786    }
787
788    #[test]
789    fn build_path_from_contours_single_point_contour_is_skipped() {
790        // n < 2 => degenerate contour, no path ops at all.
791        assert!(build_path_from_contours(&[(0, 0)], &[true], &[0]).is_none());
792    }
793
794    #[test]
795    fn build_path_from_contours_out_of_range_contour_end_is_skipped_not_indexed() {
796        let pts = [(0, 0), (64, 64)];
797        let oc = [true, true];
798        assert!(build_path_from_contours(&pts, &oc, &[10]).is_none());
799        // u16::MAX end: `end + 1` must not overflow the usize cursor either.
800        assert!(build_path_from_contours(&pts, &oc, &[u16::MAX]).is_none());
801        // Skipping a bogus end still advances the cursor to `end + 1`, so every
802        // later contour falls into the `contour_start > end` branch too. The
803        // whole glyph fails closed (None) instead of indexing out of bounds.
804        assert!(
805            build_path_from_contours(&pts, &oc, &[99, 1]).is_none(),
806            "a bogus contour end poisons the cursor for the rest of the glyph"
807        );
808    }
809
810    #[test]
811    fn build_path_from_contours_line_contour_emits_move_line_close() {
812        let path = build_path_from_contours(&[(0, 0), (128, 64)], &[true, true], &[1])
813            .expect("two on-curve points form a contour");
814        let v = path.vertices();
815        assert_eq!(v.len(), 3, "expected move_to + line_to + close");
816        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
817        assert_eq!(v[0].x, 0.0);
818        assert_eq!(v[0].y, 0.0);
819        assert_eq!(v[1].cmd, PATH_CMD_LINE_TO);
820        assert_eq!(v[1].x, 2.0, "128 F26Dot6 units == 2 px");
821        assert_eq!(v[1].y, -1.0, "Y must be negated for screen coords");
822        assert_eq!(v[2].cmd, PATH_CMD_END_POLY | PATH_FLAGS_CLOSE);
823    }
824
825    #[test]
826    fn build_path_from_contours_offcurve_point_becomes_curve3() {
827        let path = build_path_from_contours(
828            &[(0, 0), (64, 64), (128, 0)],
829            &[true, false, true],
830            &[2],
831        )
832        .expect("on/off/on contour");
833        let v = path.vertices();
834        assert_eq!(v.len(), 4, "move_to + curve3(ctrl,to) + close");
835        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
836        assert_eq!(v[1].cmd, PATH_CMD_CURVE3);
837        assert_eq!((v[1].x, v[1].y), (1.0, -1.0), "control point");
838        assert_eq!(v[2].cmd, PATH_CMD_CURVE3);
839        assert_eq!((v[2].x, v[2].y), (2.0, 0.0), "curve endpoint");
840        assert_eq!(v[3].cmd, PATH_CMD_END_POLY | PATH_FLAGS_CLOSE);
841    }
842
843    #[test]
844    fn build_path_from_contours_two_offcurve_points_insert_implicit_midpoint() {
845        let path = build_path_from_contours(
846            &[(0, 0), (64, 64), (128, 64), (192, 0)],
847            &[true, false, false, true],
848            &[3],
849        )
850        .expect("on/off/off/on contour");
851        let v = path.vertices();
852        assert_eq!(v.len(), 6, "move_to + 2 curve3 + close");
853        // First quad ends at the implicit midpoint of the two control points:
854        // mid((1,-1), (2,-1)) == (1.5, -1).
855        assert_eq!((v[1].x, v[1].y), (1.0, -1.0));
856        assert_eq!((v[2].x, v[2].y), (1.5, -1.0), "implicit on-curve midpoint");
857        assert_eq!((v[3].x, v[3].y), (2.0, -1.0));
858        assert_eq!((v[4].x, v[4].y), (3.0, 0.0));
859    }
860
861    #[test]
862    fn build_path_from_contours_all_offcurve_closes_back_to_the_synthetic_origin() {
863        // No on-curve point anywhere: origin is the midpoint of first & last,
864        // and the final curve must return to it.
865        let path = build_path_from_contours(
866            &[(0, 0), (64, 64), (128, 0)],
867            &[false, false, false],
868            &[2],
869        )
870        .expect("all-off-curve contour");
871        let v = path.vertices();
872        assert_eq!(v.len(), 8, "move_to + 3 curve3 + close");
873        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
874        assert_eq!((v[0].x, v[0].y), (1.0, 0.0), "origin = mid(first, last)");
875        let last_pt = v[6];
876        assert_eq!(
877            (last_pt.x, last_pt.y),
878            (v[0].x, v[0].y),
879            "final curve3 must land back on the origin"
880        );
881        assert_eq!(v[7].cmd, PATH_CMD_END_POLY | PATH_FLAGS_CLOSE);
882    }
883
884    #[test]
885    fn build_path_from_contours_leading_offcurve_uses_trailing_oncurve_as_origin() {
886        // flags[0] off, flags[n-1] on => origin is the LAST point, range [0, n-1).
887        let path = build_path_from_contours(&[(64, 64), (128, 0)], &[false, true], &[1])
888            .expect("off/on contour");
889        let v = path.vertices();
890        assert_eq!(v.len(), 4);
891        assert_eq!(v[0].cmd, PATH_CMD_MOVE_TO);
892        assert_eq!((v[0].x, v[0].y), (2.0, 0.0), "origin is the on-curve point");
893        assert_eq!(v[1].cmd, PATH_CMD_CURVE3);
894        assert_eq!((v[2].x, v[2].y), (2.0, 0.0), "curve returns to the origin");
895    }
896
897    #[test]
898    fn build_path_from_contours_multiple_contours_emit_multiple_subpaths() {
899        let pts = [(0, 0), (64, 0), (0, 64), (64, 64)];
900        let oc = [true; 4];
901        let path = build_path_from_contours(&pts, &oc, &[1, 3]).expect("two contours");
902        let v = path.vertices();
903        assert_eq!(v.len(), 6, "two × (move_to + line_to + close)");
904        let moves = v.iter().filter(|x| x.cmd == PATH_CMD_MOVE_TO).count();
905        assert_eq!(moves, 2, "each contour must start its own subpath");
906    }
907
908    #[test]
909    fn build_path_from_contours_non_monotonic_contour_ends_do_not_panic() {
910        let pts = [(0, 0), (64, 0), (0, 64), (64, 64)];
911        let oc = [true; 4];
912        // Descending ends: the second contour has contour_start > end => skipped.
913        let path = build_path_from_contours(&pts, &oc, &[1, 0]).expect("first contour builds");
914        assert_eq!(path.vertices().len(), 3);
915        // Duplicate ends: likewise skipped, not re-emitted.
916        let dup = build_path_from_contours(&pts, &oc, &[1, 1]).expect("first contour builds");
917        assert_eq!(dup.vertices().len(), 3);
918    }
919
920    #[test]
921    fn build_path_from_contours_extra_on_curve_flags_are_harmless() {
922        // on_curve longer than points: the extra flags must simply go unread.
923        let path = build_path_from_contours(&[(0, 0), (64, 0)], &[true; 8], &[1])
924            .expect("contour builds with a too-long flag slice");
925        assert_eq!(path.vertices().len(), 3);
926    }
927
928    #[test]
929    #[should_panic(expected = "out of range")]
930    fn build_path_from_contours_short_on_curve_slice_panics() {
931        // BUG (documented, not weakened): the bounds check only compares the
932        // contour end against `points.len()`, then slices `on_curve` with the
933        // same range. A caller passing a shorter `on_curve` than `points` gets
934        // an index-out-of-bounds panic out of a `pub fn` that otherwise
935        // signals failure by returning `None`.
936        let _ = build_path_from_contours(&[(0, 0), (64, 0)], &[true], &[1]);
937    }
938
939    #[test]
940    fn build_path_from_contours_extreme_coordinates_stay_finite() {
941        let pts = [(i32::MIN, i32::MAX), (i32::MAX, i32::MIN), (0, 0)];
942        let oc = [true, false, true];
943        let path = build_path_from_contours(&pts, &oc, &[2]).expect("extreme contour still builds");
944        for v in path.vertices() {
945            assert!(
946                v.x.is_finite() && v.y.is_finite(),
947                "F26Dot6 extremes produced a non-finite vertex: ({}, {})",
948                v.x,
949                v.y
950            );
951        }
952    }
953
954    // ---------------------------------------------------------------------
955    // env-backed predicates
956    // ---------------------------------------------------------------------
957
958    #[test]
959    fn hint_light_enabled_is_stable_across_calls() {
960        // OnceLock-backed: whatever the env says, every call must agree.
961        let first = hint_light_enabled();
962        assert_eq!(first, hint_light_enabled());
963        assert_eq!(first, hint_light_enabled());
964    }
965
966    #[test]
967    fn text_subpixel_enabled_is_stable_across_calls() {
968        let first = text_subpixel_enabled();
969        assert_eq!(first, text_subpixel_enabled());
970        assert_eq!(first, text_subpixel_enabled());
971    }
972
973    // ---------------------------------------------------------------------
974    // GlyphCache::new / paths_len / cells_len
975    // ---------------------------------------------------------------------
976
977    #[test]
978    fn new_cache_is_empty_and_default_matches_new() {
979        let cache = GlyphCache::new();
980        assert_eq!(cache.paths_len(), 0);
981        assert_eq!(cache.cells_len(), 0);
982
983        let def = GlyphCache::default();
984        assert_eq!(def.paths_len(), 0);
985        assert_eq!(def.cells_len(), 0);
986    }
987
988    #[test]
989    fn debug_impl_reports_entry_counts_without_touching_agg_types() {
990        let cache = GlyphCache::new();
991        let s = format!("{cache:?}");
992        assert!(s.contains("GlyphCache"), "{s}");
993        assert!(s.contains("paths"), "{s}");
994        assert!(s.contains("cells"), "{s}");
995    }
996
997    // ---------------------------------------------------------------------
998    // get_or_build_cells — numeric (needs no font: a path-cache miss is a
999    // legitimate, reachable state)
1000    // ---------------------------------------------------------------------
1001
1002    #[test]
1003    fn get_or_build_cells_without_a_cached_path_returns_none_and_negative_caches() {
1004        let mut cache = GlyphCache::new();
1005        let got = cache
1006            .get_or_build_cells(1, 2, 16, 0.0, 0.0, 1.0, false, 1.0)
1007            .map(|(cells, x, y)| (cells.len(), x, y));
1008        assert_eq!(got, None, "no path cached => no cells");
1009        assert_eq!(cache.cells_len(), 1, "the miss must be negative-cached");
1010
1011        // Idempotent: a repeat lookup does not add a second entry.
1012        let again = cache
1013            .get_or_build_cells(1, 2, 16, 0.0, 0.0, 1.0, false, 1.0)
1014            .map(|(cells, _, _)| cells.len());
1015        assert_eq!(again, None);
1016        assert_eq!(cache.cells_len(), 1);
1017    }
1018
1019    #[test]
1020    fn get_or_build_cells_keys_on_the_quarter_pixel_bucket_not_the_raw_position() {
1021        let mut cache = GlyphCache::new();
1022        // All of these have a fractional part < 0.25 => same sub-pixel bucket.
1023        for x in [0.0_f32, 0.1, 0.2, 5.24, 100.0] {
1024            let _ = cache.get_or_build_cells(7, 3, 16, x, 0.0, 1.0, false, 1.0);
1025        }
1026        assert_eq!(cache.cells_len(), 1, "same bucket must reuse one entry");
1027
1028        // A different bucket (0.5 => bucket 2) is a different key.
1029        let _ = cache.get_or_build_cells(7, 3, 16, 0.5, 0.0, 1.0, false, 1.0);
1030        assert_eq!(cache.cells_len(), 2);
1031
1032        // A different scale is a different key too (scale_fixed is in the key).
1033        let _ = cache.get_or_build_cells(7, 3, 16, 0.5, 0.0, 2.0, false, 1.0);
1034        assert_eq!(cache.cells_len(), 3);
1035    }
1036
1037    #[test]
1038    fn get_or_build_cells_extreme_arguments_do_not_panic() {
1039        let mut cache = GlyphCache::new();
1040        // is_hinted + scale 0.0 keeps the fixed-point debug_assert satisfied
1041        // while pushing every *other* argument to its limit.
1042        for x in [
1043            0.0_f32,
1044            f32::MAX,
1045            f32::MIN,
1046            f32::NAN,
1047            f32::INFINITY,
1048            f32::NEG_INFINITY,
1049            -0.75,
1050        ] {
1051            let got = cache
1052                .get_or_build_cells(u64::MAX, u16::MAX, u16::MAX, x, x, 0.0, true, 1.0)
1053                .map(|(cells, _, _)| cells.len());
1054            assert_eq!(got, None, "empty path cache must yield None for x = {x}");
1055        }
1056        // Zero everywhere.
1057        let zeroed = cache
1058            .get_or_build_cells(0, 0, 0, 0.0, 0.0, 0.0, false, 0.0)
1059            .map(|(cells, _, _)| cells.len());
1060        assert_eq!(zeroed, None);
1061        // Largest in-range scale (the debug_assert's exclusive upper bound - 1).
1062        let big = cache
1063            .get_or_build_cells(0, 0, 0, 0.0, 0.0, 65_535.0, false, 1.0)
1064            .map(|(cells, _, _)| cells.len());
1065        assert_eq!(big, None);
1066    }
1067
1068    #[test]
1069    fn get_or_build_cells_nan_hint_correction_falls_back_to_grid_snapped() {
1070        // (NaN - 1.0).abs() > 1e-4 is FALSE, so a NaN hint_correction is treated
1071        // exactly like the no-rescale case (scale_fixed = 0) rather than being
1072        // cast to a garbage fixed-point key. Same key => no extra entry.
1073        let mut cache = GlyphCache::new();
1074        let _ = cache.get_or_build_cells(9, 1, 12, 3.5, 4.5, 0.0, true, 1.0);
1075        assert_eq!(cache.cells_len(), 1);
1076        let _ = cache.get_or_build_cells(9, 1, 12, 3.5, 4.5, 0.0, true, f32::NAN);
1077        assert_eq!(
1078            cache.cells_len(),
1079            1,
1080            "NaN hint_correction must collapse onto the grid-snapped key"
1081        );
1082    }
1083
1084    #[test]
1085    fn get_or_build_cells_negative_scale_is_debug_asserted() {
1086        let mut cache = GlyphCache::new();
1087        let res = catch_unwind(AssertUnwindSafe(|| {
1088            cache
1089                .get_or_build_cells(1, 1, 16, 0.0, 0.0, -1.0, false, 1.0)
1090                .map(|(cells, _, _)| cells.len())
1091        }));
1092        if cfg!(debug_assertions) {
1093            assert!(
1094                res.is_err(),
1095                "a negative scale must trip the fixed-point range debug_assert"
1096            );
1097        } else {
1098            // Release: the f32 -> u32 cast saturates to 0 instead of wrapping.
1099            assert_eq!(res.ok().flatten(), None);
1100        }
1101    }
1102
1103    #[test]
1104    fn get_or_build_cells_evicts_wholesale_at_the_entry_limit() {
1105        let mut cache = GlyphCache::new();
1106        for i in 0..MAX_CELL_ENTRIES as u64 {
1107            let _ = cache.get_or_build_cells(i, 0, 16, 0.0, 0.0, 1.0, false, 1.0);
1108        }
1109        assert_eq!(cache.cells_len(), MAX_CELL_ENTRIES);
1110        // One past the limit: the whole map is cleared, then the new key lands.
1111        let _ = cache.get_or_build_cells(u64::MAX, 0, 16, 0.0, 0.0, 1.0, false, 1.0);
1112        assert_eq!(cache.cells_len(), 1, "cell cache must not grow unbounded");
1113    }
1114
1115    // ---------------------------------------------------------------------
1116    // get_or_build — needs a ParsedFont (skipped when no font is available)
1117    // ---------------------------------------------------------------------
1118
1119    #[test]
1120    fn get_or_build_outlineless_glyph_returns_none_and_caches_the_miss() {
1121        let Some(font) = test_font() else {
1122            return; // no font on this machine: skip rather than guess
1123        };
1124        let mut cache = GlyphCache::new();
1125        let glyph = empty_glyph();
1126        let got = cache
1127            .get_or_build(1, 0, &glyph, &font, 0)
1128            .map(|c| c.is_hinted);
1129        assert_eq!(got, None, "a glyph with no outline must yield None");
1130        assert_eq!(cache.paths_len(), 1, "the miss must be negative-cached");
1131    }
1132
1133    #[test]
1134    fn get_or_build_extreme_ids_and_ppem_do_not_panic() {
1135        let Some(font) = test_font() else {
1136            return;
1137        };
1138        let mut cache = GlyphCache::new();
1139        let glyph = empty_glyph();
1140        for (hash, gid, ppem) in [
1141            (0_u64, 0_u16, 0_u16),
1142            (u64::MAX, u16::MAX, u16::MAX),
1143            (u64::MAX, 0, 1),
1144            (0, u16::MAX, u16::MAX),
1145        ] {
1146            let got = cache
1147                .get_or_build(hash, gid, &glyph, &font, ppem)
1148                .map(|c| c.is_hinted);
1149            assert_eq!(got, None, "outline-less glyph at ppem {ppem}");
1150        }
1151        assert_eq!(cache.paths_len(), 4, "each (hash, gid, ppem) is its own key");
1152    }
1153
1154    #[test]
1155    fn get_or_build_is_idempotent_and_ppem_is_part_of_the_key() {
1156        let Some(font) = test_font() else {
1157            return;
1158        };
1159        let Some((gid, glyph)) = glyph_a(&font) else {
1160            return;
1161        };
1162        let mut cache = GlyphCache::new();
1163
1164        // ppem == 0 => unhinted path, in font units.
1165        let first = cache
1166            .get_or_build(font.hash, gid, &glyph, &font, 0)
1167            .map(|c| (c.is_hinted, c.path.total_vertices()));
1168        let Some((is_hinted, verts)) = first else {
1169            return; // 'A' has no outline in this font: nothing to assert
1170        };
1171        assert!(!is_hinted, "ppem == 0 must not produce a hinted path");
1172        assert!(verts > 0, "an outlined glyph must emit vertices");
1173        assert_eq!(cache.paths_len(), 1);
1174
1175        // Second lookup is a cache hit: same result, no new entry.
1176        let second = cache
1177            .get_or_build(font.hash, gid, &glyph, &font, 0)
1178            .map(|c| (c.is_hinted, c.path.total_vertices()));
1179        assert_eq!(second, Some((is_hinted, verts)));
1180        assert_eq!(cache.paths_len(), 1, "a hit must not insert a second entry");
1181
1182        // A different ppem is a different key.
1183        let _ = cache.get_or_build(font.hash, gid, &glyph, &font, 16);
1184        assert_eq!(cache.paths_len(), 2);
1185    }
1186
1187    #[test]
1188    fn get_or_build_evicts_wholesale_at_the_entry_limit() {
1189        let Some(font) = test_font() else {
1190            return;
1191        };
1192        let mut cache = GlyphCache::new();
1193        let glyph = empty_glyph(); // no outline => cheap negative entries
1194        for i in 0..MAX_PATH_ENTRIES as u64 {
1195            let _ = cache.get_or_build(i, 0, &glyph, &font, 0);
1196        }
1197        assert_eq!(cache.paths_len(), MAX_PATH_ENTRIES);
1198        let _ = cache.get_or_build(u64::MAX, 0, &glyph, &font, 0);
1199        assert_eq!(cache.paths_len(), 1, "path cache must not grow unbounded");
1200    }
1201
1202    // ---------------------------------------------------------------------
1203    // glyph_lsb — numeric / bounds
1204    // ---------------------------------------------------------------------
1205
1206    #[test]
1207    fn glyph_lsb_without_source_bytes_is_none() {
1208        let Some(font) = test_font() else {
1209            return;
1210        };
1211        let mut stripped = font;
1212        stripped.original_bytes = None;
1213        assert_eq!(
1214            glyph_lsb(&stripped, 0),
1215            None,
1216            "no retained font bytes => no hmtx to read"
1217        );
1218    }
1219
1220    #[test]
1221    fn glyph_lsb_zero_h_metrics_is_none() {
1222        let Some(mut font) = test_font() else {
1223            return;
1224        };
1225        font.hhea_table.num_h_metrics = 0;
1226        assert_eq!(glyph_lsb(&font, 0), None, "num_h_metrics == 0 must bail out");
1227    }
1228
1229    #[test]
1230    fn glyph_lsb_reads_gid_zero_and_rejects_out_of_range_gids() {
1231        let Some(font) = test_font() else {
1232            return;
1233        };
1234        let (_, len) = font.hmtx_range;
1235        let num = usize::from(font.hhea_table.num_h_metrics);
1236        if len > 0 && num > 0 && font.original_bytes.is_some() {
1237            assert!(
1238                glyph_lsb(&font, 0).is_some(),
1239                "gid 0 sits inside hmtx and must read back"
1240            );
1241        }
1242
1243        // A gid whose lsb offset lands past the table must be bounds-rejected,
1244        // not indexed. (Mirrors the impl's offset arithmetic to know it is out
1245        // of range for THIS font rather than assuming a glyph count.)
1246        let gid = usize::from(u16::MAX);
1247        let lsb_off = if gid < num {
1248            gid * 4 + 2
1249        } else {
1250            num * 4 + (gid - num) * 2
1251        };
1252        if lsb_off + 2 > len {
1253            assert_eq!(
1254                glyph_lsb(&font, u16::MAX),
1255                None,
1256                "an out-of-table gid must return None, not panic"
1257            );
1258        }
1259
1260        // Sweep the boundary around num_h_metrics (long metrics -> trailing
1261        // lsb-only array) — none of these may panic.
1262        for gid in [0_u16, 1, u16::MAX] {
1263            let _ = glyph_lsb(&font, gid);
1264        }
1265    }
1266
1267    // ---------------------------------------------------------------------
1268    // build_hinted_path — guard clauses (the interpreter path itself is only
1269    // smoke-tested at a realistic ppem)
1270    // ---------------------------------------------------------------------
1271
1272    #[test]
1273    fn build_hinted_path_without_raw_hinting_data_is_none() {
1274        let Some(font) = test_font() else {
1275            return;
1276        };
1277        let glyph = empty_glyph(); // raw_points / instructions all None
1278        assert!(build_hinted_path(0, &glyph, &font, 16).is_none());
1279        assert!(
1280            build_hinted_path(u16::MAX, &glyph, &font, u16::MAX).is_none(),
1281            "missing raw data must short-circuit before any hinting arithmetic"
1282        );
1283    }
1284
1285    #[test]
1286    fn build_hinted_path_with_empty_contours_is_none() {
1287        let Some(font) = test_font() else {
1288            return;
1289        };
1290        let mut glyph = empty_glyph();
1291        glyph.raw_points = Some(Vec::new());
1292        glyph.raw_on_curve = Some(Vec::new());
1293        glyph.raw_contour_ends = Some(Vec::new());
1294        glyph.instructions = Some(Vec::new());
1295        assert!(
1296            build_hinted_path(0, &glyph, &font, 16).is_none(),
1297            "an empty point/contour list must bail out, not hint an empty glyph"
1298        );
1299    }
1300
1301    #[test]
1302    fn build_hinted_path_without_a_hint_instance_is_none() {
1303        let Some(mut font) = test_font() else {
1304            return;
1305        };
1306        let Some((gid, glyph)) = glyph_a(&font) else {
1307            return;
1308        };
1309        if glyph.raw_points.is_none() || glyph.instructions.is_none() {
1310            return; // CFF / composite glyph: nothing to hint, test not applicable
1311        }
1312        font.hint_instance = None;
1313        assert!(
1314            build_hinted_path(gid, &glyph, &font, 16).is_none(),
1315            "no interpreter => no hinted path"
1316        );
1317    }
1318
1319    #[test]
1320    fn build_hinted_path_with_zero_upem_is_none() {
1321        let Some(mut font) = test_font() else {
1322            return;
1323        };
1324        let Some((gid, glyph)) = glyph_a(&font) else {
1325            return;
1326        };
1327        if font.hint_instance.is_none() || glyph.raw_points.is_none() {
1328            return; // the upem guard sits behind the hint-instance guard
1329        }
1330        font.font_metrics.units_per_em = 0;
1331        assert!(
1332            build_hinted_path(gid, &glyph, &font, 16).is_none(),
1333            "upem == 0 must bail out before the divide-by-upem scale"
1334        );
1335    }
1336
1337    #[test]
1338    fn build_hinted_path_at_a_realistic_ppem_produces_a_finite_path() {
1339        let Some(font) = test_font() else {
1340            return;
1341        };
1342        let Some((gid, glyph)) = glyph_a(&font) else {
1343            return;
1344        };
1345        let Some(path) = build_hinted_path(gid, &glyph, &font, 16) else {
1346            return; // unhinted font (CFF / no instructions): nothing to assert
1347        };
1348        assert!(path.total_vertices() > 0, "hinted path must have vertices");
1349        for v in path.vertices() {
1350            assert!(
1351                v.x.is_finite() && v.y.is_finite(),
1352                "hinting produced a non-finite vertex: ({}, {})",
1353                v.x,
1354                v.y
1355            );
1356        }
1357    }
1358}