Skip to main content

azul_layout/
font.rs

1//! Font parsing, metrics extraction, and subsetting.
2//!
3//! This module provides the core font infrastructure for text layout and PDF generation:
4//! - `loading`: System font cache construction and font reload errors
5//! - `mock`: Mock font implementation for testing without real font files
6//! - `parsed`: Full font parsing via allsorts (outlines, metrics, shaping tables, subsetting)
7
8#![cfg(feature = "font_loading")]
9
10use azul_css::{AzString, U8Vec};
11use rust_fontconfig::{FcFontCache, OwnedFontSource};
12
13pub mod loading {
14    #![cfg(feature = "std")]
15    #![cfg(feature = "font_loading")]
16    #![cfg_attr(not(feature = "std"), no_std)]
17
18    use std::io::Error as IoError;
19
20    use azul_css::{AzString, StringVec, U8Vec};
21    use rust_fontconfig::FcFontCache;
22
23    #[cfg(not(miri))]
24    #[must_use] pub fn build_font_cache() -> FcFontCache {
25        FcFontCache::build()
26    }
27
28    #[cfg(miri)]
29    pub fn build_font_cache() -> FcFontCache {
30        FcFontCache::default()
31    }
32
33    #[derive(Debug)]
34    pub enum FontReloadError {
35        Io(IoError, AzString),
36        FontNotFound(AzString),
37        FontLoadingNotActive(AzString),
38    }
39
40    impl Clone for FontReloadError {
41        fn clone(&self) -> Self {
42            use self::FontReloadError::{Io, FontNotFound, FontLoadingNotActive};
43            match self {
44                Io(err, path) => Io(IoError::new(err.kind(), "Io Error"), path.clone()),
45                FontNotFound(id) => FontNotFound(id.clone()),
46                FontLoadingNotActive(id) => FontLoadingNotActive(id.clone()),
47            }
48        }
49    }
50
51    azul_core::impl_display!(FontReloadError, {
52        Io(err, path_buf) => format!("Could not load \"{}\" - IO error: {}", path_buf.as_str(), err),
53        FontNotFound(id) => format!("Could not locate system font: \"{:?}\" found", id),
54        FontLoadingNotActive(id) => format!("Could not load system font: \"{:?}\": crate was not compiled with --features=\"font_loading\"", id)
55    });
56}
57pub mod mock {
58    //! Mock font implementation for testing text layout.
59    //!
60    //! Provides a `MockFont` that simulates font behavior without requiring
61    //! actual font files, useful for unit testing text layout functionality.
62
63    use std::collections::BTreeMap;
64
65    use crate::text3::cache::LayoutFontMetrics;
66
67    /// A mock font implementation for testing text layout without real fonts.
68    ///
69    /// This allows testing text shaping, layout, and rendering code paths
70    /// without needing to load actual TrueType/OpenType font files.
71    #[derive(Debug, Clone)]
72    pub struct MockFont {
73        /// Font metrics (ascent, descent, etc.).
74        pub font_metrics: LayoutFontMetrics,
75        /// Width of the space character in font units.
76        pub space_width: Option<usize>,
77        /// Horizontal advance widths keyed by glyph ID.
78        pub glyph_advances: BTreeMap<u16, u16>,
79        /// Glyph bounding box sizes (width, height) keyed by glyph ID.
80        pub glyph_sizes: BTreeMap<u16, (i32, i32)>,
81        /// Unicode codepoint to glyph ID mapping.
82        pub glyph_indices: BTreeMap<u32, u16>,
83    }
84
85    impl MockFont {
86        /// Creates a new `MockFont` with the given font metrics.
87        #[must_use] pub const fn new(font_metrics: LayoutFontMetrics) -> Self {
88            Self {
89                font_metrics,
90                space_width: Some(10),
91                glyph_advances: BTreeMap::new(),
92                glyph_sizes: BTreeMap::new(),
93                glyph_indices: BTreeMap::new(),
94            }
95        }
96
97        /// Sets the space character width.
98        #[must_use] pub const fn with_space_width(mut self, width: usize) -> Self {
99            self.space_width = Some(width);
100            self
101        }
102
103        /// Adds a horizontal advance value for a glyph.
104        #[must_use] pub fn with_glyph_advance(mut self, glyph_index: u16, advance: u16) -> Self {
105            self.glyph_advances.insert(glyph_index, advance);
106            self
107        }
108
109        /// Adds a bounding box size for a glyph.
110        #[must_use] pub fn with_glyph_size(mut self, glyph_index: u16, size: (i32, i32)) -> Self {
111            self.glyph_sizes.insert(glyph_index, size);
112            self
113        }
114
115        /// Adds a Unicode codepoint to glyph ID mapping.
116        #[must_use] pub fn with_glyph_index(mut self, unicode: u32, index: u16) -> Self {
117            self.glyph_indices.insert(unicode, index);
118            self
119        }
120    }
121}
122
123pub mod parsed {
124    use core::fmt;
125    use std::{collections::BTreeMap, sync::Arc};
126
127    use allsorts::{
128        binary::read::ReadScope,
129        font_data::FontData,
130        layout::{GDEFTable, LayoutCache, LayoutCacheData, GPOS, GSUB},
131        outline::{OutlineBuilder, OutlineSink},
132        pathfinder_geometry::{line_segment::LineSegment2F, vector::Vector2F},
133        subset::{subset as allsorts_subset, whole_font, CmapTarget, SubsetProfile},
134        tables::{
135            cmap::owned::CmapSubtable as OwnedCmapSubtable,
136            glyf::{
137                Glyph, GlyfVisitorContext, LocaGlyf, Point,
138                VariableGlyfContext, VariableGlyfContextStore,
139            },
140            kern::owned::KernTable,
141            FontTableProvider, HheaTable, MaxpTable,
142        },
143        tag,
144    };
145    use azul_core::resources::{
146        GlyphOutline, GlyphOutlineOperation, OutlineCubicTo, OutlineLineTo, OutlineMoveTo,
147        OutlineQuadTo, OwnedGlyphBoundingBox,
148    };
149    use azul_css::props::basic::FontMetrics as CssFontMetrics;
150
151    // Mock font module for testing
152    pub use crate::font::mock::MockFont;
153    use crate::text3::cache::LayoutFontMetrics;
154
155    /// Cached GSUB table for glyph substitution operations.
156    pub type GsubCache = Arc<LayoutCacheData<GSUB>>;
157    /// Cached GPOS table for glyph positioning operations.
158    pub type GposCache = Arc<LayoutCacheData<GPOS>>;
159
160    /// The `wght` variation axis `(min, default, max)` in user units.
161    ///
162    /// `None` when the font has no variable `wght` axis. Used to expand a
163    /// variable font into per-weight STATIC instances so the ordinary (static)
164    /// weight-selection path can pick the right one — no changes to
165    /// shaping/decode/PDF needed.
166    #[must_use]
167    pub fn read_wght_axis(bytes: &[u8], index: usize) -> Option<(f32, f32, f32)> {
168        let font_file = ReadScope::new(bytes).read::<FontData<'_>>().ok()?;
169        let provider = font_file.table_provider(index).ok()?;
170        let fvar_data = provider.read_table_data(tag::FVAR).ok()?;
171        let fvar = ReadScope::new(&fvar_data)
172            .read::<allsorts::tables::variable_fonts::fvar::FvarTable<'_>>()
173            .ok()?;
174        // Bind before returning so the (borrowing) axes() iterator is dropped at
175        // the end of this statement, not after `provider`/`fvar` at block end.
176        let axis = fvar.axes().find(|a| a.axis_tag == tag::WGHT);
177        axis.map(|a| {
178            (
179                f32::from(a.min_value),
180                f32::from(a.default_value),
181                f32::from(a.max_value),
182            )
183        })
184    }
185
186    /// Bake a self-contained STATIC instance of a variable font at `wght`.
187    ///
188    /// All other axes are left at their default. Returns fresh TTF bytes that
189    /// parse and embed exactly like any static font, or `None` if the font is
190    /// not a bakeable variable font.
191    #[must_use]
192    pub fn bake_weight_instance(bytes: &[u8], index: usize, wght: f32) -> Option<Vec<u8>> {
193        use allsorts::tables::Fixed;
194        let font_file = ReadScope::new(bytes).read::<FontData<'_>>().ok()?;
195        let provider = font_file.table_provider(index).ok()?;
196        let fvar_data = provider.read_table_data(tag::FVAR).ok()?;
197        let fvar = ReadScope::new(&fvar_data)
198            .read::<allsorts::tables::variable_fonts::fvar::FvarTable<'_>>()
199            .ok()?;
200        let user: Vec<Fixed> = fvar
201            .axes()
202            .map(|a| {
203                if a.axis_tag == tag::WGHT {
204                    Fixed::from(wght)
205                } else {
206                    a.default_value
207                }
208            })
209            .collect();
210        allsorts::variations::instance(&provider, &user)
211            .ok()
212            .map(|(baked, _tuple)| baked)
213    }
214
215    /// Monotonic-clock nanos since process start. Used to timestamp
216    /// `ParsedFont.last_used` for LRU eviction. Cheap (single
217    /// `Instant::now`); resolution is plenty fine for "did this
218    /// face get touched in the last N seconds" decisions. Exposed
219    /// `pub(crate)` so `FontManager::evict_unused` reads from the
220    /// same clock as `last_used` writes.
221    #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
222    #[cfg(not(target_family = "wasm"))]
223    pub(crate) fn monotonic_now_nanos() -> u64 {
224        // Safe: `Instant::elapsed` against the same launch instant is
225        // monotonic and never overflows in any realistic process
226        // lifetime (>500 years).
227        use std::sync::OnceLock;
228        use std::time::Instant;
229        static LAUNCH: OnceLock<Instant> = OnceLock::new();
230        let start = LAUNCH.get_or_init(Instant::now);
231        start.elapsed().as_nanos() as u64
232    }
233
234    /// On browser wasm `std::time::Instant::now()` panics ("time not
235    /// implemented on this platform") — it took the whole printpdf wasm demo
236    /// down with it on the first shaped glyph: every `last_used` store on a
237    /// font touch aborted the module. LRU eviction only needs *ordering*, not
238    /// wall time, and a shared atomic counter is exactly as monotonic.
239    #[cfg(target_family = "wasm")]
240    pub(crate) fn monotonic_now_nanos() -> u64 {
241        use std::sync::atomic::{AtomicU64, Ordering};
242        static TICK: AtomicU64 = AtomicU64::new(1);
243        TICK.fetch_add(1, Ordering::Relaxed)
244    }
245
246    /// Glyph-outline decoder state. See the
247    /// [`ParsedFont::loca_glyf`] field docs for the full description.
248    #[derive(Clone)]
249    pub(crate) enum LocaGlyfState {
250        /// Ready to decode immediately, or known to have no outline
251        /// data. `None` covers both CFF fonts and fonts where the
252        /// loca+glyf parse failed.
253        ///
254        /// This variant *cannot* be evicted by
255        /// [`crate::text3::cache::FontManager::evict_unused`]: there
256        /// are no source bytes retained to re-decode from. The eager
257        /// `from_bytes` path (tests, `with_source_bytes` PDF callers)
258        /// produces this variant.
259        Loaded(Option<Arc<std::sync::Mutex<LocaGlyf>>>),
260        /// Font bytes retained for lazy `LocaGlyf` construction.
261        ///
262        /// `loaded` is `Mutex<Option<…>>` (not `OnceLock`) so an
263        /// idle eviction can clear it back to `None`; the next
264        /// `get_or_decode_glyph` will re-parse from `bytes`. Two-step
265        /// double-check pattern in `resolve_loca_glyf` keeps the
266        /// expensive `LocaGlyf::load` outside the critical section.
267        Deferred {
268            bytes: Arc<rust_fontconfig::FontBytes>,
269            font_index: usize,
270            loaded: Arc<std::sync::Mutex<Option<Arc<std::sync::Mutex<LocaGlyf>>>>>,
271        },
272    }
273
274    /// Adapter that collects allsorts outline commands into our `GlyphOutline` format.
275    ///
276    /// Implements `OutlineSink` so it can be passed to `GlyfVisitorContext::visit()`.
277    /// This handles composite glyph resolution, transforms, and variable font
278    /// deltas automatically via allsorts internals.
279    struct GlyphOutlineCollector {
280        contours: Vec<GlyphOutline>,
281        current_contour: Vec<GlyphOutlineOperation>,
282    }
283
284    impl GlyphOutlineCollector {
285        const fn new() -> Self {
286            Self {
287                contours: Vec::new(),
288                current_contour: Vec::new(),
289            }
290        }
291
292        fn into_outlines(mut self) -> Vec<GlyphOutline> {
293            if !self.current_contour.is_empty() {
294                self.contours.push(GlyphOutline {
295                    operations: std::mem::take(&mut self.current_contour).into(),
296                });
297            }
298            self.contours
299        }
300    }
301
302    impl OutlineSink for GlyphOutlineCollector {
303        #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
304        fn move_to(&mut self, to: Vector2F) {
305            if !self.current_contour.is_empty() {
306                self.contours.push(GlyphOutline {
307                    operations: std::mem::take(&mut self.current_contour).into(),
308                });
309            }
310            self.current_contour.push(GlyphOutlineOperation::MoveTo(OutlineMoveTo {
311                x: to.x() as i16,
312                y: to.y() as i16,
313            }));
314        }
315
316        #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
317        fn line_to(&mut self, to: Vector2F) {
318            self.current_contour.push(GlyphOutlineOperation::LineTo(OutlineLineTo {
319                x: to.x() as i16,
320                y: to.y() as i16,
321            }));
322        }
323
324        #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
325        fn quadratic_curve_to(&mut self, ctrl: Vector2F, to: Vector2F) {
326            self.current_contour.push(GlyphOutlineOperation::QuadraticCurveTo(
327                OutlineQuadTo {
328                    ctrl_1_x: ctrl.x() as i16,
329                    ctrl_1_y: ctrl.y() as i16,
330                    end_x: to.x() as i16,
331                    end_y: to.y() as i16,
332                },
333            ));
334        }
335
336        #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
337        fn cubic_curve_to(&mut self, ctrl: LineSegment2F, to: Vector2F) {
338            self.current_contour.push(GlyphOutlineOperation::CubicCurveTo(
339                OutlineCubicTo {
340                    ctrl_1_x: ctrl.from_x() as i16,
341                    ctrl_1_y: ctrl.from_y() as i16,
342                    ctrl_2_x: ctrl.to_x() as i16,
343                    ctrl_2_y: ctrl.to_y() as i16,
344                    end_x: to.x() as i16,
345                    end_y: to.y() as i16,
346                },
347            ));
348        }
349
350        fn close(&mut self) {
351            self.current_contour.push(GlyphOutlineOperation::ClosePath);
352            self.contours.push(GlyphOutline {
353                operations: std::mem::take(&mut self.current_contour).into(),
354            });
355        }
356    }
357
358    /// Parsed font data with all required tables for text layout and PDF generation.
359    ///
360    /// This struct holds the parsed representation of a TrueType/OpenType font,
361    /// including glyph outlines, metrics, and shaping tables. It's used for:
362    /// - Text layout (via GSUB/GPOS tables)
363    /// - Glyph rendering (via glyf/CFF outlines)
364    /// - PDF font embedding (via font metrics and subsetting)
365    pub struct ParsedFont {
366        /// Hash of the font bytes for caching and equality checks.
367        pub hash: u64,
368        /// Layout-specific font metrics (ascent, descent, line gap).
369        pub font_metrics: LayoutFontMetrics,
370        /// PDF-specific detailed font metrics from HEAD, HHEA, OS/2 tables.
371        pub pdf_font_metrics: PdfFontMetrics,
372        /// Total number of glyphs in the font (from maxp table).
373        pub num_glyphs: u16,
374        /// Horizontal header table (hhea) containing global horizontal metrics.
375        pub hhea_table: HheaTable,
376        /// Offset+length into `original_bytes` for hmtx table (lazy: no copy).
377        pub hmtx_range: (usize, usize),
378        /// Offset+length into `original_bytes` for vmtx table (lazy: no copy).
379        pub vmtx_range: (usize, usize),
380        /// Vertical header table (vhea), same format as hhea. None if font has no vertical metrics.
381        pub vhea_table: Option<HheaTable>,
382        /// Maximum profile table (maxp) containing glyph count and memory hints.
383        pub maxp_table: MaxpTable,
384        /// Raw GSUB table bytes, kept as a `Vec<u8>` (tens to low-hundreds
385        /// of KiB) so the parsed `GsubCache` can be built on first shape
386        /// call instead of up-front. Access via [`ParsedFont::gsub`] —
387        /// that getter populates `gsub_cache_lazy` via `OnceLock` and
388        /// returns a borrow.
389        pub(crate) gsub_bytes: Option<Vec<u8>>,
390        /// Lazy GSUB cache: populated on first [`ParsedFont::gsub`] call.
391        /// `None` means "font has no GSUB table" *after* init attempt;
392        /// the `OnceLock` wrapper distinguishes "not yet initialised"
393        /// from "initialised to None".
394        pub(crate) gsub_cache_lazy: std::sync::OnceLock<Option<GsubCache>>,
395        /// Raw GPOS table bytes. Same lazy-parse arrangement as
396        /// `gsub_bytes` — see [`ParsedFont::gpos`].
397        pub(crate) gpos_bytes: Option<Vec<u8>>,
398        /// Lazy GPOS cache, populated on first [`ParsedFont::gpos`] call.
399        pub(crate) gpos_cache_lazy: std::sync::OnceLock<Option<GposCache>>,
400        /// Glyph definition table (GDEF) for glyph classification.
401        pub opt_gdef_table: Option<Arc<GDEFTable>>,
402        /// Legacy kerning table (kern) for fonts without GPOS.
403        pub opt_kern_table: Option<Arc<KernTable>>,
404        /// Monotonic-clock nanos at the most recent
405        /// [`ParsedFont::get_or_decode_glyph`] / `gsub()` / `gpos()`
406        /// call. `0` means "never touched". Used by
407        /// [`crate::text3::cache::FontManager::evict_unused`] to
408        /// decide which `LocaGlyfState::Deferred` faces to release.
409        pub(crate) last_used: Arc<std::sync::atomic::AtomicU64>,
410        /// `true` if this font is a variable font (carries a `gvar`
411        /// table). Cached at parse time so [`decode_glyph_inner`]
412        /// can short-circuit the variable-context construction for
413        /// the common non-variable case. Variable-glyph delta
414        /// application requires the source bytes to be retained,
415        /// so it only fires on the `LocaGlyfState::Deferred` path.
416        pub(crate) is_variable_font: bool,
417        /// Lazy outline cache. Populated on first
418        /// [`ParsedFont::get_or_decode_glyph`] call per `gid`; entries
419        /// are wrapped in `Arc` so callers can hold them without
420        /// keeping the lock. The space glyph (and `.notdef` when
421        /// present) are pre-inserted by `from_bytes_internal` so the
422        /// shaper's cmap-miss path has something to render without
423        /// racing with a decode.
424        ///
425        /// Tests that previously walked the public `glyph_records_decoded`
426        /// `BTreeMap` field now call
427        /// [`ParsedFont::prime_glyph_cache`] (decodes every glyph into
428        /// this cache) followed by
429        /// [`ParsedFont::for_each_decoded_glyph`] /
430        /// [`ParsedFont::glyph_cache_snapshot`] to walk the result.
431        // [az-web-lift] queue RwLock spins in lock_contended in single-threaded lifted wasm
432        // (only the pure-Rust queue RwLock is lifted; Mutex is Leaf-stubbed). Reuse
433        // rust_fontconfig::StLock (no-atomic single-threaded bypass). One of the 3 RwLocks total.
434        pub(crate) glyph_cache: Arc<rust_fontconfig::StLock<BTreeMap<u16, Arc<OwnedGlyph>>>>,
435        /// Glyph outline decoder state.
436        ///
437        /// - `Loaded(Some(arc))`: `LocaGlyf` is already loaded (owning
438        ///   its own `Box<[u8]>` copy of the loca+glyf tables) and
439        ///   ready to decode glyphs. Produced by the eager `from_bytes`
440        ///   constructor path (tests).
441        /// - `Loaded(None)`: the font has no usable loca+glyf (CFF, or
442        ///   a parse failure). Glyph outlines won't decode; the hmtx
443        ///   advance fallback fills in the blanks.
444        /// - `Deferred`: we retain an `Arc<[u8]>` to the full font file
445        ///   and the `font_index`; the first `get_or_decode_glyph` call
446        ///   parses a fresh `FontData` / `TableProvider` from those
447        ///   bytes and loads `LocaGlyf`, storing the result in the
448        ///   `OnceLock`. Fonts that get resolved into a chain but are
449        ///   never actually rasterized pay zero decode cost — this is
450        ///   the big win for pages like `excel.html` where 20+ fallback
451        ///   faces load but only a handful are touched.
452        pub(crate) loca_glyf: LocaGlyfState,
453        /// Cached width of the space character in font units.
454        pub space_width: Option<usize>,
455        /// Character-to-glyph mapping (cmap subtable).
456        pub cmap_subtable: Option<OwnedCmapSubtable>,
457        /// Mock font data for testing (replaces real font behavior).
458        pub mock: Option<Box<MockFont>>,
459        /// Reverse mapping: `glyph_id` -> cluster text (handles ligatures like "fi").
460        pub reverse_glyph_cache: BTreeMap<u16, String>,
461        /// Original font bytes — only retained for callers that need to
462        /// reconstruct or subset the font (PDF export). Layout / shaping /
463        /// raster never read this, so `ParsedFont::from_bytes` leaves it
464        /// as `None` by default and callers opt in via
465        /// [`ParsedFont::with_source_bytes`]. Shared across faces of the
466        /// same `.ttc` via the `Arc<FontBytes>` that
467        /// [`rust_fontconfig::FcFontCache::get_font_bytes`] returns —
468        /// for disk fonts the backing is an mmap so untouched pages
469        /// don't count toward RSS.
470        pub original_bytes: Option<Arc<rust_fontconfig::FontBytes>>,
471        /// Font index within collection (0 for single-font files).
472        pub original_index: usize,
473        /// GID to CID mapping for CFF fonts (required for PDF embedding).
474        pub index_to_cid: BTreeMap<u16, u16>,
475        /// Font type (TrueType outlines or OpenType CFF).
476        pub font_type: FontType,
477        /// PostScript font name from the NAME table.
478        pub font_name: Option<String>,
479        /// TrueType bytecode hinting instance (mutable interpreter state).
480        /// Wrapped in Mutex because hinting mutates internal state.
481        /// None for CFF fonts or fonts without hinting data.
482        pub hint_instance: Option<std::sync::Mutex<allsorts::hinting::HintInstance>>,
483    }
484
485    impl Clone for ParsedFont {
486        fn clone(&self) -> Self {
487            Self {
488                hash: self.hash,
489                font_metrics: self.font_metrics,
490                pdf_font_metrics: self.pdf_font_metrics,
491                num_glyphs: self.num_glyphs,
492                hhea_table: self.hhea_table.clone(),
493                hmtx_range: self.hmtx_range,
494                vmtx_range: self.vmtx_range,
495                vhea_table: self.vhea_table.clone(),
496                maxp_table: self.maxp_table.clone(),
497                // OnceLock<T: Clone>: Clone preserves the init state, so
498                // a clone of a parsed cache skips re-parse on first
499                // access. The raw bytes we keep around for lazy init
500                // are cloned too.
501                gsub_bytes: self.gsub_bytes.clone(),
502                gsub_cache_lazy: self.gsub_cache_lazy.clone(),
503                gpos_bytes: self.gpos_bytes.clone(),
504                gpos_cache_lazy: self.gpos_cache_lazy.clone(),
505                opt_gdef_table: self.opt_gdef_table.clone(),
506                opt_kern_table: self.opt_kern_table.clone(),
507                // Share the lazy cache and loca_glyf across clones: cheap
508                // Arc bump, amortises glyph decode across clones of the
509                // same face.
510                last_used: Arc::clone(&self.last_used),
511                is_variable_font: self.is_variable_font,
512                glyph_cache: Arc::clone(&self.glyph_cache),
513                // `LocaGlyfState` is `Clone` — for `Loaded` this is an
514                // `Arc::clone`; for `Deferred` it's an `Arc::clone` of
515                // the bytes + the `OnceLock`, so a clone of a face
516                // that's already decoded glyphs carries the decode.
517                loca_glyf: self.loca_glyf.clone(),
518                space_width: self.space_width,
519                cmap_subtable: self.cmap_subtable.clone(),
520                mock: self.mock.clone(),
521                reverse_glyph_cache: self.reverse_glyph_cache.clone(),
522                // Arc clone — O(1), just bumps refcount; no byte copy.
523                original_bytes: self.original_bytes.clone(),
524                original_index: self.original_index,
525                index_to_cid: self.index_to_cid.clone(),
526                font_type: self.font_type.clone(),
527                font_name: self.font_name.clone(),
528                // HintInstance has mutable interpreter state and is not Clone.
529                // Clones are used for PDF/serialization where hinting isn't needed.
530                hint_instance: None,
531            }
532        }
533    }
534
535    /// Distinguishes TrueType fonts from OpenType CFF fonts.
536    ///
537    /// This affects how glyph outlines are extracted and how the font
538    /// is embedded in PDF documents.
539    #[derive(Debug, Clone, PartialEq, Eq)]
540    pub enum FontType {
541        /// TrueType font with quadratic Bézier outlines in glyf table.
542        TrueType,
543        /// OpenType font with cubic Bézier outlines in CFF table.
544        /// Contains the serialized CFF data for PDF embedding.
545        OpenTypeCFF(Vec<u8>),
546    }
547
548    /// PDF-specific font metrics from HEAD, HHEA, and OS/2 tables.
549    ///
550    /// These metrics are used for PDF font descriptors and accurate
551    /// text positioning in generated PDF documents.
552    #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
553    #[repr(C)]
554    pub struct PdfFontMetrics {
555        // -- HEAD table fields --
556        /// Font units per em-square (typically 1000 or 2048).
557        pub units_per_em: u16,
558        /// Font flags (italic, bold, fixed-pitch, etc.).
559        pub font_flags: u16,
560        /// Minimum x-coordinate across all glyphs.
561        pub x_min: i16,
562        /// Minimum y-coordinate across all glyphs.
563        pub y_min: i16,
564        /// Maximum x-coordinate across all glyphs.
565        pub x_max: i16,
566        /// Maximum y-coordinate across all glyphs.
567        pub y_max: i16,
568
569        // -- HHEA table fields --
570        /// Typographic ascender (distance above baseline).
571        pub ascender: i16,
572        /// Typographic descender (distance below baseline, usually negative).
573        pub descender: i16,
574        /// Recommended line gap between lines of text.
575        pub line_gap: i16,
576        /// Maximum horizontal advance width across all glyphs.
577        pub advance_width_max: u16,
578        /// Caret slope rise for italic angle calculation.
579        pub caret_slope_rise: i16,
580        /// Caret slope run for italic angle calculation.
581        pub caret_slope_run: i16,
582
583        // -- OS/2 table fields (0 if table not present) --
584        /// Average width of lowercase letters.
585        pub x_avg_char_width: i16,
586        /// Visual weight class (100-900, 400=normal, 700=bold).
587        pub us_weight_class: u16,
588        /// Visual width class (1-9, 5=normal).
589        pub us_width_class: u16,
590        /// Thickness of strikeout stroke in font units.
591        pub y_strikeout_size: i16,
592        /// Vertical position of strikeout stroke.
593        pub y_strikeout_position: i16,
594    }
595
596    impl Default for PdfFontMetrics {
597        fn default() -> Self {
598            Self::zero()
599        }
600    }
601
602    impl PdfFontMetrics {
603        /// Returns zeroed metrics with `units_per_em` set to 1000 (standard PostScript default)
604        /// to avoid division-by-zero in scaling calculations.
605        #[must_use] pub const fn zero() -> Self {
606            Self {
607                units_per_em: 1000,
608                font_flags: 0,
609                x_min: 0,
610                y_min: 0,
611                x_max: 0,
612                y_max: 0,
613                ascender: 0,
614                descender: 0,
615                line_gap: 0,
616                advance_width_max: 0,
617                caret_slope_rise: 0,
618                caret_slope_run: 0,
619                x_avg_char_width: 0,
620                us_weight_class: 0,
621                us_width_class: 0,
622                y_strikeout_size: 0,
623                y_strikeout_position: 0,
624            }
625        }
626    }
627
628    /// Result of font subsetting operation.
629    ///
630    /// Contains the subsetted font bytes and a mapping from original
631    /// glyph IDs to new glyph IDs in the subset.
632    #[derive(Debug, Clone)]
633    pub struct SubsetFont {
634        /// The subsetted font file bytes (smaller than original).
635        pub bytes: Vec<u8>,
636        /// Mapping: original glyph ID -> (new subset glyph ID, source character).
637        pub glyph_mapping: BTreeMap<u16, (u16, char)>,
638    }
639
640    impl SubsetFont {
641        /// Return the changed text so that when rendering with the subset font (instead of the
642        /// original) the renderer will end up at the same glyph IDs as if we used the original text
643        /// on the original font
644        #[must_use] pub fn subset_text(&self, text: &str) -> String {
645            text.chars()
646                .filter_map(|c| {
647                    self.glyph_mapping.values().find_map(|(ngid, ch)| {
648                        if *ch == c {
649                            char::from_u32(u32::from(*ngid))
650                        } else {
651                            None
652                        }
653                    })
654                })
655                .collect()
656        }
657    }
658
659    /// Hash-based equality: two fonts are considered equal if their content hash matches.
660    /// This is a performance optimization — hash collisions are possible but vanishingly
661    /// unlikely (~1/2^64).
662    impl PartialEq for ParsedFont {
663        fn eq(&self, other: &Self) -> bool {
664            self.hash == other.hash
665        }
666    }
667
668    impl Eq for ParsedFont {}
669
670    const FONT_B64_START: &str = "data:font/ttf;base64,";
671
672    impl serde::Serialize for ParsedFont {
673        fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
674            use base64::Engine;
675            let s = format!(
676                "{FONT_B64_START}{}",
677                base64::prelude::BASE64_STANDARD.encode(self.to_bytes(None).unwrap_or_default())
678            );
679            s.serialize(serializer)
680        }
681    }
682
683    impl<'de> serde::Deserialize<'de> for ParsedFont {
684        fn deserialize<D: serde::Deserializer<'de>>(
685            deserializer: D,
686        ) -> Result<Self, D::Error> {
687            use base64::Engine;
688            let s = String::deserialize(deserializer)?;
689            let b64 = s.strip_prefix(FONT_B64_START).and_then(|b| base64::prelude::BASE64_STANDARD.decode(b).ok());
690
691            let mut warnings = Vec::new();
692            Self::from_bytes(&b64.unwrap_or_default(), 0, &mut warnings).ok_or_else(|| {
693                serde::de::Error::custom(format!("Font deserialization error: {warnings:?}"))
694            })
695        }
696    }
697
698    impl fmt::Debug for ParsedFont {
699        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
700            f.debug_struct("ParsedFont")
701                .field("hash", &self.hash)
702                .field("font_metrics", &self.font_metrics)
703                .field("num_glyphs", &self.num_glyphs)
704                .field("hhea_table", &self.hhea_table)
705                .field(
706                    "hmtx_range",
707                    &format_args!("<{} bytes>", self.hmtx_range.1),
708                )
709                .field("maxp_table", &self.maxp_table)
710                .field(
711                    "glyph_cache",
712                    &format_args!(
713                        "{} entries (lazy)",
714                        self.glyph_cache.read().map(|m| m.len()).unwrap_or(0),
715                    ),
716                )
717                .field("space_width", &self.space_width)
718                .field("cmap_subtable", &self.cmap_subtable)
719                .finish_non_exhaustive()
720        }
721    }
722
723    /// Warning or error message generated during font parsing.
724    #[derive(Debug, Clone, PartialEq, Eq)]
725    pub struct FontParseWarning {
726        /// Severity level of this warning.
727        pub severity: FontParseWarningSeverity,
728        /// Human-readable description of the issue.
729        pub message: String,
730    }
731
732    /// Severity level for font parsing warnings.
733    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
734    pub enum FontParseWarningSeverity {
735        /// Informational message (not an error).
736        Info,
737        /// Warning that may affect font rendering.
738        Warning,
739        /// Error that prevents proper font usage.
740        Error,
741    }
742
743    impl FontParseWarning {
744        /// Creates an info-level message.
745        #[must_use] pub const fn info(message: String) -> Self {
746            Self {
747                severity: FontParseWarningSeverity::Info,
748                message,
749            }
750        }
751
752        /// Creates a warning-level message.
753        #[must_use] pub const fn warning(message: String) -> Self {
754            Self {
755                severity: FontParseWarningSeverity::Warning,
756                message,
757            }
758        }
759
760        /// Creates an error-level message.
761        #[must_use] pub const fn error(message: String) -> Self {
762            Self {
763                severity: FontParseWarningSeverity::Error,
764                message,
765            }
766        }
767    }
768
769    // WEB-LIFT FIX (2026-06-02): a `FontTableProvider` that scans the sfnt table directory
770    // by hand from the raw font bytes. allsorts' `OffsetTableFontProvider` produces garbage
771    // on the remill/web backend: (1) `ReadArray::read_item`'s nested-tuple `TableRecord` read
772    // returns `table_tag = 0` for EVERY record (proven: tags[7]=0x0000 while the bytes there
773    // are 0x68656164 'head'); (2) even a hand-rolled scan added to the *allsorts crate* sees a
774    // bad `self.scope.data()` (the ReadScope fat-pointer mis-lifts through provider
775    // construction, or allsorts-crate code lifts differently). This provider lives in
776    // azul-layout — whose identical byte reads PROVABLY work (the `from_provider` probe read
777    // num_tables=15 from these same `font_bytes`) — and reads the slice directly. KEEP.
778    #[inline]
779    fn manual_be16(d: &[u8], o: usize) -> u32 {
780        (u32::from(d[o]) << 8) | u32::from(d[o + 1])
781    }
782    #[inline]
783    fn manual_be32(d: &[u8], o: usize) -> u32 {
784        (u32::from(d[o]) << 24)
785            | (u32::from(d[o + 1]) << 16)
786            | (u32::from(d[o + 2]) << 8)
787            | u32::from(d[o + 3])
788    }
789
790    struct ManualTableProvider<'a> {
791        data: &'a [u8],
792        dir: usize, // byte offset of the first table record (offset-table base + 12)
793        num: usize, // number of table records
794    }
795
796    impl<'a> ManualTableProvider<'a> {
797        fn new(data: &'a [u8], font_index: usize) -> Option<Self> {
798            if data.len() < 12 {
799                return None;
800            }
801            let base = if manual_be32(data, 0) == 0x7474_6366 {
802                // 'ttcf' (TrueType Collection): the font_index'th offset-table offset.
803                let num_fonts = manual_be32(data, 8) as usize;
804                if font_index >= num_fonts || 12 + font_index * 4 + 4 > data.len() {
805                    return None;
806                }
807                manual_be32(data, 12 + font_index * 4) as usize
808            } else {
809                0 // single font: offset table at the start
810            };
811            if base + 12 > data.len() {
812                return None;
813            }
814            Some(ManualTableProvider {
815                data,
816                dir: base + 12,
817                num: manual_be16(data, base + 4) as usize,
818            })
819        }
820    }
821
822    impl FontTableProvider for ManualTableProvider<'_> {
823        fn table_data(
824            &self,
825            tag: u32,
826        ) -> Result<Option<std::borrow::Cow<'_, [u8]>>, allsorts::error::ParseError> {
827            let mut i = 0;
828            while i < self.num {
829                let r = self.dir + i * 16;
830                if r + 16 > self.data.len() {
831                    break;
832                }
833                if manual_be32(self.data, r) == tag {
834                    let off = manual_be32(self.data, r + 8) as usize;
835                    let len = manual_be32(self.data, r + 12) as usize;
836                    return Ok(off
837                        .checked_add(len)
838                        .filter(|&e| e <= self.data.len())
839                        .map(|e| std::borrow::Cow::Borrowed(&self.data[off..e])));
840                }
841                i += 1;
842            }
843            Ok(None)
844        }
845
846        fn has_table(&self, tag: u32) -> bool {
847            self.table_data(tag).ok().flatten().is_some()
848        }
849
850        fn table_tags(&self) -> Option<Vec<u32>> {
851            // DIAG (REVERT): sentinel 0xFADE as tags[0] proves THIS provider ran; then
852            // self.num pushes let me see if the usize field survived; then the real reads
853            // show if self.data (slice field) survived the struct move through generics.
854            let mut tags = Vec::with_capacity(self.num + 1);
855            tags.push(0x0000_FADE);
856            let mut i = 0;
857            while i < self.num {
858                let r = self.dir + i * 16;
859                if r + 4 > self.data.len() {
860                    break;
861                }
862                tags.push(manual_be32(self.data, r));
863                i += 1;
864            }
865            Some(tags)
866        }
867    }
868
869    impl allsorts::tables::SfntVersion for ManualTableProvider<'_> {
870        fn sfnt_version(&self) -> u32 {
871            let base = self.dir.saturating_sub(12);
872            if base + 4 <= self.data.len() {
873                manual_be32(self.data, base)
874            } else {
875                0
876            }
877        }
878    }
879
880    impl ParsedFont {
881        /// Parse a font from bytes using allsorts
882        ///
883        /// # Arguments
884        /// * `font_bytes` - The font file data
885        /// * `font_index` - Index of the font in a font collection (0 for single fonts)
886        /// * `warnings` - Optional vector to collect parsing warnings
887        ///
888        /// # Returns
889        /// `Some(ParsedFont)` if parsing succeeds, `None` otherwise
890        ///
891        /// Note: Outlines are decoded lazily by `get_or_decode_glyph`;
892        /// `LocaGlyf::load` runs eagerly here. Use `from_bytes_shared`
893        /// for the lazy-LocaGlyf production path.
894        pub fn from_bytes(
895            font_bytes: &[u8],
896            font_index: usize,
897            warnings: &mut Vec<FontParseWarning>,
898        ) -> Option<Self> {
899            // `from_bytes` keeps the eager-LocaGlyf behaviour for the
900            // small number of callers (mainly tests) that don't have
901            // an `Arc<[u8]>` to keep alive for the lazy path.
902            let mut font = Self::from_bytes_internal(font_bytes, font_index, warnings, false)?;
903            // Retain an owned copy of the source bytes so the face can later be
904            // subset/embedded (PDF export, save->parse roundtrips). Callers pass a
905            // borrowed slice that may not outlive us, so we own it here. Mirrors
906            // `from_bytes_shared`, which retains the caller's `Arc<FontBytes>`.
907            if font.original_bytes.is_none() {
908                font.original_bytes = Some(Arc::new(
909                    rust_fontconfig::FontBytes::Owned(Arc::from(font_bytes.to_vec())),
910                ));
911            }
912            Some(font)
913        }
914
915        /// Shared implementation of `from_bytes` / `from_bytes_shared`.
916        ///
917        /// `defer_loca_glyf = true` skips the `LocaGlyf::load` call
918        /// here so the caller (`from_bytes_shared`) can install a
919        /// `LocaGlyfState::Deferred` slot that re-parses on first
920        /// glyph decode. Saves the load-then-drop cycle the previous
921        /// arrangement paid (`from_bytes_shared` used to call
922        /// `from_bytes` and immediately replace the loaded `LocaGlyf`
923        /// with a Deferred slot, throwing away ~hundreds of KiB of
924        /// loca+glyf bytes per face for fonts in the chain that get
925        /// loaded but never rasterized).
926        fn from_bytes_internal(
927            font_bytes: &[u8],
928            font_index: usize,
929            warnings: &mut Vec<FontParseWarning>,
930            defer_loca_glyf: bool,
931        ) -> Option<Self> {
932            use allsorts::{binary::read::ReadScope, font_data::FontData};
933            fn provider_err(font_index: usize, e: impl fmt::Display) -> FontParseWarning {
934                FontParseWarning::error(format!(
935                    "Failed to get table provider for font index {font_index}: {e}"
936                ))
937            }
938
939            let scope = ReadScope::new(font_bytes);
940            let font_file = match scope.read::<FontData<'_>>() {
941                Ok(ff) => ff,
942                Err(e) => {
943                    warnings.push(FontParseWarning::error(format!(
944                        "Failed to read font data: {e}"
945                    )));
946                    return None;
947                }
948            };
949            // FIX (2026-06-02): route OpenType fonts through the CONCRETE provider
950            // (`OffsetTableFontProvider`) instead of `FontData::table_provider`'s
951            // `Box<dyn FontTableProvider>`. On the lifted/web backend the trait-object
952            // VTABLE dispatch (allsorts font_data.rs:45 `self.provider.table_data(tag)`)
953            // mis-lifts: the vtable's fn-pointers are untranslated native addresses, so the
954            // indirect-call dispatcher routes the dyn call to the WRONG `table_data` impl,
955            // which returns a `Cow::Owned` garbage buffer → `HeadTable::read` errors → font
956            // parse returns None → text measures height 0. A concrete provider makes every
957            // `table_data` a DIRECT (monomorphized) call, which lifts correctly. Woff/Woff2
958            // keep the dyn path (they're not used on the web backend's embedded TTF).
959            match font_file {
960                FontData::OpenType(otf) => {
961                    // Prefer the hand-rolled provider (reads font_bytes directly) over
962                    // allsorts' OffsetTableFontProvider, whose lifted table reads are garbage
963                    // on the web backend. Fall back to allsorts only if the manual layout
964                    // parse can't recognise the sfnt (e.g. an unusual TTC).
965                    if let Some(mp) = ManualTableProvider::new(font_bytes, font_index) {
966                        Self::from_provider(mp, font_bytes, font_index, warnings, defer_loca_glyf)
967                    } else {
968                        match otf.table_provider(font_index) {
969                            Ok(p) => Self::from_provider(
970                                p,
971                                font_bytes,
972                                font_index,
973                                warnings,
974                                defer_loca_glyf,
975                            ),
976                            Err(e) => {
977                                warnings.push(provider_err(font_index, e));
978                                None
979                            }
980                        }
981                    }
982                }
983                other => match other.table_provider(font_index) {
984                    Ok(p) => {
985                        Self::from_provider(p, font_bytes, font_index, warnings, defer_loca_glyf)
986                    }
987                    Err(e) => {
988                        warnings.push(provider_err(font_index, e));
989                        None
990                    }
991                },
992            }
993        }
994
995        /// Build a `ParsedFont` from a concrete [`FontTableProvider`]. Split out of
996        /// `from_bytes_internal` (2026-06-02) so OpenType fonts use the concrete
997        /// `OffsetTableFontProvider` (direct `table_data` calls that lift correctly on
998        /// the web backend) rather than `FontData::table_provider`'s `Box<dyn>`, whose
999        /// trait-object vtable dispatch mis-lifts (wrong impl → Owned garbage → parse fail).
1000        #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
1001        #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1002        fn from_provider<P: FontTableProvider>(
1003            provider: P,
1004            font_bytes: &[u8],
1005            font_index: usize,
1006            warnings: &mut Vec<FontParseWarning>,
1007            defer_loca_glyf: bool,
1008        ) -> Option<Self> {
1009            use std::{
1010                collections::hash_map::DefaultHasher,
1011                hash::{Hash, Hasher},
1012            };
1013
1014            use allsorts::{
1015                binary::read::ReadScope,
1016                tables::{
1017                    cmap::{owned::CmapSubtable as OwnedCmapSubtable, CmapSubtable},
1018                    FontTableProvider, HeadTable, HheaTable, MaxpTable,
1019                },
1020                tag,
1021            };
1022
1023            // Extract font name from NAME table early (before provider is moved).
1024            // WEB-LIFT FIX (2026-06-02): NameTable::string_for_id decodes the NAME strings via
1025            // `encoding_rs` (Mac Roman / UTF-16 charset state machines), whose jump-tables
1026            // are NOT devirt'd by the remill lift → MISSING_BLOCK trap (proven: trap in
1027            // encoding_rs::Decoder::decode_to_utf8). font_name is OPTIONAL metadata (NOT used
1028            // for layout/metrics/shaping — those are binary head/hhea/maxp/cmap/glyf), so skip
1029            // the NAME-string decode on the web backend to avoid encoding_rs entirely.
1030            #[cfg(feature = "web_lift")]
1031            let font_name: Option<String> = None;
1032            #[cfg(not(feature = "web_lift"))]
1033            let font_name = provider.table_data(tag::NAME).ok().and_then(|name_data| {
1034                ReadScope::new(&name_data?)
1035                    .read::<allsorts::tables::NameTable<'_>>()
1036                    .ok()
1037                    .and_then(|name_table| {
1038                        name_table.string_for_id(allsorts::tables::NameTable::POSTSCRIPT_NAME)
1039                    })
1040            });
1041
1042            // DIAG (2026-06-02, REVERT): pinpoint the web font-parse-fails root — does HEAD
1043            // fail because table_data can't find/return the table (directory mis-lift) or
1044            // because HeadTable::read errors (table-read mis-lift)? Surfaced via warnings.
1045            let head_table = match provider.table_data(tag::HEAD) {
1046                Ok(Some(head_cow)) => {
1047                    // DIAG: is the HEAD table data CORRECT (magicNumber 0x5F0F3CF5 @ off 12 →
1048                    // HeadTable::read mis-lifts) or WRONG bytes (directory offset mis-lift)?
1049                    let bb = head_cow.as_ref();
1050                    let magic = if bb.len() >= 16 {
1051                        (u32::from(bb[12]) << 24) | (u32::from(bb[13]) << 16)
1052                            | (u32::from(bb[14]) << 8) | u32::from(bb[15])
1053                    } else { 0 };
1054                    if let Ok(h) = ReadScope::new(&head_cow).read::<HeadTable>() { h } else {
1055                        // DIAG: surface the sliced offset (how wrong) as hex — "HO" + 8 hex
1056                        // of (head_cow.ptr - font_bytes.ptr). garbage→offset-read mis-lift;
1057                        // 00000000→base; plausible-but-wrong→record mapping. "RF"=bytes-OK.
1058                        let m = if magic == 0x5F0F_3CF5 {
1059                            "RF000000".to_string()
1060                        } else {
1061                            let off = (head_cow.as_ref().as_ptr() as usize)
1062                                .wrapping_sub(font_bytes.as_ptr() as usize);
1063                            let mut msg = String::new();
1064                            // B=Borrowed(slice of font_bytes, ptr-arith/base mis-lift) vs
1065                            // O=Owned(decompressed/copied Vec — wrong path for plain TTF).
1066                            msg.push(if matches!(head_cow, std::borrow::Cow::Borrowed(_)) { 'B' } else { 'O' });
1067                            msg.push_str("HO");
1068                            let mut sh: i32 = 28;
1069                            while sh >= 0 {
1070                                let d = ((off >> sh) & 0xf) as u8;
1071                                msg.push((if d < 10 { b'0' + d } else { b'a' + d - 10 }) as char);
1072                                sh -= 4;
1073                            }
1074                            msg
1075                        };
1076                        warnings.push(FontParseWarning::error(m));
1077                        return None;
1078                    }
1079                }
1080                Ok(None) => {
1081                    // DIAG (REVERT): bytes+len+read_item-count+dir all proved OK (N0fr0fc0fg1)
1082                    // yet find_table_record(HEAD)=None though 'head' is rec[7] on disk. So
1083                    // either read_item's table_tag FIELD is garbage, or tag::HEAD mis-lifts, or
1084                    // the u32 == mis-lifts. t7 = tags[7] (should be 0x68656164 'head' low16
1085                    // =6164); H = tag::HEAD low16 (should be 6164); f = ANY tag==HEAD via an
1086                    // indexed compare loop (NOT .iter().any). "T<4h t7>H<4h HEAD>f<0|1>".
1087                    //   T6164 H6164 f1 → values+compare OK (won't reach here — HEAD found)
1088                    //   T6164 H6164 f0 → the u32 == comparison mis-lifts
1089                    //   T!=6164        → read_item table_tag FIELD garbage (tuple read mis-lift)
1090                    //   H!=6164        → tag::HEAD const mis-lifts
1091                    #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
1092                    fn hx(m: &mut String, val: u32, nibbles: i32) {
1093                        let mut sh = (nibbles - 1) * 4;
1094                        while sh >= 0 {
1095                            let d = ((val >> sh) & 0xf) as u8;
1096                            m.push((if d < 10 { b'0' + d } else { b'a' + d - 10 }) as char);
1097                            sh -= 4;
1098                        }
1099                    }
1100                    // DECISIVE: tags[8] (head, file off 124) reads 0 but tags[2] (off 28) is OK.
1101                    // Read the SAME offsets from from_provider's LOCAL font_bytes param (proven
1102                    // correct at off 4/12). If local@124 = 0x6865 'he' but provider tags[8]=0 ⇒
1103                    // STORED-SLICE issue (provider self.data fat-ptr mis-lifts) → read locally.
1104                    // If local@124 = 0 ⇒ the font CONST is only PARTIALLY MIRRORED into the wasm
1105                    // (deep data-mirror gap) → table data is simply absent. local@93596 (=0x16f9c,
1106                    // head TABLE data start) further maps the mirror: 'he'/nonzero vs 0.
1107                    let loc124 = if font_bytes.len() >= 126 {
1108                        (u32::from(font_bytes[124]) << 8) | u32::from(font_bytes[125])
1109                    } else {
1110                        0xEEEE
1111                    };
1112                    let loc_head = if font_bytes.len() >= 93598 {
1113                        (u32::from(font_bytes[93596]) << 8) | u32::from(font_bytes[93597])
1114                    } else {
1115                        0xEEEE
1116                    };
1117                    let mut m = String::from("L"); // local font_bytes[124..126] (head dir record):
1118                    hx(&mut m, loc124 & 0xffff, 4); // 6865 'he' = mirrored; 0000 = not
1119                    m.push('H'); // local font_bytes[93596..] (head TABLE data, deep):
1120                    hx(&mut m, loc_head & 0xffff, 4);
1121                    warnings.push(FontParseWarning::error(m));
1122                    return None;
1123                }
1124                Err(_) => {
1125                    warnings.push(FontParseWarning::error("HEAD_DATAERR".to_string()));
1126                    return None;
1127                }
1128            };
1129
1130            let maxp_table = provider
1131                .table_data(tag::MAXP)
1132                .ok()
1133                .and_then(|maxp_data| ReadScope::new(&maxp_data?).read::<MaxpTable>().ok())
1134                .unwrap_or(MaxpTable {
1135                    num_glyphs: 0,
1136                    version1_sub_table: None,
1137                });
1138
1139            let num_glyphs = maxp_table.num_glyphs as usize;
1140
1141            // Compute byte offset+length into font_bytes for hmtx/vmtx
1142            // instead of copying the table data. The provider returns a
1143            // borrowed slice for OpenType fonts, so we can derive the
1144            // offset via pointer arithmetic.
1145            let hmtx_range = provider
1146                .table_data(tag::HMTX)
1147                .ok()
1148                .and_then(|cow_opt| {
1149                    let cow = cow_opt?;
1150                    match cow {
1151                        std::borrow::Cow::Borrowed(slice) => {
1152                            let base = font_bytes.as_ptr() as usize;
1153                            let ptr = slice.as_ptr() as usize;
1154                            let offset = ptr.checked_sub(base)?;
1155                            if offset + slice.len() <= font_bytes.len() {
1156                                Some((offset, slice.len()))
1157                            } else {
1158                                None
1159                            }
1160                        }
1161                        std::borrow::Cow::Owned(_) => None,
1162                    }
1163                })
1164                .unwrap_or((0, 0));
1165
1166            let vmtx_range = provider
1167                .table_data(tag::VMTX)
1168                .ok()
1169                .and_then(|s| {
1170                    let slice = s?;
1171                    let base = font_bytes.as_ptr() as usize;
1172                    let ptr = slice.as_ptr() as usize;
1173                    let offset = ptr.checked_sub(base)?;
1174                    if offset + slice.len() <= font_bytes.len() {
1175                        Some((offset, slice.len()))
1176                    } else {
1177                        None
1178                    }
1179                })
1180                .unwrap_or((0, 0));
1181
1182            // Parse vhea table (same format as hhea, used for vertical metrics)
1183            let vhea_table = provider
1184                .table_data(tag::VHEA)
1185                .ok()
1186                .and_then(|vhea_data| ReadScope::new(&vhea_data?).read::<HheaTable>().ok());
1187
1188            // hhea is required per the OpenType spec; return None if missing
1189            let hhea_table = provider
1190                .table_data(tag::HHEA)
1191                .ok()
1192                .and_then(|hhea_data| ReadScope::new(&hhea_data?).read::<HheaTable>().ok())?;
1193
1194            // Build layout-specific font metrics
1195            let font_metrics = LayoutFontMetrics {
1196                units_per_em: if head_table.units_per_em == 0 {
1197                    1000
1198                } else {
1199                    head_table.units_per_em
1200                },
1201                ascent: f32::from(hhea_table.ascender),
1202                descent: f32::from(hhea_table.descender),
1203                line_gap: f32::from(hhea_table.line_gap),
1204                x_height: None, // will be populated from OS/2 table via from_font_metrics if available
1205                cap_height: None,
1206            };
1207
1208            // Build PDF-specific font metrics
1209            let pdf_font_metrics =
1210                Self::parse_pdf_font_metrics(font_bytes, font_index, &head_table, &hhea_table);
1211
1212            // Use allsorts LocaGlyf for on-demand outline extraction. We
1213            // *load* LocaGlyf eagerly (it owns ~tens of KiB of loca +
1214            // ~hundreds of KiB of glyf bytes) but we *don't* decode any
1215            // glyph outlines up front — that's the big RSS win. Glyphs
1216            // are decoded by `ParsedFont::get_or_decode_glyph` on first
1217            // access from the CPU/GPU rasterizer.
1218            //
1219            // When `defer_loca_glyf` is set (production lazy path via
1220            // `from_bytes_shared`), we skip `LocaGlyf::load` here too —
1221            // the caller will overwrite the slot with
1222            // `LocaGlyfState::Deferred` carrying the source bytes
1223            // `Arc<[u8]>`, and the load happens on the first
1224            // `get_or_decode_glyph` call. This avoids parsing
1225            // ~hundreds of KiB per face for fonts that get resolved
1226            // into a chain but never actually rasterized (typical
1227            // for fallback fonts in CSS chains).
1228            let has_glyf = provider.has_table(tag::GLYF) && provider.has_table(tag::LOCA);
1229            // Cache `has_gvar` before `provider` gets moved into
1230            // `allsorts::font::Font::new(provider)` further down —
1231            // it's the cheapest way to detect a variable font and
1232            // avoids the borrow-after-move that a later
1233            // `provider.has_table(tag::GVAR)` would incur.
1234            let has_gvar = provider.has_table(tag::GVAR);
1235            let loca_glyf_opt: Option<Arc<std::sync::Mutex<LocaGlyf>>> = if has_glyf
1236                && !defer_loca_glyf
1237            {
1238                match LocaGlyf::load(&provider) {
1239                    Ok(lg) => Some(Arc::new(std::sync::Mutex::new(lg))),
1240                    Err(e) => {
1241                        warnings.push(FontParseWarning::warning(format!(
1242                            "Failed to load LocaGlyf: {e} — falling back to hmtx-only"
1243                        )));
1244                        None
1245                    }
1246                }
1247            } else {
1248                None
1249            };
1250
1251            // Lazy `glyph_cache` starts empty; the space-glyph stub
1252            // below pre-inserts gid 0 / space so the shaper's
1253            // cmap-miss fallback has something to render without
1254            // racing with a decode.
1255
1256            let mut font_data_impl = allsorts::font::Font::new(provider).ok()?;
1257
1258            // Create TrueType hinting instance from font tables.
1259            // [az-web-lift] Skip on the web build. The lifted layout never grid-fits glyphs to a
1260            // pixel raster (it measures + ships a display list to JS), so hinting is never used.
1261            // Building it (HintInstance::new) runs the allsorts bytecode Interpreter
1262            // (Interpreter::new + ::dispatch — a large un-devirt'd opcode jump table the remill
1263            // lift can't resolve, plus ~700 op_* fns of closure bloat). This is INDEPENDENT of the
1264            // lift's jump-table devirt: even with a perfect lift, web has no use for hinting, and
1265            // hinted advances are lower-quality output than the plain scaled advance. Native keeps
1266            // real hinting unchanged.
1267            #[cfg(feature = "web_lift")]
1268            let hint_instance: Option<std::sync::Mutex<allsorts::hinting::HintInstance>> = None;
1269            #[cfg(not(feature = "web_lift"))]
1270            let hint_instance = allsorts::hinting::HintInstance::new(
1271                &font_data_impl.font_table_provider
1272            ).ok().flatten().map(std::sync::Mutex::new);
1273
1274            // Stash raw GSUB/GPOS bytes for lazy parse. Typical fonts
1275            // have ~tens of KiB of GSUB + a few-to-tens of KiB of GPOS —
1276            // dwarfed by glyph outlines — so we keep the bytes around
1277            // and only spend `LayoutTable::read` + `new_layout_cache`
1278            // cycles when the shaper actually needs them (via
1279            // `ParsedFont::gsub` / `::gpos`). For an ASCII run where no
1280            // substitution / kerning is required, we skip both entirely.
1281            let gsub_bytes = font_data_impl
1282                .font_table_provider
1283                .table_data(tag::GSUB)
1284                .ok()
1285                .flatten()
1286                .map(std::borrow::Cow::into_owned);
1287            let gpos_bytes = font_data_impl
1288                .font_table_provider
1289                .table_data(tag::GPOS)
1290                .ok()
1291                .flatten()
1292                .map(std::borrow::Cow::into_owned);
1293            let opt_gdef_table = font_data_impl.gdef_table().ok().and_then(|o| o);
1294            let num_glyphs = font_data_impl.num_glyphs();
1295
1296            let opt_kern_table = font_data_impl
1297                .kern_table()
1298                .ok()
1299                .and_then(|s| s);
1300
1301            let cmap_data = font_data_impl.cmap_subtable_data();
1302            let cmap_subtable = ReadScope::new(cmap_data);
1303            let cmap_subtable = cmap_subtable
1304                .read::<CmapSubtable<'_>>()
1305                .ok()
1306                .and_then(|s| s.to_owned());
1307
1308            // Font identity hash — used by `PartialEq` for ParsedFont.
1309            //
1310            // Previously we did `font_bytes.hash(&mut hasher)` over
1311            // the full mmap. That touched every page of the file
1312            // (a 40 MiB `.ttc` walked byte-for-byte) so the "lazy
1313            // mmap" ended up *fully resident* the moment we built
1314            // a `ParsedFont`. Cold RSS jumped ~40 MiB from this
1315            // single line.
1316            //
1317            // The hash doesn't need to be cryptographic — it just
1318            // has to disambiguate two `ParsedFont`s. `(len, first
1319            // 4 KiB, last 4 KiB, font_index)` is plenty unique and
1320            // only faults in the two header / trailer pages, which
1321            // shaping is going to need anyway.
1322            let mut hasher = DefaultHasher::new();
1323            (font_bytes.len() as u64).hash(&mut hasher);
1324            let head_len = font_bytes.len().min(4096);
1325            font_bytes[..head_len].hash(&mut hasher);
1326            let tail_start = font_bytes.len().saturating_sub(4096);
1327            font_bytes[tail_start..].hash(&mut hasher);
1328            font_index.hash(&mut hasher);
1329            let hash = hasher.finish();
1330
1331            let mut font = Self {
1332                hash,
1333                font_metrics,
1334                pdf_font_metrics,
1335                num_glyphs,
1336                hhea_table,
1337                hmtx_range,
1338                vmtx_range,
1339                vhea_table,
1340                maxp_table,
1341                gsub_bytes,
1342                gsub_cache_lazy: std::sync::OnceLock::new(),
1343                gpos_bytes,
1344                gpos_cache_lazy: std::sync::OnceLock::new(),
1345                opt_gdef_table,
1346                opt_kern_table,
1347                cmap_subtable,
1348                last_used: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1349                is_variable_font: has_gvar,
1350                glyph_cache: Arc::new(rust_fontconfig::StLock::new(BTreeMap::new())),
1351                // Eager path: `from_bytes` loaded LocaGlyf immediately
1352                // (or set None if the font has no loca+glyf). Lazy
1353                // callers use `from_bytes_shared` which replaces this
1354                // with `LocaGlyfState::Deferred` before returning.
1355                loca_glyf: LocaGlyfState::Loaded(loca_glyf_opt),
1356                space_width: None,
1357                mock: None,
1358                reverse_glyph_cache: BTreeMap::new(),
1359                // Don't retain the source bytes by default — layout and
1360                // raster don't need them. PDF subsetting / `to_bytes`
1361                // callers opt in via `with_source_bytes`.
1362                original_bytes: None,
1363                original_index: font_index,
1364                index_to_cid: BTreeMap::new(), // Will be filled for CFF fonts
1365                font_type: FontType::TrueType, // Default, will be updated if CFF
1366                font_name,
1367                hint_instance,
1368            };
1369
1370            // Calculate space width
1371            let space_width = font.get_space_width_internal();
1372
1373            // Pre-decode the space glyph straight into the lazy
1374            // `glyph_cache`. Space typically has no outline, so the
1375            // decoder's outline visitor returns nothing useful and
1376            // we'd spin re-decoding it every shape — short-circuit
1377            // here with a hand-rolled record carrying the hmtx
1378            // advance.
1379            let _ = (|| {
1380                let space_gid = font.lookup_glyph_index(' ' as u32)?;
1381                {
1382                    // StLock::read() is infallible (Result<_, Infallible>);
1383                    // kept in a tight block so the guard drops at scope end.
1384                    let Ok(cache) = font.glyph_cache.read();
1385                    if cache.contains_key(&space_gid) {
1386                        return None;
1387                    }
1388                }
1389                let space_width_val = space_width?;
1390                // Only pre-cache when we actually know a non-zero advance. During
1391                // `from_bytes_internal` the source bytes are not attached yet, so
1392                // `hmtx` is unreadable and `get_space_width_internal` reads back 0;
1393                // caching that would pin every space to a 0 advance for the life of
1394                // the face. Skip it and let the space decode lazily once bytes are
1395                // attached (mock fonts that carry a real space width still cache).
1396                if space_width_val == 0 {
1397                    return None;
1398                }
1399                let space_record = OwnedGlyph {
1400                    bounding_box: OwnedGlyphBoundingBox {
1401                        max_x: 0,
1402                        max_y: 0,
1403                        min_x: 0,
1404                        min_y: 0,
1405                    },
1406                    horz_advance: space_width_val as u16,
1407                    outline: Vec::new(),
1408                    phantom_points: None,
1409                    raw_points: None,
1410                    raw_on_curve: None,
1411                    raw_contour_ends: None,
1412                    instructions: None,
1413                };
1414                {
1415                    // StLock::write() is infallible (Result<_, Infallible>).
1416                    let Ok(mut cache) = font.glyph_cache.write();
1417                    cache.insert(space_gid, Arc::new(space_record));
1418                }
1419                Some(())
1420            })();
1421
1422            font.space_width = space_width;
1423
1424            Some(font)
1425        }
1426
1427        /// Attach the source font bytes to this `ParsedFont`, enabling
1428        /// [`ParsedFont::to_bytes`] and [`ParsedFont::subset`] (both of
1429        /// which the layout / shaping path never calls).
1430        ///
1431        /// Takes an `Arc<FontBytes>` so the same file's bytes can be
1432        /// shared across every face of a `.ttc` at zero extra cost —
1433        /// pair with [`rust_fontconfig::FcFontCache::get_font_bytes`].
1434        /// For ad-hoc PDF callers that have raw heap bytes, wrap them
1435        /// via `Arc::new(FontBytes::Owned(Arc::from(vec)))`.
1436        #[must_use]
1437        pub fn with_source_bytes(mut self, bytes: Arc<rust_fontconfig::FontBytes>) -> Self {
1438            self.original_bytes = Some(bytes);
1439            self
1440        }
1441
1442        /// Lazy-friendly constructor — identical to
1443        /// [`ParsedFont::from_bytes`] except that `LocaGlyf` is
1444        /// **not** loaded during the call. Instead, the supplied
1445        /// `Arc<[u8]>` is retained and `LocaGlyf::load` runs the first
1446        /// time [`get_or_decode_glyph`] needs glyph outlines for this
1447        /// face.
1448        ///
1449        /// Fonts that get resolved into a CSS fallback chain but are
1450        /// never actually rasterized (common on desktop — e.g. every
1451        /// face of HelveticaNeue.ttc loads, but only one or two are
1452        /// shaped) then pay zero loca/glyf cost.
1453        ///
1454        /// Production callers (the reftest harness, `LayoutWindow`,
1455        /// `cpurender`) should prefer this constructor. Tests that
1456        /// inspect `glyph_records_decoded` directly and don't want
1457        /// a lazy path keep using `from_bytes`.
1458        pub fn from_bytes_shared(
1459            bytes: Arc<rust_fontconfig::FontBytes>,
1460            font_index: usize,
1461            warnings: &mut Vec<FontParseWarning>,
1462        ) -> Option<Self> {
1463            // Skip the eager LocaGlyf::load via `defer_loca_glyf=true`
1464            // — saves the load-then-drop cycle the prior arrangement
1465            // paid (when this called `from_bytes`, allocated
1466            // ~hundreds of KiB of loca+glyf bytes, then immediately
1467            // replaced the slot with `Deferred` and dropped them).
1468            // `bytes.as_ref()` derefs FontBytes → &[u8] (mmap or owned
1469            // — same code path).
1470            let mut font = Self::from_bytes_internal(bytes.as_ref(), font_index, warnings, true)?;
1471            font.original_bytes = Some(bytes.clone());
1472            font.loca_glyf = LocaGlyfState::Deferred {
1473                bytes,
1474                font_index,
1475                loaded: Arc::new(std::sync::Mutex::new(None)),
1476            };
1477            Some(font)
1478        }
1479
1480        /// Resolve the current face's `LocaGlyf`, loading it lazily
1481        /// on first call when `loca_glyf` is `Deferred`. Returns
1482        /// `None` when the font has no usable loca+glyf (CFF fonts
1483        /// or parse failures).
1484        fn resolve_loca_glyf(&self) -> Option<Arc<std::sync::Mutex<LocaGlyf>>> {
1485            use allsorts::{
1486                binary::read::ReadScope,
1487                font_data::FontData,
1488                tables::FontTableProvider,
1489            };
1490            match &self.loca_glyf {
1491                LocaGlyfState::Loaded(inner) => inner.clone(),
1492                LocaGlyfState::Deferred { bytes, font_index, loaded } => {
1493                    // Fast path: cached LocaGlyf is present.
1494                    if let Ok(guard) = loaded.lock() {
1495                        if let Some(arc) = guard.as_ref() {
1496                            return Some(Arc::clone(arc));
1497                        }
1498                    }
1499                    let _p = crate::probe::Probe::span("resolve_loca_glyf");
1500
1501                    // Slow path: parse provider + load LocaGlyf without
1502                    // holding the slot's lock (allsorts can take a
1503                    // millisecond or two on a fresh load). Re-check
1504                    // after acquiring the write lock so a parallel
1505                    // decoder doesn't double-load.
1506                    let scope = ReadScope::new(bytes.as_slice());
1507                    let font_data = scope.read::<FontData<'_>>().ok()?;
1508                    let provider = font_data.table_provider(*font_index).ok()?;
1509                    // Gate on table presence to match the `from_bytes`
1510                    // has_glyf check; avoids a spurious warning on
1511                    // CFF fonts that sneak into the Deferred path.
1512                    if !provider.has_table(tag::GLYF) || !provider.has_table(tag::LOCA) {
1513                        return None;
1514                    }
1515                    // Zero-copy: keep `glyf` as a view into the already-resident
1516                    // (mmap'd) font bytes instead of copying the whole table
1517                    // (~20 MB for a large font) onto the heap. `bytes` is the
1518                    // exact buffer `provider` reads from, so load_shared can
1519                    // anchor the glyf range inside it (falling back to an owned
1520                    // copy if it ever can't). Audit §3.3a.
1521                    let owner: Arc<dyn AsRef<[u8]> + Send + Sync> = bytes.clone();
1522                    let new_arc = LocaGlyf::load_shared(&provider, owner)
1523                        .ok()
1524                        .map(|lg| Arc::new(std::sync::Mutex::new(lg)))?;
1525
1526                    if let Ok(mut guard) = loaded.lock() {
1527                        if let Some(existing) = guard.as_ref() {
1528                            return Some(Arc::clone(existing));
1529                        }
1530                        *guard = Some(Arc::clone(&new_arc));
1531                    }
1532                    Some(new_arc)
1533                }
1534            }
1535        }
1536
1537        /// Source bytes for PDF subsetting / table extraction.
1538        ///
1539        /// Looks in two places:
1540        /// - `original_bytes` (set by [`ParsedFont::with_source_bytes`]
1541        ///   for legacy PDF-first construction).
1542        /// - `LocaGlyfState::Deferred.bytes` (set by
1543        ///   [`ParsedFont::from_bytes_shared`] — the production lazy
1544        ///   path, which already retains an `Arc<[u8]>` for the lazy
1545        ///   loca/glyf loader).
1546        ///
1547        /// Returns `None` only for `ParsedFont`s built via the eager
1548        /// `from_bytes` path without an explicit `with_source_bytes`
1549        /// call — i.e. unit tests that load a font and don't touch
1550        /// PDF.
1551        pub fn source_bytes_for_subset(&self) -> Option<Arc<rust_fontconfig::FontBytes>> {
1552            if let Some(bytes) = &self.original_bytes {
1553                return Some(Arc::clone(bytes));
1554            }
1555            if let LocaGlyfState::Deferred { bytes, .. } = &self.loca_glyf {
1556                return Some(Arc::clone(bytes));
1557            }
1558            None
1559        }
1560
1561        /// Read the monotonic-clock nanos timestamp of the most
1562        /// recent [`get_or_decode_glyph`] call on this face, or `0`
1563        /// if it's never been touched.
1564        pub fn last_used_nanos(&self) -> u64 {
1565            self.last_used.load(std::sync::atomic::Ordering::Relaxed)
1566        }
1567
1568        /// Drop the cached `LocaGlyf` for this face if it's
1569        /// `Deferred`-with-bytes-retained — so the next
1570        /// [`get_or_decode_glyph`] re-parses from `bytes`. No-op for
1571        /// `Loaded` faces (no source bytes to fall back to).
1572        ///
1573        /// Used by [`crate::text3::cache::FontManager::evict_unused`]
1574        /// and exposed publicly so embedders can free memory under
1575        /// pressure on fonts they no longer need to render.
1576        pub fn evict_loca_glyf(&self) -> bool {
1577            match &self.loca_glyf {
1578                LocaGlyfState::Deferred { loaded, .. } => {
1579                    if let Ok(mut guard) = loaded.lock() {
1580                        if guard.is_some() {
1581                            *guard = None;
1582                            return true;
1583                        }
1584                    }
1585                    false
1586                }
1587                LocaGlyfState::Loaded(_) => false,
1588            }
1589        }
1590
1591        /// Fetch the parsed GSUB cache if this font has one, parsing
1592        /// it from the retained `gsub_bytes` on first access.
1593        ///
1594        /// Moved out of the eager `from_bytes` path because most text
1595        /// runs never trigger GSUB — plain ASCII without ligatures is
1596        /// handled entirely by the cmap + hmtx fast path. Building
1597        /// `LayoutCacheData<GSUB>` up front reserved ~0.5–2 MiB per
1598        /// face just to throw it away on pages that don't shape
1599        /// complex scripts.
1600        pub fn gsub(&self) -> Option<&GsubCache> {
1601            self.gsub_cache_lazy
1602                .get_or_init(|| {
1603                    use allsorts::{
1604                        binary::read::ReadScope,
1605                        layout::{new_layout_cache, LayoutTable, GSUB},
1606                    };
1607                    let bytes = self.gsub_bytes.as_ref()?;
1608                    ReadScope::new(bytes)
1609                        .read::<LayoutTable<GSUB>>()
1610                        .ok()
1611                        .map(new_layout_cache)
1612                })
1613                .as_ref()
1614        }
1615
1616        /// Fetch the parsed GPOS cache if this font has one, parsing
1617        /// it from the retained `gpos_bytes` on first access. See
1618        /// [`ParsedFont::gsub`] for the motivation.
1619        pub fn gpos(&self) -> Option<&GposCache> {
1620            self.gpos_cache_lazy
1621                .get_or_init(|| {
1622                    use allsorts::{
1623                        binary::read::ReadScope,
1624                        layout::{new_layout_cache, LayoutTable, GPOS},
1625                    };
1626                    let bytes = self.gpos_bytes.as_ref()?;
1627                    ReadScope::new(bytes)
1628                        .read::<LayoutTable<GPOS>>()
1629                        .ok()
1630                        .map(new_layout_cache)
1631                })
1632                .as_ref()
1633        }
1634
1635        /// Fetch an `OwnedGlyph` for `gid`, decoding it on first access.
1636        ///
1637        /// Cached in the `Arc<RwLock<…>>` `glyph_cache` so subsequent
1638        /// calls (including across clones of this `ParsedFont`) hit the
1639        /// cache. Returns `None` when `gid >= num_glyphs` or the font
1640        /// has no loca+glyf and no hmtx entry for the glyph. For CFF
1641        /// fonts the returned record has an empty outline and an advance
1642        /// pulled from hmtx — matching the pre-lazy behaviour.
1643        ///
1644        /// Called on the rasterizer hot path; performance budget is a
1645        /// few µs per unique glyph (first hit) and an Arc bump + `BTreeMap`
1646        /// lookup (cache hits). The write lock is held only across the
1647        /// decode, not across the caller's use of the returned Arc.
1648        pub fn get_or_decode_glyph(&self, gid: u16) -> Option<Arc<OwnedGlyph>> {
1649            use std::sync::Arc;
1650            if usize::from(gid) >= self.num_glyphs as usize {
1651                return None;
1652            }
1653            // Bump the LRU timestamp so `FontManager::evict_unused`
1654            // can tell this face is still in use. Cheap atomic store
1655            // (Relaxed — eviction reads the same atomic and tolerates
1656            // a slightly stale value, which only causes "evict, then
1657            // re-load on next access" — never an incorrect render).
1658            self.last_used
1659                .store(monotonic_now_nanos(), std::sync::atomic::Ordering::Relaxed);
1660
1661            // Fast path: cache hit.
1662            {
1663                // StLock::read() is infallible; tight block drops the read
1664                // guard before the write lock below (deadlock avoidance).
1665                let Ok(cache) = self.glyph_cache.read();
1666                if let Some(existing) = cache.get(&gid) {
1667                    return Some(Arc::clone(existing));
1668                }
1669            }
1670
1671            // Miss: decode. We drop the read lock before taking the
1672            // write lock to avoid deadlock, and we re-check on the way
1673            // in because another thread may have decoded the same glyph
1674            // in between.
1675            let record = self.decode_glyph_inner(gid);
1676            let arc = Arc::new(record);
1677            {
1678                // StLock::write() is infallible (Result<_, Infallible>).
1679                let Ok(mut cache) = self.glyph_cache.write();
1680                cache
1681                    .entry(gid)
1682                    .or_insert_with(|| Arc::clone(&arc));
1683                // If another thread beat us to the insert, return theirs
1684                // so all callers observe the same Arc.
1685                if let Some(winner) = cache.get(&gid) {
1686                    return Some(Arc::clone(winner));
1687                }
1688            }
1689            Some(arc)
1690        }
1691
1692        /// Eagerly decode every glyph into the lazy `glyph_cache`,
1693        /// restoring the pre-lazy "every glyph is materialised at
1694        /// construction time" behaviour. Used by tests that iterate
1695        /// or compare against reference tooling, and by embedders
1696        /// that want a walkable view without driving every shape
1697        /// through `get_or_decode_glyph`.
1698        ///
1699        /// After `prime_glyph_cache`, callers can use
1700        /// [`ParsedFont::for_each_decoded_glyph`] or
1701        /// [`ParsedFont::glyph_cache_snapshot`] to observe the
1702        /// populated cache.
1703        #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
1704        pub fn prime_glyph_cache(&mut self) {
1705            let n = self.num_glyphs as usize;
1706            for glyph_index in 0..n {
1707                let gid = glyph_index as u16;
1708                drop(self.get_or_decode_glyph(gid));
1709            }
1710        }
1711
1712        /// Walk every entry currently in the lazy `glyph_cache`,
1713        /// invoking `f(gid, &OwnedGlyph)` for each. Holds a read
1714        /// lock for the duration; do not call back into the font
1715        /// from `f`. The cache is populated on demand by
1716        /// [`ParsedFont::get_or_decode_glyph`] (and bulk-prefilled
1717        /// by [`ParsedFont::prime_glyph_cache`]).
1718        pub fn for_each_decoded_glyph<F: FnMut(u16, &OwnedGlyph)>(&self, mut f: F) {
1719            {
1720                // StLock::read() is infallible (Result<_, Infallible>).
1721                let Ok(cache) = self.glyph_cache.read();
1722                for (gid, glyph) in cache.iter() {
1723                    f(*gid, glyph.as_ref());
1724                }
1725            }
1726        }
1727
1728        /// Snapshot of the currently-decoded glyphs as a
1729        /// `BTreeMap<u16, Arc<OwnedGlyph>>`. Cheap (clones the
1730        /// Arcs, not the records). Used by callers that want to
1731        /// hand the map off across an API boundary; for in-place
1732        /// iteration prefer [`ParsedFont::for_each_decoded_glyph`].
1733        pub fn glyph_cache_snapshot(&self) -> BTreeMap<u16, Arc<OwnedGlyph>> {
1734            self.glyph_cache
1735                .read()
1736                .map(|c| c.clone())
1737                .unwrap_or_default()
1738        }
1739
1740        /// Core decode routine: produces one `OwnedGlyph` for `gid` by
1741        /// locking `loca_glyf` and running allsorts' outline visitor +
1742        /// raw-simple-glyph extraction. Factored out so both
1743        /// [`get_or_decode_glyph`] and [`prime_glyph_cache`] share it.
1744        ///
1745        /// Always returns an `OwnedGlyph` — if anything in the decode
1746        /// chain fails, falls back to an empty-outline record with the
1747        /// `hmtx` advance. This mirrors the pre-lazy behaviour where
1748        /// every gid ended up in `glyph_records_decoded`.
1749        fn hmtx_bytes(&self) -> &[u8] {
1750            let (off, len) = self.hmtx_range;
1751            if len == 0 { return &[]; }
1752            self.original_bytes.as_ref()
1753                .map_or(&[], |b| &b.as_ref()[off..off+len])
1754        }
1755
1756        fn vmtx_bytes(&self) -> &[u8] {
1757            let (off, len) = self.vmtx_range;
1758            if len == 0 { return &[]; }
1759            self.original_bytes.as_ref()
1760                .map_or(&[], |b| &b.as_ref()[off..off+len])
1761        }
1762
1763        #[allow(clippy::cast_possible_wrap)] // bounded graphics/coord/font/fixed-point/debug-marker cast
1764        fn decode_glyph_inner(&self, gid: u16) -> OwnedGlyph {
1765            let _p = crate::probe::Probe::span("decode_glyph");
1766            // [az-web-lift] use get_horizontal_advance (reads hmtx directly on the web build)
1767            // instead of allsorts::glyph_info::advance, whose lifted ReadArray parse has an
1768            // un-devirt'd jump table → MISSING_BLOCK → OOB during measure.
1769            let horz_advance = self.get_horizontal_advance(gid);
1770
1771            let mut record = OwnedGlyph {
1772                horz_advance,
1773                bounding_box: OwnedGlyphBoundingBox {
1774                    min_x: 0,
1775                    min_y: 0,
1776                    max_x: horz_advance as i16,
1777                    max_y: 0,
1778                },
1779                outline: Vec::new(),
1780                phantom_points: None,
1781                raw_points: None,
1782                raw_on_curve: None,
1783                raw_contour_ends: None,
1784                instructions: None,
1785            };
1786
1787            // Resolve the `LocaGlyf` for this face. For `Loaded` that's
1788            // a cheap `Arc::clone`; for `Deferred` this is where the
1789            // actual `LocaGlyf::load` happens on first access, paid once
1790            // per face that ever decodes a glyph.
1791            let Some(loca_glyf_arc) = self.resolve_loca_glyf() else {
1792                // No usable loca+glyf → CFF / OpenType-PostScript font
1793                // (Noto Sans/Serif CJK and most .otf). Decode the glyph
1794                // from the `CFF ` table instead; the TrueType-only glyf
1795                // path below can't see these, which left every CFF glyph
1796                // blank on the cpurender/headless path (CJK rendered as
1797                // empty space with the hmtx advance still reserved).
1798                self.decode_cff_glyph_into(gid, &mut record);
1799                return record;
1800            };
1801            let Ok(mut loca_glyf) = loca_glyf_arc.lock() else {
1802                return record;
1803            };
1804
1805            // Visit the outline. If this is a variable font (gvar
1806            // table present) AND we still have source bytes (only
1807            // the `LocaGlyfState::Deferred` path retains them), we
1808            // re-derive a `VariableGlyfContext` here so default-
1809            // instance vs designed-instance differences land in
1810            // the decoded outline. The chained `if let` pattern
1811            // keeps `provider` and `store` in scope for the
1812            // visit, which the borrow checker requires (the
1813            // store's `Cow::Borrowed(&[u8])` tables tie its
1814            // lifetime to the provider).
1815            //
1816            // Eager-`from_bytes` faces (no retained bytes) and
1817            // non-variable fonts skip the var-context machinery
1818            // and decode the default instance — same behaviour as
1819            // before R4.
1820            // [az-web-lift] The lifted web layout NEVER rasterizes (it measures + positions, then
1821            // ships a display list to JS) — so glyph OUTLINES + TrueType hinting raw-points are
1822            // never needed in wasm. Decoding them (allsorts GlyfVisitorContext::visit +
1823            // GlyphOutlineCollector::into_outlines, whose GlyphOutlineOperation match is a 5-arm
1824            // jump table the remill lift doesn't devirtualize → MISSING_BLOCK → OOB) crashes the
1825            // measure pass. Skip BOTH decode passes on the web build; the record keeps its hmtx
1826            // advance/metrics (set above) which is all text measurement needs.
1827            if !cfg!(feature = "web_lift") {
1828            let mut outline_done = false;
1829            if self.is_variable_font {
1830                if let LocaGlyfState::Deferred { bytes, .. } = &self.loca_glyf {
1831                    let scope = ReadScope::new(bytes);
1832                    if let Ok(font_data) =
1833                        scope.read::<FontData<'_>>()
1834                    {
1835                        if let Ok(provider) = font_data.table_provider(self.original_index) {
1836                            if let Ok(store) = VariableGlyfContextStore::read(&provider) {
1837                                if let Ok(var_ctx) = VariableGlyfContext::new(&store) {
1838                                    let mut visitor = GlyfVisitorContext::new(
1839                                        &mut loca_glyf,
1840                                        Some(var_ctx),
1841                                    );
1842                                    let mut collector = GlyphOutlineCollector::new();
1843                                    if visitor.visit(gid, None, &mut collector).is_ok() {
1844                                        record.outline = collector.into_outlines();
1845                                        let (min_x, min_y, max_x, max_y) =
1846                                            compute_outline_bbox(&record.outline);
1847                                        record.bounding_box = OwnedGlyphBoundingBox {
1848                                            min_x,
1849                                            min_y,
1850                                            max_x,
1851                                            max_y,
1852                                        };
1853                                        outline_done = true;
1854                                    }
1855                                }
1856                            }
1857                        }
1858                    }
1859                }
1860            }
1861            if !outline_done {
1862                let mut visitor =
1863                    GlyfVisitorContext::new(&mut loca_glyf, None);
1864                let mut collector = GlyphOutlineCollector::new();
1865                if visitor.visit(gid, None, &mut collector).is_ok() {
1866                    record.outline = collector.into_outlines();
1867                    let (min_x, min_y, max_x, max_y) =
1868                        compute_outline_bbox(&record.outline);
1869                    record.bounding_box = OwnedGlyphBoundingBox {
1870                        min_x,
1871                        min_y,
1872                        max_x,
1873                        max_y,
1874                    };
1875                }
1876            }
1877
1878            // Second pass: pull raw SimpleGlyph data for TrueType
1879            // bytecode hinting. LocaGlyf caches the `Arc<Glyph>`
1880            // internally so this lookup is cheap after the first call.
1881            if let Ok(glyph_arc) = loca_glyf.glyph(gid) {
1882                // `is_on_curve` moved onto the `SimpleGlyphFlagExt` trait in
1883                // allsorts 0.17 (SimpleGlyphFlags is now a BitFlags alias).
1884                use allsorts::tables::glyf::SimpleGlyphFlagExt;
1885                if let allsorts::tables::glyf::Glyph::Simple(sg) = glyph_arc.as_ref() {
1886                    record.raw_points = Some(
1887                        sg.coordinates.iter().map(|(_, pt)| (pt.0, pt.1)).collect(),
1888                    );
1889                    record.raw_on_curve = Some(
1890                        sg.coordinates.iter().map(|(f, _)| f.is_on_curve()).collect(),
1891                    );
1892                    record.raw_contour_ends = Some(sg.end_pts_of_contours.clone());
1893                    record.instructions = Some(sg.instructions.to_vec());
1894                }
1895            }
1896            } // [az-web-lift] end skip glyph outline/hinting decode on web
1897
1898            record
1899        }
1900
1901        /// Decode a single glyph outline from the `CFF ` (OpenType
1902        /// PostScript) table into `record`. Used for fonts with no `glyf`
1903        /// table — `decode_glyph_inner`'s TrueType path returns an empty
1904        /// outline for them, so without this every CFF glyph rasterised as
1905        /// blank on the CPU renderer. Notably this hit ALL CJK text: the
1906        /// installed Noto Sans/Serif CJK fonts are CID-keyed CFF. allsorts'
1907        /// `CFFOutlines` feeds the same `GlyphOutlineCollector` the glyf
1908        /// path uses and resolves CID-keyed local subrs internally.
1909        fn decode_cff_glyph_into(&self, gid: u16, record: &mut OwnedGlyph) {
1910            use allsorts::cff::{outline::CFFOutlines, CFF};
1911
1912            let Some(ref original) = self.original_bytes else {
1913                return;
1914            };
1915            let bytes: &[u8] = original.as_slice();
1916            let Ok(font_data) = ReadScope::new(bytes).read::<FontData<'_>>() else {
1917                return;
1918            };
1919            let Ok(provider) = font_data.table_provider(self.original_index) else {
1920                return;
1921            };
1922            let Ok(Some(cff_data)) = provider.table_data(tag::CFF) else {
1923                return;
1924            };
1925            let Ok(cff) = ReadScope::new(&cff_data).read::<CFF<'_>>() else {
1926                return;
1927            };
1928            let mut outlines = CFFOutlines { table: &cff };
1929            let mut collector = GlyphOutlineCollector::new();
1930            if outlines.visit(gid, None, &mut collector).is_ok() {
1931                record.outline = collector.into_outlines();
1932                let (min_x, min_y, max_x, max_y) = compute_outline_bbox(&record.outline);
1933                record.bounding_box = OwnedGlyphBoundingBox {
1934                    min_x,
1935                    min_y,
1936                    max_x,
1937                    max_y,
1938                };
1939            }
1940        }
1941
1942        /// Parse PDF-specific font metrics from HEAD, HHEA, and OS/2 tables
1943        fn parse_pdf_font_metrics(
1944            font_bytes: &[u8],
1945            font_index: usize,
1946            head_table: &allsorts::tables::HeadTable,
1947            hhea_table: &HheaTable,
1948        ) -> PdfFontMetrics {
1949            use allsorts::{
1950                binary::read::ReadScope,
1951                font_data::FontData,
1952                tables::{os2::Os2, FontTableProvider},
1953                tag,
1954            };
1955
1956            let scope = ReadScope::new(font_bytes);
1957            let font_file = scope.read::<FontData<'_>>().ok();
1958            let provider = font_file
1959                .as_ref()
1960                .and_then(|ff| ff.table_provider(font_index).ok());
1961
1962            let os2_table = provider
1963                .as_ref()
1964                .and_then(|p| p.table_data(tag::OS_2).ok())
1965                .and_then(|os2_data| {
1966                    let data = os2_data?;
1967                    let scope = ReadScope::new(&data);
1968                    scope.read_dep::<Os2>(data.len()).ok()
1969                });
1970
1971            // Base metrics from HEAD and HHEA (always present)
1972            let base = PdfFontMetrics {
1973                units_per_em: head_table.units_per_em,
1974                font_flags: head_table.flags,
1975                x_min: head_table.x_min,
1976                y_min: head_table.y_min,
1977                x_max: head_table.x_max,
1978                y_max: head_table.y_max,
1979                ascender: hhea_table.ascender,
1980                descender: hhea_table.descender,
1981                line_gap: hhea_table.line_gap,
1982                advance_width_max: hhea_table.advance_width_max,
1983                caret_slope_rise: hhea_table.caret_slope_rise,
1984                caret_slope_run: hhea_table.caret_slope_run,
1985                ..PdfFontMetrics::zero()
1986            };
1987
1988            // Add OS/2 metrics if available
1989            os2_table
1990                .map_or(base, |os2| PdfFontMetrics {
1991                    x_avg_char_width: os2.x_avg_char_width,
1992                    us_weight_class: os2.us_weight_class,
1993                    us_width_class: os2.us_width_class,
1994                    y_strikeout_size: os2.y_strikeout_size,
1995                    y_strikeout_position: os2.y_strikeout_position,
1996                    ..base
1997                })
1998        }
1999
2000        /// Returns the width of the space character in font units.
2001        ///
2002        /// This is used internally for text layout calculations.
2003        /// Returns `None` if the font has no space glyph or its width cannot be determined.
2004        fn get_space_width_internal(&self) -> Option<usize> {
2005            if let Some(mock) = self.mock.as_ref() {
2006                return mock.space_width;
2007            }
2008            let glyph_index = self.lookup_glyph_index(' ' as u32)?;
2009
2010            // [az-web-lift] use get_horizontal_advance (direct hmtx on web) instead of
2011            // allsorts::glyph_info::advance (un-devirt'd jump table → OOB).
2012            Some(self.get_horizontal_advance(glyph_index) as usize)
2013        }
2014
2015        /// Look up the glyph index for a Unicode codepoint
2016        pub fn lookup_glyph_index(&self, codepoint: u32) -> Option<u16> {
2017            let cmap = self.cmap_subtable.as_ref()?;
2018            cmap.map_glyph(codepoint).ok().flatten()
2019        }
2020
2021        /// Get the horizontal advance width for a glyph in font units.
2022        ///
2023        /// Pulled straight from the `hmtx` table — no glyph-outline
2024        /// decode. Called once per shaped glyph per layout pass, so
2025        /// avoiding the lazy decode here is a meaningful win over
2026        /// routing through `get_or_decode_glyph`.
2027        pub fn get_horizontal_advance(&self, glyph_index: u16) -> u16 {
2028            if let Some(mock) = self.mock.as_ref() {
2029                return mock.glyph_advances.get(&glyph_index).copied().unwrap_or(0);
2030            }
2031            // [az-web-lift] Read the hmtx advance DIRECTLY (a plain longHorMetric table lookup)
2032            // instead of allsorts::glyph_info::advance, whose lifted binary `ReadArray` parse has
2033            // an un-devirt'd jump table → MISSING_BLOCK → OOB during text measure. Identical result
2034            // for non-variable fonts (the web fallback font is non-variable); native keeps the
2035            // allsorts path (variable-font deltas etc.).
2036            #[cfg(feature = "web_lift")]
2037            {
2038                let hmtx = self.hmtx_bytes();
2039                let num = usize::from(self.hhea_table.num_h_metrics);
2040                if num == 0 {
2041                    return 0;
2042                }
2043                let idx = (glyph_index as usize).min(num - 1);
2044                let off = idx * 4;
2045                return if off + 2 <= hmtx.len() {
2046                    ((hmtx[off] as u16) << 8) | (hmtx[off + 1] as u16)
2047                } else {
2048                    0
2049                };
2050            }
2051            #[cfg(not(feature = "web_lift"))]
2052            {
2053                allsorts::glyph_info::advance(
2054                    &self.maxp_table,
2055                    &self.hhea_table,
2056                    self.hmtx_bytes(),
2057                    glyph_index,
2058                )
2059                .unwrap_or_default()
2060            }
2061        }
2062
2063        /// Get the hinted advance width in pixels for a glyph at the given ppem.
2064        ///
2065        /// For glyphs with outlines, runs TrueType bytecode hinting to get the
2066        /// grid-fitted advance from phantom points. For glyphs without outlines
2067        /// (e.g. space), rounds the scaled advance to the pixel grid, matching
2068        /// `FreeType`'s behavior.
2069        ///
2070        /// Returns `None` if hinting is not available or fails.
2071        #[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
2072        pub fn get_hinted_advance_px(&self, glyph_index: u16, ppem: u16) -> Option<f32> {
2073            // [az-web-lift] No pixel grid-fitting on the web (measure-only): return None so the
2074            // caller falls back to the plain scaled advance. Hard-cfg (not a runtime `if cfg!`)
2075            // so the whole hinting body — get_or_decode_glyph's outline path AND set_ppem →
2076            // allsorts Interpreter::dispatch (opcode jump table → OOB) — is removed from the lift
2077            // closure entirely. SEPARATE concern from the transpiler's jump-table devirt: web has
2078            // no use for hinted advances regardless of lift quality. Native is unchanged.
2079            #[cfg(feature = "web_lift")]
2080            {
2081                let _ = (glyph_index, ppem);
2082                None
2083            }
2084            #[cfg(not(feature = "web_lift"))]
2085            {
2086            use allsorts::hinting::f26dot6::{compute_scale, F26Dot6};
2087            let glyph = self.get_or_decode_glyph(glyph_index)?;
2088
2089            let upem = self.font_metrics.units_per_em;
2090            if upem == 0 || ppem == 0 {
2091                return None;
2092            }
2093
2094            // Check if we even have a hint instance
2095            let hint_mutex = self.hint_instance.as_ref()?;
2096
2097            let scale = compute_scale(ppem, upem);
2098            // Round the LIVE hmtx advance, not the decoded glyph's cached
2099            // `horz_advance`. The space glyph is eagerly pre-cached during
2100            // `from_bytes_internal` before `original_bytes` is attached, so its
2101            // cached advance can be a stale 0; and this function only ever rounds
2102            // the scaled hmtx advance to the pixel grid anyway (see below), never
2103            // the hinted phantom point. For every correctly-decoded glyph the two
2104            // are identical, so this is a no-op except for the stale-space case.
2105            let hmtx_advance = self.get_horizontal_advance(glyph_index);
2106            let adv_f26dot6 = F26Dot6::from_funits(i32::from(hmtx_advance), scale);
2107
2108            // For glyphs with outline data, run bytecode hinting
2109            if let (Some(raw_points), Some(raw_on_curve), Some(raw_contour_ends)) = (
2110                glyph.raw_points.as_ref(),
2111                glyph.raw_on_curve.as_ref(),
2112                glyph.raw_contour_ends.as_ref(),
2113            ) {
2114                let instructions = glyph.instructions.as_deref().unwrap_or(&[]);
2115                let mut hint = hint_mutex.lock().ok()?;
2116                hint.set_ppem(ppem, f64::from(ppem)).ok()?;
2117                drop(hint);
2118
2119                let points_f26dot6: Vec<(i32, i32)> = raw_points
2120                    .iter()
2121                    .map(|&(x, y)| {
2122                        let sx = F26Dot6::from_funits(i32::from(x), scale);
2123                        let sy = F26Dot6::from_funits(i32::from(y), scale);
2124                        (sx.to_bits(), sy.to_bits())
2125                    })
2126                    .collect();
2127            }
2128
2129            // Use the scaled advance rounded to pixel grid, NOT the hinted
2130            // phantom point.  Some glyph programs apply ClearType-specific SHPIX
2131            // adjustments to the advance phantom point that are wrong for
2132            // non-ClearType rendering.  The rounded scaled advance matches
2133            // FreeType's DEFAULT mode advance output (and, for glyphs without an
2134            // outline such as space, FreeType's phantom-point pre-rounding).
2135            let rounded = (adv_f26dot6.to_bits() + 32) & !63;
2136            Some(rounded as f32 / 64.0)
2137            } // [az-web-lift] end #[cfg(not(web_lift))] hinting body
2138        }
2139
2140        /// Get the number of glyphs in this font
2141        pub const fn num_glyphs(&self) -> u16 {
2142            self.num_glyphs
2143        }
2144
2145        /// Check if this font has a glyph for the given codepoint
2146        pub fn has_glyph(&self, codepoint: u32) -> bool {
2147            self.lookup_glyph_index(codepoint).is_some()
2148        }
2149
2150        /// Get vertical metrics for a glyph (for vertical text layout).
2151        ///
2152        /// Uses vhea+vmtx tables (same binary format as hhea+hmtx).
2153        /// Returns None if font has no vertical metrics tables.
2154        #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
2155        pub fn get_vertical_metrics(
2156            &self,
2157            glyph_id: u16,
2158        ) -> Option<crate::text3::cache::VerticalMetrics> {
2159            let vhea = self.vhea_table.as_ref()?;
2160            if self.vmtx_range.1 == 0 {
2161                return None;
2162            }
2163            let vert_advance = f32::from(allsorts::glyph_info::advance(
2164                &self.maxp_table, vhea, self.vmtx_bytes(), glyph_id,
2165            ).ok()?);
2166
2167            let units_per_em = f32::from(self.font_metrics.units_per_em);
2168            let scale = if units_per_em > 0.0 { 1.0 / units_per_em } else { 0.001 };
2169
2170            // Vertical bearing: approximate from glyph bbox if available
2171            let (bearing_x, bearing_y) = self.get_or_decode_glyph(glyph_id)
2172                .map_or((0.0, 0.0), |g| {
2173                    let bbox = &g.bounding_box;
2174                    // tsb (top side bearing): origin_y - max_y
2175                    // lsb for vertical: center the glyph horizontally
2176                    let width = f32::from(bbox.max_x - bbox.min_x);
2177                    (-(width / 2.0) * scale, (vert_advance * scale) - (f32::from(bbox.max_y) * scale))
2178                });
2179
2180            Some(crate::text3::cache::VerticalMetrics {
2181                advance: vert_advance * scale,
2182                bearing_x,
2183                bearing_y,
2184                origin_y: self.font_metrics.ascent * scale,
2185            })
2186        }
2187
2188        /// Get layout-specific font metrics
2189        #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
2190        pub fn get_font_metrics(&self) -> LayoutFontMetrics {
2191            // Ensure descent is positive (OpenType may have negative descent)
2192            let descent = if self.font_metrics.descent > 0.0 {
2193                self.font_metrics.descent
2194            } else {
2195                -self.font_metrics.descent
2196            };
2197
2198            LayoutFontMetrics {
2199                ascent: self.font_metrics.ascent,
2200                descent,
2201                line_gap: self.font_metrics.line_gap,
2202                units_per_em: self.font_metrics.units_per_em,
2203                x_height: self.font_metrics.x_height,
2204                cap_height: self.font_metrics.cap_height,
2205            }
2206        }
2207
2208        /// Convert the `ParsedFont` back to bytes using `allsorts::whole_font`
2209        /// This reconstructs the entire font from the parsed data
2210        ///
2211        /// Source bytes come from either the explicit
2212        /// [`ParsedFont::with_source_bytes`] handle (PDF-first
2213        /// construction) *or* the `LocaGlyfState::Deferred` slot
2214        /// installed by [`ParsedFont::from_bytes_shared`]. The
2215        /// production lazy path retains bytes for the lazy `LocaGlyf`
2216        /// loader, so PDF subsetting Just Works without an extra
2217        /// `with_source_bytes` call.
2218        ///
2219        /// # Arguments
2220        /// * `tags` - Optional list of specific table tags to include (None = all tables)
2221        /// # Errors
2222        ///
2223        /// Returns an error string if serializing the font fails.
2224        pub fn to_bytes(&self, tags: Option<&[u32]>) -> Result<Vec<u8>, String> {
2225            let source = self.source_bytes_for_subset().ok_or_else(|| {
2226                "ParsedFont::to_bytes requires source bytes; construct via \
2227                 ParsedFont::from_bytes_shared (production lazy path) or \
2228                 attach via ParsedFont::with_source_bytes"
2229                    .to_string()
2230            })?;
2231            let scope = ReadScope::new(source.as_slice());
2232            let font_file = scope.read::<FontData<'_>>().map_err(|e| e.to_string())?;
2233            let provider = font_file
2234                .table_provider(self.original_index)
2235                .map_err(|e| e.to_string())?;
2236
2237            let tags_to_use = tags.unwrap_or(&[
2238                tag::CMAP,
2239                tag::HEAD,
2240                tag::HHEA,
2241                tag::HMTX,
2242                tag::MAXP,
2243                tag::NAME,
2244                tag::OS_2,
2245                tag::POST,
2246                tag::GLYF,
2247                tag::LOCA,
2248            ]);
2249
2250            whole_font(&provider, tags_to_use).map_err(|e| e.to_string())
2251        }
2252
2253        /// Create a subset font containing only the specified glyph IDs
2254        /// Returns the subset font bytes and a mapping from old to new glyph IDs
2255        ///
2256        /// # Arguments
2257        /// * `glyph_ids` - The glyph IDs to include in the subset (glyph 0/.notdef is always
2258        ///   included)
2259        /// * `cmap_target` - Target cmap format (Unicode for web, `MacRoman` for compatibility)
2260        ///
2261        /// # Returns
2262        /// A tuple of (`subset_font_bytes`, `glyph_mapping`) where `glyph_mapping` maps
2263        /// `original_glyph_id` -> (`new_glyph_id`, `original_char`)
2264        #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
2265        /// # Errors
2266        ///
2267        /// Returns an error string if subsetting the font fails.
2268        pub fn subset(
2269            &self,
2270            glyph_ids: &[(u16, char)],
2271            cmap_target: CmapTarget,
2272        ) -> Result<(Vec<u8>, BTreeMap<u16, (u16, char)>), String> {
2273            let source = self.source_bytes_for_subset().ok_or_else(|| {
2274                "ParsedFont::subset requires source bytes; construct via \
2275                 ParsedFont::from_bytes_shared (production lazy path) or \
2276                 attach via ParsedFont::with_source_bytes"
2277                    .to_string()
2278            })?;
2279            let scope = ReadScope::new(source.as_slice());
2280            let font_file = scope.read::<FontData<'_>>().map_err(|e| e.to_string())?;
2281            let provider = font_file
2282                .table_provider(self.original_index)
2283                .map_err(|e| e.to_string())?;
2284
2285            // Build glyph mapping: original_id -> (new_id, char)
2286            let glyph_mapping: BTreeMap<u16, (u16, char)> = glyph_ids
2287                .iter()
2288                .enumerate()
2289                .map(|(new_id, &(original_id, ch))| (original_id, (new_id as u16, ch)))
2290                .collect();
2291
2292            // Extract just the glyph IDs for subsetting
2293            let ids: Vec<u16> = glyph_ids.iter().map(|(id, _)| *id).collect();
2294
2295            // Use PDF profile for embedding fonts in PDFs
2296            let font_bytes = allsorts_subset(&provider, &ids, &SubsetProfile::Pdf, cmap_target)
2297                .map_err(|e| format!("Subset error: {e:?}"))?;
2298
2299            Ok((font_bytes, glyph_mapping))
2300        }
2301
2302        /// Get the width of a glyph in font units (internal, unscaled)
2303        pub fn get_glyph_width_internal(&self, glyph_index: u16) -> Option<usize> {
2304            allsorts::glyph_info::advance(
2305                &self.maxp_table,
2306                &self.hhea_table,
2307                self.hmtx_bytes(),
2308                glyph_index,
2309            )
2310            .ok()
2311            .map(|s| s as usize)
2312        }
2313
2314        /// Get the width of the space character (unscaled font units)
2315        #[inline]
2316        pub const fn get_space_width(&self) -> Option<usize> {
2317            self.space_width
2318        }
2319
2320        /// Add glyph-to-text mapping to reverse cache
2321        /// This should be called during text shaping when we know both the source text and
2322        /// resulting glyphs
2323        pub fn cache_glyph_mapping(&mut self, glyph_id: u16, cluster_text: &str) {
2324            self.reverse_glyph_cache
2325                .insert(glyph_id, cluster_text.to_string());
2326        }
2327
2328        /// Get the cluster text that produced a specific glyph ID
2329        /// Returns the original text that was shaped into this glyph (handles ligatures correctly)
2330        pub fn get_glyph_cluster_text(&self, glyph_id: u16) -> Option<&str> {
2331            self.reverse_glyph_cache.get(&glyph_id).map(String::as_str)
2332        }
2333
2334        /// Get the first character from the cluster text for a glyph ID
2335        /// This is useful for PDF `ToUnicode` `CMap` generation which requires single character
2336        /// mappings
2337        pub fn get_glyph_primary_char(&self, glyph_id: u16) -> Option<char> {
2338            self.reverse_glyph_cache
2339                .get(&glyph_id)
2340                .and_then(|text| text.chars().next())
2341        }
2342
2343        /// Clear the reverse glyph cache (useful for memory management)
2344        pub fn clear_glyph_cache(&mut self) {
2345            self.reverse_glyph_cache.clear();
2346        }
2347
2348        /// Get the bounding box size of a glyph (unscaled units) - for PDF
2349        /// Returns (width, height) in font units
2350        pub fn get_glyph_bbox_size(&self, glyph_index: u16) -> Option<(i32, i32)> {
2351            let g = self.get_or_decode_glyph(glyph_index)?;
2352            let glyph_width = i32::from(g.horz_advance);
2353            let glyph_height = i32::from(g.bounding_box.max_y) - i32::from(g.bounding_box.min_y);
2354            Some((glyph_width, glyph_height))
2355        }
2356    }
2357
2358    /// Compute the bounding box from collected glyph outlines.
2359    fn compute_outline_bbox(outlines: &[GlyphOutline]) -> (i16, i16, i16, i16) {
2360        let mut min_x = i16::MAX;
2361        let mut min_y = i16::MAX;
2362        let mut max_x = i16::MIN;
2363        let mut max_y = i16::MIN;
2364        let mut has_points = false;
2365
2366        for outline in outlines {
2367            for op in outline.operations.as_slice() {
2368                let points: &[(i16, i16)] = match op {
2369                    GlyphOutlineOperation::MoveTo(m) => &[(m.x, m.y)],
2370                    GlyphOutlineOperation::LineTo(l) => &[(l.x, l.y)],
2371                    GlyphOutlineOperation::QuadraticCurveTo(q) => {
2372                        // Check both control and end point for bbox
2373                        min_x = min_x.min(q.ctrl_1_x).min(q.end_x);
2374                        min_y = min_y.min(q.ctrl_1_y).min(q.end_y);
2375                        max_x = max_x.max(q.ctrl_1_x).max(q.end_x);
2376                        max_y = max_y.max(q.ctrl_1_y).max(q.end_y);
2377                        has_points = true;
2378                        continue;
2379                    }
2380                    GlyphOutlineOperation::CubicCurveTo(c) => {
2381                        min_x = min_x.min(c.ctrl_1_x).min(c.ctrl_2_x).min(c.end_x);
2382                        min_y = min_y.min(c.ctrl_1_y).min(c.ctrl_2_y).min(c.end_y);
2383                        max_x = max_x.max(c.ctrl_1_x).max(c.ctrl_2_x).max(c.end_x);
2384                        max_y = max_y.max(c.ctrl_1_y).max(c.ctrl_2_y).max(c.end_y);
2385                        has_points = true;
2386                        continue;
2387                    }
2388                    GlyphOutlineOperation::ClosePath => continue,
2389                };
2390                for &(x, y) in points {
2391                    min_x = min_x.min(x);
2392                    min_y = min_y.min(y);
2393                    max_x = max_x.max(x);
2394                    max_y = max_y.max(y);
2395                    has_points = true;
2396                }
2397            }
2398        }
2399
2400        if has_points {
2401            (min_x, min_y, max_x, max_y)
2402        } else {
2403            (0, 0, 0, 0)
2404        }
2405    }
2406
2407    #[derive(Debug, Clone)]
2408    pub struct OwnedGlyph {
2409        pub bounding_box: OwnedGlyphBoundingBox,
2410        pub horz_advance: u16,
2411        pub outline: Vec<GlyphOutline>,
2412        pub phantom_points: Option<[Point; 4]>,
2413        /// Raw TrueType points in font units (for hinting). None for composite/CFF glyphs.
2414        pub raw_points: Option<Vec<(i16, i16)>>,
2415        /// On-curve flags for each raw point.
2416        pub raw_on_curve: Option<Vec<bool>>,
2417        /// Contour end-point indices (TrueType).
2418        pub raw_contour_ends: Option<Vec<u16>>,
2419        /// Per-glyph TrueType hinting instructions.
2420        pub instructions: Option<Vec<u8>>,
2421    }
2422
2423    // --- ParsedFontTrait Implementation for ParsedFont ---
2424
2425    impl crate::text3::cache::ShallowClone for ParsedFont {
2426        fn shallow_clone(&self) -> Self {
2427            self.clone() // ParsedFont::clone uses Arc internally, so it's shallow
2428        }
2429    }
2430
2431    impl crate::text3::cache::ParsedFontTrait for ParsedFont {
2432        fn shape_text(
2433            &self,
2434            text: &str,
2435            script: crate::font_traits::Script,
2436            language: crate::font_traits::Language,
2437            direction: crate::font_traits::BidiDirection,
2438            style: &crate::font_traits::StyleProperties,
2439        ) -> Result<Vec<crate::font_traits::Glyph>, crate::font_traits::LayoutError> {
2440            // Call the existing shape_text_for_parsed_font method (defined in default.rs)
2441            crate::text3::default::shape_text_for_parsed_font(
2442                self, text, script, language, direction, style,
2443            )
2444        }
2445
2446        fn get_hash(&self) -> u64 {
2447            self.hash
2448        }
2449
2450        fn get_glyph_size(
2451            &self,
2452            glyph_id: u16,
2453            font_size_px: f32,
2454        ) -> Option<azul_core::geom::LogicalSize> {
2455            self.get_or_decode_glyph(glyph_id).map(|record| {
2456                let units_per_em = f32::from(self.font_metrics.units_per_em);
2457                let scale_factor = if units_per_em > 0.0 {
2458                    font_size_px / units_per_em
2459                } else {
2460                    0.01
2461                };
2462                let bbox = &record.bounding_box;
2463                azul_core::geom::LogicalSize {
2464                    width: f32::from(bbox.max_x - bbox.min_x) * scale_factor,
2465                    height: f32::from(bbox.max_y - bbox.min_y) * scale_factor,
2466                }
2467            })
2468        }
2469
2470        fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
2471            let glyph_id = self.lookup_glyph_index('-' as u32)?;
2472            let advance_units = self.get_horizontal_advance(glyph_id);
2473            let scale_factor = if self.font_metrics.units_per_em > 0 {
2474                font_size / f32::from(self.font_metrics.units_per_em)
2475            } else {
2476                return None;
2477            };
2478            let scaled_advance = f32::from(advance_units) * scale_factor;
2479            Some((glyph_id, scaled_advance))
2480        }
2481
2482        fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
2483            let glyph_id = self.lookup_glyph_index('\u{0640}' as u32)?;
2484            let advance_units = self.get_horizontal_advance(glyph_id);
2485            let scale_factor = if self.font_metrics.units_per_em > 0 {
2486                font_size / f32::from(self.font_metrics.units_per_em)
2487            } else {
2488                return None;
2489            };
2490            let scaled_advance = f32::from(advance_units) * scale_factor;
2491            Some((glyph_id, scaled_advance))
2492        }
2493
2494        fn has_glyph(&self, codepoint: u32) -> bool {
2495            self.lookup_glyph_index(codepoint).is_some()
2496        }
2497
2498        fn get_vertical_metrics(
2499            &self,
2500            glyph_id: u16,
2501        ) -> Option<crate::text3::cache::VerticalMetrics> {
2502            self.get_vertical_metrics(glyph_id)
2503        }
2504
2505        fn get_font_metrics(&self) -> LayoutFontMetrics {
2506            self.font_metrics
2507        }
2508
2509        fn num_glyphs(&self) -> u16 {
2510            self.num_glyphs
2511        }
2512
2513        fn get_space_width(&self) -> Option<usize> {
2514            self.space_width
2515        }
2516    }
2517
2518    /// Build an agg-rust `PathStorage` from an `OwnedGlyph` outline (in font units, Y-up → Y-down).
2519    ///
2520    /// Returns `None` if the glyph has no outline operations (e.g. space).
2521    /// The caller is responsible for applying scale and translation transforms.
2522    #[cfg(feature = "cpurender")]
2523    #[must_use] pub fn build_glyph_path(glyph: &OwnedGlyph) -> Option<agg_rust::path_storage::PathStorage> {
2524        use agg_rust::{basics::PATH_FLAGS_NONE, path_storage::PathStorage};
2525
2526        let mut path = PathStorage::new();
2527        let mut has_ops = false;
2528        for outline in &glyph.outline {
2529            for op in outline.operations.as_slice() {
2530                has_ops = true;
2531                match op {
2532                    GlyphOutlineOperation::MoveTo(OutlineMoveTo { x, y }) => {
2533                        path.move_to(f64::from(*x), -f64::from(*y));
2534                    }
2535                    GlyphOutlineOperation::LineTo(OutlineLineTo { x, y }) => {
2536                        path.line_to(f64::from(*x), -f64::from(*y));
2537                    }
2538                    GlyphOutlineOperation::QuadraticCurveTo(OutlineQuadTo {
2539                        ctrl_1_x, ctrl_1_y, end_x, end_y,
2540                    }) => {
2541                        path.curve3(
2542                            f64::from(*ctrl_1_x), -f64::from(*ctrl_1_y),
2543                            f64::from(*end_x), -f64::from(*end_y),
2544                        );
2545                    }
2546                    GlyphOutlineOperation::CubicCurveTo(OutlineCubicTo {
2547                        ctrl_1_x, ctrl_1_y, ctrl_2_x, ctrl_2_y, end_x, end_y,
2548                    }) => {
2549                        path.curve4(
2550                            f64::from(*ctrl_1_x), -f64::from(*ctrl_1_y),
2551                            f64::from(*ctrl_2_x), -f64::from(*ctrl_2_y),
2552                            f64::from(*end_x), -f64::from(*end_y),
2553                        );
2554                    }
2555                    GlyphOutlineOperation::ClosePath => {
2556                        path.close_polygon(PATH_FLAGS_NONE);
2557                    }
2558                }
2559            }
2560        }
2561        if !has_ops {
2562            return None;
2563        }
2564        Some(path)
2565    }
2566
2567    #[cfg(test)]
2568    mod autotest_generated {
2569        //! Adversarial unit tests generated by the autotest fleet.
2570        //!
2571        //! Lives inside `mod parsed` (not at file scope) so it can reach the
2572        //! private sfnt scanner (`manual_be16` / `manual_be32` /
2573        //! `ManualTableProvider`), the outline collector, and the private
2574        //! `ParsedFont` getters.
2575
2576        use allsorts::tables::SfntVersion;
2577
2578        use super::*;
2579
2580        /// Positive control: the built-in `Azul Mock Mono` TrueType font.
2581        /// 13 tables, `glyf` outlines (no CFF), no `vhea`/`vmtx`, upem 1000,
2582        /// 96 glyphs, GSUB + GPOS + GDEF present (empty lists, presence-only).
2583        const MOCK_MONO: &[u8] = crate::text3::mock_fonts::MOCK_MONO_TTF;
2584
2585        fn parse_mock() -> ParsedFont {
2586            let mut warnings = Vec::new();
2587            ParsedFont::from_bytes(MOCK_MONO, 0, &mut warnings)
2588                .expect("Azul Mock Mono must parse (positive control)")
2589        }
2590
2591        fn mock_shared() -> ParsedFont {
2592            let bytes = Arc::new(rust_fontconfig::FontBytes::Owned(Arc::from(MOCK_MONO.to_vec())));
2593            let mut warnings = Vec::new();
2594            ParsedFont::from_bytes_shared(bytes, 0, &mut warnings)
2595                .expect("from_bytes_shared must parse the positive control")
2596        }
2597
2598        fn plain_metrics() -> LayoutFontMetrics {
2599            LayoutFontMetrics {
2600                ascent: 800.0,
2601                descent: -200.0,
2602                line_gap: 0.0,
2603                units_per_em: 1000,
2604                x_height: None,
2605                cap_height: None,
2606            }
2607        }
2608
2609        fn plain_hhea() -> HheaTable {
2610            HheaTable {
2611                ascender: 800,
2612                descender: -200,
2613                line_gap: 0,
2614                advance_width_max: 1000,
2615                min_left_side_bearing: 0,
2616                min_right_side_bearing: 0,
2617                x_max_extent: 0,
2618                caret_slope_rise: 1,
2619                caret_slope_run: 0,
2620                caret_offset: 0,
2621                num_h_metrics: 0,
2622            }
2623        }
2624
2625        /// A hand-built `ParsedFont` with no tables, no cmap and no source
2626        /// bytes — the "default / empty / extreme instance" every getter has
2627        /// to survive.
2628        fn synthetic_font(num_glyphs: u16, mock: Option<Box<MockFont>>) -> ParsedFont {
2629            ParsedFont {
2630                hash: 0,
2631                font_metrics: plain_metrics(),
2632                pdf_font_metrics: PdfFontMetrics::zero(),
2633                num_glyphs,
2634                hhea_table: plain_hhea(),
2635                hmtx_range: (0, 0),
2636                vmtx_range: (0, 0),
2637                vhea_table: None,
2638                maxp_table: MaxpTable {
2639                    num_glyphs,
2640                    version1_sub_table: None,
2641                },
2642                gsub_bytes: None,
2643                gsub_cache_lazy: std::sync::OnceLock::new(),
2644                gpos_bytes: None,
2645                gpos_cache_lazy: std::sync::OnceLock::new(),
2646                opt_gdef_table: None,
2647                opt_kern_table: None,
2648                last_used: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2649                is_variable_font: false,
2650                glyph_cache: Arc::new(rust_fontconfig::StLock::new(BTreeMap::new())),
2651                loca_glyf: LocaGlyfState::Loaded(None),
2652                space_width: None,
2653                cmap_subtable: None,
2654                mock,
2655                reverse_glyph_cache: BTreeMap::new(),
2656                original_bytes: None,
2657                original_index: 0,
2658                index_to_cid: BTreeMap::new(),
2659                font_type: FontType::TrueType,
2660                font_name: None,
2661                hint_instance: None,
2662            }
2663        }
2664
2665        // ---------------------------------------------------------------
2666        // manual_be16 / manual_be32 (numeric)
2667        // ---------------------------------------------------------------
2668
2669        #[test]
2670        fn manual_be16_zero_max_and_offset() {
2671            assert_eq!(manual_be16(&[0x00, 0x00], 0), 0);
2672            assert_eq!(manual_be16(&[0xFF, 0xFF], 0), 0xFFFF);
2673            assert_eq!(manual_be16(&[0x12, 0x34], 0), 0x1234);
2674            // the widest 16-bit value still fits the u32 return: no truncation
2675            assert_eq!(manual_be16(&[0xAA, 0xBB, 0xFF, 0xFF], 2), u32::from(u16::MAX));
2676            // offsets address the right bytes, not the first ones
2677            assert_eq!(manual_be16(&[0xAA, 0xBB, 0x00, 0x01], 2), 1);
2678        }
2679
2680        #[test]
2681        fn manual_be32_zero_max_and_known_tags() {
2682            assert_eq!(manual_be32(&[0, 0, 0, 0], 0), 0);
2683            // saturating the whole word must not overflow the u32 accumulator
2684            assert_eq!(manual_be32(&[0xFF, 0xFF, 0xFF, 0xFF], 0), u32::MAX);
2685            assert_eq!(manual_be32(b"ttcf", 0), 0x7474_6366);
2686            assert_eq!(manual_be32(&[0x00, 0x01, 0x00, 0x00], 0), 0x0001_0000);
2687            assert_eq!(manual_be32(&[0xEE, 0x00, 0x01, 0x00, 0x00], 1), 0x0001_0000);
2688            // high bit set: the shift must stay unsigned (no sign extension)
2689            assert_eq!(manual_be32(&[0x80, 0x00, 0x00, 0x00], 0), 0x8000_0000);
2690        }
2691
2692        // ---------------------------------------------------------------
2693        // ManualTableProvider (parser)
2694        // ---------------------------------------------------------------
2695
2696        #[test]
2697        fn manual_table_provider_rejects_short_input() {
2698            assert!(ManualTableProvider::new(&[], 0).is_none()); // empty
2699            assert!(ManualTableProvider::new(b"   ", 0).is_none()); // whitespace-only
2700            assert!(ManualTableProvider::new(b"  \t\n", 0).is_none());
2701            assert!(ManualTableProvider::new(&[0xFF, 0xFE, 0x00], 0).is_none()); // invalid utf8
2702            assert!(ManualTableProvider::new(&[0u8; 11], 0).is_none()); // one byte short
2703            assert!(ManualTableProvider::new(&[0u8; 12], 0).is_some()); // exact minimum
2704        }
2705
2706        #[test]
2707        fn manual_table_provider_garbage_header_terminates() {
2708            // numTables reads back 0xFFFF but every record is past the end of the
2709            // buffer: the scan must break immediately instead of walking off it.
2710            let data = [0xFFu8; 12];
2711            let p = ManualTableProvider::new(&data, 0).expect("12 bytes form an offset table");
2712            assert_eq!(p.num, 0xFFFF);
2713            assert!(p.table_data(tag::HEAD).unwrap().is_none());
2714            assert!(!p.has_table(tag::GLYF));
2715            // table_tags always leads with the 0xFADE sentinel, then stops at the end
2716            assert_eq!(p.table_tags().unwrap(), vec![0x0000_FADE]);
2717        }
2718
2719        #[test]
2720        fn manual_table_provider_ttc_index_out_of_range_is_none() {
2721            let mut data = b"ttcf".to_vec();
2722            data.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]); // version 1.0
2723            data.extend_from_slice(&1u32.to_be_bytes()); // numFonts = 1
2724            data.extend_from_slice(&0u32.to_be_bytes()); // offset[0]
2725
2726            assert!(ManualTableProvider::new(&data, 0).is_some());
2727            // index >= numFonts short-circuits *before* the `12 + font_index * 4`
2728            // arithmetic, so even usize::MAX cannot overflow it
2729            assert!(ManualTableProvider::new(&data, 1).is_none());
2730            assert!(ManualTableProvider::new(&data, 999_999).is_none());
2731            assert!(ManualTableProvider::new(&data, usize::MAX).is_none());
2732        }
2733
2734        #[test]
2735        fn manual_table_provider_ttc_offset_past_end_is_none() {
2736            let mut data = b"ttcf".to_vec();
2737            data.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
2738            data.extend_from_slice(&1u32.to_be_bytes());
2739            data.extend_from_slice(&u32::MAX.to_be_bytes()); // offset[0] = 4 GiB
2740            assert!(ManualTableProvider::new(&data, 0).is_none());
2741        }
2742
2743        #[test]
2744        fn manual_table_provider_table_record_past_end_yields_none() {
2745            // One record whose (offset, length) points past the buffer: the
2746            // checked_add + bounds filter must turn it into None, not an OOB slice.
2747            let mut data = vec![0u8; 12 + 16];
2748            data[0..4].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // sfnt version
2749            data[4..6].copy_from_slice(&1u16.to_be_bytes()); // numTables = 1
2750            data[12..16].copy_from_slice(&tag::HEAD.to_be_bytes()); // tag
2751            data[20..24].copy_from_slice(&u32::MAX.to_be_bytes()); // offset = 4 GiB
2752            data[24..28].copy_from_slice(&u32::MAX.to_be_bytes()); // length = 4 GiB
2753            let p = ManualTableProvider::new(&data, 0).unwrap();
2754            assert!(p.table_data(tag::HEAD).unwrap().is_none());
2755            assert!(!p.has_table(tag::HEAD));
2756        }
2757
2758        #[test]
2759        fn manual_table_provider_one_megabyte_of_zeros_terminates() {
2760            let data = vec![0u8; 1_000_000];
2761            let p = ManualTableProvider::new(&data, 0).expect("zeros form a degenerate header");
2762            assert_eq!(p.num, 0); // numTables = 0: nothing to scan
2763            assert!(p.table_data(tag::HEAD).unwrap().is_none());
2764            assert_eq!(p.sfnt_version(), 0);
2765        }
2766
2767        #[test]
2768        fn manual_table_provider_reads_a_real_font() {
2769            let p = ManualTableProvider::new(MOCK_MONO, 0).expect("a real TTF must produce a provider");
2770            assert_eq!(p.sfnt_version(), 0x0001_0000);
2771            assert_eq!(p.num, 13);
2772            assert!(p.has_table(tag::HEAD));
2773            assert!(p.has_table(tag::GLYF) && p.has_table(tag::LOCA));
2774            assert!(!p.has_table(tag::CFF)); // Azul Mock Mono is TrueType, not OpenType-PostScript
2775
2776            let head = p.table_data(tag::HEAD).unwrap().expect("head must be present");
2777            // head.magicNumber sits at offset 12 and is fixed by the spec
2778            assert_eq!(manual_be32(head.as_ref(), 12), 0x5F0F_3CF5);
2779
2780            let tags = p.table_tags().unwrap();
2781            assert_eq!(tags[0], 0x0000_FADE); // diagnostic sentinel
2782            assert_eq!(tags.len(), 14); // sentinel + 13 records
2783            assert!(tags.contains(&tag::GLYF));
2784        }
2785
2786        // ---------------------------------------------------------------
2787        // monotonic_now_nanos
2788        // ---------------------------------------------------------------
2789
2790        #[test]
2791        fn monotonic_now_nanos_never_goes_backwards() {
2792            let a = monotonic_now_nanos();
2793            let b = monotonic_now_nanos();
2794            assert!(b >= a, "clock went backwards: {a} -> {b}");
2795            // the `as u64` truncation cannot wrap in any realistic process lifetime
2796            assert!(b < 60 * 60 * 1_000_000_000);
2797        }
2798
2799        // ---------------------------------------------------------------
2800        // GlyphOutlineCollector
2801        // ---------------------------------------------------------------
2802
2803        #[test]
2804        fn glyph_outline_collector_new_is_empty() {
2805            assert!(GlyphOutlineCollector::new().into_outlines().is_empty());
2806        }
2807
2808        #[test]
2809        fn glyph_outline_collector_flushes_an_unclosed_contour() {
2810            let mut c = GlyphOutlineCollector::new();
2811            c.move_to(Vector2F::new(1.0, 2.0));
2812            c.line_to(Vector2F::new(3.0, 4.0));
2813            // no close(): into_outlines must still flush the pending contour
2814            let outlines = c.into_outlines();
2815            assert_eq!(outlines.len(), 1);
2816            assert_eq!(outlines[0].operations.as_slice().len(), 2);
2817        }
2818
2819        #[test]
2820        fn glyph_outline_collector_splits_contours_on_move_to() {
2821            let mut c = GlyphOutlineCollector::new();
2822            c.move_to(Vector2F::new(0.0, 0.0));
2823            c.line_to(Vector2F::new(10.0, 0.0));
2824            c.close();
2825            // close() already flushed, so this move_to must not emit an empty contour
2826            c.move_to(Vector2F::new(0.0, 0.0));
2827            c.quadratic_curve_to(Vector2F::new(1.0, 1.0), Vector2F::new(2.0, 2.0));
2828            c.cubic_curve_to(
2829                LineSegment2F::new(Vector2F::new(1.0, 1.0), Vector2F::new(2.0, 2.0)),
2830                Vector2F::new(3.0, 3.0),
2831            );
2832            let outlines = c.into_outlines();
2833            assert_eq!(outlines.len(), 2);
2834            assert_eq!(outlines[0].operations.as_slice().len(), 3); // move, line, close
2835            assert_eq!(outlines[1].operations.as_slice().len(), 3); // move, quad, cubic
2836        }
2837
2838        #[test]
2839        fn glyph_outline_collector_saturates_nan_and_infinite_coordinates() {
2840            // allsorts hands us f32s; the `as i16` casts in the sink saturate
2841            // (NaN -> 0, ±inf -> i16::MAX/MIN) rather than wrapping or trapping.
2842            let mut c = GlyphOutlineCollector::new();
2843            c.move_to(Vector2F::new(f32::NAN, f32::INFINITY));
2844            c.line_to(Vector2F::new(f32::NEG_INFINITY, 1e30));
2845            let outlines = c.into_outlines();
2846            let ops = outlines[0].operations.as_slice();
2847            if let GlyphOutlineOperation::MoveTo(m) = &ops[0] {
2848                assert_eq!(m.x, 0); // NaN -> 0
2849                assert_eq!(m.y, i16::MAX); // +inf saturates
2850            } else {
2851                panic!("first op must be a MoveTo");
2852            }
2853            if let GlyphOutlineOperation::LineTo(l) = &ops[1] {
2854                assert_eq!(l.x, i16::MIN); // -inf saturates
2855                assert_eq!(l.y, i16::MAX); // 1e30 saturates
2856            } else {
2857                panic!("second op must be a LineTo");
2858            }
2859        }
2860
2861        // ---------------------------------------------------------------
2862        // compute_outline_bbox
2863        // ---------------------------------------------------------------
2864
2865        #[test]
2866        fn compute_outline_bbox_without_points_is_zero_not_the_sentinel_seed() {
2867            assert_eq!(compute_outline_bbox(&[]), (0, 0, 0, 0));
2868            let only_close = GlyphOutline {
2869                operations: vec![GlyphOutlineOperation::ClosePath].into(),
2870            };
2871            // ClosePath contributes no points: must not leak the
2872            // (i16::MAX, i16::MAX, i16::MIN, i16::MIN) accumulator seed
2873            assert_eq!(
2874                compute_outline_bbox(std::slice::from_ref(&only_close)),
2875                (0, 0, 0, 0)
2876            );
2877        }
2878
2879        #[test]
2880        fn compute_outline_bbox_covers_control_points_and_i16_extremes() {
2881            let outline = GlyphOutline {
2882                operations: vec![
2883                    GlyphOutlineOperation::MoveTo(OutlineMoveTo {
2884                        x: i16::MIN,
2885                        y: i16::MAX,
2886                    }),
2887                    GlyphOutlineOperation::LineTo(OutlineLineTo { x: 0, y: 0 }),
2888                    GlyphOutlineOperation::QuadraticCurveTo(OutlineQuadTo {
2889                        ctrl_1_x: 5,
2890                        ctrl_1_y: -5,
2891                        end_x: 6,
2892                        end_y: -6,
2893                    }),
2894                    GlyphOutlineOperation::CubicCurveTo(OutlineCubicTo {
2895                        ctrl_1_x: i16::MAX,
2896                        ctrl_1_y: i16::MIN,
2897                        ctrl_2_x: 0,
2898                        ctrl_2_y: 0,
2899                        end_x: 1,
2900                        end_y: 1,
2901                    }),
2902                    GlyphOutlineOperation::ClosePath,
2903                ]
2904                .into(),
2905            };
2906            // control points participate in the bbox, and the i16 extremes must not wrap
2907            assert_eq!(
2908                compute_outline_bbox(std::slice::from_ref(&outline)),
2909                (i16::MIN, i16::MIN, i16::MAX, i16::MAX)
2910            );
2911        }
2912
2913        #[test]
2914        fn compute_outline_bbox_spans_every_contour() {
2915            let a = GlyphOutline {
2916                operations: vec![GlyphOutlineOperation::MoveTo(OutlineMoveTo { x: -10, y: -10 })]
2917                    .into(),
2918            };
2919            let b = GlyphOutline {
2920                operations: vec![GlyphOutlineOperation::LineTo(OutlineLineTo { x: 20, y: 30 })]
2921                    .into(),
2922            };
2923            assert_eq!(compute_outline_bbox(&[a, b]), (-10, -10, 20, 30));
2924        }
2925
2926        // ---------------------------------------------------------------
2927        // PdfFontMetrics
2928        // ---------------------------------------------------------------
2929
2930        #[test]
2931        fn pdf_font_metrics_zero_never_divides_by_zero() {
2932            let z = PdfFontMetrics::zero();
2933            assert_eq!(z.units_per_em, 1000); // callers divide by this: never 0
2934            assert_eq!(z.ascender, 0);
2935            assert_eq!(z.descender, 0);
2936            assert_eq!(z.line_gap, 0);
2937            assert_eq!(z.advance_width_max, 0);
2938            assert_eq!(z.us_weight_class, 0);
2939            assert_eq!(z.y_strikeout_position, 0);
2940            assert_eq!(PdfFontMetrics::default(), z); // Default is the neutral element
2941            let copied = z; // Copy: the zero value is trivially duplicable
2942            assert_eq!(copied, z);
2943        }
2944
2945        // ---------------------------------------------------------------
2946        // SubsetFont::subset_text
2947        // ---------------------------------------------------------------
2948
2949        #[test]
2950        fn subset_text_with_an_empty_mapping_drops_everything() {
2951            let f = SubsetFont {
2952                bytes: Vec::new(),
2953                glyph_mapping: BTreeMap::new(),
2954            };
2955            assert_eq!(f.subset_text(""), "");
2956            assert_eq!(f.subset_text("hello"), "");
2957            // emoji + combining mark: multibyte input must not panic or slice mid-char
2958            assert_eq!(f.subset_text("\u{1F600}e\u{0301}"), "");
2959        }
2960
2961        #[test]
2962        fn subset_text_remaps_chars_to_their_new_gids() {
2963            let mut m = BTreeMap::new();
2964            m.insert(40u16, (65u16, 'A')); // old gid 40 -> new gid 65 -> U+0041
2965            m.insert(41u16, (66u16, 'B'));
2966            let f = SubsetFont {
2967                bytes: Vec::new(),
2968                glyph_mapping: m,
2969            };
2970            assert_eq!(f.subset_text("AB"), "AB");
2971            assert_eq!(f.subset_text("BA"), "BA");
2972            assert_eq!(f.subset_text("A?B"), "AB"); // unmapped chars are dropped
2973        }
2974
2975        #[test]
2976        fn subset_text_gid_boundaries() {
2977            let mut m = BTreeMap::new();
2978            m.insert(1u16, (0u16, 'x')); // new gid 0 -> U+0000
2979            m.insert(2u16, (0xD800u16, 'y')); // surrogate: char::from_u32 -> None
2980            m.insert(3u16, (u16::MAX, 'z')); // U+FFFF is a valid scalar value
2981            let f = SubsetFont {
2982                bytes: Vec::new(),
2983                glyph_mapping: m,
2984            };
2985            assert_eq!(f.subset_text("x"), "\u{0}");
2986            assert_eq!(f.subset_text("y"), ""); // a surrogate gid is silently dropped
2987            assert_eq!(f.subset_text("z"), "\u{FFFF}");
2988            assert_eq!(f.subset_text("xyz"), "\u{0}\u{FFFF}");
2989        }
2990
2991        #[test]
2992        fn subset_text_long_input_terminates() {
2993            let mut m = BTreeMap::new();
2994            m.insert(7u16, (97u16, 'a'));
2995            let f = SubsetFont {
2996                bytes: Vec::new(),
2997                glyph_mapping: m,
2998            };
2999            assert_eq!(f.subset_text(&"a".repeat(100_000)).len(), 100_000);
3000        }
3001
3002        // ---------------------------------------------------------------
3003        // FontParseWarning
3004        // ---------------------------------------------------------------
3005
3006        #[test]
3007        fn font_parse_warning_constructors_preserve_severity_and_message() {
3008            let empty = FontParseWarning::info(String::new());
3009            assert_eq!(empty.severity, FontParseWarningSeverity::Info);
3010            assert!(empty.message.is_empty());
3011
3012            let unicode = FontParseWarning::warning("\u{1F4A5} e\u{0301}\u{202E}".to_string());
3013            assert_eq!(unicode.severity, FontParseWarningSeverity::Warning);
3014            assert_eq!(unicode.message, "\u{1F4A5} e\u{0301}\u{202E}");
3015
3016            let huge = FontParseWarning::error("x".repeat(1_000_000));
3017            assert_eq!(huge.severity, FontParseWarningSeverity::Error);
3018            assert_eq!(huge.message.len(), 1_000_000);
3019
3020            // severity is part of the identity: same message, different level
3021            assert_ne!(
3022                FontParseWarning::error("boom".to_string()),
3023                FontParseWarning::warning("boom".to_string())
3024            );
3025        }
3026
3027        // ---------------------------------------------------------------
3028        // MockFont (constructors)
3029        // ---------------------------------------------------------------
3030
3031        #[test]
3032        fn mock_font_new_defaults_and_extreme_metrics() {
3033            let m = MockFont::new(plain_metrics());
3034            assert_eq!(m.space_width, Some(10)); // documented default
3035            assert!(m.glyph_advances.is_empty());
3036            assert!(m.glyph_sizes.is_empty());
3037            assert!(m.glyph_indices.is_empty());
3038
3039            // metrics are stored verbatim: no normalisation, no panic on NaN/inf/0-upem
3040            let extreme = MockFont::new(LayoutFontMetrics {
3041                ascent: f32::INFINITY,
3042                descent: f32::NAN,
3043                line_gap: f32::MIN,
3044                units_per_em: 0,
3045                x_height: Some(f32::MAX),
3046                cap_height: None,
3047            });
3048            assert!(extreme.font_metrics.ascent.is_infinite());
3049            assert!(extreme.font_metrics.descent.is_nan());
3050            assert_eq!(extreme.font_metrics.units_per_em, 0);
3051            assert_eq!(extreme.space_width, Some(10));
3052        }
3053
3054        #[test]
3055        fn mock_font_builders_store_boundary_values() {
3056            let m = MockFont::new(plain_metrics())
3057                .with_space_width(usize::MAX)
3058                .with_glyph_advance(0, 0)
3059                .with_glyph_advance(u16::MAX, u16::MAX)
3060                .with_glyph_size(u16::MAX, (i32::MIN, i32::MAX))
3061                .with_glyph_index(0, 0)
3062                .with_glyph_index(u32::MAX, u16::MAX); // not a scalar value: stored anyway
3063
3064            assert_eq!(m.space_width, Some(usize::MAX));
3065            assert_eq!(m.glyph_advances.len(), 2);
3066            assert_eq!(m.glyph_advances.get(&u16::MAX), Some(&u16::MAX));
3067            assert_eq!(m.glyph_sizes.get(&u16::MAX), Some(&(i32::MIN, i32::MAX)));
3068            assert_eq!(m.glyph_indices.len(), 2);
3069            assert_eq!(m.glyph_indices.get(&u32::MAX), Some(&u16::MAX));
3070
3071            // last write wins, and an overwrite must not grow the map
3072            let m = m.with_glyph_advance(u16::MAX, 7).with_space_width(0);
3073            assert_eq!(m.glyph_advances.get(&u16::MAX), Some(&7));
3074            assert_eq!(m.glyph_advances.len(), 2);
3075            assert_eq!(m.space_width, Some(0));
3076        }
3077
3078        // ---------------------------------------------------------------
3079        // ParsedFont::from_bytes (parser)
3080        // ---------------------------------------------------------------
3081
3082        #[test]
3083        fn from_bytes_rejects_malformed_input() {
3084            let cases: Vec<(&str, Vec<u8>)> = vec![
3085                ("empty", Vec::new()),
3086                ("whitespace_only", b"   \t\n".to_vec()),
3087                ("garbage", (0u8..=255).cycle().take(4096).collect()),
3088                ("invalid_utf8", vec![0xFF, 0xFE, 0x00]),
3089                ("header_only", MOCK_MONO[..12].to_vec()),
3090                ("truncated_font", MOCK_MONO[..64].to_vec()),
3091                ("half_a_font", MOCK_MONO[..MOCK_MONO.len() / 2].to_vec()),
3092                ("one_megabyte_of_nuls", vec![0u8; 1_000_000]),
3093                ("ttcf_junk", {
3094                    let mut v = b"ttcf".to_vec();
3095                    v.extend_from_slice(&[0xABu8; 1024].repeat(1000));
3096                    v
3097                }),
3098            ];
3099            for (name, bytes) in cases {
3100                let mut warnings = Vec::new();
3101                assert!(
3102                    ParsedFont::from_bytes(&bytes, 0, &mut warnings).is_none(),
3103                    "{name} must not parse into a font"
3104                );
3105            }
3106        }
3107
3108        #[test]
3109        fn from_bytes_appends_a_warning_on_failure() {
3110            let mut warnings = Vec::new();
3111            assert!(ParsedFont::from_bytes(&[], 0, &mut warnings).is_none());
3112            assert!(!warnings.is_empty(), "a failed parse must explain itself");
3113            assert!(warnings
3114                .iter()
3115                .any(|w| w.severity == FontParseWarningSeverity::Error));
3116
3117            // the caller's vec is appended to, never cleared
3118            let before = warnings.len();
3119            assert!(ParsedFont::from_bytes(&[0xFF; 3], 0, &mut warnings).is_none());
3120            assert!(warnings.len() > before);
3121        }
3122
3123        #[test]
3124        fn from_bytes_parses_the_positive_control() {
3125            let font = parse_mock();
3126            assert_eq!(font.num_glyphs(), 96);
3127            assert_eq!(font.num_glyphs(), font.maxp_table.num_glyphs);
3128            assert_eq!(font.font_metrics.units_per_em, 1000);
3129            assert!(font.font_metrics.ascent > 0.0);
3130            assert_eq!(font.font_type, FontType::TrueType);
3131            assert_eq!(font.original_index, 0);
3132            assert_eq!(font.pdf_font_metrics.units_per_em, 1000);
3133            assert!(font.pdf_font_metrics.x_max > font.pdf_font_metrics.x_min);
3134            assert!(font.cmap_subtable.is_some());
3135            assert!(font.gsub_bytes.is_some() && font.gpos_bytes.is_some());
3136            assert!(font.hmtx_range.1 > 0, "hmtx must be located in the source");
3137            assert_eq!(font.vmtx_range, (0, 0), "Azul Mock Mono has no vertical metrics");
3138            assert!(!font.is_variable_font);
3139        }
3140
3141        #[test]
3142        fn from_bytes_with_an_out_of_range_font_index_is_deterministic() {
3143            let baseline = parse_mock();
3144            let mut warnings = Vec::new();
3145            // Azul Mock Mono is a single face, not a .ttc: the manual provider ignores the index
3146            // rather than rejecting it. What must NOT happen is a panic or a
3147            // `12 + font_index * 4` overflow.
3148            if let Some(font) = ParsedFont::from_bytes(MOCK_MONO, usize::MAX, &mut warnings) {
3149                assert_eq!(font.num_glyphs(), baseline.num_glyphs());
3150                assert_eq!(font.original_index, usize::MAX);
3151                assert_ne!(font.hash, baseline.hash, "font_index feeds the identity hash");
3152            }
3153        }
3154
3155        #[test]
3156        fn from_bytes_internal_deferred_flag_skips_the_eager_loca_glyf_load() {
3157            let mut warnings = Vec::new();
3158            let eager = ParsedFont::from_bytes_internal(MOCK_MONO, 0, &mut warnings, false)
3159                .expect("eager parse");
3160            assert!(matches!(eager.loca_glyf, LocaGlyfState::Loaded(Some(_))));
3161
3162            let deferred = ParsedFont::from_bytes_internal(MOCK_MONO, 0, &mut warnings, true)
3163                .expect("deferred parse");
3164            // deferring leaves the slot empty for from_bytes_shared to overwrite
3165            assert!(matches!(deferred.loca_glyf, LocaGlyfState::Loaded(None)));
3166            assert_eq!(eager.num_glyphs(), deferred.num_glyphs());
3167            assert_eq!(eager.hash, deferred.hash);
3168        }
3169
3170        // ---------------------------------------------------------------
3171        // cmap lookups (numeric / predicate)
3172        // ---------------------------------------------------------------
3173
3174        #[test]
3175        fn lookup_glyph_index_handles_codepoint_extremes() {
3176            let font = parse_mock();
3177            assert!(font.lookup_glyph_index('A' as u32).is_some());
3178
3179            // 0, the last valid scalar value, and values beyond the Unicode range must
3180            // all resolve deterministically without panicking
3181            for cp in [0u32, 0x0010_FFFF, 0x0011_0000, u32::MAX / 2, u32::MAX] {
3182                let first = font.lookup_glyph_index(cp);
3183                assert_eq!(first, font.lookup_glyph_index(cp), "cp {cp} must be stable");
3184                // has_glyph is defined as lookup_glyph_index().is_some()
3185                assert_eq!(first.is_some(), font.has_glyph(cp));
3186            }
3187            assert!(!font.has_glyph(0x0010_FFFF));
3188            assert!(!font.has_glyph(u32::MAX));
3189        }
3190
3191        // ---------------------------------------------------------------
3192        // advances (numeric)
3193        // ---------------------------------------------------------------
3194
3195        #[test]
3196        fn get_horizontal_advance_saturates_out_of_range_gids() {
3197            let font = parse_mock();
3198            let gid_a = font.lookup_glyph_index('A' as u32).expect("'A' is in Azul Mock Mono");
3199            assert!(font.get_horizontal_advance(gid_a) > 0);
3200
3201            // out-of-range gid: allsorts short-circuits to 0 rather than erroring
3202            assert!(font.num_glyphs() < u16::MAX);
3203            assert_eq!(font.get_horizontal_advance(font.num_glyphs()), 0);
3204            assert_eq!(font.get_horizontal_advance(u16::MAX), 0);
3205        }
3206
3207        #[test]
3208        fn get_glyph_width_internal_matches_hmtx_and_never_panics() {
3209            let font = parse_mock();
3210            let gid_a = font.lookup_glyph_index('A' as u32).unwrap();
3211            assert_eq!(
3212                font.get_glyph_width_internal(gid_a),
3213                Some(usize::from(font.get_horizontal_advance(gid_a)))
3214            );
3215            // an out-of-range gid yields Some(0) (allsorts returns Ok(0)), never None/panic
3216            assert_eq!(font.get_glyph_width_internal(u16::MAX), Some(0));
3217            assert_eq!(font.get_glyph_width_internal(font.num_glyphs()), Some(0));
3218        }
3219
3220        #[test]
3221        fn space_width_is_cached_at_parse_time() {
3222            let font = parse_mock();
3223            let space_gid = font
3224                .lookup_glyph_index(' ' as u32)
3225                .expect("Azul Mock Mono has a space glyph");
3226            let live = usize::from(font.get_horizontal_advance(space_gid));
3227            assert!(live > 0, "the live hmtx advance for space must be non-zero");
3228
3229            let cached = font
3230                .get_space_width()
3231                .expect("space_width is populated at parse time");
3232            // NOTE: `space_width` is computed inside `from_bytes_internal`, *before* the
3233            // source bytes are attached, so hmtx is unreadable at that point and the
3234            // cached value can legitimately read back 0 (see the comment there).
3235            assert!(
3236                cached == 0 || cached == live,
3237                "space_width must be 0 (stale) or the hmtx advance; got {cached} vs {live}"
3238            );
3239            assert_eq!(font.get_space_width(), font.space_width);
3240        }
3241
3242        #[test]
3243        fn get_hinted_advance_px_rejects_degenerate_inputs() {
3244            let font = parse_mock();
3245            let gid = font.lookup_glyph_index('A' as u32).unwrap();
3246
3247            // ppem 0 would divide by zero when computing the scale
3248            assert!(font.get_hinted_advance_px(gid, 0).is_none());
3249            // out-of-range gid is gated by get_or_decode_glyph
3250            assert!(font.get_hinted_advance_px(u16::MAX, 16).is_none());
3251            assert!(font.get_hinted_advance_px(font.num_glyphs(), 16).is_none());
3252
3253            // extreme ppem must not overflow the F26Dot6 fixed-point math; whatever
3254            // comes back is a finite, non-negative, whole-pixel value
3255            for ppem in [1u16, 16, 255, 4096, u16::MAX] {
3256                if let Some(px) = font.get_hinted_advance_px(gid, ppem) {
3257                    assert!(px.is_finite(), "ppem {ppem} produced {px}");
3258                    assert!(px >= 0.0, "ppem {ppem} produced {px}");
3259                    assert!(
3260                        (px - px.trunc()).abs() < f32::EPSILON,
3261                        "the advance is rounded to the whole-pixel grid, got {px}"
3262                    );
3263                }
3264            }
3265        }
3266
3267        // ---------------------------------------------------------------
3268        // glyph decode + lazy cache
3269        // ---------------------------------------------------------------
3270
3271        #[test]
3272        fn get_or_decode_glyph_bounds_and_cache_identity() {
3273            let font = parse_mock();
3274            assert!(font.num_glyphs() > 1);
3275
3276            // gid >= num_glyphs is refused before any decode is attempted
3277            assert!(font.get_or_decode_glyph(font.num_glyphs()).is_none());
3278            assert!(font.get_or_decode_glyph(u16::MAX).is_none());
3279
3280            let a = font.get_or_decode_glyph(0).expect(".notdef must decode");
3281            let b = font.get_or_decode_glyph(0).expect("the cache must hand it back");
3282            assert!(Arc::ptr_eq(&a, &b), "a second call must hit the cache");
3283
3284            let snap = font.glyph_cache_snapshot();
3285            assert!(Arc::ptr_eq(snap.get(&0).expect("gid 0 is cached"), &a));
3286            assert!(!snap.contains_key(&font.num_glyphs()));
3287        }
3288
3289        #[test]
3290        fn get_or_decode_glyph_stamps_the_lru_clock() {
3291            let font = parse_mock();
3292            assert_eq!(font.last_used_nanos(), 0, "an untouched face reports 0");
3293            let _ = font.get_or_decode_glyph(0);
3294            let t = font.last_used_nanos();
3295            assert!(t > 0, "decoding must stamp last_used");
3296            let _ = font.get_or_decode_glyph(1);
3297            assert!(font.last_used_nanos() >= t, "the stamp must not go backwards");
3298        }
3299
3300        #[test]
3301        fn prime_glyph_cache_decodes_every_glyph_and_is_idempotent() {
3302            let mut font = parse_mock();
3303            // the lazy cache starts empty (the space stub is skipped while the source
3304            // bytes are still unattached)
3305            assert!(font.glyph_cache_snapshot().is_empty());
3306
3307            font.prime_glyph_cache();
3308            let snap = font.glyph_cache_snapshot();
3309            assert_eq!(snap.len(), usize::from(font.num_glyphs()));
3310            assert!(snap.contains_key(&0));
3311            assert!(!snap.contains_key(&font.num_glyphs()), "never decodes past the end");
3312
3313            let mut seen = 0usize;
3314            let mut max_gid = 0u16;
3315            font.for_each_decoded_glyph(|gid, _| {
3316                seen += 1;
3317                max_gid = max_gid.max(gid);
3318            });
3319            assert_eq!(seen, snap.len());
3320            assert_eq!(max_gid, font.num_glyphs() - 1);
3321
3322            font.prime_glyph_cache(); // priming twice must not duplicate or grow
3323            assert_eq!(font.glyph_cache_snapshot().len(), snap.len());
3324        }
3325
3326        #[test]
3327        fn decode_glyph_inner_seeds_the_bbox_from_the_advance() {
3328            let font = parse_mock();
3329            let gid_a = font.lookup_glyph_index('A' as u32).unwrap();
3330            let g = font.decode_glyph_inner(gid_a);
3331            assert_eq!(g.horz_advance, font.get_horizontal_advance(gid_a));
3332            assert!(!g.outline.is_empty(), "'A' has a glyf outline");
3333            assert!(g.bounding_box.max_x > g.bounding_box.min_x);
3334            assert!(g.bounding_box.max_y > g.bounding_box.min_y);
3335            assert!(g.raw_points.is_some() && g.raw_on_curve.is_some());
3336            assert_eq!(
3337                g.raw_points.as_ref().map(Vec::len),
3338                g.raw_on_curve.as_ref().map(Vec::len),
3339                "one on-curve flag per raw point"
3340            );
3341
3342            // gid 0 (.notdef) decodes too, and the bbox stays inside i16
3343            let notdef = font.decode_glyph_inner(0);
3344            assert!(notdef.bounding_box.max_x >= notdef.bounding_box.min_x);
3345        }
3346
3347        #[test]
3348        fn get_glyph_bbox_size_bounds() {
3349            let font = parse_mock();
3350            let gid_a = font.lookup_glyph_index('A' as u32).unwrap();
3351            let (w, h) = font.get_glyph_bbox_size(gid_a).expect("'A' has a bbox");
3352            assert!(w > 0 && h > 0);
3353            assert_eq!(w, i32::from(font.get_horizontal_advance(gid_a)));
3354            // out-of-range gid -> None (not a zero-sized box)
3355            assert!(font.get_glyph_bbox_size(u16::MAX).is_none());
3356            assert!(font.get_glyph_bbox_size(font.num_glyphs()).is_none());
3357        }
3358
3359        #[test]
3360        fn get_vertical_metrics_without_vmtx_is_none() {
3361            let font = parse_mock();
3362            // Azul Mock Mono has neither vhea nor vmtx
3363            assert!(font.get_vertical_metrics(0).is_none());
3364            assert!(font.get_vertical_metrics(u16::MAX).is_none());
3365
3366            // vhea present but a zero-length vmtx range must still bail out
3367            let mut synthetic = synthetic_font(4, None);
3368            synthetic.vhea_table = Some(plain_hhea());
3369            assert_eq!(synthetic.vmtx_range.1, 0);
3370            assert!(synthetic.get_vertical_metrics(0).is_none());
3371        }
3372
3373        // ---------------------------------------------------------------
3374        // lazy loca/glyf: from_bytes_shared, eviction
3375        // ---------------------------------------------------------------
3376
3377        #[test]
3378        fn from_bytes_shared_defers_loca_glyf_and_can_evict() {
3379            let font = mock_shared();
3380            assert!(matches!(font.loca_glyf, LocaGlyfState::Deferred { .. }));
3381            assert!(font.source_bytes_for_subset().is_some());
3382
3383            // nothing is loaded until the first decode, so there is nothing to evict yet
3384            assert!(!font.evict_loca_glyf());
3385
3386            let g1 = font.get_or_decode_glyph(1).expect("gid 1 must decode");
3387            assert!(font.evict_loca_glyf(), "a loaded Deferred slot is evictable");
3388            assert!(!font.evict_loca_glyf(), "evicting twice is a no-op");
3389
3390            // after eviction the face still decodes: it re-parses from the retained bytes
3391            let g2 = font.get_or_decode_glyph(2).expect("gid 2 decodes after eviction");
3392            assert_eq!(g2.horz_advance, font.get_horizontal_advance(2));
3393            // and already-decoded glyphs still come from the glyph cache
3394            assert!(Arc::ptr_eq(&g1, &font.get_or_decode_glyph(1).unwrap()));
3395        }
3396
3397        #[test]
3398        fn eager_faces_are_not_evictable() {
3399            let font = parse_mock();
3400            assert!(matches!(font.loca_glyf, LocaGlyfState::Loaded(Some(_))));
3401            let _ = font.get_or_decode_glyph(0);
3402            // Loaded faces have no retained source bytes to re-parse from
3403            assert!(!font.evict_loca_glyf());
3404            assert!(font.resolve_loca_glyf().is_some());
3405        }
3406
3407        #[test]
3408        fn eager_and_deferred_paths_decode_identical_glyphs() {
3409            let eager = parse_mock();
3410            let lazy = mock_shared();
3411            assert_eq!(eager.num_glyphs(), lazy.num_glyphs());
3412            assert_eq!(eager.hash, lazy.hash); // same bytes + index -> same identity
3413            assert_eq!(eager, lazy); // PartialEq is hash-based
3414
3415            let gid = eager.lookup_glyph_index('A' as u32).unwrap();
3416            assert_eq!(lazy.lookup_glyph_index('A' as u32), Some(gid));
3417            let a = eager.get_or_decode_glyph(gid).unwrap();
3418            let b = lazy.get_or_decode_glyph(gid).unwrap();
3419            assert_eq!(a.horz_advance, b.horz_advance);
3420            assert_eq!(a.outline.len(), b.outline.len());
3421            assert_eq!(a.bounding_box.min_x, b.bounding_box.min_x);
3422            assert_eq!(a.bounding_box.min_y, b.bounding_box.min_y);
3423            assert_eq!(a.bounding_box.max_x, b.bounding_box.max_x);
3424            assert_eq!(a.bounding_box.max_y, b.bounding_box.max_y);
3425        }
3426
3427        #[test]
3428        fn with_source_bytes_shares_the_arc() {
3429            let font = parse_mock();
3430            // from_bytes retains an owned copy, so subsetting works without an
3431            // explicit with_source_bytes call
3432            let auto = font
3433                .source_bytes_for_subset()
3434                .expect("from_bytes retains the source bytes");
3435            assert_eq!(auto.as_slice(), MOCK_MONO);
3436
3437            let arc = Arc::new(rust_fontconfig::FontBytes::Owned(Arc::from(MOCK_MONO.to_vec())));
3438            let font = font.with_source_bytes(Arc::clone(&arc));
3439            let got = font.source_bytes_for_subset().unwrap();
3440            assert!(Arc::ptr_eq(&got, &arc), "attached bytes are shared, not copied");
3441        }
3442
3443        #[test]
3444        fn clone_shares_the_glyph_cache_and_drops_hinting() {
3445            let font = parse_mock();
3446            let g = font.get_or_decode_glyph(0).unwrap();
3447            let clone = font.clone();
3448            assert_eq!(clone, font);
3449
3450            // the decode cache is Arc-shared, so the clone sees the decoded glyph...
3451            assert!(Arc::ptr_eq(&clone.get_or_decode_glyph(0).unwrap(), &g));
3452            // ...and decodes through the clone are visible from the original
3453            let _ = clone.get_or_decode_glyph(1);
3454            assert!(font.glyph_cache_snapshot().contains_key(&1));
3455            // HintInstance is not Clone: it is deliberately dropped
3456            assert!(clone.hint_instance.is_none());
3457        }
3458
3459        #[test]
3460        fn gsub_and_gpos_are_memoised() {
3461            let font = parse_mock();
3462            assert!(font.gsub_bytes.is_some(), "Azul Mock Mono ships a GSUB table");
3463            assert!(font.gpos_bytes.is_some(), "Azul Mock Mono ships a GPOS table");
3464
3465            match (font.gsub(), font.gsub()) {
3466                (Some(a), Some(b)) => assert!(Arc::ptr_eq(a, b), "gsub() must be memoised"),
3467                (None, None) => {}
3468                _ => panic!("gsub() must be deterministic across calls"),
3469            }
3470            match (font.gpos(), font.gpos()) {
3471                (Some(a), Some(b)) => assert!(Arc::ptr_eq(a, b), "gpos() must be memoised"),
3472                (None, None) => {}
3473                _ => panic!("gpos() must be deterministic across calls"),
3474            }
3475        }
3476
3477        // ---------------------------------------------------------------
3478        // to_bytes / subset (round-trip)
3479        // ---------------------------------------------------------------
3480
3481        #[test]
3482        fn to_bytes_round_trips_through_from_bytes() {
3483            let font = parse_mock();
3484            let rebuilt = font
3485                .to_bytes(None)
3486                .expect("from_bytes retains source bytes, so to_bytes must succeed");
3487            assert!(rebuilt.len() > 12);
3488
3489            let mut warnings = Vec::new();
3490            let re = ParsedFont::from_bytes(&rebuilt, 0, &mut warnings)
3491                .expect("a font emitted by to_bytes must parse back");
3492            assert_eq!(re.num_glyphs(), font.num_glyphs());
3493            assert_eq!(re.font_metrics.units_per_em, font.font_metrics.units_per_em);
3494            assert_eq!(re.pdf_font_metrics.x_min, font.pdf_font_metrics.x_min);
3495            assert_eq!(re.pdf_font_metrics.y_max, font.pdf_font_metrics.y_max);
3496
3497            let gid = font.lookup_glyph_index('A' as u32).unwrap();
3498            assert_eq!(
3499                re.lookup_glyph_index('A' as u32),
3500                Some(gid),
3501                "the cmap must survive the round-trip"
3502            );
3503            assert_eq!(
3504                re.get_horizontal_advance(gid),
3505                font.get_horizontal_advance(gid),
3506                "hmtx must survive the round-trip"
3507            );
3508        }
3509
3510        #[test]
3511        fn to_bytes_with_an_absent_tag_errors_instead_of_panicking() {
3512            let font = parse_mock();
3513            // Azul Mock Mono has no CFF table: asking for it must surface an Err string
3514            assert!(font.to_bytes(Some(&[tag::CFF])).is_err());
3515            // an empty tag list still reads head+maxp internally: it must return, not panic
3516            drop(font.to_bytes(Some(&[])));
3517            // duplicate tags must not double-insert into the builder and blow up
3518            drop(font.to_bytes(Some(&[tag::HEAD, tag::HEAD, tag::MAXP])));
3519        }
3520
3521        #[test]
3522        fn subset_maps_glyph_ids_and_produces_a_parseable_font() {
3523            let font = parse_mock();
3524            let gid_a = font.lookup_glyph_index('A' as u32).unwrap();
3525            let gid_b = font.lookup_glyph_index('B' as u32).unwrap();
3526            let (bytes, mapping) = font
3527                .subset(&[(0, '\0'), (gid_a, 'A'), (gid_b, 'B')], CmapTarget::Unrestricted)
3528                .expect("subsetting a TrueType font must succeed");
3529
3530            // the mapping is positional: original gid -> (index in the request, char)
3531            assert_eq!(mapping.len(), 3);
3532            assert_eq!(mapping.get(&0), Some(&(0, '\0')));
3533            assert_eq!(mapping.get(&gid_a), Some(&(1, 'A')));
3534            assert_eq!(mapping.get(&gid_b), Some(&(2, 'B')));
3535
3536            let mut warnings = Vec::new();
3537            let sub =
3538                ParsedFont::from_bytes(&bytes, 0, &mut warnings).expect("the subset must re-parse");
3539            assert!(sub.num_glyphs() >= 3);
3540            assert!(sub.num_glyphs() <= font.num_glyphs());
3541            assert_eq!(
3542                sub.get_horizontal_advance(1),
3543                font.get_horizontal_advance(gid_a),
3544                "the remapped 'A' keeps its advance"
3545            );
3546        }
3547
3548        #[test]
3549        fn subset_edge_inputs_do_not_panic() {
3550            let font = parse_mock();
3551            // an empty glyph list may be accepted or rejected; it must not panic
3552            if let Ok((_, mapping)) = font.subset(&[], CmapTarget::Unrestricted) {
3553                assert!(mapping.is_empty());
3554            }
3555            // an out-of-range gid must come back as a Result, never an OOB read
3556            drop(font.subset(&[(0, '\0'), (u16::MAX, 'x')], CmapTarget::Unrestricted));
3557
3558            // duplicate gids collapse in the returned map: the later entry wins
3559            let gid_a = font.lookup_glyph_index('A' as u32).unwrap();
3560            if let Ok((_, mapping)) = font.subset(
3561                &[(0, '\0'), (gid_a, 'a'), (gid_a, 'b')],
3562                CmapTarget::Unrestricted,
3563            ) {
3564                assert_eq!(mapping.len(), 2);
3565                assert_eq!(mapping.get(&gid_a), Some(&(2, 'b')));
3566            }
3567        }
3568
3569        // ---------------------------------------------------------------
3570        // empty / extreme instances (getters, predicates)
3571        // ---------------------------------------------------------------
3572
3573        #[test]
3574        fn every_getter_survives_an_empty_font() {
3575            let font = synthetic_font(0, None);
3576            assert_eq!(font.num_glyphs(), 0);
3577            assert!(font.get_or_decode_glyph(0).is_none()); // 0 >= num_glyphs
3578            assert!(font.get_or_decode_glyph(u16::MAX).is_none());
3579            assert!(font.get_glyph_bbox_size(0).is_none());
3580            assert!(font.lookup_glyph_index('A' as u32).is_none()); // no cmap
3581            assert!(!font.has_glyph('A' as u32));
3582            assert!(!font.has_glyph(0));
3583            assert_eq!(font.get_horizontal_advance(0), 0);
3584            assert_eq!(font.get_horizontal_advance(u16::MAX), 0);
3585            assert_eq!(font.get_glyph_width_internal(0), Some(0));
3586            assert!(font.get_vertical_metrics(0).is_none());
3587            assert!(font.get_space_width().is_none());
3588            assert!(font.get_space_width_internal().is_none());
3589            assert!(font.glyph_cache_snapshot().is_empty());
3590            assert!(font.gsub().is_none() && font.gpos().is_none());
3591            assert!(font.resolve_loca_glyf().is_none());
3592            assert!(font.source_bytes_for_subset().is_none());
3593            assert_eq!(font.last_used_nanos(), 0);
3594            assert!(!font.evict_loca_glyf());
3595            assert!(font.hmtx_bytes().is_empty());
3596            assert!(font.vmtx_bytes().is_empty());
3597            assert!(font.get_hinted_advance_px(0, 16).is_none());
3598            font.for_each_decoded_glyph(|_, _| panic!("nothing has been decoded"));
3599
3600            // without source bytes, the PDF paths must report an error, not panic
3601            assert!(font.to_bytes(None).is_err());
3602            assert!(font.subset(&[], CmapTarget::Unrestricted).is_err());
3603        }
3604
3605        #[test]
3606        fn hmtx_bytes_without_source_bytes_is_empty_not_a_panic() {
3607            let mut font = synthetic_font(4, None);
3608            // ranges pointing into bytes we never retained must degrade to an empty
3609            // slice rather than unwrapping a None
3610            font.hmtx_range = (16, 32);
3611            font.vmtx_range = (48, 64);
3612            assert!(font.hmtx_bytes().is_empty());
3613            assert!(font.vmtx_bytes().is_empty());
3614            assert_eq!(font.get_horizontal_advance(0), 0);
3615        }
3616
3617        #[test]
3618        fn hmtx_bytes_slices_the_retained_source() {
3619            let mut font = synthetic_font(2, None);
3620            let raw: Vec<u8> = (0u8..100).collect();
3621            font.original_bytes = Some(Arc::new(rust_fontconfig::FontBytes::Owned(Arc::from(raw))));
3622            font.hmtx_range = (10, 4);
3623            font.vmtx_range = (0, 0);
3624            assert_eq!(font.hmtx_bytes(), [10u8, 11, 12, 13].as_slice());
3625            assert!(font.vmtx_bytes().is_empty()); // a zero-length range short-circuits
3626        }
3627
3628        #[test]
3629        fn mock_backed_font_overrides_advances_and_space_width() {
3630            let mock = MockFont::new(plain_metrics())
3631                .with_space_width(42)
3632                .with_glyph_advance(1, 500);
3633            let font = synthetic_font(4, Some(Box::new(mock)));
3634
3635            assert_eq!(font.get_horizontal_advance(1), 500); // the mock wins over hmtx
3636            assert_eq!(font.get_horizontal_advance(3), 0); // unmapped gid -> 0, no panic
3637            assert_eq!(font.get_horizontal_advance(u16::MAX), 0);
3638            assert_eq!(font.get_space_width_internal(), Some(42));
3639
3640            // the decoded record inherits the mock advance and, with no outline,
3641            // seeds its bbox from it
3642            let g = font.get_or_decode_glyph(1).expect("gid 1 < num_glyphs");
3643            assert_eq!(g.horz_advance, 500);
3644            assert_eq!(g.bounding_box.max_x, 500);
3645            assert!(g.outline.is_empty());
3646            assert_eq!(font.get_glyph_bbox_size(1), Some((500, 0)));
3647        }
3648
3649        #[test]
3650        fn get_font_metrics_normalises_the_descent_sign() {
3651            let mut font = synthetic_font(1, None);
3652
3653            font.font_metrics.descent = -200.0;
3654            assert_eq!(font.get_font_metrics().descent, 200.0);
3655            font.font_metrics.descent = 200.0;
3656            assert_eq!(font.get_font_metrics().descent, 200.0);
3657
3658            // -0.0 is not > 0.0, so it takes the negation branch and comes out +0.0
3659            font.font_metrics.descent = -0.0;
3660            assert!(font.get_font_metrics().descent.is_sign_positive());
3661
3662            font.font_metrics.descent = f32::NEG_INFINITY;
3663            assert_eq!(font.get_font_metrics().descent, f32::INFINITY);
3664
3665            // NaN compares false against 0.0, so it is negated — and stays NaN
3666            font.font_metrics.descent = f32::NAN;
3667            assert!(font.get_font_metrics().descent.is_nan());
3668
3669            // every other field passes through untouched
3670            font.font_metrics.descent = -10.0;
3671            font.font_metrics.ascent = f32::MAX;
3672            font.font_metrics.line_gap = -1.0;
3673            let m = font.get_font_metrics();
3674            assert_eq!(m.ascent, f32::MAX);
3675            assert_eq!(m.line_gap, -1.0);
3676            assert_eq!(m.units_per_em, font.font_metrics.units_per_em);
3677        }
3678
3679        #[test]
3680        fn real_font_metrics_have_a_non_negative_descent() {
3681            let font = parse_mock();
3682            let m = font.get_font_metrics();
3683            assert!(m.descent >= 0.0, "get_font_metrics flips the descent sign");
3684            assert_eq!(m.descent, font.font_metrics.descent.abs());
3685            assert_eq!(m.ascent, font.font_metrics.ascent);
3686            assert_eq!(m.units_per_em, 1000);
3687        }
3688
3689        // ---------------------------------------------------------------
3690        // reverse glyph cache
3691        // ---------------------------------------------------------------
3692
3693        #[test]
3694        fn reverse_glyph_cache_round_trip_and_boundaries() {
3695            let mut font = synthetic_font(2, None);
3696            assert!(font.get_glyph_cluster_text(0).is_none());
3697            assert!(font.get_glyph_primary_char(0).is_none());
3698
3699            font.cache_glyph_mapping(0, ""); // empty cluster
3700            font.cache_glyph_mapping(u16::MAX, "fi"); // ligature at the gid boundary
3701            font.cache_glyph_mapping(7, "\u{1F468}\u{200D}\u{1F469}"); // ZWJ cluster
3702
3703            assert_eq!(font.get_glyph_cluster_text(0), Some(""));
3704            assert_eq!(font.get_glyph_primary_char(0), None); // no first char in ""
3705            assert_eq!(font.get_glyph_cluster_text(u16::MAX), Some("fi"));
3706            assert_eq!(font.get_glyph_primary_char(u16::MAX), Some('f'));
3707            assert_eq!(font.get_glyph_primary_char(7), Some('\u{1F468}'));
3708            assert!(font.get_glyph_cluster_text(1).is_none()); // never written
3709
3710            font.cache_glyph_mapping(u16::MAX, "ffi"); // last write wins
3711            assert_eq!(font.get_glyph_cluster_text(u16::MAX), Some("ffi"));
3712
3713            // clear_glyph_cache drops ONLY the reverse map, not the decoded outlines
3714            let decoded = font.get_or_decode_glyph(0).expect("gid 0 decodes");
3715            font.clear_glyph_cache();
3716            assert!(font.get_glyph_cluster_text(0).is_none());
3717            assert!(font.get_glyph_cluster_text(u16::MAX).is_none());
3718            assert!(font.get_glyph_primary_char(7).is_none());
3719            assert!(Arc::ptr_eq(
3720                font.glyph_cache_snapshot().get(&0).expect("outline cache is untouched"),
3721                &decoded
3722            ));
3723            font.clear_glyph_cache(); // idempotent
3724        }
3725
3726        // ---------------------------------------------------------------
3727        // loading / cpurender
3728        // ---------------------------------------------------------------
3729
3730        #[test]
3731        fn build_font_cache_does_not_panic() {
3732            // Smoke: the system font scan must complete. An empty cache is legitimate
3733            // (a headless image may ship no fonts), so only the call itself is asserted.
3734            let _cache = crate::font::loading::build_font_cache();
3735        }
3736
3737        #[cfg(feature = "cpurender")]
3738        #[test]
3739        fn build_glyph_path_needs_at_least_one_operation() {
3740            let empty = OwnedGlyph {
3741                bounding_box: OwnedGlyphBoundingBox {
3742                    max_x: 0,
3743                    max_y: 0,
3744                    min_x: 0,
3745                    min_y: 0,
3746                },
3747                horz_advance: 500,
3748                outline: Vec::new(),
3749                phantom_points: None,
3750                raw_points: None,
3751                raw_on_curve: None,
3752                raw_contour_ends: None,
3753                instructions: None,
3754            };
3755            // a space-like glyph has no ops -> None (the caller must skip it)
3756            assert!(build_glyph_path(&empty).is_none());
3757            // an outline whose only contour is empty is still "no ops"
3758            let hollow = OwnedGlyph {
3759                outline: vec![GlyphOutline {
3760                    operations: Vec::<GlyphOutlineOperation>::new().into(),
3761                }],
3762                ..empty.clone()
3763            };
3764            assert!(build_glyph_path(&hollow).is_none());
3765
3766            // every op kind, at the i16 extremes, must survive the f64 conversion
3767            let full = OwnedGlyph {
3768                outline: vec![GlyphOutline {
3769                    operations: vec![
3770                        GlyphOutlineOperation::MoveTo(OutlineMoveTo {
3771                            x: i16::MIN,
3772                            y: i16::MAX,
3773                        }),
3774                        GlyphOutlineOperation::LineTo(OutlineLineTo {
3775                            x: i16::MAX,
3776                            y: i16::MIN,
3777                        }),
3778                        GlyphOutlineOperation::QuadraticCurveTo(OutlineQuadTo {
3779                            ctrl_1_x: 0,
3780                            ctrl_1_y: 0,
3781                            end_x: 1,
3782                            end_y: 1,
3783                        }),
3784                        GlyphOutlineOperation::CubicCurveTo(OutlineCubicTo {
3785                            ctrl_1_x: 0,
3786                            ctrl_1_y: 0,
3787                            ctrl_2_x: 0,
3788                            ctrl_2_y: 0,
3789                            end_x: 2,
3790                            end_y: 2,
3791                        }),
3792                        GlyphOutlineOperation::ClosePath,
3793                    ]
3794                    .into(),
3795                }],
3796                ..empty
3797            };
3798            assert!(build_glyph_path(&full).is_some());
3799        }
3800    }
3801}