Skip to main content

azul_layout/text3/
default.rs

1//! Default / concrete implementations of the text3 trait abstractions.
2//!
3//! This module bridges the generic text3 layout engine and the concrete
4//! `FontRef` / `ParsedFont` types.  It provides:
5//!
6//! - `ParsedFontTrait` implementation for `FontRef`
7//! - Font loading via `PathLoader`
8//! - The core `shape_text_internal` shaping function
9
10use std::{path::Path, sync::Arc};
11
12use allsorts::{
13    gpos,
14    gsub::{self, Feature, FeatureInfo, FeatureMask, FeatureMaskExt},
15};
16use azul_core::geom::LogicalSize;
17use azul_css::props::basic::FontRef;
18
19use crate::{
20    font::parsed::ParsedFont,
21    text3::{
22        cache::{
23            BidiDirection, BidiLevel, FontManager, FontSelector, FontVariantCaps,
24            FontVariantLigatures, FontVariantNumeric, Glyph, GlyphOrientation, GlyphSource,
25            LayoutError, LayoutFontMetrics, ParsedFontTrait, Point, ShallowClone, StyleProperties,
26            TextCombineUpright, TextDecoration, TextOrientation, VerticalMetrics, WritingMode,
27        },
28        script::Script,
29    },
30};
31
32/// Creates a `FontRef` from font bytes by parsing them into a `ParsedFont`.
33///
34/// This is a bridge function that:
35///
36/// 1. Parses the bytes into a `ParsedFont`
37/// 2. Wraps it in a `FontRef` with proper reference counting
38///
39/// # Arguments
40///
41/// - `font_bytes` - The raw font file data
42/// - `font_index` - Index of the font in a font collection (0 for single fonts)
43/// - `parse_outlines` - Whether to parse glyph outlines (expensive, usually false for layout)
44#[must_use] pub fn font_ref_from_bytes(
45    font_bytes: &[u8],
46    font_index: usize,
47    parse_outlines: bool,
48) -> Option<FontRef> {
49    // Parse the font bytes into ParsedFont
50    let mut warnings = Vec::new();
51    let parsed_font = ParsedFont::from_bytes(font_bytes, font_index, &mut warnings)?;
52
53    Some(crate::parsed_font_to_font_ref(parsed_font))
54}
55
56/// A `FontLoader` that parses font data from a byte slice.
57///
58/// It is designed to be used in conjunction with a mechanism that reads font files
59/// from paths into memory. This loader simply handles the parsing aspect.
60#[derive(Copy, Debug, Default, Clone)]
61pub struct PathLoader;
62
63impl PathLoader {
64    /// Creates a new `PathLoader`.
65    #[must_use] pub const fn new() -> Self {
66        Self
67    }
68
69    /// Read a font from disk and parse via the lazy-LocaGlyf path.
70    /// Convenience wrapper for callers that have a path but no
71    /// `Arc<FontBytes>` yet — uses a heap read (`Owned`) since a
72    /// loose path won't go through the fontconfig dedup cache.
73    pub(crate) fn load_from_path(self, path: &Path, font_index: usize) -> Result<FontRef, LayoutError> {
74        let font_bytes = std::fs::read(path).map_err(|_| {
75            LayoutError::FontNotFound(FontSelector {
76                family: path.to_string_lossy().into_owned(),
77                weight: rust_fontconfig::FcWeight::Normal,
78                style: crate::text3::cache::FontStyle::Normal,
79                unicode_ranges: Vec::new(),
80            })
81        })?;
82        let arc_owned = Arc::<[u8]>::from(font_bytes);
83        let bytes = Arc::new(rust_fontconfig::FontBytes::Owned(arc_owned));
84        self.load_font_shared(bytes, font_index)
85    }
86
87    /// Lazy-friendly loader: takes an `Arc<FontBytes>` (typically
88    /// from [`rust_fontconfig::FcFontCache::get_font_bytes`]) and
89    /// uses the [`ParsedFont::from_bytes_shared`] constructor so
90    /// `LocaGlyf::load` is deferred until the first glyph decode.
91    ///
92    /// This is the only loader on the production path —
93    /// `load_fonts_from_disk` calls this via the closure passed
94    /// into `FontManager::load_missing_for_chains`. Fonts that
95    /// never get rasterized (common — every face of a `.ttc` gets a
96    /// `FontId`, but pages only hit a couple of them) skip their
97    /// per-face loca+glyf materialisation entirely; with
98    /// `FontBytes::Mmapped` the unread pages also never count
99    /// toward RSS.
100    /// # Errors
101    ///
102    /// Returns a `LayoutError` if the font cannot be loaded.
103    pub fn load_font_shared(
104        &self,
105        font_bytes: Arc<rust_fontconfig::FontBytes>,
106        font_index: usize,
107    ) -> Result<FontRef, LayoutError> {
108        let mut warnings = Vec::new();
109        let parsed_font = ParsedFont::from_bytes_shared(font_bytes, font_index, &mut warnings)
110            .ok_or_else(|| {
111                LayoutError::ShapingError("Failed to parse font with allsorts".to_string())
112            })?;
113        Ok(crate::parsed_font_to_font_ref(parsed_font))
114    }
115}
116
117impl FontManager<FontRef> {
118    /// Evict the cached `LocaGlyf` for every face that hasn't had a
119    /// `get_or_decode_glyph` call within the last `idle` duration.
120    /// Only `LocaGlyfState::Deferred` faces (the production lazy
121    /// path) can be evicted — they keep their source `Arc<[u8]>` so
122    /// the next glyph access re-parses cheaply. `LocaGlyfState::Loaded`
123    /// faces from the eager path stay put.
124    ///
125    /// Returns the number of faces evicted. Embedders can call this
126    /// from a memory-pressure hook or on a timer; servo-shot
127    /// exposes it via `--azul-evict-after-each` for measurement.
128    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
129    pub fn evict_unused(&self, idle: std::time::Duration) -> usize {
130        use crate::font::parsed::ParsedFont;
131        let Ok(parsed) = self.parsed_fonts.lock() else {
132            return 0;
133        };
134        // We compare against the same monotonic clock the font's
135        // `last_used` is sampled from. `last_used == 0` means
136        // "never touched" -> eligible. Otherwise we only evict if
137        // `now_nanos - last_used >= idle.as_nanos()`.
138        let cutoff = idle.as_nanos() as u64;
139        let now_nanos = crate::font::parsed::monotonic_now_nanos();
140        let mut evicted = 0usize;
141        for font_ref in parsed.values() {
142            let font: &ParsedFont = crate::font_ref_to_parsed_font(font_ref);
143            let last = font.last_used_nanos();
144            // Untouched faces are eligible immediately. Touched
145            // faces need to be `idle` past their last use.
146            let stale = last == 0 || now_nanos.saturating_sub(last) >= cutoff;
147            if stale && font.evict_loca_glyf() {
148                evicted += 1;
149            }
150        }
151        evicted
152    }
153}
154
155
156// ParsedFontTrait Implementation for FontRef
157
158// Implement ShallowClone for FontRef
159impl ShallowClone for FontRef {
160    fn shallow_clone(&self) -> Self {
161        // FontRef::clone increments the reference count
162        self.clone()
163    }
164}
165
166// Use crate::font_ref_to_parsed_font instead of a local duplicate
167
168impl ParsedFontTrait for FontRef {
169    // +spec:block-formatting-context:21ec9a - bidi direction handled during text shaping for vertical writing modes
170    fn shape_text(
171        &self,
172        text: &str,
173        script: Script,
174        language: crate::text3::script::Language,
175        direction: BidiDirection,
176        style: &StyleProperties,
177    ) -> Result<Vec<Glyph>, LayoutError> {
178        // Delegate to the inner ParsedFont's shape_text, passing self as font_ref
179        let parsed = crate::font_ref_to_parsed_font(self);
180        parsed.shape_text_for_font_ref(self, text, script, language, direction, style)
181    }
182
183    fn get_hash(&self) -> u64 {
184        crate::font_ref_to_parsed_font(self).hash
185    }
186
187    fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
188        crate::font_ref_to_parsed_font(self).get_glyph_size(glyph_id, font_size)
189    }
190
191    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
192        crate::font_ref_to_parsed_font(self).get_hyphen_glyph_and_advance(font_size)
193    }
194
195    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
196        crate::font_ref_to_parsed_font(self).get_kashida_glyph_and_advance(font_size)
197    }
198
199    fn has_glyph(&self, codepoint: u32) -> bool {
200        crate::font_ref_to_parsed_font(self).has_glyph(codepoint)
201    }
202
203    fn get_vertical_metrics(&self, glyph_id: u16) -> Option<VerticalMetrics> {
204        crate::font_ref_to_parsed_font(self).get_vertical_metrics(glyph_id)
205    }
206
207    fn get_font_metrics(&self) -> LayoutFontMetrics {
208        crate::font_ref_to_parsed_font(self).font_metrics
209    }
210
211    fn num_glyphs(&self) -> u16 {
212        crate::font_ref_to_parsed_font(self).num_glyphs
213    }
214
215    fn get_space_width(&self) -> Option<usize> {
216        crate::font_ref_to_parsed_font(self).get_space_width()
217    }
218}
219
220/// Extension trait for `FontRef` to provide access to font bytes and metrics
221///
222/// This trait provides methods that require access to the inner `ParsedFont` data.
223pub trait FontRefExt {
224    /// Get the original font bytes. Returns an empty slice when the
225    /// underlying `ParsedFont` was created without retaining its
226    /// source bytes (the default since the lazy-font-loading refactor).
227    /// Callers that need the bytes for PDF embedding must construct
228    /// the `ParsedFont` via `ParsedFont::with_source_bytes`.
229    fn get_bytes(&self) -> &[u8];
230    /// Get the full font metrics (PDF-style metrics from HEAD, HHEA, OS/2 tables)
231    fn get_full_font_metrics(&self) -> azul_css::props::basic::FontMetrics;
232}
233
234impl FontRefExt for FontRef {
235    fn get_bytes(&self) -> &[u8] {
236        crate::font_ref_to_parsed_font(self)
237            .original_bytes
238            .as_ref()
239            .map_or(&[], |b| b.as_slice())
240    }
241
242    fn get_full_font_metrics(&self) -> azul_css::props::basic::FontMetrics {
243        use azul_css::{OptionI16, OptionU16, OptionU32};
244
245        let parsed = crate::font_ref_to_parsed_font(self);
246        let pdf = &parsed.pdf_font_metrics;
247
248        // PdfFontMetrics only has a subset of fields; fill others with defaults
249        azul_css::props::basic::FontMetrics {
250            // OS/2 version 1 fields (u32 - align 4, placed first)
251            ul_code_page_range1: OptionU32::None,
252            ul_code_page_range2: OptionU32::None,
253
254            // OS/2 table (u32 fields)
255            ul_unicode_range1: 0,   // Not in PdfFontMetrics
256            ul_unicode_range2: 0,   // Not in PdfFontMetrics
257            ul_unicode_range3: 0,   // Not in PdfFontMetrics
258            ul_unicode_range4: 0,   // Not in PdfFontMetrics
259            ach_vend_id: 0,         // Not in PdfFontMetrics
260
261            // OS/2 version 0 fields (optional)
262            s_typo_ascender: OptionI16::None,
263            s_typo_descender: OptionI16::None,
264            s_typo_line_gap: OptionI16::None,
265            us_win_ascent: OptionU16::None,
266            us_win_descent: OptionU16::None,
267
268            // OS/2 version 2 fields (optional)
269            sx_height: OptionI16::None,
270            s_cap_height: OptionI16::None,
271            us_default_char: OptionU16::None,
272            us_break_char: OptionU16::None,
273            us_max_context: OptionU16::None,
274
275            // OS/2 version 3 fields (optional)
276            us_lower_optical_point_size: OptionU16::None,
277            us_upper_optical_point_size: OptionU16::None,
278
279            // HEAD table fields
280            units_per_em: pdf.units_per_em,
281            font_flags: pdf.font_flags,
282            x_min: pdf.x_min,
283            y_min: pdf.y_min,
284            x_max: pdf.x_max,
285            y_max: pdf.y_max,
286
287            // HHEA table fields
288            ascender: pdf.ascender,
289            descender: pdf.descender,
290            line_gap: pdf.line_gap,
291            advance_width_max: pdf.advance_width_max,
292            min_left_side_bearing: 0,  // Not in PdfFontMetrics
293            min_right_side_bearing: 0, // Not in PdfFontMetrics
294            x_max_extent: 0,           // Not in PdfFontMetrics
295            caret_slope_rise: pdf.caret_slope_rise,
296            caret_slope_run: pdf.caret_slope_run,
297            caret_offset: 0,  // Not in PdfFontMetrics
298            num_h_metrics: 0, // Not in PdfFontMetrics
299
300            // OS/2 table fields
301            x_avg_char_width: pdf.x_avg_char_width,
302            us_weight_class: pdf.us_weight_class,
303            us_width_class: pdf.us_width_class,
304            fs_type: 0,                // Not in PdfFontMetrics
305            y_subscript_x_size: 0,     // Not in PdfFontMetrics
306            y_subscript_y_size: 0,     // Not in PdfFontMetrics
307            y_subscript_x_offset: 0,   // Not in PdfFontMetrics
308            y_subscript_y_offset: 0,   // Not in PdfFontMetrics
309            y_superscript_x_size: 0,   // Not in PdfFontMetrics
310            y_superscript_y_size: 0,   // Not in PdfFontMetrics
311            y_superscript_x_offset: 0, // Not in PdfFontMetrics
312            y_superscript_y_offset: 0, // Not in PdfFontMetrics
313            y_strikeout_size: pdf.y_strikeout_size,
314            y_strikeout_position: pdf.y_strikeout_position,
315            s_family_class: 0, // Not in PdfFontMetrics
316            fs_selection: 0,        // Not in PdfFontMetrics
317            us_first_char_index: 0, // Not in PdfFontMetrics
318            us_last_char_index: 0,  // Not in PdfFontMetrics
319
320            // Panose (align 1 - last)
321            panose: azul_css::props::basic::Panose::zero(),
322        }
323    }
324}
325
326// ParsedFont helper method for FontRef
327//
328// This allows ParsedFont to create glyphs that use FontRef
329//
330// FontRef is just a C-style Arc wrapper around ParsedFont, so we delegate to
331// the common shaping implementation and convert the font reference type.
332
333impl ParsedFont {
334    /// Internal helper that shapes text and returns Glyph
335    /// Delegates to `shape_text_internal` and converts the font reference.
336    fn shape_text_for_font_ref(
337        &self,
338        _font_ref: &FontRef,
339        text: &str,
340        script: Script,
341        language: crate::text3::script::Language,
342        direction: BidiDirection,
343        style: &StyleProperties,
344    ) -> Result<Vec<Glyph>, LayoutError> {
345        // `shape_text_internal` already stamps each glyph with `font_hash`
346        // and `font_metrics` derived from `self`, which is the same
347        // `ParsedFont` backing `_font_ref`, so no per-glyph rewrite is needed.
348        shape_text_internal(self, text, script, language, direction, style)
349    }
350
351    const fn get_hash(&self) -> u64 {
352        self.hash
353    }
354
355    fn get_glyph_size(&self, glyph_id: u16, font_size_px: f32) -> Option<LogicalSize> {
356        self.get_or_decode_glyph(glyph_id).map(|record| {
357            let units_per_em = f32::from(self.font_metrics.units_per_em);
358            let scale_factor = if units_per_em > 0.0 {
359                font_size_px / units_per_em
360            } else {
361                FALLBACK_SCALE
362            };
363
364            // max_x, max_y, min_x, min_y in font units
365            let bbox = &record.bounding_box;
366
367            LogicalSize {
368                width: f32::from(bbox.max_x - bbox.min_x) * scale_factor,
369                height: f32::from(bbox.max_y - bbox.min_y) * scale_factor,
370            }
371        })
372    }
373
374    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
375        let glyph_id = self.lookup_glyph_index('-' as u32)?;
376        let advance_units = self.get_horizontal_advance(glyph_id);
377        let scale_factor = if self.font_metrics.units_per_em > 0 {
378            font_size / f32::from(self.font_metrics.units_per_em)
379        } else {
380            return None;
381        };
382        let scaled_advance = f32::from(advance_units) * scale_factor;
383        Some((glyph_id, scaled_advance))
384    }
385
386    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
387        // U+0640 is the Arabic Tatweel character, used for kashida justification.
388        let glyph_id = self.lookup_glyph_index('\u{0640}' as u32)?;
389        let advance_units = self.get_horizontal_advance(glyph_id);
390        let scale_factor = if self.font_metrics.units_per_em > 0 {
391            font_size / f32::from(self.font_metrics.units_per_em)
392        } else {
393            return None;
394        };
395        let scaled_advance = f32::from(advance_units) * scale_factor;
396        Some((glyph_id, scaled_advance))
397    }
398}
399
400/// Fallback scale factor when `units_per_em` is zero (corrupt/broken font).
401const FALLBACK_SCALE: f32 = 0.01;
402
403// Helper Functions
404
405/// Builds a `FeatureMask` with the appropriate OpenType features for a given script.
406/// This ensures proper text shaping for complex scripts like Arabic, Devanagari, etc.
407///
408/// The function includes:
409/// - Common features for all scripts (ligatures, contextual alternates, etc.)
410/// - Script-specific features (positional forms for Arabic, conjuncts for Indic, etc.)
411///
412/// This is designed to be stable and explicit - we control exactly which features
413/// are enabled rather than relying on allsorts' defaults which may change.
414#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
415fn build_feature_mask_for_script(script: Script) -> FeatureMask {
416    use Script::{Arabic, Devanagari, Bengali, Gujarati, Gurmukhi, Kannada, Malayalam, Oriya, Tamil, Telugu, Myanmar, Khmer, Thai, Hebrew, Hangul, Ethiopic, Latin, Greek, Cyrillic, Georgian, Hiragana, Katakana, Mandarin, Sinhala};
417
418    // Start with common features that apply to most scripts
419    let mut mask = FeatureMask::default_mask(); // Includes: CALT, CCMP, CLIG, LIGA, LOCL, RLIG
420
421    // Add script-specific features
422    match script {
423        // Arabic and related scripts - require positional forms
424        Arabic => {
425            mask |= Feature::INIT; // Initial forms (at start of word)
426            mask |= Feature::MEDI; // Medial forms (middle of word)
427            mask |= Feature::FINA; // Final forms (end of word)
428            mask |= Feature::ISOL; // Isolated forms (standalone)
429                                       // Note: RLIG (required ligatures) already in default for
430                                       // lam-alef ligatures
431        }
432
433        // Indic scripts - require complex conjunct formation and reordering
434        Devanagari | Bengali | Gujarati | Gurmukhi | Kannada | Malayalam | Oriya | Tamil
435        | Telugu => {
436            mask |= Feature::NUKT; // Nukta forms
437            mask |= Feature::AKHN; // Akhand ligatures
438            mask |= Feature::RPHF; // Reph form
439            mask |= Feature::RKRF; // Rakar form
440            mask |= Feature::PREF; // Pre-base forms
441            mask |= Feature::BLWF; // Below-base forms
442            mask |= Feature::ABVF; // Above-base forms
443            mask |= Feature::HALF; // Half forms
444            mask |= Feature::PSTF; // Post-base forms
445            mask |= Feature::VATU; // Vattu variants
446            mask |= Feature::CJCT; // Conjunct forms
447        }
448
449        // Myanmar (Burmese) - has complex reordering
450        Myanmar => {
451            mask |= Feature::PREF; // Pre-base forms
452            mask |= Feature::BLWF; // Below-base forms
453            mask |= Feature::PSTF; // Post-base forms
454        }
455
456        // Khmer - has complex reordering and stacking
457        Khmer => {
458            mask |= Feature::PREF; // Pre-base forms
459            mask |= Feature::BLWF; // Below-base forms
460            mask |= Feature::ABVF; // Above-base forms
461            mask |= Feature::PSTF; // Post-base forms
462        }
463
464        // Thai - has tone marks and vowel reordering
465        Thai => {
466            // Thai mostly uses default features, but may have some special marks
467            // The default mask is sufficient for most Thai fonts
468        }
469
470        // Hebrew - may have contextual forms but less complex than Arabic
471        Hebrew => {
472            // Hebrew fonts may use contextual alternates already in default
473            // Some fonts have special features but they're rare
474        }
475
476        // Hangul (Korean) - has complex syllable composition
477        Hangul => {
478            // Note: Hangul jamo features (LJMO, VJMO, TJMO) are not available in allsorts'
479            // FeatureMask Most modern Hangul fonts work correctly with the default
480            // features as syllable composition is usually handled at a lower level
481        }
482
483        // Ethiopic - has syllabic script with some ligatures
484        Ethiopic => {
485            // Default features are usually sufficient
486            // LIGA and CLIG already in default mask
487        }
488
489        // Latin, Greek, Cyrillic - standard features are sufficient
490        Latin | Greek | Cyrillic => {
491            // Default mask includes all needed features:
492            // - LIGA: standard ligatures (fi, fl, etc.)
493            // - CLIG: contextual ligatures
494            // - CALT: contextual alternates
495            // - CCMP: mark composition
496        }
497
498        // Georgian - uses standard features
499        Georgian => {
500            // Default features sufficient
501        }
502
503        // CJK scripts (Hiragana, Katakana, Mandarin/Hani)
504        Hiragana | Katakana | Mandarin => {
505            // CJK fonts may use vertical alternates, but those are controlled
506            // by writing-mode, not GSUB features in the horizontal direction.
507            // Default features are sufficient.
508        }
509
510        // Sinhala - Indic-derived but simpler
511        Sinhala => {
512            mask |= Feature::AKHN; // Akhand ligatures
513            mask |= Feature::RPHF; // Reph form
514            mask |= Feature::VATU; // Vattu variants
515        }
516    }
517
518    mask
519}
520
521/// Maps the layout engine's `Script` enum to an OpenType script tag `u32`.
522#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
523const fn to_opentype_script_tag(script: Script) -> u32 {
524    use Script::{Arabic, Bengali, Cyrillic, Devanagari, Ethiopic, Georgian, Greek, Gujarati, Gurmukhi, Hangul, Hebrew, Hiragana, Kannada, Katakana, Khmer, Latin, Malayalam, Mandarin, Myanmar, Oriya, Sinhala, Tamil, Telugu, Thai};
525    // Tags from https://docs.microsoft.com/en-us/typography/opentype/spec/scripttags
526    match script {
527        Arabic => u32::from_be_bytes(*b"arab"),
528        Bengali => u32::from_be_bytes(*b"beng"),
529        Cyrillic => u32::from_be_bytes(*b"cyrl"),
530        Devanagari => u32::from_be_bytes(*b"deva"),
531        Ethiopic => u32::from_be_bytes(*b"ethi"),
532        Georgian => u32::from_be_bytes(*b"geor"),
533        Greek => u32::from_be_bytes(*b"grek"),
534        Gujarati => u32::from_be_bytes(*b"gujr"),
535        Gurmukhi => u32::from_be_bytes(*b"guru"),
536        Hangul => u32::from_be_bytes(*b"hang"),
537        Hebrew => u32::from_be_bytes(*b"hebr"),
538        // OpenType does not define a separate Hiragana script tag;
539        // both Hiragana and Katakana intentionally use "kana".
540        Hiragana => u32::from_be_bytes(*b"kana"),
541        Kannada => u32::from_be_bytes(*b"knda"),
542        Katakana => u32::from_be_bytes(*b"kana"),
543        Khmer => u32::from_be_bytes(*b"khmr"),
544        Latin => u32::from_be_bytes(*b"latn"),
545        Malayalam => u32::from_be_bytes(*b"mlym"),
546        Mandarin => u32::from_be_bytes(*b"hani"),
547        Myanmar => u32::from_be_bytes(*b"mymr"),
548        Oriya => u32::from_be_bytes(*b"orya"),
549        Sinhala => u32::from_be_bytes(*b"sinh"),
550        Tamil => u32::from_be_bytes(*b"taml"),
551        Telugu => u32::from_be_bytes(*b"telu"),
552        Thai => u32::from_be_bytes(*b"thai"),
553    }
554}
555
556/// Parses a CSS-style font-feature-settings string like `"liga"`, `"liga=0"`, or `"ss01"`.
557/// Returns an OpenType tag and a value.
558fn parse_font_feature(feature_str: &str) -> Option<(u32, u32)> {
559    let mut parts = feature_str.split('=');
560    let tag_str = parts.next()?.trim();
561    let value_str = parts.next().unwrap_or("1").trim(); // Default to 1 (on) if no value
562
563    // OpenType feature tags must be 4 characters long.
564    if tag_str.len() > 4 {
565        return None;
566    }
567    // Pad with spaces if necessary
568    let padded_tag_str = format!("{tag_str:<4}");
569
570    let tag = u32::from_be_bytes(padded_tag_str.as_bytes().try_into().ok()?);
571    let value = value_str.parse::<u32>().ok()?;
572
573    Some((tag, value))
574}
575
576/// A helper to add OpenType features based on CSS `font-variant-*` properties.
577fn add_variant_features(style: &StyleProperties, features: &mut Vec<FeatureInfo>) {
578    // Helper to add a feature that is simply "on".
579    let mut add_on = |tag_str: &[u8; 4]| {
580        features.push(FeatureInfo {
581            feature_tag: u32::from_be_bytes(*tag_str),
582            alternate: None,
583        });
584    };
585
586    // Note on disabling features: The CSS properties `font-variant-ligatures: none` or
587    // `no-common-ligatures` are meant to disable features that may be on by default for a
588    // given script. The `allsorts` API for applying custom features is additive and does not
589    // currently support disabling default features. This implementation only handles enabling
590    // non-default features.
591
592    // Ligatures
593    match style.font_variant_ligatures {
594        FontVariantLigatures::Discretionary => add_on(b"dlig"),
595        FontVariantLigatures::Historical => add_on(b"hlig"),
596        FontVariantLigatures::Contextual => add_on(b"calt"),
597        _ => {} // Other cases are either default-on or require disabling.
598    }
599
600    // Caps
601    match style.font_variant_caps {
602        FontVariantCaps::SmallCaps => add_on(b"smcp"),
603        FontVariantCaps::AllSmallCaps => {
604            add_on(b"c2sc");
605            add_on(b"smcp");
606        }
607        FontVariantCaps::PetiteCaps => add_on(b"pcap"),
608        FontVariantCaps::AllPetiteCaps => {
609            add_on(b"c2pc");
610            add_on(b"pcap");
611        }
612        FontVariantCaps::Unicase => add_on(b"unic"),
613        FontVariantCaps::TitlingCaps => add_on(b"titl"),
614        FontVariantCaps::Normal => {}
615    }
616
617    // Numeric
618    match style.font_variant_numeric {
619        FontVariantNumeric::LiningNums => add_on(b"lnum"),
620        FontVariantNumeric::OldstyleNums => add_on(b"onum"),
621        FontVariantNumeric::ProportionalNums => add_on(b"pnum"),
622        FontVariantNumeric::TabularNums => add_on(b"tnum"),
623        FontVariantNumeric::DiagonalFractions => add_on(b"frac"),
624        FontVariantNumeric::StackedFractions => add_on(b"afrc"),
625        FontVariantNumeric::Ordinal => add_on(b"ordn"),
626        FontVariantNumeric::SlashedZero => add_on(b"zero"),
627        FontVariantNumeric::Normal => {}
628    }
629}
630
631/// Maps the `hyphenation::Language` enum to an OpenType language tag `u32`.
632#[cfg(feature = "text_layout_hyphenation")]
633#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
634const fn to_opentype_lang_tag(lang: hyphenation::Language) -> u32 {
635    use hyphenation::Language::{Afrikaans, Albanian, Armenian, Assamese, Basque, Belarusian, Bengali, Bulgarian, Catalan, Chinese, Coptic, Croatian, Czech, Danish, Dutch, EnglishGB, EnglishUS, Esperanto, Estonian, Ethiopic, Finnish, FinnishScholastic, French, Friulan, Galician, Georgian, German1901, German1996, GermanSwiss, GreekAncient, GreekMono, GreekPoly, Gujarati, Hindi, Hungarian, Icelandic, Indonesian, Interlingua, Irish, Italian, Kannada, Kurmanji, Latin, LatinClassic, LatinLiturgical, Latvian, Lithuanian, Macedonian, Malayalam, Marathi, Mongolian, NorwegianBokmal, NorwegianNynorsk, Occitan, Oriya, Pali, Panjabi, Piedmontese, Polish, Portuguese, Romanian, Romansh, Russian, Sanskrit, SerbianCyrillic, SerbocroatianCyrillic, SerbocroatianLatin, SlavonicChurch, Slovak, Slovenian, Spanish, Swedish, Tamil, Telugu, Thai, Turkish, Turkmen, Ukrainian, Uppersorbian, Welsh};
636    // A complete list of language tags can be found at:
637    // https://docs.microsoft.com/en-us/typography/opentype/spec/languagetags
638    let tag_bytes = match lang {
639        Afrikaans => *b"AFK ",
640        Albanian => *b"SQI ",
641        Armenian => *b"HYE ",
642        Assamese => *b"ASM ",
643        Basque => *b"EUQ ",
644        Belarusian => *b"BEL ",
645        Bengali => *b"BEN ",
646        Bulgarian => *b"BGR ",
647        Catalan => *b"CAT ",
648        Chinese => *b"ZHS ",
649        Coptic => *b"COP ",
650        Croatian => *b"HRV ",
651        Czech => *b"CSY ",
652        Danish => *b"DAN ",
653        Dutch => *b"NLD ",
654        EnglishGB => *b"ENG ",
655        EnglishUS => *b"ENU ",
656        Esperanto => *b"ESP ",
657        Estonian => *b"ETI ",
658        Ethiopic => *b"ETH ",
659        Finnish => *b"FIN ",
660        FinnishScholastic => *b"FIN ",
661        French => *b"FRA ",
662        Friulan => *b"FRL ",
663        Galician => *b"GLC ",
664        Georgian => *b"KAT ",
665        German1901 => *b"DEU ",
666        German1996 => *b"DEU ",
667        GermanSwiss => *b"DES ",
668        GreekAncient => *b"GRC ",
669        GreekMono => *b"ELL ",
670        GreekPoly => *b"ELL ",
671        Gujarati => *b"GUJ ",
672        Hindi => *b"HIN ",
673        Hungarian => *b"HUN ",
674        Icelandic => *b"ISL ",
675        Indonesian => *b"IND ",
676        Interlingua => *b"INA ",
677        Irish => *b"IRI ",
678        Italian => *b"ITA ",
679        Kannada => *b"KAN ",
680        Kurmanji => *b"KUR ",
681        Latin => *b"LAT ",
682        LatinClassic => *b"LAT ",
683        LatinLiturgical => *b"LAT ",
684        Latvian => *b"LVI ",
685        Lithuanian => *b"LTH ",
686        Macedonian => *b"MKD ",
687        Malayalam => *b"MAL ",
688        Marathi => *b"MAR ",
689        Mongolian => *b"MNG ",
690        NorwegianBokmal => *b"NOR ",
691        NorwegianNynorsk => *b"NYN ",
692        Occitan => *b"OCI ",
693        Oriya => *b"ORI ",
694        Pali => *b"PLI ",
695        Panjabi => *b"PAN ",
696        Piedmontese => *b"PMS ",
697        Polish => *b"PLK ",
698        Portuguese => *b"PTG ",
699        Romanian => *b"ROM ",
700        Romansh => *b"RMC ",
701        Russian => *b"RUS ",
702        Sanskrit => *b"SAN ",
703        SerbianCyrillic => *b"SRB ",
704        SerbocroatianCyrillic => *b"SHC ",
705        SerbocroatianLatin => *b"SHL ",
706        SlavonicChurch => *b"CSL ",
707        Slovak => *b"SKY ",
708        Slovenian => *b"SLV ",
709        Spanish => *b"ESP ",
710        Swedish => *b"SVE ",
711        Tamil => *b"TAM ",
712        Telugu => *b"TEL ",
713        Thai => *b"THA ",
714        Turkish => *b"TRK ",
715        Turkmen => *b"TUK ",
716        Ukrainian => *b"UKR ",
717        Uppersorbian => *b"HSB ",
718        Welsh => *b"CYM ",
719    };
720    u32::from_be_bytes(tag_bytes)
721}
722
723/// Internal shaping implementation - the single source of truth for text shaping.
724/// Both `FontRef` and `ParsedFont` use this function.
725#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // bounded layout/render numeric cast
726#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
727#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
728fn shape_text_internal(
729    parsed_font: &ParsedFont,
730    text: &str,
731    script: Script,
732    language: crate::text3::script::Language,
733    direction: BidiDirection,
734    style: &StyleProperties,
735) -> Result<Vec<Glyph>, LayoutError> {
736    let script_tag = to_opentype_script_tag(script);
737    #[cfg(feature = "text_layout_hyphenation")]
738    let lang_tag = to_opentype_lang_tag(language);
739    #[cfg(not(feature = "text_layout_hyphenation"))]
740    let lang_tag = 0u32;
741
742    // +spec:text-alignment-spacing:4357e6 - non-zero letter-spacing should disable optional ligatures; allsorts API is additive-only so default liga cannot be disabled here
743    // +spec:text-alignment-spacing:24d624 - cursive script letter-spacing behavior is advisory (outside CSS scope per spec note)
744    let mut user_features: Vec<FeatureInfo> = style
745        .font_features
746        .iter()
747        .filter_map(|s| parse_font_feature(s))
748        .map(|(tag, value)| FeatureInfo {
749            feature_tag: tag,
750            alternate: if value > 1 {
751                Some(value as usize)
752            } else {
753                None
754            },
755        })
756        .collect();
757    add_variant_features(style, &mut user_features);
758
759    let opt_gdef = parsed_font.opt_gdef_table.as_deref();
760
761    let mut raw_glyphs: Vec<gsub::RawGlyph<()>> = Vec::new();
762    {
763        let mut ci = 0usize;
764        while ci < text.len() {
765            let Some(ch) = text[ci..].chars().next() else {
766                break;
767            };
768            let glyph_index = parsed_font.lookup_glyph_index(ch as u32).unwrap_or(0);
769            // NOTE: `liga_component_pos` MUST be left at allsorts' managed default (0)
770            // here. It is a GPOS ligature-COMPONENT index, and mark-to-mark /
771            // mark-to-ligature attachment (gpos::forall_mark_mark_glyph_pairs,
772            // gpos::markligpos) is gated on equality of this field between glyphs.
773            // Overloading it with the source byte offset (as an earlier version did)
774            // made every glyph carry a distinct value, silently disabling mkmk stacking
775            // and mis-selecting ligature-component anchors. Source byte offsets are
776            // instead reconstructed after shaping from each glyph's `unicodes` (see the
777            // read-back loop below), which also removes the old u16 byte-offset cap that
778            // dropped every glyph past byte 65535.
779            raw_glyphs.push(gsub::RawGlyph {
780                unicodes: tinyvec::tiny_vec![[char; 1] => ch],
781                glyph_index,
782                liga_component_pos: 0,
783                glyph_origin: gsub::GlyphOrigin::Char(ch),
784                flags: gsub::RawGlyphFlags::empty(),
785                extra_data: (),
786                variation: None,
787            });
788            ci += ch.len_utf8();
789        }
790    }
791
792    if let Some(gsub) = parsed_font.gsub() {
793        // Always start from the script's default feature mask (LIGA, CLIG, CALT,
794        // CCMP, LOCL, RLIG + script-specific shaping features) and additively layer
795        // any user-supplied features on top. gsub::apply applies the mask AND the
796        // custom features, so these are NOT mutually exclusive. The previous code
797        // replaced the mask with `empty()` whenever ANY font-feature/font-variant
798        // was set, which silently disabled default ligatures/contextual-alternates
799        // for Latin-family text (ScriptType::Default only re-adds CCMP|RLIG|LOCL).
800        let feature_mask = build_feature_mask_for_script(script);
801        let custom_features: &[FeatureInfo] = user_features.as_slice();
802
803        let dotted_circle_index = parsed_font
804            .lookup_glyph_index(allsorts::DOTTED_CIRCLE as u32)
805            .unwrap_or(0);
806        gsub::apply(
807            dotted_circle_index,
808            gsub,
809            opt_gdef,
810            script_tag,
811            Some(lang_tag),
812            feature_mask,
813            custom_features,
814            None,
815            parsed_font.num_glyphs(),
816            &mut raw_glyphs,
817        )
818        .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
819    }
820
821    let mut infos = gpos::Info::init_from_glyphs(opt_gdef, raw_glyphs);
822
823    if let Some(gpos) = parsed_font.gpos() {
824        let kern_table = parsed_font
825            .opt_kern_table
826            .as_ref()
827            .map(|kt| kt.as_borrowed());
828        let apply_kerning = true; // Always enable GPOS kern feature (not just when legacy kern table exists)
829        gpos::apply(
830            gpos,
831            opt_gdef,
832            kern_table,
833            apply_kerning,
834            FeatureMask::empty(),
835            &user_features,
836            None,
837            script_tag,
838            Some(lang_tag),
839            &mut infos,
840        )
841        .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
842    } else {
843        // No GPOS table: apply the legacy `kern` table (and fallback mark
844        // positioning) directly, so fonts that ship only a legacy `kern` table
845        // still kern — matching CoreText/HarfBuzz behavior. Without this,
846        // GPOS-less fonts got zero kerning.
847        let kern_table = parsed_font
848            .opt_kern_table
849            .as_ref()
850            .map(|kt| kt.as_borrowed());
851        gpos::apply_fallback(kern_table, script_tag, &mut infos)
852            .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
853    }
854
855    let font_size = style.font_size_px;
856    let scale_factor = if parsed_font.font_metrics.units_per_em > 0 {
857        font_size / f32::from(parsed_font.font_metrics.units_per_em)
858    } else {
859        FALLBACK_SCALE
860    };
861
862    let font_hash = parsed_font.get_hash();
863    let font_metrics = LayoutFontMetrics {
864        ascent: parsed_font.font_metrics.ascent,
865        descent: parsed_font.font_metrics.descent,
866        line_gap: parsed_font.font_metrics.line_gap,
867        units_per_em: parsed_font.font_metrics.units_per_em,
868        x_height: parsed_font.font_metrics.x_height,
869        cap_height: parsed_font.font_metrics.cap_height,
870    };
871    let style_arc = Arc::new(style.clone());
872    let bidi_level = BidiLevel::new(u8::from(direction.is_rtl()));
873
874    let mut shaped_glyphs = Vec::new();
875    // Reconstruct source byte spans by walking the source text in logical order,
876    // consuming each glyph's `unicodes`. This replaces the removed liga_component_pos
877    // byte-offset overload. A ligature glyph carries ALL of its component chars in
878    // `unicodes`, so its span covers every merged component (fixing the ligature
879    // logical_byte_len that previously reported only the first component's length).
880    // Multiple-substitution duplicates (MULTI_SUBST_DUP) and unicode-less inserted
881    // glyphs share the current cursor position and do not advance it.
882    let mut byte_cursor = 0usize;
883    for info in &infos {
884        let uni_len: usize = info.glyph.unicodes.iter().map(|c| c.len_utf8()).sum();
885        let (byte_index, byte_len) = if info.glyph.multi_subst_dup() || uni_len == 0 {
886            (byte_cursor.min(text.len()), 0)
887        } else {
888            let start = byte_cursor.min(text.len());
889            byte_cursor = (byte_cursor + uni_len).min(text.len());
890            (start, uni_len)
891        };
892        let cluster = byte_index as u32;
893        let source_char = info
894            .glyph
895            .unicodes
896            .first()
897            .copied()
898            .or_else(|| text.get(byte_index..).and_then(|s| s.chars().next()))
899            .unwrap_or('\u{FFFD}');
900
901        let base_advance = parsed_font.get_horizontal_advance(info.glyph.glyph_index);
902        // Use hinted advance width when available (matches FreeType/Chrome behavior).
903        // Hinting grid-fits at an INTEGER ppem, so rescale the result back to the exact
904        // (fractional) font size to keep the advance on the SAME size basis as the GPOS
905        // offsets and kerning below (which use the unrounded scale_factor). Otherwise a
906        // run at e.g. 14.4px would pair a 14px-basis advance with 14.4px-basis positioning.
907        let ppem = font_size.round().max(1.0) as u16;
908        let advance = parsed_font
909            .get_hinted_advance_px(info.glyph.glyph_index, ppem)
910            .map_or_else(
911                || f32::from(base_advance) * scale_factor,
912                |hinted| hinted * font_size / f32::from(ppem),
913            );
914        let kerning = f32::from(info.kerning) * scale_factor;
915
916        let (offset_x_units, offset_y_units) =
917            if let gpos::Placement::Distance(x, y) = info.placement {
918                (x, y)
919            } else {
920                (0, 0)
921            };
922        let offset_x = offset_x_units as f32 * scale_factor;
923        let offset_y = offset_y_units as f32 * scale_factor;
924
925        let vert = parsed_font.get_vertical_metrics(info.glyph.glyph_index);
926        let glyph = Glyph {
927            glyph_id: info.glyph.glyph_index,
928            codepoint: source_char,
929            font_hash,
930            font_metrics,
931            style: Arc::clone(&style_arc),
932            source: GlyphSource::Char,
933            logical_byte_index: byte_index,
934            logical_byte_len: byte_len,
935            content_index: 0,
936            cluster,
937            advance,
938            kerning,
939            offset: Point {
940                x: offset_x,
941                y: offset_y,
942            },
943            vertical_advance: vert.as_ref().map_or(0.0, |v| v.advance * font_size),
944            vertical_origin_y: vert.as_ref().map_or(0.0, |v| v.origin_y * font_size),
945            vertical_bearing: vert
946                .map_or(Point { x: 0.0, y: 0.0 }, |v| Point { x: v.bearing_x * font_size, y: v.bearing_y * font_size }),
947            orientation: GlyphOrientation::Horizontal,
948            script,
949            bidi_level,
950        };
951        shaped_glyphs.push(glyph);
952    }
953
954    Ok(shaped_glyphs)
955}
956
957/// Public helper function to shape text for `ParsedFont`, returning Glyph
958/// This is used by the `ParsedFontTrait` implementation for `ParsedFont`
959/// # Errors
960///
961/// Returns a `LayoutError` if the text cannot be shaped.
962pub fn shape_text_for_parsed_font(
963    parsed_font: &ParsedFont,
964    text: &str,
965    script: Script,
966    language: crate::text3::script::Language,
967    direction: BidiDirection,
968    style: &StyleProperties,
969) -> Result<Vec<Glyph>, LayoutError> {
970    // Delegate to the single internal implementation
971    shape_text_internal(parsed_font, text, script, language, direction, style)
972}
973
974#[cfg(test)]
975#[allow(clippy::float_cmp, clippy::cast_lossless, clippy::unreadable_literal)]
976mod autotest_generated {
977    use std::time::Duration;
978
979    use rust_fontconfig::{FcFontCache, FontBytes, FontId};
980
981    use super::*;
982    use crate::text3::script::Language;
983
984    /// Positive control: the built-in `Azul Mock Mono` TrueType face.
985    const MOCK_MONO: &[u8] = crate::text3::mock_fonts::MOCK_MONO_TTF;
986
987    /// Every `Script` variant, so the exhaustive mapping tables below can never
988    /// silently miss one.
989    const ALL_SCRIPTS: [Script; 24] = [
990        Script::Arabic,
991        Script::Bengali,
992        Script::Cyrillic,
993        Script::Devanagari,
994        Script::Ethiopic,
995        Script::Georgian,
996        Script::Greek,
997        Script::Gujarati,
998        Script::Gurmukhi,
999        Script::Hangul,
1000        Script::Hebrew,
1001        Script::Hiragana,
1002        Script::Kannada,
1003        Script::Katakana,
1004        Script::Khmer,
1005        Script::Latin,
1006        Script::Malayalam,
1007        Script::Mandarin,
1008        Script::Myanmar,
1009        Script::Oriya,
1010        Script::Sinhala,
1011        Script::Tamil,
1012        Script::Telugu,
1013        Script::Thai,
1014    ];
1015
1016    /// Eager parse (`LocaGlyfState::Loaded`).
1017    fn mock() -> ParsedFont {
1018        let mut warnings = Vec::new();
1019        ParsedFont::from_bytes(MOCK_MONO, 0, &mut warnings).expect("Azul Mock Mono must parse")
1020    }
1021
1022    /// Lazy parse (`LocaGlyfState::Deferred`) — the production path.
1023    fn mock_deferred() -> ParsedFont {
1024        let bytes = Arc::new(FontBytes::Owned(Arc::from(MOCK_MONO.to_vec())));
1025        let mut warnings = Vec::new();
1026        ParsedFont::from_bytes_shared(bytes, 0, &mut warnings)
1027            .expect("from_bytes_shared must parse the positive control")
1028    }
1029
1030    fn style_at(font_size_px: f32) -> StyleProperties {
1031        StyleProperties {
1032            font_size_px,
1033            ..StyleProperties::default()
1034        }
1035    }
1036
1037    fn shape(font: &ParsedFont, text: &str) -> Result<Vec<Glyph>, LayoutError> {
1038        shape_text_internal(
1039            font,
1040            text,
1041            Script::Latin,
1042            Language::EnglishUS,
1043            BidiDirection::Ltr,
1044            &style_at(16.0),
1045        )
1046    }
1047
1048    /// Invariants that must hold for *any* shaping result, no matter how hostile
1049    /// the input: byte spans stay inside the source, land on char boundaries and
1050    /// never run backwards.
1051    fn assert_spans_are_sane(glyphs: &[Glyph], text: &str) {
1052        let mut prev_index = 0usize;
1053        for g in glyphs {
1054            assert!(
1055                g.logical_byte_index <= text.len(),
1056                "byte index {} escapes the {}-byte source",
1057                g.logical_byte_index,
1058                text.len()
1059            );
1060            let end = g.logical_byte_index + g.logical_byte_len;
1061            assert!(
1062                end <= text.len(),
1063                "span {}..{end} escapes the {}-byte source",
1064                g.logical_byte_index,
1065                text.len()
1066            );
1067            assert!(
1068                text.is_char_boundary(g.logical_byte_index) && text.is_char_boundary(end),
1069                "span {}..{end} splits a UTF-8 sequence",
1070                g.logical_byte_index
1071            );
1072            assert!(
1073                g.logical_byte_index >= prev_index,
1074                "byte cursor ran backwards: {} after {prev_index}",
1075                g.logical_byte_index
1076            );
1077            prev_index = g.logical_byte_index;
1078            assert_eq!(
1079                u64::from(g.cluster),
1080                g.logical_byte_index as u64,
1081                "cluster must mirror the logical byte index"
1082            );
1083        }
1084    }
1085
1086    // -----------------------------------------------------------------
1087    // font_ref_from_bytes (parser)
1088    // -----------------------------------------------------------------
1089
1090    #[test]
1091    fn font_ref_from_bytes_rejects_empty_and_malformed_input() {
1092        // empty / whitespace-only / invalid-UTF-8 / garbage: None, never a panic
1093        assert!(font_ref_from_bytes(b"", 0, false).is_none());
1094        assert!(font_ref_from_bytes(b"   \t\n", 0, false).is_none());
1095        assert!(font_ref_from_bytes(&[0xFF, 0xFE, 0x00], 0, false).is_none());
1096        assert!(font_ref_from_bytes(b"not a font at all, just prose", 0, false).is_none());
1097        // a 4-byte "sfnt-ish" header with nothing behind it
1098        assert!(font_ref_from_bytes(&[0x00, 0x01, 0x00, 0x00], 0, false).is_none());
1099        // truncated real font: header only, then a torso
1100        assert!(font_ref_from_bytes(&MOCK_MONO[..12], 0, false).is_none());
1101        assert!(font_ref_from_bytes(&MOCK_MONO[..64], 0, false).is_none());
1102        // leading junk in front of an otherwise valid font must not parse
1103        let mut prefixed = vec![0xABu8; 32];
1104        prefixed.extend_from_slice(MOCK_MONO);
1105        assert!(font_ref_from_bytes(&prefixed, 0, false).is_none());
1106    }
1107
1108    #[test]
1109    fn font_ref_from_bytes_extremely_long_garbage_terminates() {
1110        // 1 MiB of NULs must be rejected without hanging or allocating wildly
1111        assert!(font_ref_from_bytes(&vec![0u8; 1_000_000], 0, false).is_none());
1112        // a "ttcf" collection header followed by a megabyte of noise
1113        let mut ttc_junk = b"ttcf".to_vec();
1114        ttc_junk.extend_from_slice(&vec![0xCDu8; 1_000_000]);
1115        assert!(font_ref_from_bytes(&ttc_junk, 0, false).is_none());
1116    }
1117
1118    #[test]
1119    fn font_ref_from_bytes_valid_minimal_positive_control() {
1120        let font_ref =
1121            font_ref_from_bytes(MOCK_MONO, 0, false).expect("Azul Mock Mono must parse into a FontRef");
1122        assert!(font_ref.num_glyphs() > 0, "a real font has glyphs");
1123        assert_eq!(font_ref.get_hash(), mock().get_hash());
1124        assert!(font_ref.has_glyph('a' as u32));
1125    }
1126
1127    #[test]
1128    fn font_ref_from_bytes_ignores_the_parse_outlines_flag() {
1129        // NOTE: `parse_outlines` is accepted but never forwarded to
1130        // `ParsedFont::from_bytes` — both settings must therefore produce
1131        // an identical face. This pins the current (no-op) behaviour.
1132        let with = font_ref_from_bytes(MOCK_MONO, 0, true).expect("parse with outlines");
1133        let without = font_ref_from_bytes(MOCK_MONO, 0, false).expect("parse without outlines");
1134        assert_eq!(with.get_hash(), without.get_hash());
1135        assert_eq!(with.num_glyphs(), without.num_glyphs());
1136        assert_eq!(with.get_space_width(), without.get_space_width());
1137    }
1138
1139    #[test]
1140    fn font_ref_from_bytes_extreme_font_index_does_not_panic() {
1141        // Out-of-range collection indices must resolve to Some/None, never an
1142        // out-of-bounds index panic.
1143        for index in [0usize, 1, 255, usize::MAX / 2, usize::MAX] {
1144            let _ = font_ref_from_bytes(MOCK_MONO, index, false);
1145            let _ = font_ref_from_bytes(b"", index, false);
1146        }
1147    }
1148
1149    // -----------------------------------------------------------------
1150    // PathLoader::new / load_from_path / load_font_shared
1151    // -----------------------------------------------------------------
1152
1153    #[test]
1154    fn path_loader_new_is_a_zero_sized_stateless_handle() {
1155        assert_eq!(core::mem::size_of::<PathLoader>(), 0);
1156        let a = PathLoader::new();
1157        let b = PathLoader;
1158        // both handles behave identically: no hidden per-instance state
1159        assert!(a.load_from_path(Path::new("/nonexistent/azul/x.ttf"), 0).is_err());
1160        assert!(b.load_from_path(Path::new("/nonexistent/azul/x.ttf"), 0).is_err());
1161    }
1162
1163    #[test]
1164    fn load_from_path_missing_empty_and_directory_paths_are_font_not_found() {
1165        let loader = PathLoader::new();
1166        for path in [
1167            "",
1168            "/nonexistent/definitely/not/a/font.ttf",
1169            "/dev/null",
1170            env!("CARGO_MANIFEST_DIR"), // a directory, not a file
1171        ] {
1172            match loader.load_from_path(Path::new(path), 0) {
1173                Err(LayoutError::FontNotFound(selector)) => {
1174                    assert_eq!(selector.family, path, "the failing path is reported back");
1175                    assert!(selector.unicode_ranges.is_empty());
1176                }
1177                // /dev/null reads as zero bytes on Linux -> the parse fails instead
1178                Err(LayoutError::ShapingError(_)) => {}
1179                other => panic!("{path:?} must not load a font: {other:?}"),
1180            }
1181        }
1182    }
1183
1184    #[test]
1185    fn load_from_path_parses_a_real_font_and_survives_extreme_indices() {
1186        let loader = PathLoader::new();
1187        let path = concat!(
1188            env!("CARGO_MANIFEST_DIR"),
1189            "/assets/fonts/test/azul-mock-mono.ttf"
1190        );
1191        let font_ref = loader
1192            .load_from_path(Path::new(path), 0)
1193            .expect("the positive control must load from disk");
1194        assert_eq!(font_ref.num_glyphs(), mock().num_glyphs());
1195
1196        // 0 / MAX face index: either resolves or errors, but never panics
1197        for index in [0usize, 1, usize::MAX] {
1198            let _ = loader.load_from_path(Path::new(path), index);
1199        }
1200    }
1201
1202    #[test]
1203    fn load_font_shared_rejects_empty_and_garbage_byte_blobs() {
1204        let loader = PathLoader::new();
1205        let cases: Vec<Vec<u8>> = vec![
1206            Vec::new(),
1207            b"   \t\n".to_vec(),
1208            vec![0xFF, 0xFE, 0x00],
1209            MOCK_MONO[..32].to_vec(),
1210            vec![0u8; 1_000_000],
1211        ];
1212        for bytes in cases {
1213            let shared = Arc::new(FontBytes::Owned(Arc::from(bytes)));
1214            match loader.load_font_shared(shared, 0) {
1215                Err(LayoutError::ShapingError(msg)) => assert!(!msg.is_empty()),
1216                other => panic!("garbage must not parse: {other:?}"),
1217            }
1218        }
1219    }
1220
1221    #[test]
1222    fn load_font_shared_matches_the_eager_parse_and_tolerates_extreme_indices() {
1223        let loader = PathLoader::new();
1224        let shared = Arc::new(FontBytes::Owned(Arc::from(MOCK_MONO.to_vec())));
1225        let font_ref = loader
1226            .load_font_shared(Arc::clone(&shared), 0)
1227            .expect("the positive control must parse");
1228        let eager = mock();
1229        assert_eq!(font_ref.num_glyphs(), eager.num_glyphs());
1230        assert_eq!(font_ref.get_hash(), eager.get_hash());
1231
1232        // font_index at the numeric extremes must not index out of bounds
1233        for index in [0usize, 1, usize::MAX] {
1234            let _ = loader.load_font_shared(Arc::clone(&shared), index);
1235        }
1236    }
1237
1238    // -----------------------------------------------------------------
1239    // FontManager::evict_unused
1240    // -----------------------------------------------------------------
1241
1242    #[test]
1243    fn evict_unused_on_an_empty_manager_is_zero_for_extreme_durations() {
1244        let manager: FontManager<FontRef> =
1245            FontManager::new(FcFontCache::default()).expect("an empty FontManager must build");
1246        // Duration::MAX truncates when cast to u64 nanos; with no faces cached
1247        // the result is 0 either way — the point is that it must not panic.
1248        for idle in [
1249            Duration::ZERO,
1250            Duration::from_nanos(1),
1251            Duration::from_secs(3600),
1252            Duration::MAX,
1253        ] {
1254            assert_eq!(manager.evict_unused(idle), 0);
1255        }
1256    }
1257
1258    #[test]
1259    fn evict_unused_only_reclaims_stale_deferred_faces() {
1260        let manager: FontManager<FontRef> =
1261            FontManager::new(FcFontCache::default()).expect("an empty FontManager must build");
1262
1263        let deferred = crate::parsed_font_to_font_ref(mock_deferred());
1264        let eager = crate::parsed_font_to_font_ref(mock());
1265        {
1266            let mut fonts = manager.parsed_fonts.lock().unwrap();
1267            fonts.insert(FontId::new(), deferred.clone());
1268            fonts.insert(FontId::new(), eager.clone());
1269        }
1270
1271        // Nothing has decoded a glyph yet: the deferred face holds no LocaGlyf,
1272        // so there is nothing to release even though it is "never touched".
1273        assert_eq!(manager.evict_unused(Duration::ZERO), 0);
1274
1275        // Touch the deferred face -> it materialises loca+glyf and stamps last_used.
1276        let parsed = crate::font_ref_to_parsed_font(&deferred);
1277        assert!(parsed.get_or_decode_glyph(1).is_some(), "gid 1 must decode");
1278        let _ = crate::font_ref_to_parsed_font(&eager).get_or_decode_glyph(1);
1279
1280        // A face used microseconds ago is not idle for an hour.
1281        assert_eq!(manager.evict_unused(Duration::from_secs(3600)), 0);
1282
1283        // With a zero idle window it is stale immediately. The eager face keeps
1284        // no source bytes, so it can never be evicted -> exactly one eviction.
1285        assert_eq!(manager.evict_unused(Duration::ZERO), 1);
1286        // Evicting twice is a no-op.
1287        assert_eq!(manager.evict_unused(Duration::ZERO), 0);
1288
1289        // The evicted face still decodes: it re-parses from its retained bytes.
1290        assert!(parsed.get_or_decode_glyph(2).is_some());
1291    }
1292
1293    // -----------------------------------------------------------------
1294    // shape_text_internal / shape_text_for_parsed_font / FontRef::shape_text
1295    // -----------------------------------------------------------------
1296
1297    #[test]
1298    fn shape_text_empty_input_yields_no_glyphs() {
1299        let font = mock();
1300        let glyphs = shape(&font, "").expect("empty text is not an error");
1301        assert!(glyphs.is_empty());
1302
1303        let via_public = shape_text_for_parsed_font(
1304            &font,
1305            "",
1306            Script::Latin,
1307            Language::EnglishUS,
1308            BidiDirection::Ltr,
1309            &style_at(16.0),
1310        )
1311        .expect("empty text is not an error");
1312        assert!(via_public.is_empty());
1313
1314        let font_ref = crate::parsed_font_to_font_ref(mock());
1315        let via_ref = font_ref
1316            .shape_text(
1317                "",
1318                Script::Latin,
1319                Language::EnglishUS,
1320                BidiDirection::Ltr,
1321                &style_at(16.0),
1322            )
1323            .expect("empty text is not an error");
1324        assert!(via_ref.is_empty());
1325    }
1326
1327    #[test]
1328    fn shape_text_valid_minimal_input_maps_bytes_one_to_one() {
1329        let font = mock();
1330        let glyphs = shape(&font, "abc").expect("plain ASCII must shape");
1331        assert_eq!(glyphs.len(), 3);
1332        assert_spans_are_sane(&glyphs, "abc");
1333
1334        for (i, (g, ch)) in glyphs.iter().zip("abc".chars()).enumerate() {
1335            assert_eq!(g.codepoint, ch);
1336            assert_eq!(g.logical_byte_index, i);
1337            assert_eq!(g.logical_byte_len, 1);
1338            assert_eq!(g.glyph_id, font.lookup_glyph_index(ch as u32).unwrap_or(0));
1339            assert!(g.advance > 0.0, "a real glyph has a positive advance");
1340            assert!(g.advance.is_finite());
1341            assert_eq!(g.font_hash, font.get_hash());
1342            assert_eq!(g.script, Script::Latin);
1343        }
1344    }
1345
1346    #[test]
1347    fn shape_text_whitespace_only_input_is_shaped_not_trimmed() {
1348        let font = mock();
1349        let text = " \t\n\r ";
1350        let glyphs = shape(&font, text).expect("whitespace must shape");
1351        assert!(!glyphs.is_empty(), "whitespace is not silently dropped");
1352        assert_spans_are_sane(&glyphs, text);
1353        for g in &glyphs {
1354            assert!(g.advance.is_finite());
1355        }
1356    }
1357
1358    #[test]
1359    fn shape_text_unicode_and_missing_glyphs_keep_multibyte_spans_intact() {
1360        let font = mock();
1361        // emoji (almost certainly absent from a text face), combining marks, RTL,
1362        // CJK and an unassigned plane-15 codepoint
1363        for text in [
1364            "\u{1F600}",
1365            "e\u{0301}\u{0327}",
1366            "\u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064A}\u{0629}",
1367            "\u{4E2D}\u{6587}",
1368            "\u{FFFD}\u{FDD0}\u{F0000}",
1369            "a\u{200B}b\u{00AD}c",
1370        ] {
1371            let glyphs = shape(&font, text).unwrap_or_else(|e| panic!("{text:?} must shape: {e:?}"));
1372            assert!(!glyphs.is_empty(), "{text:?} produced no glyphs");
1373            assert_spans_are_sane(&glyphs, text);
1374        }
1375
1376        // a missing glyph falls back to .notdef but must still carry the full
1377        // 4-byte span of the source character
1378        let emoji = "\u{1F600}";
1379        let glyphs = shape(&font, emoji).expect("emoji must shape");
1380        assert_eq!(glyphs.len(), 1);
1381        assert_eq!(glyphs[0].codepoint, '\u{1F600}');
1382        assert_eq!(glyphs[0].logical_byte_index, 0);
1383        assert_eq!(glyphs[0].logical_byte_len, 4, "the whole 4-byte char is covered");
1384        assert_eq!(
1385            glyphs[0].glyph_id,
1386            font.lookup_glyph_index('\u{1F600}' as u32).unwrap_or(0)
1387        );
1388    }
1389
1390    #[test]
1391    fn shape_text_extremely_long_input_terminates() {
1392        let font = mock();
1393        let text = "a".repeat(10_000);
1394        let glyphs = shape(&font, &text).expect("a long run must shape");
1395        assert_eq!(glyphs.len(), 10_000);
1396        assert_spans_are_sane(&glyphs, &text);
1397        // the cursor must have walked the whole source, not stalled at 0
1398        assert_eq!(glyphs.last().unwrap().logical_byte_index, 9_999);
1399    }
1400
1401    #[test]
1402    fn shape_text_deeply_nested_brackets_does_not_stack_overflow() {
1403        let font = mock();
1404        let text = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000));
1405        let glyphs = shape(&font, &text).expect("nested brackets are just characters");
1406        assert_eq!(glyphs.len(), 20_000);
1407        assert_spans_are_sane(&glyphs, &text);
1408    }
1409
1410    #[test]
1411    fn shape_text_boundary_numeric_text_is_shaped_verbatim() {
1412        let font = mock();
1413        let text = "0 -0 9223372036854775807 -9223372036854775808 NaN inf 1e309 0.0000001";
1414        let glyphs = shape(&font, text).expect("numeric-looking text is still text");
1415        assert_spans_are_sane(&glyphs, text);
1416        assert!(!glyphs.is_empty());
1417        for g in &glyphs {
1418            assert!(g.advance.is_finite(), "a 16px advance must stay finite");
1419        }
1420    }
1421
1422    #[test]
1423    fn shape_text_nan_and_infinite_font_sizes_do_not_panic() {
1424        let font = mock();
1425        // ppem is `font_size.round().max(1.0) as u16` — an unchecked float->int
1426        // cast. NaN/inf must saturate, not trap.
1427        for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1428            let glyphs = shape_text_internal(
1429                &font,
1430                "ab",
1431                Script::Latin,
1432                Language::EnglishUS,
1433                BidiDirection::Ltr,
1434                &style_at(size),
1435            )
1436            .unwrap_or_else(|e| panic!("font_size {size} must not fail shaping: {e:?}"));
1437            assert_eq!(glyphs.len(), 2);
1438            assert_spans_are_sane(&glyphs, "ab");
1439            for g in &glyphs {
1440                assert!(
1441                    !g.advance.is_finite(),
1442                    "a non-finite font size must not manufacture a finite advance ({size})"
1443                );
1444            }
1445        }
1446
1447        // Finite-but-absurd sizes drive the float->u16 ppem cast to its
1448        // saturation points; they may overflow to ±inf but must not panic and
1449        // must not turn a non-NaN size into a NaN advance.
1450        for size in [f32::MAX, -f32::MAX, 1e30f32] {
1451            let glyphs = shape_text_internal(
1452                &font,
1453                "ab",
1454                Script::Latin,
1455                Language::EnglishUS,
1456                BidiDirection::Ltr,
1457                &style_at(size),
1458            )
1459            .unwrap_or_else(|e| panic!("font_size {size} must not fail shaping: {e:?}"));
1460            assert_eq!(glyphs.len(), 2);
1461            for g in &glyphs {
1462                assert!(!g.advance.is_nan(), "size {size} produced a NaN advance");
1463            }
1464        }
1465    }
1466
1467    #[test]
1468    fn shape_text_zero_and_tiny_font_sizes_produce_zero_or_finite_advances() {
1469        let font = mock();
1470        for (size, expect_zero) in [(0.0f32, true), (-0.0f32, true), (f32::MIN_POSITIVE, false)] {
1471            let glyphs = shape_text_internal(
1472                &font,
1473                "ab",
1474                Script::Latin,
1475                Language::EnglishUS,
1476                BidiDirection::Ltr,
1477                &style_at(size),
1478            )
1479            .expect("degenerate font sizes must still shape");
1480            assert_eq!(glyphs.len(), 2);
1481            for g in &glyphs {
1482                assert!(g.advance.is_finite(), "size {size} produced {}", g.advance);
1483                if expect_zero {
1484                    assert_eq!(g.advance, 0.0, "a 0px font has zero-width glyphs");
1485                    assert_eq!(g.kerning, 0.0);
1486                }
1487            }
1488        }
1489    }
1490
1491    #[test]
1492    fn shape_text_negative_font_size_mirrors_the_advance_sign() {
1493        let font = mock();
1494        let positive = shape_text_internal(
1495            &font,
1496            "a",
1497            Script::Latin,
1498            Language::EnglishUS,
1499            BidiDirection::Ltr,
1500            &style_at(16.0),
1501        )
1502        .expect("shape at +16px");
1503        let negative = shape_text_internal(
1504            &font,
1505            "a",
1506            Script::Latin,
1507            Language::EnglishUS,
1508            BidiDirection::Ltr,
1509            &style_at(-16.0),
1510        )
1511        .expect("a negative font size must not panic");
1512        assert_eq!(positive.len(), 1);
1513        assert_eq!(negative.len(), 1);
1514        assert_eq!(positive[0].glyph_id, negative[0].glyph_id);
1515        assert!(negative[0].advance.is_finite());
1516        assert!(
1517            negative[0].advance <= 0.0,
1518            "a negative size cannot yield a positive advance"
1519        );
1520    }
1521
1522    #[test]
1523    fn shape_text_garbage_font_features_are_skipped_not_fatal() {
1524        let font = mock();
1525        let style = StyleProperties {
1526            font_features: vec![
1527                String::new(),
1528                "   ".to_string(),
1529                "waytoolongtag".to_string(),
1530                "liga=-1".to_string(),
1531                "liga=99999999999999999999".to_string(),
1532                "\u{1F600}".to_string(),
1533                "liga".to_string(),   // one good one, to prove the filter is per-item
1534                "ss01=2".to_string(),
1535            ],
1536            ..StyleProperties::default()
1537        };
1538        let glyphs = shape_text_internal(
1539            &font,
1540            "abc",
1541            Script::Latin,
1542            Language::EnglishUS,
1543            BidiDirection::Ltr,
1544            &style,
1545        )
1546        .expect("malformed feature strings must be dropped, not fatal");
1547        assert_eq!(glyphs.len(), 3);
1548        assert_spans_are_sane(&glyphs, "abc");
1549    }
1550
1551    #[test]
1552    fn shape_text_direction_drives_the_bidi_level() {
1553        let font = mock();
1554        for (direction, expected) in [(BidiDirection::Ltr, 0u8), (BidiDirection::Rtl, 1u8)] {
1555            let glyphs = shape_text_internal(
1556                &font,
1557                "abc",
1558                Script::Latin,
1559                Language::EnglishUS,
1560                direction,
1561                &style_at(16.0),
1562            )
1563            .expect("both directions must shape");
1564            assert!(!glyphs.is_empty());
1565            for g in &glyphs {
1566                assert_eq!(g.bidi_level.level(), expected);
1567                assert_eq!(g.bidi_level.is_rtl(), direction.is_rtl());
1568            }
1569        }
1570    }
1571
1572    #[test]
1573    fn shape_text_every_script_and_language_pairing_is_shapeable() {
1574        let font = mock();
1575        // A script tag that the font has no coverage for must degrade to
1576        // .notdef glyphs, never to an Err or a panic.
1577        for script in ALL_SCRIPTS {
1578            let glyphs = shape_text_internal(
1579                &font,
1580                "Hello \u{0e2a}\u{0e27}\u{0e31}\u{0e2a}\u{0e14}\u{0e35}",
1581                script,
1582                Language::EnglishUS,
1583                BidiDirection::Ltr,
1584                &style_at(16.0),
1585            )
1586            .unwrap_or_else(|e| panic!("{script:?} must shape: {e:?}"));
1587            assert!(!glyphs.is_empty(), "{script:?} produced no glyphs");
1588            for g in &glyphs {
1589                assert_eq!(g.script, script, "the requested script is stamped on the glyph");
1590            }
1591        }
1592    }
1593
1594    #[test]
1595    fn shape_text_public_internal_and_font_ref_paths_agree() {
1596        // Round-trip / consistency: the three entry points are documented as
1597        // sharing one implementation, so they must produce identical glyphs.
1598        let font = mock();
1599        let font_ref = crate::parsed_font_to_font_ref(mock());
1600        let text = "Wafer fi\u{0301}x \u{1F600}";
1601        let style = style_at(13.5);
1602
1603        let internal = shape_text_internal(
1604            &font,
1605            text,
1606            Script::Latin,
1607            Language::EnglishUS,
1608            BidiDirection::Ltr,
1609            &style,
1610        )
1611        .expect("internal shaping");
1612        let public = shape_text_for_parsed_font(
1613            &font,
1614            text,
1615            Script::Latin,
1616            Language::EnglishUS,
1617            BidiDirection::Ltr,
1618            &style,
1619        )
1620        .expect("public shaping");
1621        let via_ref = font_ref
1622            .shape_text(
1623                text,
1624                Script::Latin,
1625                Language::EnglishUS,
1626                BidiDirection::Ltr,
1627                &style,
1628            )
1629            .expect("FontRef shaping");
1630        let via_helper = font
1631            .shape_text_for_font_ref(
1632                &font_ref,
1633                text,
1634                Script::Latin,
1635                Language::EnglishUS,
1636                BidiDirection::Ltr,
1637                &style,
1638            )
1639            .expect("shape_text_for_font_ref");
1640
1641        assert_eq!(internal.len(), public.len());
1642        assert_eq!(internal.len(), via_ref.len());
1643        assert_eq!(internal.len(), via_helper.len());
1644        for (((a, b), c), d) in internal
1645            .iter()
1646            .zip(public.iter())
1647            .zip(via_ref.iter())
1648            .zip(via_helper.iter())
1649        {
1650            for other in [b, c, d] {
1651                assert_eq!(a.glyph_id, other.glyph_id);
1652                assert_eq!(a.codepoint, other.codepoint);
1653                assert_eq!(a.advance, other.advance);
1654                assert_eq!(a.kerning, other.kerning);
1655                assert_eq!(a.logical_byte_index, other.logical_byte_index);
1656                assert_eq!(a.logical_byte_len, other.logical_byte_len);
1657                assert_eq!(a.font_hash, other.font_hash);
1658            }
1659        }
1660        assert_spans_are_sane(&internal, text);
1661    }
1662
1663    // -----------------------------------------------------------------
1664    // ParsedFont::get_hash (getter)
1665    // -----------------------------------------------------------------
1666
1667    #[test]
1668    fn get_hash_is_stable_and_shared_with_the_font_ref_view() {
1669        let a = mock();
1670        let b = mock();
1671        assert_eq!(a.get_hash(), b.get_hash(), "parsing is deterministic");
1672        assert_eq!(a.get_hash(), a.hash, "the getter reads the cached field");
1673
1674        let font_ref = crate::parsed_font_to_font_ref(mock());
1675        assert_eq!(font_ref.get_hash(), a.get_hash());
1676
1677        // the lazy constructor must not change identity
1678        assert_eq!(mock_deferred().get_hash(), a.get_hash());
1679    }
1680
1681    // -----------------------------------------------------------------
1682    // ParsedFont::get_glyph_size (numeric)
1683    // -----------------------------------------------------------------
1684
1685    #[test]
1686    fn get_glyph_size_out_of_range_glyph_ids_are_none() {
1687        let font = mock();
1688        assert!(font.get_glyph_size(u16::MAX, 16.0).is_none());
1689        assert!(font.get_glyph_size(font.num_glyphs, 16.0).is_none());
1690        // an in-range gid still decodes (positive control)
1691        let gid = font.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
1692        assert!(gid < font.num_glyphs);
1693        assert!(font.get_glyph_size(gid, 16.0).is_some());
1694    }
1695
1696    #[test]
1697    fn get_glyph_size_zero_negative_and_non_finite_font_sizes() {
1698        let font = mock();
1699        let gid = font.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
1700
1701        let zero = font.get_glyph_size(gid, 0.0).expect("gid decodes");
1702        assert_eq!(zero.width, 0.0);
1703        assert_eq!(zero.height, 0.0);
1704
1705        let base = font.get_glyph_size(gid, 16.0).expect("gid decodes");
1706        assert!(base.width > 0.0 && base.height > 0.0);
1707
1708        // scaling is linear in the font size
1709        let doubled = font.get_glyph_size(gid, 32.0).expect("gid decodes");
1710        assert!((doubled.width - 2.0 * base.width).abs() <= 1e-3 * base.width.max(1.0));
1711
1712        let negative = font.get_glyph_size(gid, -16.0).expect("gid decodes");
1713        assert!(negative.width <= 0.0 && negative.width.is_finite());
1714
1715        for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN_POSITIVE] {
1716            let size_result = font
1717                .get_glyph_size(gid, size)
1718                .unwrap_or_else(|| panic!("gid must still decode at {size}"));
1719            assert!(
1720                !size_result.width.is_nan() || size.is_nan(),
1721                "only a NaN input may produce a NaN width"
1722            );
1723        }
1724    }
1725
1726    #[test]
1727    fn get_glyph_size_zero_units_per_em_uses_the_constant_fallback_scale() {
1728        assert_eq!(FALLBACK_SCALE, 0.01);
1729        let mut font = mock();
1730        let gid = font.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
1731        font.font_metrics.units_per_em = 0; // corrupt/broken font
1732
1733        // With upem == 0 the scale is the constant FALLBACK_SCALE, so the size no
1734        // longer depends on the requested font size at all.
1735        let small = font.get_glyph_size(gid, 16.0).expect("gid decodes");
1736        let huge = font.get_glyph_size(gid, 1000.0).expect("gid decodes");
1737        assert!(small.width > 0.0 && small.height > 0.0, "no divide-by-zero NaN");
1738        assert_eq!(small.width, huge.width);
1739        assert_eq!(small.height, huge.height);
1740    }
1741
1742    // -----------------------------------------------------------------
1743    // ParsedFont::get_hyphen_glyph_and_advance / get_kashida_glyph_and_advance
1744    // -----------------------------------------------------------------
1745
1746    #[test]
1747    fn get_hyphen_glyph_and_advance_follows_the_cmap_and_scales_linearly() {
1748        let font = mock();
1749        let expected_gid = font
1750            .lookup_glyph_index('-' as u32)
1751            .expect("the positive control has a hyphen");
1752
1753        let (gid, zero_advance) = font.get_hyphen_glyph_and_advance(0.0).expect("hyphen at 0px");
1754        assert_eq!(gid, expected_gid);
1755        assert_eq!(zero_advance, 0.0, "a 0px font gives a 0px advance");
1756
1757        let (_, a16) = font.get_hyphen_glyph_and_advance(16.0).expect("hyphen at 16px");
1758        let (_, a32) = font.get_hyphen_glyph_and_advance(32.0).expect("hyphen at 32px");
1759        assert!(a16 > 0.0 && a16.is_finite());
1760        assert!((a32 - 2.0 * a16).abs() <= 1e-3 * a16, "advance is linear in font size");
1761
1762        let (_, negative) = font.get_hyphen_glyph_and_advance(-16.0).expect("hyphen at -16px");
1763        assert!(negative.is_finite() && negative <= 0.0);
1764    }
1765
1766    #[test]
1767    fn get_kashida_glyph_and_advance_presence_matches_the_cmap() {
1768        let font = mock();
1769        // U+0640 ARABIC TATWEEL: present or not, the two views must agree.
1770        let has_tatweel = font.has_glyph(0x0640);
1771        let result = font.get_kashida_glyph_and_advance(16.0);
1772        assert_eq!(
1773            result.is_some(),
1774            has_tatweel,
1775            "kashida availability must track the cmap"
1776        );
1777        if let Some((gid, advance)) = result {
1778            assert_eq!(Some(gid), font.lookup_glyph_index(0x0640));
1779            assert!(advance.is_finite() && advance >= 0.0);
1780            let (_, doubled) = font.get_kashida_glyph_and_advance(32.0).expect("still mapped");
1781            assert!((doubled - 2.0 * advance).abs() <= 1e-3 * advance.max(1.0));
1782        }
1783    }
1784
1785    #[test]
1786    fn hyphen_and_kashida_survive_nan_inf_and_extreme_font_sizes() {
1787        let font = mock();
1788        let hyphen_gid = font
1789            .lookup_glyph_index('-' as u32)
1790            .expect("the positive control has a hyphen");
1791        assert!(
1792            font.get_horizontal_advance(hyphen_gid) > 0,
1793            "the hyphen must have a non-zero advance for this test to mean anything"
1794        );
1795
1796        // NaN / ±inf in -> non-finite out, and never a panic.
1797        for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1798            let (gid, advance) = font
1799                .get_hyphen_glyph_and_advance(size)
1800                .expect("the glyph id does not depend on the font size");
1801            assert_eq!(gid, hyphen_gid);
1802            assert!(!advance.is_finite(), "{size} produced a finite advance");
1803            let _ = font.get_kashida_glyph_and_advance(size);
1804        }
1805
1806        // The numeric extremes may overflow to ±inf, but a non-NaN size must
1807        // never produce a NaN advance and must never panic.
1808        for size in [f32::MAX, -f32::MAX, f32::MIN_POSITIVE, -f32::MIN_POSITIVE] {
1809            let (gid, advance) = font
1810                .get_hyphen_glyph_and_advance(size)
1811                .expect("the glyph id does not depend on the font size");
1812            assert_eq!(gid, hyphen_gid);
1813            assert!(!advance.is_nan(), "size {size} produced a NaN advance");
1814            let _ = font.get_kashida_glyph_and_advance(size);
1815        }
1816    }
1817
1818    #[test]
1819    fn hyphen_and_kashida_return_none_when_units_per_em_is_zero() {
1820        let mut font = mock();
1821        font.font_metrics.units_per_em = 0;
1822        // A zero upem would divide by zero: both getters must bail out instead.
1823        assert!(font.get_hyphen_glyph_and_advance(16.0).is_none());
1824        assert!(font.get_kashida_glyph_and_advance(16.0).is_none());
1825        assert!(font.get_hyphen_glyph_and_advance(f32::NAN).is_none());
1826    }
1827
1828    // -----------------------------------------------------------------
1829    // build_feature_mask_for_script (other)
1830    // -----------------------------------------------------------------
1831
1832    #[test]
1833    fn build_feature_mask_always_contains_the_default_mask() {
1834        let default_bits = FeatureMask::default_mask().bits();
1835        for script in ALL_SCRIPTS {
1836            let mask = build_feature_mask_for_script(script);
1837            assert_eq!(
1838                mask.bits() & default_bits,
1839                default_bits,
1840                "{script:?} dropped a default feature"
1841            );
1842            assert!(
1843                mask.contains(Feature::LIGA) && mask.contains(Feature::CCMP),
1844                "{script:?} must keep LIGA/CCMP"
1845            );
1846        }
1847    }
1848
1849    #[test]
1850    fn build_feature_mask_adds_the_script_specific_features() {
1851        // Arabic needs positional forms, or cursive joining silently breaks.
1852        let arabic = build_feature_mask_for_script(Script::Arabic);
1853        for feature in [Feature::INIT, Feature::MEDI, Feature::FINA, Feature::ISOL] {
1854            assert!(arabic.contains(feature), "Arabic is missing a positional form");
1855        }
1856
1857        // Indic needs conjunct formation.
1858        for script in [
1859            Script::Devanagari,
1860            Script::Bengali,
1861            Script::Gujarati,
1862            Script::Gurmukhi,
1863            Script::Kannada,
1864            Script::Malayalam,
1865            Script::Oriya,
1866            Script::Tamil,
1867            Script::Telugu,
1868        ] {
1869            let mask = build_feature_mask_for_script(script);
1870            for feature in [Feature::CJCT, Feature::HALF, Feature::RPHF, Feature::NUKT] {
1871                assert!(mask.contains(feature), "{script:?} is missing an Indic feature");
1872            }
1873        }
1874
1875        // Sinhala is Indic-derived but explicitly simpler: no conjunct feature.
1876        let sinhala = build_feature_mask_for_script(Script::Sinhala);
1877        assert!(sinhala.contains(Feature::AKHN) && sinhala.contains(Feature::RPHF));
1878        assert!(!sinhala.contains(Feature::CJCT));
1879
1880        // Myanmar/Khmer get pre/below/post-base forms.
1881        assert!(build_feature_mask_for_script(Script::Myanmar).contains(Feature::PSTF));
1882        assert!(build_feature_mask_for_script(Script::Khmer).contains(Feature::ABVF));
1883
1884        // Simple scripts must be exactly the default mask — no accidental extras.
1885        for script in [Script::Latin, Script::Greek, Script::Cyrillic, Script::Georgian] {
1886            assert_eq!(
1887                build_feature_mask_for_script(script).bits(),
1888                FeatureMask::default_mask().bits(),
1889                "{script:?} must not add script-specific features"
1890            );
1891        }
1892    }
1893
1894    // -----------------------------------------------------------------
1895    // to_opentype_script_tag (other)
1896    // -----------------------------------------------------------------
1897
1898    #[test]
1899    fn script_tags_are_four_printable_ascii_bytes() {
1900        for script in ALL_SCRIPTS {
1901            let tag = to_opentype_script_tag(script);
1902            let bytes = tag.to_be_bytes();
1903            assert_eq!(bytes.len(), 4);
1904            for b in bytes {
1905                assert!(
1906                    b.is_ascii_lowercase() || b.is_ascii_digit(),
1907                    "{script:?} -> {tag:#010x} is not a lowercase OpenType tag"
1908                );
1909            }
1910            assert_ne!(tag, 0, "{script:?} must not map to the null tag");
1911        }
1912    }
1913
1914    #[test]
1915    fn script_tags_match_the_opentype_registry_and_alias_kana() {
1916        assert_eq!(to_opentype_script_tag(Script::Latin), u32::from_be_bytes(*b"latn"));
1917        assert_eq!(to_opentype_script_tag(Script::Arabic), u32::from_be_bytes(*b"arab"));
1918        assert_eq!(to_opentype_script_tag(Script::Mandarin), u32::from_be_bytes(*b"hani"));
1919        assert_eq!(to_opentype_script_tag(Script::Devanagari), u32::from_be_bytes(*b"deva"));
1920
1921        // Hiragana and Katakana intentionally share "kana" (documented).
1922        assert_eq!(
1923            to_opentype_script_tag(Script::Hiragana),
1924            to_opentype_script_tag(Script::Katakana)
1925        );
1926        assert_eq!(to_opentype_script_tag(Script::Hiragana), u32::from_be_bytes(*b"kana"));
1927
1928        // Every other pair must be distinct: 24 scripts, 23 distinct tags.
1929        let mut tags: Vec<u32> = ALL_SCRIPTS.iter().map(|s| to_opentype_script_tag(*s)).collect();
1930        tags.sort_unstable();
1931        tags.dedup();
1932        assert_eq!(tags.len(), 23, "only the kana pair may collide");
1933    }
1934
1935    // -----------------------------------------------------------------
1936    // parse_font_feature (parser)
1937    // -----------------------------------------------------------------
1938
1939    #[test]
1940    fn parse_font_feature_valid_minimal_inputs() {
1941        assert_eq!(
1942            parse_font_feature("liga"),
1943            Some((u32::from_be_bytes(*b"liga"), 1)),
1944            "a bare tag defaults to value 1"
1945        );
1946        assert_eq!(parse_font_feature("liga=0"), Some((u32::from_be_bytes(*b"liga"), 0)));
1947        assert_eq!(parse_font_feature("ss01"), Some((u32::from_be_bytes(*b"ss01"), 1)));
1948        assert_eq!(parse_font_feature("smcp=2"), Some((u32::from_be_bytes(*b"smcp"), 2)));
1949        // u32::MAX is the largest accepted value
1950        assert_eq!(
1951            parse_font_feature("ss01=4294967295"),
1952            Some((u32::from_be_bytes(*b"ss01"), u32::MAX))
1953        );
1954    }
1955
1956    #[test]
1957    fn parse_font_feature_pads_short_tags_with_spaces() {
1958        assert_eq!(parse_font_feature("aa"), Some((u32::from_be_bytes(*b"aa  "), 1)));
1959        assert_eq!(parse_font_feature("a"), Some((u32::from_be_bytes(*b"a   "), 1)));
1960        assert_eq!(parse_font_feature("abc=3"), Some((u32::from_be_bytes(*b"abc "), 3)));
1961    }
1962
1963    #[test]
1964    fn parse_font_feature_trims_surrounding_whitespace() {
1965        assert_eq!(parse_font_feature("  liga  "), Some((u32::from_be_bytes(*b"liga"), 1)));
1966        assert_eq!(parse_font_feature("\tliga\n=\t2 "), Some((u32::from_be_bytes(*b"liga"), 2)));
1967    }
1968
1969    #[test]
1970    fn parse_font_feature_empty_and_whitespace_only_yield_the_all_space_tag() {
1971        // Documents current behaviour: an empty/blank tag is NOT rejected — it is
1972        // padded to the four-space tag (0x20202020), which no font can match.
1973        let space_tag = u32::from_be_bytes(*b"    ");
1974        assert_eq!(parse_font_feature(""), Some((space_tag, 1)));
1975        assert_eq!(parse_font_feature("   "), Some((space_tag, 1)));
1976        assert_eq!(parse_font_feature("\t\n"), Some((space_tag, 1)));
1977        // ...but an empty *value* is still rejected.
1978        assert_eq!(parse_font_feature("="), None);
1979        assert_eq!(parse_font_feature("liga="), None);
1980        assert_eq!(parse_font_feature("liga=  "), None);
1981    }
1982
1983    #[test]
1984    fn parse_font_feature_rejects_over_long_tags_and_junk() {
1985        assert_eq!(parse_font_feature("toolongtag"), None);
1986        assert_eq!(parse_font_feature("lig a"), None); // 5 bytes after trim
1987        assert_eq!(parse_font_feature("liga;garbage"), None);
1988        assert_eq!(parse_font_feature("valid;garbage=1"), None);
1989        // a megabyte-long tag must be rejected by the length check, not parsed
1990        assert_eq!(parse_font_feature(&"x".repeat(1_000_000)), None);
1991        assert_eq!(parse_font_feature(&format!("{}=1", "x".repeat(1_000_000))), None);
1992    }
1993
1994    #[test]
1995    fn parse_font_feature_rejects_boundary_and_non_numeric_values() {
1996        for bad in [
1997            "liga=-1",
1998            "liga=-0",
1999            "liga=1.5",
2000            "liga=NaN",
2001            "liga=inf",
2002            "liga=0x1",
2003            "liga=4294967296",          // u32::MAX + 1
2004            "liga=9223372036854775807", // i64::MAX
2005            "liga=99999999999999999999999999",
2006            "liga= 1 2",
2007        ] {
2008            assert_eq!(parse_font_feature(bad), None, "{bad:?} must be rejected");
2009        }
2010        // `u32::from_str` accepts a leading '+', so this one is (surprisingly) valid
2011        assert_eq!(parse_font_feature("liga=+1"), Some((u32::from_be_bytes(*b"liga"), 1)));
2012        // a trailing extra '=' segment is ignored: only the first value is read
2013        assert_eq!(parse_font_feature("liga=1=2"), Some((u32::from_be_bytes(*b"liga"), 1)));
2014    }
2015
2016    #[test]
2017    fn parse_font_feature_unicode_input_does_not_panic() {
2018        // Multibyte tags pad by *chars* but the tag must be exactly 4 *bytes*,
2019        // so every one of these must fall out as None rather than slicing a
2020        // char boundary or panicking on the array conversion.
2021        for input in [
2022            "\u{1F600}",         // 4 bytes, 1 char
2023            "\u{1F600}\u{1F600}",
2024            "é",
2025            "ß=1",
2026            "e\u{0301}",         // combining acute
2027            "\u{202E}liga",      // RTL override
2028            "\u{0000}\u{0001}",
2029        ] {
2030            let _ = parse_font_feature(input); // must not panic
2031        }
2032        assert_eq!(parse_font_feature("\u{1F600}"), None);
2033        assert_eq!(parse_font_feature("é"), None);
2034    }
2035
2036    // -----------------------------------------------------------------
2037    // add_variant_features (other)
2038    // -----------------------------------------------------------------
2039
2040    #[test]
2041    fn add_variant_features_maps_css_variants_to_opentype_tags() {
2042        let tags = |style: &StyleProperties| -> Vec<u32> {
2043            let mut features = Vec::new();
2044            add_variant_features(style, &mut features);
2045            assert!(
2046                features.iter().all(|f| f.alternate.is_none()),
2047                "variant features are on/off, never alternates"
2048            );
2049            features.iter().map(|f| f.feature_tag).collect()
2050        };
2051
2052        // the default style adds nothing
2053        assert!(tags(&StyleProperties::default()).is_empty());
2054
2055        let small_caps = StyleProperties {
2056            font_variant_caps: FontVariantCaps::SmallCaps,
2057            ..StyleProperties::default()
2058        };
2059        assert_eq!(tags(&small_caps), vec![u32::from_be_bytes(*b"smcp")]);
2060
2061        let all_small = StyleProperties {
2062            font_variant_caps: FontVariantCaps::AllSmallCaps,
2063            ..StyleProperties::default()
2064        };
2065        assert_eq!(
2066            tags(&all_small),
2067            vec![u32::from_be_bytes(*b"c2sc"), u32::from_be_bytes(*b"smcp")]
2068        );
2069
2070        let combined = StyleProperties {
2071            font_variant_ligatures: FontVariantLigatures::Discretionary,
2072            font_variant_numeric: FontVariantNumeric::TabularNums,
2073            font_variant_caps: FontVariantCaps::TitlingCaps,
2074            ..StyleProperties::default()
2075        };
2076        assert_eq!(
2077            tags(&combined),
2078            vec![
2079                u32::from_be_bytes(*b"dlig"),
2080                u32::from_be_bytes(*b"titl"),
2081                u32::from_be_bytes(*b"tnum"),
2082            ],
2083            "ligature, caps and numeric features are all emitted, in that order"
2084        );
2085    }
2086
2087    #[test]
2088    fn add_variant_features_is_additive_and_never_panics_for_any_variant() {
2089        let ligatures = [
2090            FontVariantLigatures::Normal,
2091            FontVariantLigatures::None,
2092            FontVariantLigatures::Common,
2093            FontVariantLigatures::NoCommon,
2094            FontVariantLigatures::Discretionary,
2095            FontVariantLigatures::NoDiscretionary,
2096            FontVariantLigatures::Historical,
2097            FontVariantLigatures::NoHistorical,
2098            FontVariantLigatures::Contextual,
2099            FontVariantLigatures::NoContextual,
2100        ];
2101        let caps = [
2102            FontVariantCaps::Normal,
2103            FontVariantCaps::SmallCaps,
2104            FontVariantCaps::AllSmallCaps,
2105            FontVariantCaps::PetiteCaps,
2106            FontVariantCaps::AllPetiteCaps,
2107            FontVariantCaps::Unicase,
2108            FontVariantCaps::TitlingCaps,
2109        ];
2110        let numeric = [
2111            FontVariantNumeric::Normal,
2112            FontVariantNumeric::LiningNums,
2113            FontVariantNumeric::OldstyleNums,
2114            FontVariantNumeric::ProportionalNums,
2115            FontVariantNumeric::TabularNums,
2116            FontVariantNumeric::DiagonalFractions,
2117            FontVariantNumeric::StackedFractions,
2118            FontVariantNumeric::Ordinal,
2119            FontVariantNumeric::SlashedZero,
2120        ];
2121
2122        // a pre-existing feature must survive: the helper appends, never clears
2123        let sentinel = FeatureInfo {
2124            feature_tag: u32::from_be_bytes(*b"kern"),
2125            alternate: Some(7),
2126        };
2127        for l in ligatures {
2128            for c in caps {
2129                for n in numeric {
2130                    let style = StyleProperties {
2131                        font_variant_ligatures: l,
2132                        font_variant_caps: c,
2133                        font_variant_numeric: n,
2134                        ..StyleProperties::default()
2135                    };
2136                    let mut features = vec![sentinel];
2137                    add_variant_features(&style, &mut features);
2138                    assert_eq!(features[0].feature_tag, sentinel.feature_tag);
2139                    assert_eq!(features[0].alternate, Some(7));
2140                    // at most 2 (caps) + 1 (ligature) + 1 (numeric) new tags
2141                    assert!(features.len() <= 5, "{l:?}/{c:?}/{n:?} emitted too many features");
2142                    for f in &features[1..] {
2143                        assert!(f.feature_tag.to_be_bytes().iter().all(u8::is_ascii_graphic));
2144                    }
2145                }
2146            }
2147        }
2148    }
2149
2150    // -----------------------------------------------------------------
2151    // to_opentype_lang_tag (other, feature-gated)
2152    // -----------------------------------------------------------------
2153
2154    #[cfg(feature = "text_layout_hyphenation")]
2155    #[test]
2156    fn lang_tags_are_four_byte_uppercase_padded_tags() {
2157        use hyphenation::Language as HL;
2158
2159        // A representative spread across the mapping table, including the two
2160        // arms that intentionally share a tag.
2161        let sample = [
2162            HL::EnglishUS,
2163            HL::EnglishGB,
2164            HL::German1901,
2165            HL::German1996,
2166            HL::French,
2167            HL::Russian,
2168            HL::Finnish,
2169            HL::FinnishScholastic,
2170            HL::Latin,
2171            HL::LatinClassic,
2172            HL::Welsh,
2173            HL::Thai,
2174        ];
2175        for lang in sample {
2176            let tag = to_opentype_lang_tag(lang);
2177            let bytes = tag.to_be_bytes();
2178            for b in bytes {
2179                assert!(
2180                    b.is_ascii_uppercase() || b == b' ',
2181                    "{lang:?} -> {tag:#010x} is not an uppercase, space-padded tag"
2182                );
2183            }
2184            assert_ne!(tag, 0);
2185        }
2186
2187        assert_eq!(to_opentype_lang_tag(HL::EnglishUS), u32::from_be_bytes(*b"ENU "));
2188        assert_eq!(to_opentype_lang_tag(HL::EnglishGB), u32::from_be_bytes(*b"ENG "));
2189        assert_eq!(to_opentype_lang_tag(HL::German1996), u32::from_be_bytes(*b"DEU "));
2190        assert_eq!(to_opentype_lang_tag(HL::French), u32::from_be_bytes(*b"FRA "));
2191        assert_eq!(to_opentype_lang_tag(HL::Russian), u32::from_be_bytes(*b"RUS "));
2192        // documented aliases: both German orthographies and both Finnish variants
2193        assert_eq!(
2194            to_opentype_lang_tag(HL::German1901),
2195            to_opentype_lang_tag(HL::German1996)
2196        );
2197        assert_eq!(
2198            to_opentype_lang_tag(HL::Finnish),
2199            to_opentype_lang_tag(HL::FinnishScholastic)
2200        );
2201    }
2202
2203    // -----------------------------------------------------------------
2204    // FontRef trait surface: delegation invariants
2205    // -----------------------------------------------------------------
2206
2207    #[test]
2208    fn font_ref_trait_getters_delegate_to_the_inner_parsed_font() {
2209        let parsed = mock();
2210        let font_ref = crate::parsed_font_to_font_ref(mock());
2211
2212        assert_eq!(font_ref.num_glyphs(), parsed.num_glyphs);
2213        assert_eq!(font_ref.get_space_width(), parsed.get_space_width());
2214        assert_eq!(font_ref.get_font_metrics().units_per_em, parsed.font_metrics.units_per_em);
2215        assert!(font_ref.has_glyph('a' as u32) == parsed.has_glyph('a' as u32));
2216        assert!(!font_ref.has_glyph(0x0011_0000), "an invalid scalar value has no glyph");
2217
2218        let gid = parsed.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
2219        let via_ref = font_ref.get_glyph_size(gid, 16.0).expect("gid decodes");
2220        let via_parsed = parsed.get_glyph_size(gid, 16.0).expect("gid decodes");
2221        assert_eq!(via_ref.width, via_parsed.width);
2222        assert_eq!(via_ref.height, via_parsed.height);
2223
2224        assert_eq!(
2225            font_ref.get_hyphen_glyph_and_advance(16.0).map(|(g, _)| g),
2226            parsed.get_hyphen_glyph_and_advance(16.0).map(|(g, _)| g)
2227        );
2228        assert_eq!(
2229            font_ref.get_kashida_glyph_and_advance(16.0).map(|(g, _)| g),
2230            parsed.get_kashida_glyph_and_advance(16.0).map(|(g, _)| g)
2231        );
2232
2233        // shallow_clone shares the same underlying face
2234        let cloned = font_ref.shallow_clone();
2235        assert_eq!(cloned.get_hash(), font_ref.get_hash());
2236    }
2237}