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}