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::path_storage::PathStorage;
13use agg_rust::rasterizer_cells_aa::CellAa;
14
15use crate::font::parsed::{build_glyph_path, OwnedGlyph, ParsedFont};
16
17/// Cache key for a glyph path.
18/// ppem = 0 means unhinted (font-unit path), ppem > 0 means hinted at that size.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20struct GlyphPathKey {
21    font_hash: u64,
22    glyph_id: u16,
23    ppem: u16,
24}
25
26/// Cache key for pre-rasterized glyph cells.
27/// Includes sub-pixel x/y fractional position quantized to 1/4 pixel.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29struct GlyphCellKey {
30    font_hash: u64,
31    glyph_id: u16,
32    ppem: u16,
33    /// Scale factor encoded as fixed-point (scale * 65536) for unhinted glyphs.
34    /// 0 for hinted glyphs (already in pixel coords).
35    scale_fixed: u32,
36    /// Sub-pixel x position quantized to 1/4 pixel (0..3).
37    subpx_x: u8,
38    /// Sub-pixel y position quantized to 1/4 pixel (0..3).
39    subpx_y: u8,
40}
41
42/// Result of a cache lookup: the path plus whether it's hinted (pixel coords) or not.
43pub struct CachedGlyph<'a> {
44    pub path: &'a PathStorage,
45    pub is_hinted: bool,
46}
47
48impl core::fmt::Debug for CachedGlyph<'_> {
49    // `path` is agg_rust's PathStorage (not Debug); show the rest.
50    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
51        f.debug_struct("CachedGlyph")
52            .field("is_hinted", &self.is_hinted)
53            .finish_non_exhaustive()
54    }
55}
56
57/// Pre-rasterized glyph cells at a canonical position.
58/// Contains the rasterizer's cell output for a glyph at sub-pixel position (`subpx_x`, `subpx_y`).
59/// To render at actual position (x, y), add integer pixel offset to each cell.
60struct CachedCells {
61    cells: Vec<CellAa>,
62}
63
64/// Maximum number of glyph path entries before eviction.
65/// ~8K glyphs covers most Latin + CJK pages without unbounded growth.
66const MAX_PATH_ENTRIES: usize = 8192;
67/// Maximum number of cell cache entries before eviction.
68/// Cell entries are larger than paths, so a lower limit is appropriate.
69const MAX_CELL_ENTRIES: usize = 16384;
70
71/// Cache of built glyph paths and pre-rasterized cells.
72pub struct GlyphCache {
73    paths: HashMap<GlyphPathKey, Option<(PathStorage, bool)>>,
74    cells: HashMap<GlyphCellKey, Option<CachedCells>>,
75}
76
77impl core::fmt::Debug for GlyphCache {
78    // Values hold agg_rust PathStorage / CellAa (not Debug); show entry counts.
79    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80        f.debug_struct("GlyphCache")
81            .field("paths", &self.paths.len())
82            .field("cells", &self.cells.len())
83            .finish_non_exhaustive()
84    }
85}
86
87/// Quantize a fractional pixel position to 1/4 pixel (0..3).
88#[inline]
89#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
90fn quantize_subpx(frac: f32) -> u8 {
91    let f = frac - frac.floor();
92    (f * 4.0).min(3.0) as u8
93}
94
95impl Default for GlyphCache {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl GlyphCache {
102    #[must_use]
103    pub fn new() -> Self {
104        Self {
105            paths: HashMap::new(),
106            cells: HashMap::new(),
107        }
108    }
109
110    /// Entry count of the glyph-path cache (for leak probes).
111    #[must_use] pub fn paths_len(&self) -> usize { self.paths.len() }
112
113    /// Entry count of the pre-rasterized cell cache (for leak probes).
114    #[must_use] pub fn cells_len(&self) -> usize { self.cells.len() }
115
116    /// Get a cached path, or build it on cache miss.
117    /// Returns `None` if the glyph has no outline (e.g. space character).
118    pub fn get_or_build(
119        &mut self,
120        font_hash: u64,
121        glyph_id: u16,
122        glyph_data: &OwnedGlyph,
123        parsed_font: &ParsedFont,
124        ppem: u16,
125    ) -> Option<CachedGlyph<'_>> {
126        if self.paths.len() >= MAX_PATH_ENTRIES {
127            self.paths.clear();
128        }
129        let key = GlyphPathKey { font_hash, glyph_id, ppem };
130        let entry = self
131            .paths
132            .entry(key)
133            .or_insert_with(|| {
134                // Try hinted path first if ppem > 0
135                if ppem > 0 {
136                    if let Some(path) = build_hinted_path(glyph_id, glyph_data, parsed_font, ppem) {
137                        return Some((path, true));
138                    }
139                }
140                // Fall back to unhinted path
141                build_glyph_path(glyph_data).map(|p| (p, false))
142            });
143        entry.as_ref().map(|(path, is_hinted)| CachedGlyph {
144            path,
145            is_hinted: *is_hinted,
146        })
147    }
148
149    /// Get cached rasterizer cells for a glyph, or build them from the path.
150    ///
151    /// - `glyph_x`, `glyph_y`: final pixel position (used for sub-pixel quantization)
152    /// - `scale`: font-unit→pixel scale (0.0 for hinted glyphs)
153    /// - `is_hinted`: whether the path is in pixel coords (hinted) or font units
154    /// - `hint_correction`: `effective_px / ppem` for hinted glyphs (1.0 otherwise).
155    ///   A hinted outline is built at the *integer* ppem; when the requested
156    ///   effective size (`font_size * dpi`) is fractional this rescales it back to
157    ///   the true target size so hinted glyphs match their unhinted neighbours and
158    ///   animate smoothly instead of snapping between integer ppems. When the
159    ///   effective size is already integral this is 1.0 and the hinted glyph keeps
160    ///   its pixel-grid-snapped placement.
161    ///
162    /// Returns the cached cells and the integer pixel offset to apply.
163    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
164    pub fn get_or_build_cells(
165        &mut self,
166        font_hash: u64,
167        glyph_id: u16,
168        ppem: u16,
169        glyph_x: f32,
170        glyph_y: f32,
171        scale: f32,
172        is_hinted: bool,
173        hint_correction: f32,
174    ) -> Option<(&[CellAa], i32, i32)> {
175        if self.cells.len() >= MAX_CELL_ENTRIES {
176            self.cells.clear();
177        }
178        // Hinted outline built at integer ppem needs rescaling only when the
179        // effective size is fractional (hint_correction != 1). Otherwise it stays
180        // pixel-grid-snapped (rounded placement) as hinting intends.
181        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
182        let grid_snapped = is_hinted && !rescale_hinted;
183
184        // Sub-pixel HORIZONTAL positioning (default ON): even a grid-snapped
185        // (hinted-at-integer-ppem) glyph places its ORIGIN at a 1/4-pixel X
186        // bucket, so advances accumulate smoothly and the run lands where
187        // CoreText (fractional-x) puts it, instead of each origin rounding to a
188        // whole pixel. The grid-fitted OUTLINE is unchanged — only where we drop
189        // it horizontally shifts — so vertical stems stay crisp. The Y baseline
190        // stays grid-snapped (`subpx_y == 0` for grid_snapped). With
191        // `AZ_TEXT_SUBPIXEL=0` the grid_snapped case reverts to integer X
192        // (sub-pixel 0, rounded origin), the previous behaviour.
193        let subpx_x_snap = grid_snapped && !text_subpixel_enabled();
194        let subpx_x = if subpx_x_snap { 0 } else { quantize_subpx(glyph_x) };
195        let subpx_y = if grid_snapped { 0 } else { quantize_subpx(glyph_y) };
196        debug_assert!((0.0..65536.0).contains(&scale), "scale out of range for fixed-point: {scale}");
197        let scale_fixed = if is_hinted {
198            if rescale_hinted { (hint_correction * 65536.0) as u32 } else { 0 }
199        } else {
200            (scale * 65536.0) as u32
201        };
202
203        let cell_key = GlyphCellKey {
204            font_hash, glyph_id, ppem, scale_fixed, subpx_x, subpx_y,
205        };
206
207        // Integer pixel offset — the cells are at sub-pixel origin, offset by int
208        // part. `int_x + subpx_x*0.25` must reconstruct `glyph_x`, so the floor
209        // pairs with the quantized fraction; only the integer-X-snap case rounds.
210        let int_x = if subpx_x_snap { glyph_x.round() as i32 } else { glyph_x.floor() as i32 };
211        let int_y = if grid_snapped { glyph_y.round() as i32 } else { glyph_y.floor() as i32 };
212
213        if !self.cells.contains_key(&cell_key) {
214            // Build cells from cached path
215            let path_key = GlyphPathKey { font_hash, glyph_id, ppem };
216            let path_entry = self.paths.get(&path_key);
217            let cached_cells = path_entry.and_then(|entry| {
218                use agg_rust::trans_affine::TransAffine;
219                use agg_rust::basics::FillingRule;
220                use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
221                let (path, _) = entry.as_ref()?;
222                let frac_x = f64::from(subpx_x) * 0.25;
223                let frac_y = f64::from(subpx_y) * 0.25;
224
225                let mut ras = RasterizerScanlineAa::new();
226                ras.filling_rule(FillingRule::NonZero);
227
228                let transform = if is_hinted {
229                    if rescale_hinted {
230                        let mut t = TransAffine::new_scaling_uniform(f64::from(hint_correction));
231                        t.multiply(&TransAffine::new_translation(frac_x, frac_y));
232                        t
233                    } else {
234                        TransAffine::new_translation(frac_x, frac_y)
235                    }
236                } else {
237                    let mut t = TransAffine::new_scaling_uniform(f64::from(scale));
238                    t.multiply(&TransAffine::new_translation(frac_x, frac_y));
239                    t
240                };
241
242                let verts = path.vertices();
243                ras.add_path_vertices_transformed(verts, &transform);
244                let cells = ras.outline_cells();
245                if cells.is_empty() { None } else { Some(CachedCells { cells }) }
246            });
247            self.cells.insert(cell_key, cached_cells);
248        }
249
250        let entry = self.cells.get(&cell_key)?;
251        entry.as_ref().map(|cc| (cc.cells.as_slice(), int_x, int_y))
252    }
253}
254
255/// Build a hinted glyph path using TrueType bytecode hinting.
256///
257/// The returned path is in pixel coordinates (1 unit = 1 pixel at the given ppem).
258/// Returns `None` if the glyph has no raw hinting data or hinting fails.
259/// Read a glyph's left side bearing (font units) straight from the `hmtx`
260/// table. Mirrors the `FreeType` `TT_Get_HMetrics` lookup used to place phantom
261/// point pp1 at `xMin - lsb`. Returns `None` if hmtx is unavailable.
262fn glyph_lsb(parsed_font: &ParsedFont, glyph_id: u16) -> Option<i16> {
263    let (off, len) = parsed_font.hmtx_range;
264    if len == 0 {
265        return None;
266    }
267    let bytes = parsed_font.original_bytes.as_ref()?;
268    let hmtx = bytes.as_ref().get(off..off + len)?;
269    let num = usize::from(parsed_font.hhea_table.num_h_metrics);
270    if num == 0 {
271        return None;
272    }
273    let gid = usize::from(glyph_id);
274    // longHorMetric[i] = { advanceWidth: u16, lsb: i16 } (4 bytes) for i < num;
275    // trailing leftSideBearing: i16 array for the remaining glyphs.
276    let lsb_off = if gid < num {
277        gid * 4 + 2
278    } else {
279        num * 4 + (gid - num) * 2
280    };
281    let b = hmtx.get(lsb_off..lsb_off + 2)?;
282    Some(i16::from_be_bytes([b[0], b[1]]))
283}
284
285fn build_hinted_path(
286    glyph_id: u16,
287    glyph: &OwnedGlyph,
288    parsed_font: &ParsedFont,
289    ppem: u16,
290) -> Option<PathStorage> {
291    use allsorts::hinting::f26dot6::F26Dot6;
292    let raw_points = glyph.raw_points.as_ref()?;
293    let raw_on_curve = glyph.raw_on_curve.as_ref()?;
294    let raw_contour_ends = glyph.raw_contour_ends.as_ref()?;
295    let instructions = glyph.instructions.as_ref()?;
296
297    if raw_points.is_empty() || raw_contour_ends.is_empty() {
298        return None;
299    }
300
301    let hint_mutex = parsed_font.hint_instance.as_ref()?;
302    let mut hint = hint_mutex.lock().ok()?;
303
304    let upem = parsed_font.font_metrics.units_per_em;
305    if upem == 0 {
306        return None;
307    }
308
309    // Set up hinting for this ppem (scales CVT, runs prep)
310    if hint.set_ppem(ppem, f64::from(ppem)).is_err() {
311        return None;
312    }
313
314    // Scale raw points from font units to F26Dot6
315    let scale = allsorts::hinting::f26dot6::compute_scale(ppem, upem);
316
317    let points_f26dot6: Vec<(i32, i32)> = raw_points
318        .iter()
319        .map(|&(x, y)| {
320            let sx = F26Dot6::from_funits(i32::from(x), scale);
321            let sy = F26Dot6::from_funits(i32::from(y), scale);
322            (sx.to_bits(), sy.to_bits())
323        })
324        .collect();
325
326    // Scale advance width to F26Dot6 for phantom points
327    let adv_f26dot6 = F26Dot6::from_funits(i32::from(glyph.horz_advance), scale).to_bits();
328
329    // Phantom point pp1.x = (xMin - lsb) scaled (FreeType tt_loader_set_pp).
330    // Threading the real lsb makes left-side-bearing grid-fitting match FreeType
331    // for fonts where lsb != xMin; when lsb is unavailable, fall back to xMin
332    // (i.e. lsb == xMin => pp1.x = 0, the previous hardcoded behaviour).
333    let x_min = i32::from(glyph.bounding_box.min_x);
334    let lsb = glyph_lsb(parsed_font, glyph_id).map_or(x_min, i32::from);
335    let pp1_x_f26dot6 = F26Dot6::from_funits(x_min - lsb, scale).to_bits();
336
337    // Run hinting and capture the POST-hinting on-curve flags. FLIPPT/FLIPRGON/
338    // FLIPRGOFF can flip a point between on-curve and off-curve during the glyph
339    // program; the contour builder must use the updated flags, not the original
340    // raw_on_curve, or it treats a flipped control point as a line endpoint (and
341    // vice versa), kinking the outline.
342    let Ok((hinted, hinted_on_curve)) = hint.hint_glyph_with_flags_pp1(
343        &points_f26dot6,
344        raw_on_curve,
345        raw_contour_ends,
346        instructions,
347        adv_f26dot6,
348        pp1_x_f26dot6,
349    ) else {
350        return None;
351    };
352    drop(hint);
353
354    // Optional "light" hinting (CoreText / DirectWrite grayscale style): keep the
355    // grid-fitted Y (baseline, x-height and horizontal stems snap crisply to the
356    // pixel grid) but restore the UNHINTED fractional X, so vertical stems stay
357    // sub-pixel-positioned and anti-alias to soft gray instead of snapping to a
358    // hard full-black 1px column (Windows-style full grid-fit). This is what
359    // converges our CPU text onto CoreText — full bytecode hinting over-snaps X,
360    // rendering stems thinner + darker than CoreText's. On by default; set
361    // AZ_HINT_LIGHT=0 to force Windows-style full grid-fit. The interpreter still
362    // runs in full (its Y output + FLIP'd on-curve flags are used), so no hinting
363    // correctness is lost — only the X axis is left sub-pixel for grayscale AA.
364    if hint_light_enabled() {
365        let light: Vec<(i32, i32)> = hinted
366            .iter()
367            .enumerate()
368            .map(|(i, &(hx, hy))| (points_f26dot6.get(i).map_or(hx, |p| p.0), hy))
369            .collect();
370        return build_path_from_contours(&light, &hinted_on_curve, raw_contour_ends);
371    }
372
373    // Build path from hinted points using TrueType quadratic contour conventions
374    build_path_from_contours(&hinted, &hinted_on_curve, raw_contour_ends)
375}
376
377/// Whether to apply CoreText-style "light" hinting (grid-fit Y only, fractional X).
378/// ON by default (matches CoreText / modern browser grayscale rendering); set
379/// `AZ_HINT_LIGHT=0` (or `false`) to force Windows-style full grid-fit. Read once.
380fn hint_light_enabled() -> bool {
381    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
382    *V.get_or_init(|| {
383        std::env::var("AZ_HINT_LIGHT")
384            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
385            .unwrap_or(true)
386    })
387}
388
389/// Whether to place each glyph ORIGIN at a sub-pixel HORIZONTAL position (a
390/// 1/4-pixel X bucket) instead of snapping it to a whole pixel.
391///
392/// ON by default. This is the horizontal half of the light-hinting philosophy
393/// (crisp vertical, soft/sub-pixel horizontal): the grid-fitted glyph *outline*
394/// still snaps its stems and baseline to the pixel grid, but the pen advances
395/// accumulate at fractional precision so a run of glyphs lands where CoreText
396/// (which positions glyphs at fractional x) puts them, rather than each origin
397/// rounding to an integer pixel and drifting the whole line. Set
398/// `AZ_TEXT_SUBPIXEL=0` (or `false`) to force integer X placement. Read once.
399pub(crate) fn text_subpixel_enabled() -> bool {
400    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
401    *V.get_or_init(|| {
402        std::env::var("AZ_TEXT_SUBPIXEL")
403            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
404            .unwrap_or(true)
405    })
406}
407
408/// Build an agg `PathStorage` from TrueType contour data (points in `F26Dot6`).
409///
410/// Matches allsorts' `visit_simple_glyph_outline` algorithm exactly:
411/// - On-curve points are endpoints of line/curve segments
412/// - Off-curve points are quadratic Bézier control points
413/// - Two consecutive off-curve points have an implicit on-curve midpoint
414/// - Y is negated for screen coordinates (font Y-up → screen Y-down)
415/// - The origin point is NOT revisited in the loop; `close()` handles the final segment
416#[must_use] pub fn build_path_from_contours(
417    points: &[(i32, i32)],
418    on_curve: &[bool],
419    contour_ends: &[u16],
420) -> Option<PathStorage> {
421    use agg_rust::basics::PATH_FLAGS_NONE;
422
423    let mut path = PathStorage::new();
424    let mut has_ops = false;
425    let mut contour_start = 0usize;
426
427    for &end_idx in contour_ends {
428        let end = end_idx as usize;
429        if end >= points.len() || contour_start > end {
430            contour_start = end + 1;
431            continue;
432        }
433
434        let pts = &points[contour_start..=end];
435        let flags = &on_curve[contour_start..=end];
436        let n = pts.len();
437        if n < 2 {
438            contour_start = end + 1;
439            continue;
440        }
441
442        // Helper: get point as (f64, f64) with Y negated
443        let px = |i: usize| -> (f64, f64) {
444            (f64::from(f26_to_px(pts[i].0)), f64::from(-f26_to_px(pts[i].1)))
445        };
446        let mid = |a: (f64, f64), b: (f64, f64)| -> (f64, f64) {
447            ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5)
448        };
449
450        // Determine origin and processing range (matching allsorts' calculate_origin)
451        let (origin, start, until) = if flags[0] {
452            (px(0), 1usize, n)
453        } else if flags[n - 1] {
454            (px(n - 1), 0usize, n - 1)
455        } else {
456            (mid(px(0), px(n - 1)), 0usize, n)
457        };
458
459        path.move_to(origin.0, origin.1);
460        has_ops = true;
461
462        let mut i = start;
463        while i < until {
464            if flags[i] {
465                // On-curve: line segment
466                let to = px(i);
467                path.line_to(to.0, to.1);
468                i += 1;
469            } else {
470                // Off-curve control point
471                let ctrl = px(i);
472                let next = i + 1;
473                if next < until {
474                    if flags[next] {
475                        // Next is on-curve: quad to it, consume both
476                        let to = px(next);
477                        path.curve3(ctrl.0, ctrl.1, to.0, to.1);
478                        i = next + 1;
479                    } else {
480                        // Next is also off-curve: quad to implicit midpoint
481                        let m = mid(ctrl, px(next));
482                        path.curve3(ctrl.0, ctrl.1, m.0, m.1);
483                        i = next;
484                    }
485                } else {
486                    // End of range: curve back to origin
487                    path.curve3(ctrl.0, ctrl.1, origin.0, origin.1);
488                    i = next;
489                }
490            }
491        }
492        path.close_polygon(PATH_FLAGS_NONE);
493
494        contour_start = end + 1;
495    }
496
497    if !has_ops {
498        return None;
499    }
500    Some(path)
501}
502
503/// Convert `F26Dot6` value to pixel coordinate (f32).
504#[inline]
505#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
506fn f26_to_px(v: i32) -> f32 {
507    v as f32 / 64.0
508}