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

            
10
use std::{path::Path, sync::Arc};
11

            
12
use allsorts::{
13
    gpos,
14
    gsub::{self, Feature, FeatureInfo, FeatureMask, FeatureMaskExt},
15
};
16
use azul_core::geom::LogicalSize;
17
use azul_css::props::basic::FontRef;
18

            
19
use 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
23
#[must_use] pub fn font_ref_from_bytes(
45
23
    font_bytes: &[u8],
46
23
    font_index: usize,
47
23
    parse_outlines: bool,
48
23
) -> Option<FontRef> {
49
    // Parse the font bytes into ParsedFont
50
23
    let mut warnings = Vec::new();
51
23
    let parsed_font = ParsedFont::from_bytes(font_bytes, font_index, &mut warnings)?;
52

            
53
8
    Some(crate::parsed_font_to_font_ref(parsed_font))
54
23
}
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)]
61
pub struct PathLoader;
62

            
63
impl PathLoader {
64
    /// Creates a new `PathLoader`.
65
5738
    #[must_use] pub const fn new() -> Self {
66
5738
        Self
67
5738
    }
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
10
    pub(crate) fn load_from_path(self, path: &Path, font_index: usize) -> Result<FontRef, LayoutError> {
74
10
        let font_bytes = std::fs::read(path).map_err(|_| {
75
5
            LayoutError::FontNotFound(FontSelector {
76
5
                family: path.to_string_lossy().into_owned(),
77
5
                weight: rust_fontconfig::FcWeight::Normal,
78
5
                style: crate::text3::cache::FontStyle::Normal,
79
5
                unicode_ranges: Vec::new(),
80
5
            })
81
5
        })?;
82
5
        let arc_owned = Arc::<[u8]>::from(font_bytes);
83
5
        let bytes = Arc::new(rust_fontconfig::FontBytes::Owned(arc_owned));
84
5
        self.load_font_shared(bytes, font_index)
85
10
    }
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
17813
    pub fn load_font_shared(
104
17813
        &self,
105
17813
        font_bytes: Arc<rust_fontconfig::FontBytes>,
106
17813
        font_index: usize,
107
17813
    ) -> Result<FontRef, LayoutError> {
108
17813
        let mut warnings = Vec::new();
109
17813
        let parsed_font = ParsedFont::from_bytes_shared(font_bytes, font_index, &mut warnings)
110
17813
            .ok_or_else(|| {
111
6
                LayoutError::ShapingError("Failed to parse font with allsorts".to_string())
112
6
            })?;
113
17807
        Ok(crate::parsed_font_to_font_ref(parsed_font))
114
17813
    }
115
}
116

            
117
impl 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
8
    pub fn evict_unused(&self, idle: std::time::Duration) -> usize {
130
        use crate::font::parsed::ParsedFont;
131
8
        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
8
        let cutoff = idle.as_nanos() as u64;
139
8
        let now_nanos = crate::font::parsed::monotonic_now_nanos();
140
8
        let mut evicted = 0usize;
141
8
        for font_ref in parsed.values() {
142
8
            let font: &ParsedFont = crate::font_ref_to_parsed_font(font_ref);
143
8
            let last = font.last_used_nanos();
144
            // Untouched faces are eligible immediately. Touched
145
            // faces need to be `idle` past their last use.
146
8
            let stale = last == 0 || now_nanos.saturating_sub(last) >= cutoff;
147
8
            if stale && font.evict_loca_glyf() {
148
1
                evicted += 1;
149
7
            }
150
        }
151
8
        evicted
152
8
    }
153
}
154

            
155

            
156
// ParsedFontTrait Implementation for FontRef
157

            
158
// Implement ShallowClone for FontRef
159
impl ShallowClone for FontRef {
160
1082066
    fn shallow_clone(&self) -> Self {
161
        // FontRef::clone increments the reference count
162
1082066
        self.clone()
163
1082066
    }
164
}
165

            
166
// Use crate::font_ref_to_parsed_font instead of a local duplicate
167

            
168
impl ParsedFontTrait for FontRef {
169
    // +spec:block-formatting-context:21ec9a - bidi direction handled during text shaping for vertical writing modes
170
49647
    fn shape_text(
171
49647
        &self,
172
49647
        text: &str,
173
49647
        script: Script,
174
49647
        language: crate::text3::script::Language,
175
49647
        direction: BidiDirection,
176
49647
        style: &StyleProperties,
177
49647
    ) -> Result<Vec<Glyph>, LayoutError> {
178
        // Delegate to the inner ParsedFont's shape_text, passing self as font_ref
179
49647
        let parsed = crate::font_ref_to_parsed_font(self);
180
49647
        parsed.shape_text_for_font_ref(self, text, script, language, direction, style)
181
49647
    }
182

            
183
2014388
    fn get_hash(&self) -> u64 {
184
2014388
        crate::font_ref_to_parsed_font(self).hash
185
2014388
    }
186

            
187
1
    fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
188
1
        crate::font_ref_to_parsed_font(self).get_glyph_size(glyph_id, font_size)
189
1
    }
190

            
191
1
    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
192
1
        crate::font_ref_to_parsed_font(self).get_hyphen_glyph_and_advance(font_size)
193
1
    }
194

            
195
1
    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
196
1
        crate::font_ref_to_parsed_font(self).get_kashida_glyph_and_advance(font_size)
197
1
    }
198

            
199
57
    fn has_glyph(&self, codepoint: u32) -> bool {
200
57
        crate::font_ref_to_parsed_font(self).has_glyph(codepoint)
201
57
    }
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
1
    fn get_font_metrics(&self) -> LayoutFontMetrics {
208
1
        crate::font_ref_to_parsed_font(self).font_metrics
209
1
    }
210

            
211
6
    fn num_glyphs(&self) -> u16 {
212
6
        crate::font_ref_to_parsed_font(self).num_glyphs
213
6
    }
214

            
215
3
    fn get_space_width(&self) -> Option<usize> {
216
3
        crate::font_ref_to_parsed_font(self).get_space_width()
217
3
    }
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.
223
pub 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

            
234
impl 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

            
333
impl ParsedFont {
334
    /// Internal helper that shapes text and returns Glyph
335
    /// Delegates to `shape_text_internal` and converts the font reference.
336
49648
    fn shape_text_for_font_ref(
337
49648
        &self,
338
49648
        _font_ref: &FontRef,
339
49648
        text: &str,
340
49648
        script: Script,
341
49648
        language: crate::text3::script::Language,
342
49648
        direction: BidiDirection,
343
49648
        style: &StyleProperties,
344
49648
    ) -> 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
49648
        shape_text_internal(self, text, script, language, direction, style)
349
49648
    }
350

            
351
49902
    const fn get_hash(&self) -> u64 {
352
49902
        self.hash
353
49902
    }
354

            
355
16
    fn get_glyph_size(&self, glyph_id: u16, font_size_px: f32) -> Option<LogicalSize> {
356
16
        self.get_or_decode_glyph(glyph_id).map(|record| {
357
14
            let units_per_em = f32::from(self.font_metrics.units_per_em);
358
14
            let scale_factor = if units_per_em > 0.0 {
359
12
                font_size_px / units_per_em
360
            } else {
361
2
                FALLBACK_SCALE
362
            };
363

            
364
            // max_x, max_y, min_x, min_y in font units
365
14
            let bbox = &record.bounding_box;
366

            
367
14
            LogicalSize {
368
14
                width: f32::from(bbox.max_x - bbox.min_x) * scale_factor,
369
14
                height: f32::from(bbox.max_y - bbox.min_y) * scale_factor,
370
14
            }
371
14
        })
372
16
    }
373

            
374
15
    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
375
15
        let glyph_id = self.lookup_glyph_index('-' as u32)?;
376
15
        let advance_units = self.get_horizontal_advance(glyph_id);
377
15
        let scale_factor = if self.font_metrics.units_per_em > 0 {
378
13
            font_size / f32::from(self.font_metrics.units_per_em)
379
        } else {
380
2
            return None;
381
        };
382
13
        let scaled_advance = f32::from(advance_units) * scale_factor;
383
13
        Some((glyph_id, scaled_advance))
384
15
    }
385

            
386
11
    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
11
        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
11
    }
398
}
399

            
400
/// Fallback scale factor when `units_per_em` is zero (corrupt/broken font).
401
const 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)
415
47646
fn 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
47646
    let mut mask = FeatureMask::default_mask(); // Includes: CALT, CCMP, CLIG, LIGA, LOCL, RLIG
420

            
421
    // Add script-specific features
422
47646
    match script {
423
        // Arabic and related scripts - require positional forms
424
66
        Arabic => {
425
66
            mask |= Feature::INIT; // Initial forms (at start of word)
426
66
            mask |= Feature::MEDI; // Medial forms (middle of word)
427
66
            mask |= Feature::FINA; // Final forms (end of word)
428
66
            mask |= Feature::ISOL; // Isolated forms (standalone)
429
66
                                       // Note: RLIG (required ligatures) already in default for
430
66
                                       // lam-alef ligatures
431
66
        }
432

            
433
        // Indic scripts - require complex conjunct formation and reordering
434
        Devanagari | Bengali | Gujarati | Gurmukhi | Kannada | Malayalam | Oriya | Tamil
435
27
        | Telugu => {
436
27
            mask |= Feature::NUKT; // Nukta forms
437
27
            mask |= Feature::AKHN; // Akhand ligatures
438
27
            mask |= Feature::RPHF; // Reph form
439
27
            mask |= Feature::RKRF; // Rakar form
440
27
            mask |= Feature::PREF; // Pre-base forms
441
27
            mask |= Feature::BLWF; // Below-base forms
442
27
            mask |= Feature::ABVF; // Above-base forms
443
27
            mask |= Feature::HALF; // Half forms
444
27
            mask |= Feature::PSTF; // Post-base forms
445
27
            mask |= Feature::VATU; // Vattu variants
446
27
            mask |= Feature::CJCT; // Conjunct forms
447
27
        }
448

            
449
        // Myanmar (Burmese) - has complex reordering
450
3
        Myanmar => {
451
3
            mask |= Feature::PREF; // Pre-base forms
452
3
            mask |= Feature::BLWF; // Below-base forms
453
3
            mask |= Feature::PSTF; // Post-base forms
454
3
        }
455

            
456
        // Khmer - has complex reordering and stacking
457
3
        Khmer => {
458
3
            mask |= Feature::PREF; // Pre-base forms
459
3
            mask |= Feature::BLWF; // Below-base forms
460
3
            mask |= Feature::ABVF; // Above-base forms
461
3
            mask |= Feature::PSTF; // Post-base forms
462
3
        }
463

            
464
        // Thai - has tone marks and vowel reordering
465
2
        Thai => {
466
2
            // Thai mostly uses default features, but may have some special marks
467
2
            // The default mask is sufficient for most Thai fonts
468
2
        }
469

            
470
        // Hebrew - may have contextual forms but less complex than Arabic
471
2
        Hebrew => {
472
2
            // Hebrew fonts may use contextual alternates already in default
473
2
            // Some fonts have special features but they're rare
474
2
        }
475

            
476
        // Hangul (Korean) - has complex syllable composition
477
2
        Hangul => {
478
2
            // Note: Hangul jamo features (LJMO, VJMO, TJMO) are not available in allsorts'
479
2
            // FeatureMask Most modern Hangul fonts work correctly with the default
480
2
            // features as syllable composition is usually handled at a lower level
481
2
        }
482

            
483
        // Ethiopic - has syllabic script with some ligatures
484
2
        Ethiopic => {
485
2
            // Default features are usually sufficient
486
2
            // LIGA and CLIG already in default mask
487
2
        }
488

            
489
        // Latin, Greek, Cyrillic - standard features are sufficient
490
47527
        Latin | Greek | Cyrillic => {
491
47527
            // Default mask includes all needed features:
492
47527
            // - LIGA: standard ligatures (fi, fl, etc.)
493
47527
            // - CLIG: contextual ligatures
494
47527
            // - CALT: contextual alternates
495
47527
            // - CCMP: mark composition
496
47527
        }
497

            
498
        // Georgian - uses standard features
499
3
        Georgian => {
500
3
            // Default features sufficient
501
3
        }
502

            
503
        // CJK scripts (Hiragana, Katakana, Mandarin/Hani)
504
6
        Hiragana | Katakana | Mandarin => {
505
6
            // CJK fonts may use vertical alternates, but those are controlled
506
6
            // by writing-mode, not GSUB features in the horizontal direction.
507
6
            // Default features are sufficient.
508
6
        }
509

            
510
        // Sinhala - Indic-derived but simpler
511
3
        Sinhala => {
512
3
            mask |= Feature::AKHN; // Akhand ligatures
513
3
            mask |= Feature::RPHF; // Reph form
514
3
            mask |= Feature::VATU; // Vattu variants
515
3
        }
516
    }
517

            
518
47646
    mask
519
47646
}
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)
523
49946
const 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
49946
    match script {
527
67
        Arabic => u32::from_be_bytes(*b"arab"),
528
3
        Bengali => u32::from_be_bytes(*b"beng"),
529
3
        Cyrillic => u32::from_be_bytes(*b"cyrl"),
530
4
        Devanagari => u32::from_be_bytes(*b"deva"),
531
3
        Ethiopic => u32::from_be_bytes(*b"ethi"),
532
3
        Georgian => u32::from_be_bytes(*b"geor"),
533
21
        Greek => u32::from_be_bytes(*b"grek"),
534
3
        Gujarati => u32::from_be_bytes(*b"gujr"),
535
3
        Gurmukhi => u32::from_be_bytes(*b"guru"),
536
3
        Hangul => u32::from_be_bytes(*b"hang"),
537
210
        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
5
        Hiragana => u32::from_be_bytes(*b"kana"),
541
3
        Kannada => u32::from_be_bytes(*b"knda"),
542
4
        Katakana => u32::from_be_bytes(*b"kana"),
543
3
        Khmer => u32::from_be_bytes(*b"khmr"),
544
49502
        Latin => u32::from_be_bytes(*b"latn"),
545
3
        Malayalam => u32::from_be_bytes(*b"mlym"),
546
85
        Mandarin => u32::from_be_bytes(*b"hani"),
547
3
        Myanmar => u32::from_be_bytes(*b"mymr"),
548
3
        Oriya => u32::from_be_bytes(*b"orya"),
549
3
        Sinhala => u32::from_be_bytes(*b"sinh"),
550
3
        Tamil => u32::from_be_bytes(*b"taml"),
551
3
        Telugu => u32::from_be_bytes(*b"telu"),
552
3
        Thai => u32::from_be_bytes(*b"thai"),
553
    }
554
49946
}
555

            
556
/// Parses a CSS-style font-feature-settings string like `"liga"`, `"liga=0"`, or `"ss01"`.
557
/// Returns an OpenType tag and a value.
558
51
fn parse_font_feature(feature_str: &str) -> Option<(u32, u32)> {
559
51
    let mut parts = feature_str.split('=');
560
51
    let tag_str = parts.next()?.trim();
561
51
    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
51
    if tag_str.len() > 4 {
565
9
        return None;
566
42
    }
567
    // Pad with spaces if necessary
568
42
    let padded_tag_str = format!("{tag_str:<4}");
569

            
570
42
    let tag = u32::from_be_bytes(padded_tag_str.as_bytes().try_into().ok()?);
571
35
    let value = value_str.parse::<u32>().ok()?;
572

            
573
20
    Some((tag, value))
574
51
}
575

            
576
/// A helper to add OpenType features based on CSS `font-variant-*` properties.
577
50525
fn add_variant_features(style: &StyleProperties, features: &mut Vec<FeatureInfo>) {
578
    // Helper to add a feature that is simply "on".
579
51297
    let mut add_on = |tag_str: &[u8; 4]| {
580
1475
        features.push(FeatureInfo {
581
1475
            feature_tag: u32::from_be_bytes(*tag_str),
582
1475
            alternate: None,
583
1475
        });
584
1475
    };
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
50525
    match style.font_variant_ligatures {
594
64
        FontVariantLigatures::Discretionary => add_on(b"dlig"),
595
63
        FontVariantLigatures::Historical => add_on(b"hlig"),
596
63
        FontVariantLigatures::Contextual => add_on(b"calt"),
597
50335
        _ => {} // Other cases are either default-on or require disabling.
598
    }
599

            
600
    // Caps
601
50525
    match style.font_variant_caps {
602
91
        FontVariantCaps::SmallCaps => add_on(b"smcp"),
603
91
        FontVariantCaps::AllSmallCaps => {
604
91
            add_on(b"c2sc");
605
91
            add_on(b"smcp");
606
91
        }
607
90
        FontVariantCaps::PetiteCaps => add_on(b"pcap"),
608
90
        FontVariantCaps::AllPetiteCaps => {
609
90
            add_on(b"c2pc");
610
90
            add_on(b"pcap");
611
90
        }
612
90
        FontVariantCaps::Unicase => add_on(b"unic"),
613
91
        FontVariantCaps::TitlingCaps => add_on(b"titl"),
614
49982
        FontVariantCaps::Normal => {}
615
    }
616

            
617
    // Numeric
618
50525
    match style.font_variant_numeric {
619
70
        FontVariantNumeric::LiningNums => add_on(b"lnum"),
620
70
        FontVariantNumeric::OldstyleNums => add_on(b"onum"),
621
70
        FontVariantNumeric::ProportionalNums => add_on(b"pnum"),
622
71
        FontVariantNumeric::TabularNums => add_on(b"tnum"),
623
70
        FontVariantNumeric::DiagonalFractions => add_on(b"frac"),
624
70
        FontVariantNumeric::StackedFractions => add_on(b"afrc"),
625
70
        FontVariantNumeric::Ordinal => add_on(b"ordn"),
626
70
        FontVariantNumeric::SlashedZero => add_on(b"zero"),
627
49964
        FontVariantNumeric::Normal => {}
628
    }
629
50525
}
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)
634
49912
const 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
49912
    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
306
        Chinese => *b"ZHS ",
649
        Coptic => *b"COP ",
650
        Croatian => *b"HRV ",
651
        Czech => *b"CSY ",
652
        Danish => *b"DAN ",
653
        Dutch => *b"NLD ",
654
2
        EnglishGB => *b"ENG ",
655
49560
        EnglishUS => *b"ENU ",
656
        Esperanto => *b"ESP ",
657
        Estonian => *b"ETI ",
658
        Ethiopic => *b"ETH ",
659
2
        Finnish => *b"FIN ",
660
2
        FinnishScholastic => *b"FIN ",
661
2
        French => *b"FRA ",
662
        Friulan => *b"FRL ",
663
        Galician => *b"GLC ",
664
        Georgian => *b"KAT ",
665
2
        German1901 => *b"DEU ",
666
12
        German1996 => *b"DEU ",
667
        GermanSwiss => *b"DES ",
668
        GreekAncient => *b"GRC ",
669
18
        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
1
        Latin => *b"LAT ",
682
1
        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
2
        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
1
        Thai => *b"THA ",
714
        Turkish => *b"TRK ",
715
        Turkmen => *b"TUK ",
716
        Ukrainian => *b"UKR ",
717
        Uppersorbian => *b"HSB ",
718
1
        Welsh => *b"CYM ",
719
    };
720
49912
    u32::from_be_bytes(tag_bytes)
721
49912
}
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)
728
49891
fn shape_text_internal(
729
49891
    parsed_font: &ParsedFont,
730
49891
    text: &str,
731
49891
    script: Script,
732
49891
    language: crate::text3::script::Language,
733
49891
    direction: BidiDirection,
734
49891
    style: &StyleProperties,
735
49891
) -> Result<Vec<Glyph>, LayoutError> {
736
49891
    let script_tag = to_opentype_script_tag(script);
737
    #[cfg(feature = "text_layout_hyphenation")]
738
49891
    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
49891
    let mut user_features: Vec<FeatureInfo> = style
745
49891
        .font_features
746
49891
        .iter()
747
49891
        .filter_map(|s| parse_font_feature(s))
748
49891
        .map(|(tag, value)| FeatureInfo {
749
4
            feature_tag: tag,
750
4
            alternate: if value > 1 {
751
1
                Some(value as usize)
752
            } else {
753
3
                None
754
            },
755
4
        })
756
49891
        .collect();
757
49891
    add_variant_features(style, &mut user_features);
758

            
759
49891
    let opt_gdef = parsed_font.opt_gdef_table.as_deref();
760

            
761
49891
    let mut raw_glyphs: Vec<gsub::RawGlyph<()>> = Vec::new();
762
    {
763
49891
        let mut ci = 0usize;
764
912557
        while ci < text.len() {
765
862666
            let Some(ch) = text[ci..].chars().next() else {
766
                break;
767
            };
768
862666
            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
862666
            raw_glyphs.push(gsub::RawGlyph {
780
862666
                unicodes: tinyvec::tiny_vec![[char; 1] => ch],
781
862666
                glyph_index,
782
                liga_component_pos: 0,
783
862666
                glyph_origin: gsub::GlyphOrigin::Char(ch),
784
862666
                flags: gsub::RawGlyphFlags::empty(),
785
862666
                extra_data: (),
786
862666
                variation: None,
787
            });
788
862666
            ci += ch.len_utf8();
789
        }
790
    }
791

            
792
49891
    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
47605
        let feature_mask = build_feature_mask_for_script(script);
801
47605
        let custom_features: &[FeatureInfo] = user_features.as_slice();
802

            
803
47605
        let dotted_circle_index = parsed_font
804
47605
            .lookup_glyph_index(allsorts::DOTTED_CIRCLE as u32)
805
47605
            .unwrap_or(0);
806
47605
        gsub::apply(
807
47605
            dotted_circle_index,
808
47605
            gsub,
809
47605
            opt_gdef,
810
47605
            script_tag,
811
47605
            Some(lang_tag),
812
47605
            feature_mask,
813
47605
            custom_features,
814
47605
            None,
815
47605
            parsed_font.num_glyphs(),
816
47605
            &mut raw_glyphs,
817
        )
818
47605
        .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
819
2286
    }
820

            
821
49891
    let mut infos = gpos::Info::init_from_glyphs(opt_gdef, raw_glyphs);
822

            
823
49891
    if let Some(gpos) = parsed_font.gpos() {
824
47560
        let kern_table = parsed_font
825
47560
            .opt_kern_table
826
47560
            .as_ref()
827
47560
            .map(|kt| kt.as_borrowed());
828
47560
        let apply_kerning = true; // Always enable GPOS kern feature (not just when legacy kern table exists)
829
47560
        gpos::apply(
830
47560
            gpos,
831
47560
            opt_gdef,
832
47560
            kern_table,
833
47560
            apply_kerning,
834
47560
            FeatureMask::empty(),
835
47560
            &user_features,
836
47560
            None,
837
47560
            script_tag,
838
47560
            Some(lang_tag),
839
47560
            &mut infos,
840
        )
841
47560
        .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
2331
        let kern_table = parsed_font
848
2331
            .opt_kern_table
849
2331
            .as_ref()
850
2331
            .map(|kt| kt.as_borrowed());
851
2331
        gpos::apply_fallback(kern_table, script_tag, &mut infos)
852
2331
            .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
853
    }
854

            
855
49891
    let font_size = style.font_size_px;
856
49891
    let scale_factor = if parsed_font.font_metrics.units_per_em > 0 {
857
49891
        font_size / f32::from(parsed_font.font_metrics.units_per_em)
858
    } else {
859
        FALLBACK_SCALE
860
    };
861

            
862
49891
    let font_hash = parsed_font.get_hash();
863
49891
    let font_metrics = LayoutFontMetrics {
864
49891
        ascent: parsed_font.font_metrics.ascent,
865
49891
        descent: parsed_font.font_metrics.descent,
866
49891
        line_gap: parsed_font.font_metrics.line_gap,
867
49891
        units_per_em: parsed_font.font_metrics.units_per_em,
868
49891
        x_height: parsed_font.font_metrics.x_height,
869
49891
        cap_height: parsed_font.font_metrics.cap_height,
870
49891
    };
871
49891
    let style_arc = Arc::new(style.clone());
872
49891
    let bidi_level = BidiLevel::new(u8::from(direction.is_rtl()));
873

            
874
49891
    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
49891
    let mut byte_cursor = 0usize;
883
911479
    for info in &infos {
884
862666
        let uni_len: usize = info.glyph.unicodes.iter().map(|c| c.len_utf8()).sum();
885
861588
        let (byte_index, byte_len) = if info.glyph.multi_subst_dup() || uni_len == 0 {
886
            (byte_cursor.min(text.len()), 0)
887
        } else {
888
861588
            let start = byte_cursor.min(text.len());
889
861588
            byte_cursor = (byte_cursor + uni_len).min(text.len());
890
861588
            (start, uni_len)
891
        };
892
861588
        let cluster = byte_index as u32;
893
861588
        let source_char = info
894
861588
            .glyph
895
861588
            .unicodes
896
861588
            .first()
897
861588
            .copied()
898
861588
            .or_else(|| text.get(byte_index..).and_then(|s| s.chars().next()))
899
861588
            .unwrap_or('\u{FFFD}');
900

            
901
861588
        let base_advance = parsed_font.get_horizontal_advance(info.glyph.glyph_index);
902
        // Layout uses LINEAR (design-space) advances, like Chrome/Blink and
903
        // CoreText: with sub-pixel text positioning the rasterizer places
904
        // each glyph at a fractional origin, and under light hinting the X
905
        // axis is not grid-fit at all, so integer-quantized hinted advances
906
        // would only ADD error. Measured: hinted advances inflated runs by
907
        // ~0.2px per glyph ('l' at 14px: 4.0 hinted vs 3.612 linear), which
908
        // made every long text run visibly wider than Chrome's (cascade-*
909
        // reftests). Full grid-fit mode (AZ_HINT_LIGHT=0) keeps the hinted
910
        // advance so raster and metrics stay mutually consistent; it is
911
        // rescaled from the integer hinting ppem back to the exact
912
        // fractional font size to stay on the same basis as the GPOS
913
        // offsets and kerning below.
914
        // `hint_light_enabled` lives in glyph_cache, which is `cpurender`-
915
        // gated; text layout itself is not. Without cpurender there is no
916
        // rasterizer to stay consistent WITH, so the light (linear-advance)
917
        // basis — also the default when the module IS present — is the only
918
        // sensible answer. Reading the same env var here keeps a
919
        // cpurender-less build honest about AZ_HINT_LIGHT=0 being a
920
        // raster-side knob it cannot honor.
921
        #[cfg(feature = "cpurender")]
922
861588
        let hint_light = crate::glyph_cache::hint_light_enabled();
923
        #[cfg(not(feature = "cpurender"))]
924
        let hint_light = true;
925
861588
        let advance = if hint_light {
926
861588
            f32::from(base_advance) * scale_factor
927
        } else {
928
            let ppem = font_size.round().max(1.0) as u16;
929
            parsed_font
930
                .get_hinted_advance_px(info.glyph.glyph_index, ppem)
931
                .map_or_else(
932
                    || f32::from(base_advance) * scale_factor,
933
                    |hinted| hinted * font_size / f32::from(ppem),
934
                )
935
        };
936
861588
        let kerning = f32::from(info.kerning) * scale_factor;
937

            
938
861588
        let (offset_x_units, offset_y_units) =
939
861588
            if let gpos::Placement::Distance(x, y) = info.placement {
940
                (x, y)
941
            } else {
942
861588
                (0, 0)
943
            };
944
861588
        let offset_x = offset_x_units as f32 * scale_factor;
945
861588
        let offset_y = offset_y_units as f32 * scale_factor;
946

            
947
861588
        let vert = parsed_font.get_vertical_metrics(info.glyph.glyph_index);
948
861588
        let glyph = Glyph {
949
861588
            glyph_id: info.glyph.glyph_index,
950
861588
            codepoint: source_char,
951
861588
            font_hash,
952
861588
            font_metrics,
953
861588
            style: Arc::clone(&style_arc),
954
861588
            source: GlyphSource::Char,
955
861588
            logical_byte_index: byte_index,
956
861588
            logical_byte_len: byte_len,
957
            content_index: 0,
958
861588
            cluster,
959
861588
            advance,
960
861588
            kerning,
961
861588
            offset: Point {
962
861588
                x: offset_x,
963
861588
                y: offset_y,
964
861588
            },
965
861588
            vertical_advance: vert.as_ref().map_or(0.0, |v| v.advance * font_size),
966
861588
            vertical_origin_y: vert.as_ref().map_or(0.0, |v| v.origin_y * font_size),
967
861588
            vertical_bearing: vert
968
861588
                .map_or(Point { x: 0.0, y: 0.0 }, |v| Point { x: v.bearing_x * font_size, y: v.bearing_y * font_size }),
969
861588
            orientation: GlyphOrientation::Horizontal,
970
861588
            script,
971
861588
            bidi_level,
972
        };
973
861588
        shaped_glyphs.push(glyph);
974
    }
975

            
976
49891
    Ok(shaped_glyphs)
977
49891
}
978

            
979
/// Public helper function to shape text for `ParsedFont`, returning Glyph
980
/// This is used by the `ParsedFontTrait` implementation for `ParsedFont`
981
/// # Errors
982
///
983
/// Returns a `LayoutError` if the text cannot be shaped.
984
191
pub fn shape_text_for_parsed_font(
985
191
    parsed_font: &ParsedFont,
986
191
    text: &str,
987
191
    script: Script,
988
191
    language: crate::text3::script::Language,
989
191
    direction: BidiDirection,
990
191
    style: &StyleProperties,
991
191
) -> Result<Vec<Glyph>, LayoutError> {
992
    // Delegate to the single internal implementation
993
191
    shape_text_internal(parsed_font, text, script, language, direction, style)
994
191
}
995

            
996
#[cfg(test)]
997
#[allow(clippy::float_cmp, clippy::cast_lossless, clippy::unreadable_literal)]
998
mod autotest_generated {
999
    use std::time::Duration;
    use rust_fontconfig::{FcFontCache, FontBytes, FontId};
    use super::*;
    use crate::text3::script::Language;
    /// Positive control: the built-in `Azul Mock Mono` TrueType face.
    const MOCK_MONO: &[u8] = crate::text3::mock_fonts::MOCK_MONO_TTF;
    /// Every `Script` variant, so the exhaustive mapping tables below can never
    /// silently miss one.
    const ALL_SCRIPTS: [Script; 24] = [
        Script::Arabic,
        Script::Bengali,
        Script::Cyrillic,
        Script::Devanagari,
        Script::Ethiopic,
        Script::Georgian,
        Script::Greek,
        Script::Gujarati,
        Script::Gurmukhi,
        Script::Hangul,
        Script::Hebrew,
        Script::Hiragana,
        Script::Kannada,
        Script::Katakana,
        Script::Khmer,
        Script::Latin,
        Script::Malayalam,
        Script::Mandarin,
        Script::Myanmar,
        Script::Oriya,
        Script::Sinhala,
        Script::Tamil,
        Script::Telugu,
        Script::Thai,
    ];
    /// Eager parse (`LocaGlyfState::Loaded`).
    fn mock() -> ParsedFont {
        let mut warnings = Vec::new();
        ParsedFont::from_bytes(MOCK_MONO, 0, &mut warnings).expect("Azul Mock Mono must parse")
    }
    /// Lazy parse (`LocaGlyfState::Deferred`) — the production path.
    fn mock_deferred() -> ParsedFont {
        let bytes = Arc::new(FontBytes::Owned(Arc::from(MOCK_MONO.to_vec())));
        let mut warnings = Vec::new();
        ParsedFont::from_bytes_shared(bytes, 0, &mut warnings)
            .expect("from_bytes_shared must parse the positive control")
    }
    fn style_at(font_size_px: f32) -> StyleProperties {
        StyleProperties {
            font_size_px,
            ..StyleProperties::default()
        }
    }
    fn shape(font: &ParsedFont, text: &str) -> Result<Vec<Glyph>, LayoutError> {
        shape_text_internal(
            font,
            text,
            Script::Latin,
            Language::EnglishUS,
            BidiDirection::Ltr,
            &style_at(16.0),
        )
    }
    /// Invariants that must hold for *any* shaping result, no matter how hostile
    /// the input: byte spans stay inside the source, land on char boundaries and
    /// never run backwards.
    fn assert_spans_are_sane(glyphs: &[Glyph], text: &str) {
        let mut prev_index = 0usize;
        for g in glyphs {
            assert!(
                g.logical_byte_index <= text.len(),
                "byte index {} escapes the {}-byte source",
                g.logical_byte_index,
                text.len()
            );
            let end = g.logical_byte_index + g.logical_byte_len;
            assert!(
                end <= text.len(),
                "span {}..{end} escapes the {}-byte source",
                g.logical_byte_index,
                text.len()
            );
            assert!(
                text.is_char_boundary(g.logical_byte_index) && text.is_char_boundary(end),
                "span {}..{end} splits a UTF-8 sequence",
                g.logical_byte_index
            );
            assert!(
                g.logical_byte_index >= prev_index,
                "byte cursor ran backwards: {} after {prev_index}",
                g.logical_byte_index
            );
            prev_index = g.logical_byte_index;
            assert_eq!(
                u64::from(g.cluster),
                g.logical_byte_index as u64,
                "cluster must mirror the logical byte index"
            );
        }
    }
    // -----------------------------------------------------------------
    // font_ref_from_bytes (parser)
    // -----------------------------------------------------------------
    #[test]
    fn font_ref_from_bytes_rejects_empty_and_malformed_input() {
        // empty / whitespace-only / invalid-UTF-8 / garbage: None, never a panic
        assert!(font_ref_from_bytes(b"", 0, false).is_none());
        assert!(font_ref_from_bytes(b"   \t\n", 0, false).is_none());
        assert!(font_ref_from_bytes(&[0xFF, 0xFE, 0x00], 0, false).is_none());
        assert!(font_ref_from_bytes(b"not a font at all, just prose", 0, false).is_none());
        // a 4-byte "sfnt-ish" header with nothing behind it
        assert!(font_ref_from_bytes(&[0x00, 0x01, 0x00, 0x00], 0, false).is_none());
        // truncated real font: header only, then a torso
        assert!(font_ref_from_bytes(&MOCK_MONO[..12], 0, false).is_none());
        assert!(font_ref_from_bytes(&MOCK_MONO[..64], 0, false).is_none());
        // leading junk in front of an otherwise valid font must not parse
        let mut prefixed = vec![0xABu8; 32];
        prefixed.extend_from_slice(MOCK_MONO);
        assert!(font_ref_from_bytes(&prefixed, 0, false).is_none());
    }
    #[test]
    fn font_ref_from_bytes_extremely_long_garbage_terminates() {
        // 1 MiB of NULs must be rejected without hanging or allocating wildly
        assert!(font_ref_from_bytes(&vec![0u8; 1_000_000], 0, false).is_none());
        // a "ttcf" collection header followed by a megabyte of noise
        let mut ttc_junk = b"ttcf".to_vec();
        ttc_junk.extend_from_slice(&vec![0xCDu8; 1_000_000]);
        assert!(font_ref_from_bytes(&ttc_junk, 0, false).is_none());
    }
    #[test]
    fn font_ref_from_bytes_valid_minimal_positive_control() {
        let font_ref =
            font_ref_from_bytes(MOCK_MONO, 0, false).expect("Azul Mock Mono must parse into a FontRef");
        assert!(font_ref.num_glyphs() > 0, "a real font has glyphs");
        assert_eq!(font_ref.get_hash(), mock().get_hash());
        assert!(font_ref.has_glyph('a' as u32));
    }
    #[test]
    fn font_ref_from_bytes_ignores_the_parse_outlines_flag() {
        // NOTE: `parse_outlines` is accepted but never forwarded to
        // `ParsedFont::from_bytes` — both settings must therefore produce
        // an identical face. This pins the current (no-op) behaviour.
        let with = font_ref_from_bytes(MOCK_MONO, 0, true).expect("parse with outlines");
        let without = font_ref_from_bytes(MOCK_MONO, 0, false).expect("parse without outlines");
        assert_eq!(with.get_hash(), without.get_hash());
        assert_eq!(with.num_glyphs(), without.num_glyphs());
        assert_eq!(with.get_space_width(), without.get_space_width());
    }
    #[test]
    fn font_ref_from_bytes_extreme_font_index_does_not_panic() {
        // Out-of-range collection indices must resolve to Some/None, never an
        // out-of-bounds index panic.
        for index in [0usize, 1, 255, usize::MAX / 2, usize::MAX] {
            let _ = font_ref_from_bytes(MOCK_MONO, index, false);
            let _ = font_ref_from_bytes(b"", index, false);
        }
    }
    // -----------------------------------------------------------------
    // PathLoader::new / load_from_path / load_font_shared
    // -----------------------------------------------------------------
    #[test]
    fn path_loader_new_is_a_zero_sized_stateless_handle() {
        assert_eq!(size_of::<PathLoader>(), 0);
        let a = PathLoader::new();
        let b = PathLoader;
        // both handles behave identically: no hidden per-instance state
        assert!(a.load_from_path(Path::new("/nonexistent/azul/x.ttf"), 0).is_err());
        assert!(b.load_from_path(Path::new("/nonexistent/azul/x.ttf"), 0).is_err());
    }
    #[test]
    fn load_from_path_missing_empty_and_directory_paths_are_font_not_found() {
        let loader = PathLoader::new();
        for path in [
            "",
            "/nonexistent/definitely/not/a/font.ttf",
            "/dev/null",
            env!("CARGO_MANIFEST_DIR"), // a directory, not a file
        ] {
            match loader.load_from_path(Path::new(path), 0) {
                Err(LayoutError::FontNotFound(selector)) => {
                    assert_eq!(selector.family, path, "the failing path is reported back");
                    assert!(selector.unicode_ranges.is_empty());
                }
                // /dev/null reads as zero bytes on Linux -> the parse fails instead
                Err(LayoutError::ShapingError(_)) => {}
                other => panic!("{path:?} must not load a font: {other:?}"),
            }
        }
    }
    #[test]
    fn load_from_path_parses_a_real_font_and_survives_extreme_indices() {
        let loader = PathLoader::new();
        let path = concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/assets/fonts/test/azul-mock-mono.ttf"
        );
        let font_ref = loader
            .load_from_path(Path::new(path), 0)
            .expect("the positive control must load from disk");
        assert_eq!(font_ref.num_glyphs(), mock().num_glyphs());
        // 0 / MAX face index: either resolves or errors, but never panics
        for index in [0usize, 1, usize::MAX] {
            let _ = loader.load_from_path(Path::new(path), index);
        }
    }
    #[test]
    fn load_font_shared_rejects_empty_and_garbage_byte_blobs() {
        let loader = PathLoader::new();
        let cases: Vec<Vec<u8>> = vec![
            Vec::new(),
            b"   \t\n".to_vec(),
            vec![0xFF, 0xFE, 0x00],
            MOCK_MONO[..32].to_vec(),
            vec![0u8; 1_000_000],
        ];
        for bytes in cases {
            let shared = Arc::new(FontBytes::Owned(Arc::from(bytes)));
            match loader.load_font_shared(shared, 0) {
                Err(LayoutError::ShapingError(msg)) => assert!(!msg.is_empty()),
                other => panic!("garbage must not parse: {other:?}"),
            }
        }
    }
    #[test]
    fn load_font_shared_matches_the_eager_parse_and_tolerates_extreme_indices() {
        let loader = PathLoader::new();
        let shared = Arc::new(FontBytes::Owned(Arc::from(MOCK_MONO.to_vec())));
        let font_ref = loader
            .load_font_shared(Arc::clone(&shared), 0)
            .expect("the positive control must parse");
        let eager = mock();
        assert_eq!(font_ref.num_glyphs(), eager.num_glyphs());
        assert_eq!(font_ref.get_hash(), eager.get_hash());
        // font_index at the numeric extremes must not index out of bounds
        for index in [0usize, 1, usize::MAX] {
            let _ = loader.load_font_shared(Arc::clone(&shared), index);
        }
    }
    // -----------------------------------------------------------------
    // FontManager::evict_unused
    // -----------------------------------------------------------------
    #[test]
    fn evict_unused_on_an_empty_manager_is_zero_for_extreme_durations() {
        let manager: FontManager<FontRef> =
            FontManager::new(FcFontCache::default()).expect("an empty FontManager must build");
        // Duration::MAX truncates when cast to u64 nanos; with no faces cached
        // the result is 0 either way — the point is that it must not panic.
        for idle in [
            Duration::ZERO,
            Duration::from_nanos(1),
            Duration::from_secs(3600),
            Duration::MAX,
        ] {
            assert_eq!(manager.evict_unused(idle), 0);
        }
    }
    #[test]
    fn evict_unused_only_reclaims_stale_deferred_faces() {
        let manager: FontManager<FontRef> =
            FontManager::new(FcFontCache::default()).expect("an empty FontManager must build");
        let deferred = crate::parsed_font_to_font_ref(mock_deferred());
        let eager = crate::parsed_font_to_font_ref(mock());
        {
            let mut fonts = manager.parsed_fonts.lock().unwrap();
            fonts.insert(FontId::new(), deferred.clone());
            fonts.insert(FontId::new(), eager.clone());
        }
        // Nothing has decoded a glyph yet: the deferred face holds no LocaGlyf,
        // so there is nothing to release even though it is "never touched".
        assert_eq!(manager.evict_unused(Duration::ZERO), 0);
        // Touch the deferred face -> it materialises loca+glyf and stamps last_used.
        let parsed = crate::font_ref_to_parsed_font(&deferred);
        assert!(parsed.get_or_decode_glyph(1).is_some(), "gid 1 must decode");
        let _ = crate::font_ref_to_parsed_font(&eager).get_or_decode_glyph(1);
        // A face used microseconds ago is not idle for an hour.
        assert_eq!(manager.evict_unused(Duration::from_secs(3600)), 0);
        // With a zero idle window it is stale immediately. The eager face keeps
        // no source bytes, so it can never be evicted -> exactly one eviction.
        assert_eq!(manager.evict_unused(Duration::ZERO), 1);
        // Evicting twice is a no-op.
        assert_eq!(manager.evict_unused(Duration::ZERO), 0);
        // The evicted face still decodes: it re-parses from its retained bytes.
        assert!(parsed.get_or_decode_glyph(2).is_some());
    }
    // -----------------------------------------------------------------
    // shape_text_internal / shape_text_for_parsed_font / FontRef::shape_text
    // -----------------------------------------------------------------
    #[test]
    fn shape_text_empty_input_yields_no_glyphs() {
        let font = mock();
        let glyphs = shape(&font, "").expect("empty text is not an error");
        assert!(glyphs.is_empty());
        let via_public = shape_text_for_parsed_font(
            &font,
            "",
            Script::Latin,
            Language::EnglishUS,
            BidiDirection::Ltr,
            &style_at(16.0),
        )
        .expect("empty text is not an error");
        assert!(via_public.is_empty());
        let font_ref = crate::parsed_font_to_font_ref(mock());
        let via_ref = font_ref
            .shape_text(
                "",
                Script::Latin,
                Language::EnglishUS,
                BidiDirection::Ltr,
                &style_at(16.0),
            )
            .expect("empty text is not an error");
        assert!(via_ref.is_empty());
    }
    #[test]
    fn shape_text_valid_minimal_input_maps_bytes_one_to_one() {
        let font = mock();
        let glyphs = shape(&font, "abc").expect("plain ASCII must shape");
        assert_eq!(glyphs.len(), 3);
        assert_spans_are_sane(&glyphs, "abc");
        for (i, (g, ch)) in glyphs.iter().zip("abc".chars()).enumerate() {
            assert_eq!(g.codepoint, ch);
            assert_eq!(g.logical_byte_index, i);
            assert_eq!(g.logical_byte_len, 1);
            assert_eq!(g.glyph_id, font.lookup_glyph_index(ch as u32).unwrap_or(0));
            assert!(g.advance > 0.0, "a real glyph has a positive advance");
            assert!(g.advance.is_finite());
            assert_eq!(g.font_hash, font.get_hash());
            assert_eq!(g.script, Script::Latin);
        }
    }
    #[test]
    fn shape_text_whitespace_only_input_is_shaped_not_trimmed() {
        let font = mock();
        let text = " \t\n\r ";
        let glyphs = shape(&font, text).expect("whitespace must shape");
        assert!(!glyphs.is_empty(), "whitespace is not silently dropped");
        assert_spans_are_sane(&glyphs, text);
        for g in &glyphs {
            assert!(g.advance.is_finite());
        }
    }
    #[test]
    fn shape_text_unicode_and_missing_glyphs_keep_multibyte_spans_intact() {
        let font = mock();
        // emoji (almost certainly absent from a text face), combining marks, RTL,
        // CJK and an unassigned plane-15 codepoint
        for text in [
            "\u{1F600}",
            "e\u{0301}\u{0327}",
            "\u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064A}\u{0629}",
            "\u{4E2D}\u{6587}",
            "\u{FFFD}\u{FDD0}\u{F0000}",
            "a\u{200B}b\u{00AD}c",
        ] {
            let glyphs = shape(&font, text).unwrap_or_else(|e| panic!("{text:?} must shape: {e:?}"));
            assert!(!glyphs.is_empty(), "{text:?} produced no glyphs");
            assert_spans_are_sane(&glyphs, text);
        }
        // a missing glyph falls back to .notdef but must still carry the full
        // 4-byte span of the source character
        let emoji = "\u{1F600}";
        let glyphs = shape(&font, emoji).expect("emoji must shape");
        assert_eq!(glyphs.len(), 1);
        assert_eq!(glyphs[0].codepoint, '\u{1F600}');
        assert_eq!(glyphs[0].logical_byte_index, 0);
        assert_eq!(glyphs[0].logical_byte_len, 4, "the whole 4-byte char is covered");
        assert_eq!(
            glyphs[0].glyph_id,
            font.lookup_glyph_index('\u{1F600}' as u32).unwrap_or(0)
        );
    }
    #[test]
    fn shape_text_extremely_long_input_terminates() {
        let font = mock();
        let text = "a".repeat(10_000);
        let glyphs = shape(&font, &text).expect("a long run must shape");
        assert_eq!(glyphs.len(), 10_000);
        assert_spans_are_sane(&glyphs, &text);
        // the cursor must have walked the whole source, not stalled at 0
        assert_eq!(glyphs.last().unwrap().logical_byte_index, 9_999);
    }
    #[test]
    fn shape_text_deeply_nested_brackets_does_not_stack_overflow() {
        let font = mock();
        let text = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000));
        let glyphs = shape(&font, &text).expect("nested brackets are just characters");
        assert_eq!(glyphs.len(), 20_000);
        assert_spans_are_sane(&glyphs, &text);
    }
    #[test]
    fn shape_text_boundary_numeric_text_is_shaped_verbatim() {
        let font = mock();
        let text = "0 -0 9223372036854775807 -9223372036854775808 NaN inf 1e309 0.0000001";
        let glyphs = shape(&font, text).expect("numeric-looking text is still text");
        assert_spans_are_sane(&glyphs, text);
        assert!(!glyphs.is_empty());
        for g in &glyphs {
            assert!(g.advance.is_finite(), "a 16px advance must stay finite");
        }
    }
    #[test]
    fn shape_text_nan_and_infinite_font_sizes_do_not_panic() {
        let font = mock();
        // ppem is `font_size.round().max(1.0) as u16` — an unchecked float->int
        // cast. NaN/inf must saturate, not trap.
        for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            let glyphs = shape_text_internal(
                &font,
                "ab",
                Script::Latin,
                Language::EnglishUS,
                BidiDirection::Ltr,
                &style_at(size),
            )
            .unwrap_or_else(|e| panic!("font_size {size} must not fail shaping: {e:?}"));
            assert_eq!(glyphs.len(), 2);
            assert_spans_are_sane(&glyphs, "ab");
            for g in &glyphs {
                assert!(
                    !g.advance.is_finite(),
                    "a non-finite font size must not manufacture a finite advance ({size})"
                );
            }
        }
        // Finite-but-absurd sizes drive the float->u16 ppem cast to its
        // saturation points; they may overflow to ±inf but must not panic and
        // must not turn a non-NaN size into a NaN advance.
        for size in [f32::MAX, -f32::MAX, 1e30f32] {
            let glyphs = shape_text_internal(
                &font,
                "ab",
                Script::Latin,
                Language::EnglishUS,
                BidiDirection::Ltr,
                &style_at(size),
            )
            .unwrap_or_else(|e| panic!("font_size {size} must not fail shaping: {e:?}"));
            assert_eq!(glyphs.len(), 2);
            for g in &glyphs {
                assert!(!g.advance.is_nan(), "size {size} produced a NaN advance");
            }
        }
    }
    #[test]
    fn shape_text_zero_and_tiny_font_sizes_produce_zero_or_finite_advances() {
        let font = mock();
        for (size, expect_zero) in [(0.0f32, true), (-0.0f32, true), (f32::MIN_POSITIVE, false)] {
            let glyphs = shape_text_internal(
                &font,
                "ab",
                Script::Latin,
                Language::EnglishUS,
                BidiDirection::Ltr,
                &style_at(size),
            )
            .expect("degenerate font sizes must still shape");
            assert_eq!(glyphs.len(), 2);
            for g in &glyphs {
                assert!(g.advance.is_finite(), "size {size} produced {}", g.advance);
                if expect_zero {
                    assert_eq!(g.advance, 0.0, "a 0px font has zero-width glyphs");
                    assert_eq!(g.kerning, 0.0);
                }
            }
        }
    }
    #[test]
    fn shape_text_negative_font_size_mirrors_the_advance_sign() {
        let font = mock();
        let positive = shape_text_internal(
            &font,
            "a",
            Script::Latin,
            Language::EnglishUS,
            BidiDirection::Ltr,
            &style_at(16.0),
        )
        .expect("shape at +16px");
        let negative = shape_text_internal(
            &font,
            "a",
            Script::Latin,
            Language::EnglishUS,
            BidiDirection::Ltr,
            &style_at(-16.0),
        )
        .expect("a negative font size must not panic");
        assert_eq!(positive.len(), 1);
        assert_eq!(negative.len(), 1);
        assert_eq!(positive[0].glyph_id, negative[0].glyph_id);
        assert!(negative[0].advance.is_finite());
        assert!(
            negative[0].advance <= 0.0,
            "a negative size cannot yield a positive advance"
        );
    }
    #[test]
    fn shape_text_garbage_font_features_are_skipped_not_fatal() {
        let font = mock();
        let style = StyleProperties {
            font_features: vec![
                String::new(),
                "   ".to_string(),
                "waytoolongtag".to_string(),
                "liga=-1".to_string(),
                "liga=99999999999999999999".to_string(),
                "\u{1F600}".to_string(),
                "liga".to_string(),   // one good one, to prove the filter is per-item
                "ss01=2".to_string(),
            ],
            ..StyleProperties::default()
        };
        let glyphs = shape_text_internal(
            &font,
            "abc",
            Script::Latin,
            Language::EnglishUS,
            BidiDirection::Ltr,
            &style,
        )
        .expect("malformed feature strings must be dropped, not fatal");
        assert_eq!(glyphs.len(), 3);
        assert_spans_are_sane(&glyphs, "abc");
    }
    #[test]
    fn shape_text_direction_drives_the_bidi_level() {
        let font = mock();
        for (direction, expected) in [(BidiDirection::Ltr, 0u8), (BidiDirection::Rtl, 1u8)] {
            let glyphs = shape_text_internal(
                &font,
                "abc",
                Script::Latin,
                Language::EnglishUS,
                direction,
                &style_at(16.0),
            )
            .expect("both directions must shape");
            assert!(!glyphs.is_empty());
            for g in &glyphs {
                assert_eq!(g.bidi_level.level(), expected);
                assert_eq!(g.bidi_level.is_rtl(), direction.is_rtl());
            }
        }
    }
    #[test]
    fn shape_text_every_script_and_language_pairing_is_shapeable() {
        let font = mock();
        // A script tag that the font has no coverage for must degrade to
        // .notdef glyphs, never to an Err or a panic.
        for script in ALL_SCRIPTS {
            let glyphs = shape_text_internal(
                &font,
                "Hello \u{0e2a}\u{0e27}\u{0e31}\u{0e2a}\u{0e14}\u{0e35}",
                script,
                Language::EnglishUS,
                BidiDirection::Ltr,
                &style_at(16.0),
            )
            .unwrap_or_else(|e| panic!("{script:?} must shape: {e:?}"));
            assert!(!glyphs.is_empty(), "{script:?} produced no glyphs");
            for g in &glyphs {
                assert_eq!(g.script, script, "the requested script is stamped on the glyph");
            }
        }
    }
    #[test]
    fn shape_text_public_internal_and_font_ref_paths_agree() {
        // Round-trip / consistency: the three entry points are documented as
        // sharing one implementation, so they must produce identical glyphs.
        let font = mock();
        let font_ref = crate::parsed_font_to_font_ref(mock());
        let text = "Wafer fi\u{0301}x \u{1F600}";
        let style = style_at(13.5);
        let internal = shape_text_internal(
            &font,
            text,
            Script::Latin,
            Language::EnglishUS,
            BidiDirection::Ltr,
            &style,
        )
        .expect("internal shaping");
        let public = shape_text_for_parsed_font(
            &font,
            text,
            Script::Latin,
            Language::EnglishUS,
            BidiDirection::Ltr,
            &style,
        )
        .expect("public shaping");
        let via_ref = font_ref
            .shape_text(
                text,
                Script::Latin,
                Language::EnglishUS,
                BidiDirection::Ltr,
                &style,
            )
            .expect("FontRef shaping");
        let via_helper = font
            .shape_text_for_font_ref(
                &font_ref,
                text,
                Script::Latin,
                Language::EnglishUS,
                BidiDirection::Ltr,
                &style,
            )
            .expect("shape_text_for_font_ref");
        assert_eq!(internal.len(), public.len());
        assert_eq!(internal.len(), via_ref.len());
        assert_eq!(internal.len(), via_helper.len());
        for (((a, b), c), d) in internal
            .iter()
            .zip(public.iter())
            .zip(via_ref.iter())
            .zip(via_helper.iter())
        {
            for other in [b, c, d] {
                assert_eq!(a.glyph_id, other.glyph_id);
                assert_eq!(a.codepoint, other.codepoint);
                assert_eq!(a.advance, other.advance);
                assert_eq!(a.kerning, other.kerning);
                assert_eq!(a.logical_byte_index, other.logical_byte_index);
                assert_eq!(a.logical_byte_len, other.logical_byte_len);
                assert_eq!(a.font_hash, other.font_hash);
            }
        }
        assert_spans_are_sane(&internal, text);
    }
    // -----------------------------------------------------------------
    // ParsedFont::get_hash (getter)
    // -----------------------------------------------------------------
    #[test]
    fn get_hash_is_stable_and_shared_with_the_font_ref_view() {
        let a = mock();
        let b = mock();
        assert_eq!(a.get_hash(), b.get_hash(), "parsing is deterministic");
        assert_eq!(a.get_hash(), a.hash, "the getter reads the cached field");
        let font_ref = crate::parsed_font_to_font_ref(mock());
        assert_eq!(font_ref.get_hash(), a.get_hash());
        // the lazy constructor must not change identity
        assert_eq!(mock_deferred().get_hash(), a.get_hash());
    }
    // -----------------------------------------------------------------
    // ParsedFont::get_glyph_size (numeric)
    // -----------------------------------------------------------------
    #[test]
    fn get_glyph_size_out_of_range_glyph_ids_are_none() {
        let font = mock();
        assert!(font.get_glyph_size(u16::MAX, 16.0).is_none());
        assert!(font.get_glyph_size(font.num_glyphs, 16.0).is_none());
        // an in-range gid still decodes (positive control)
        let gid = font.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
        assert!(gid < font.num_glyphs);
        assert!(font.get_glyph_size(gid, 16.0).is_some());
    }
    #[test]
    fn get_glyph_size_zero_negative_and_non_finite_font_sizes() {
        let font = mock();
        let gid = font.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
        let zero = font.get_glyph_size(gid, 0.0).expect("gid decodes");
        assert_eq!(zero.width, 0.0);
        assert_eq!(zero.height, 0.0);
        let base = font.get_glyph_size(gid, 16.0).expect("gid decodes");
        assert!(base.width > 0.0 && base.height > 0.0);
        // scaling is linear in the font size
        let doubled = font.get_glyph_size(gid, 32.0).expect("gid decodes");
        assert!((doubled.width - 2.0 * base.width).abs() <= 1e-3 * base.width.max(1.0));
        let negative = font.get_glyph_size(gid, -16.0).expect("gid decodes");
        assert!(negative.width <= 0.0 && negative.width.is_finite());
        for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN_POSITIVE] {
            let size_result = font
                .get_glyph_size(gid, size)
                .unwrap_or_else(|| panic!("gid must still decode at {size}"));
            assert!(
                !size_result.width.is_nan() || size.is_nan(),
                "only a NaN input may produce a NaN width"
            );
        }
    }
    #[test]
    fn get_glyph_size_zero_units_per_em_uses_the_constant_fallback_scale() {
        assert_eq!(FALLBACK_SCALE, 0.01);
        let mut font = mock();
        let gid = font.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
        font.font_metrics.units_per_em = 0; // corrupt/broken font
        // With upem == 0 the scale is the constant FALLBACK_SCALE, so the size no
        // longer depends on the requested font size at all.
        let small = font.get_glyph_size(gid, 16.0).expect("gid decodes");
        let huge = font.get_glyph_size(gid, 1000.0).expect("gid decodes");
        assert!(small.width > 0.0 && small.height > 0.0, "no divide-by-zero NaN");
        assert_eq!(small.width, huge.width);
        assert_eq!(small.height, huge.height);
    }
    // -----------------------------------------------------------------
    // ParsedFont::get_hyphen_glyph_and_advance / get_kashida_glyph_and_advance
    // -----------------------------------------------------------------
    #[test]
    fn get_hyphen_glyph_and_advance_follows_the_cmap_and_scales_linearly() {
        let font = mock();
        let expected_gid = font
            .lookup_glyph_index('-' as u32)
            .expect("the positive control has a hyphen");
        let (gid, zero_advance) = font.get_hyphen_glyph_and_advance(0.0).expect("hyphen at 0px");
        assert_eq!(gid, expected_gid);
        assert_eq!(zero_advance, 0.0, "a 0px font gives a 0px advance");
        let (_, a16) = font.get_hyphen_glyph_and_advance(16.0).expect("hyphen at 16px");
        let (_, a32) = font.get_hyphen_glyph_and_advance(32.0).expect("hyphen at 32px");
        assert!(a16 > 0.0 && a16.is_finite());
        assert!((a32 - 2.0 * a16).abs() <= 1e-3 * a16, "advance is linear in font size");
        let (_, negative) = font.get_hyphen_glyph_and_advance(-16.0).expect("hyphen at -16px");
        assert!(negative.is_finite() && negative <= 0.0);
    }
    #[test]
    fn get_kashida_glyph_and_advance_presence_matches_the_cmap() {
        let font = mock();
        // U+0640 ARABIC TATWEEL: present or not, the two views must agree.
        let has_tatweel = font.has_glyph(0x0640);
        let result = font.get_kashida_glyph_and_advance(16.0);
        assert_eq!(
            result.is_some(),
            has_tatweel,
            "kashida availability must track the cmap"
        );
        if let Some((gid, advance)) = result {
            assert_eq!(Some(gid), font.lookup_glyph_index(0x0640));
            assert!(advance.is_finite() && advance >= 0.0);
            let (_, doubled) = font.get_kashida_glyph_and_advance(32.0).expect("still mapped");
            assert!((doubled - 2.0 * advance).abs() <= 1e-3 * advance.max(1.0));
        }
    }
    #[test]
    fn hyphen_and_kashida_survive_nan_inf_and_extreme_font_sizes() {
        let font = mock();
        let hyphen_gid = font
            .lookup_glyph_index('-' as u32)
            .expect("the positive control has a hyphen");
        assert!(
            font.get_horizontal_advance(hyphen_gid) > 0,
            "the hyphen must have a non-zero advance for this test to mean anything"
        );
        // NaN / ±inf in -> non-finite out, and never a panic.
        for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            let (gid, advance) = font
                .get_hyphen_glyph_and_advance(size)
                .expect("the glyph id does not depend on the font size");
            assert_eq!(gid, hyphen_gid);
            assert!(!advance.is_finite(), "{size} produced a finite advance");
            let _ = font.get_kashida_glyph_and_advance(size);
        }
        // The numeric extremes may overflow to ±inf, but a non-NaN size must
        // never produce a NaN advance and must never panic.
        for size in [f32::MAX, -f32::MAX, f32::MIN_POSITIVE, -f32::MIN_POSITIVE] {
            let (gid, advance) = font
                .get_hyphen_glyph_and_advance(size)
                .expect("the glyph id does not depend on the font size");
            assert_eq!(gid, hyphen_gid);
            assert!(!advance.is_nan(), "size {size} produced a NaN advance");
            let _ = font.get_kashida_glyph_and_advance(size);
        }
    }
    #[test]
    fn hyphen_and_kashida_return_none_when_units_per_em_is_zero() {
        let mut font = mock();
        font.font_metrics.units_per_em = 0;
        // A zero upem would divide by zero: both getters must bail out instead.
        assert!(font.get_hyphen_glyph_and_advance(16.0).is_none());
        assert!(font.get_kashida_glyph_and_advance(16.0).is_none());
        assert!(font.get_hyphen_glyph_and_advance(f32::NAN).is_none());
    }
    // -----------------------------------------------------------------
    // build_feature_mask_for_script (other)
    // -----------------------------------------------------------------
    #[test]
    fn build_feature_mask_always_contains_the_default_mask() {
        let default_bits = FeatureMask::default_mask().bits();
        for script in ALL_SCRIPTS {
            let mask = build_feature_mask_for_script(script);
            assert_eq!(
                mask.bits() & default_bits,
                default_bits,
                "{script:?} dropped a default feature"
            );
            assert!(
                mask.contains(Feature::LIGA) && mask.contains(Feature::CCMP),
                "{script:?} must keep LIGA/CCMP"
            );
        }
    }
    #[test]
    fn build_feature_mask_adds_the_script_specific_features() {
        // Arabic needs positional forms, or cursive joining silently breaks.
        let arabic = build_feature_mask_for_script(Script::Arabic);
        for feature in [Feature::INIT, Feature::MEDI, Feature::FINA, Feature::ISOL] {
            assert!(arabic.contains(feature), "Arabic is missing a positional form");
        }
        // Indic needs conjunct formation.
        for script in [
            Script::Devanagari,
            Script::Bengali,
            Script::Gujarati,
            Script::Gurmukhi,
            Script::Kannada,
            Script::Malayalam,
            Script::Oriya,
            Script::Tamil,
            Script::Telugu,
        ] {
            let mask = build_feature_mask_for_script(script);
            for feature in [Feature::CJCT, Feature::HALF, Feature::RPHF, Feature::NUKT] {
                assert!(mask.contains(feature), "{script:?} is missing an Indic feature");
            }
        }
        // Sinhala is Indic-derived but explicitly simpler: no conjunct feature.
        let sinhala = build_feature_mask_for_script(Script::Sinhala);
        assert!(sinhala.contains(Feature::AKHN) && sinhala.contains(Feature::RPHF));
        assert!(!sinhala.contains(Feature::CJCT));
        // Myanmar/Khmer get pre/below/post-base forms.
        assert!(build_feature_mask_for_script(Script::Myanmar).contains(Feature::PSTF));
        assert!(build_feature_mask_for_script(Script::Khmer).contains(Feature::ABVF));
        // Simple scripts must be exactly the default mask — no accidental extras.
        for script in [Script::Latin, Script::Greek, Script::Cyrillic, Script::Georgian] {
            assert_eq!(
                build_feature_mask_for_script(script).bits(),
                FeatureMask::default_mask().bits(),
                "{script:?} must not add script-specific features"
            );
        }
    }
    // -----------------------------------------------------------------
    // to_opentype_script_tag (other)
    // -----------------------------------------------------------------
    #[test]
    fn script_tags_are_four_printable_ascii_bytes() {
        for script in ALL_SCRIPTS {
            let tag = to_opentype_script_tag(script);
            let bytes = tag.to_be_bytes();
            assert_eq!(bytes.len(), 4);
            for b in bytes {
                assert!(
                    b.is_ascii_lowercase() || b.is_ascii_digit(),
                    "{script:?} -> {tag:#010x} is not a lowercase OpenType tag"
                );
            }
            assert_ne!(tag, 0, "{script:?} must not map to the null tag");
        }
    }
    #[test]
    fn script_tags_match_the_opentype_registry_and_alias_kana() {
        assert_eq!(to_opentype_script_tag(Script::Latin), u32::from_be_bytes(*b"latn"));
        assert_eq!(to_opentype_script_tag(Script::Arabic), u32::from_be_bytes(*b"arab"));
        assert_eq!(to_opentype_script_tag(Script::Mandarin), u32::from_be_bytes(*b"hani"));
        assert_eq!(to_opentype_script_tag(Script::Devanagari), u32::from_be_bytes(*b"deva"));
        // Hiragana and Katakana intentionally share "kana" (documented).
        assert_eq!(
            to_opentype_script_tag(Script::Hiragana),
            to_opentype_script_tag(Script::Katakana)
        );
        assert_eq!(to_opentype_script_tag(Script::Hiragana), u32::from_be_bytes(*b"kana"));
        // Every other pair must be distinct: 24 scripts, 23 distinct tags.
        let mut tags: Vec<u32> = ALL_SCRIPTS.iter().map(|s| to_opentype_script_tag(*s)).collect();
        tags.sort_unstable();
        tags.dedup();
        assert_eq!(tags.len(), 23, "only the kana pair may collide");
    }
    // -----------------------------------------------------------------
    // parse_font_feature (parser)
    // -----------------------------------------------------------------
    #[test]
    fn parse_font_feature_valid_minimal_inputs() {
        assert_eq!(
            parse_font_feature("liga"),
            Some((u32::from_be_bytes(*b"liga"), 1)),
            "a bare tag defaults to value 1"
        );
        assert_eq!(parse_font_feature("liga=0"), Some((u32::from_be_bytes(*b"liga"), 0)));
        assert_eq!(parse_font_feature("ss01"), Some((u32::from_be_bytes(*b"ss01"), 1)));
        assert_eq!(parse_font_feature("smcp=2"), Some((u32::from_be_bytes(*b"smcp"), 2)));
        // u32::MAX is the largest accepted value
        assert_eq!(
            parse_font_feature("ss01=4294967295"),
            Some((u32::from_be_bytes(*b"ss01"), u32::MAX))
        );
    }
    #[test]
    fn parse_font_feature_pads_short_tags_with_spaces() {
        assert_eq!(parse_font_feature("aa"), Some((u32::from_be_bytes(*b"aa  "), 1)));
        assert_eq!(parse_font_feature("a"), Some((u32::from_be_bytes(*b"a   "), 1)));
        assert_eq!(parse_font_feature("abc=3"), Some((u32::from_be_bytes(*b"abc "), 3)));
    }
    #[test]
    fn parse_font_feature_trims_surrounding_whitespace() {
        assert_eq!(parse_font_feature("  liga  "), Some((u32::from_be_bytes(*b"liga"), 1)));
        assert_eq!(parse_font_feature("\tliga\n=\t2 "), Some((u32::from_be_bytes(*b"liga"), 2)));
    }
    #[test]
    fn parse_font_feature_empty_and_whitespace_only_yield_the_all_space_tag() {
        // Documents current behaviour: an empty/blank tag is NOT rejected — it is
        // padded to the four-space tag (0x20202020), which no font can match.
        let space_tag = u32::from_be_bytes(*b"    ");
        assert_eq!(parse_font_feature(""), Some((space_tag, 1)));
        assert_eq!(parse_font_feature("   "), Some((space_tag, 1)));
        assert_eq!(parse_font_feature("\t\n"), Some((space_tag, 1)));
        // ...but an empty *value* is still rejected.
        assert_eq!(parse_font_feature("="), None);
        assert_eq!(parse_font_feature("liga="), None);
        assert_eq!(parse_font_feature("liga=  "), None);
    }
    #[test]
    fn parse_font_feature_rejects_over_long_tags_and_junk() {
        assert_eq!(parse_font_feature("toolongtag"), None);
        assert_eq!(parse_font_feature("lig a"), None); // 5 bytes after trim
        assert_eq!(parse_font_feature("liga;garbage"), None);
        assert_eq!(parse_font_feature("valid;garbage=1"), None);
        // a megabyte-long tag must be rejected by the length check, not parsed
        assert_eq!(parse_font_feature(&"x".repeat(1_000_000)), None);
        assert_eq!(parse_font_feature(&format!("{}=1", "x".repeat(1_000_000))), None);
    }
    #[test]
    fn parse_font_feature_rejects_boundary_and_non_numeric_values() {
        for bad in [
            "liga=-1",
            "liga=-0",
            "liga=1.5",
            "liga=NaN",
            "liga=inf",
            "liga=0x1",
            "liga=4294967296",          // u32::MAX + 1
            "liga=9223372036854775807", // i64::MAX
            "liga=99999999999999999999999999",
            "liga= 1 2",
        ] {
            assert_eq!(parse_font_feature(bad), None, "{bad:?} must be rejected");
        }
        // `u32::from_str` accepts a leading '+', so this one is (surprisingly) valid
        assert_eq!(parse_font_feature("liga=+1"), Some((u32::from_be_bytes(*b"liga"), 1)));
        // a trailing extra '=' segment is ignored: only the first value is read
        assert_eq!(parse_font_feature("liga=1=2"), Some((u32::from_be_bytes(*b"liga"), 1)));
    }
    #[test]
    fn parse_font_feature_unicode_input_does_not_panic() {
        // Multibyte tags pad by *chars* but the tag must be exactly 4 *bytes*,
        // so every one of these must fall out as None rather than slicing a
        // char boundary or panicking on the array conversion.
        for input in [
            "\u{1F600}",         // 4 bytes, 1 char
            "\u{1F600}\u{1F600}",
            "é",
            "ß=1",
            "e\u{0301}",         // combining acute
            "\u{202E}liga",      // RTL override
            "\u{0000}\u{0001}",
        ] {
            let _ = parse_font_feature(input); // must not panic
        }
        assert_eq!(parse_font_feature("\u{1F600}"), None);
        assert_eq!(parse_font_feature("é"), None);
    }
    // -----------------------------------------------------------------
    // add_variant_features (other)
    // -----------------------------------------------------------------
    #[test]
    fn add_variant_features_maps_css_variants_to_opentype_tags() {
        let tags = |style: &StyleProperties| -> Vec<u32> {
            let mut features = Vec::new();
            add_variant_features(style, &mut features);
            assert!(
                features.iter().all(|f| f.alternate.is_none()),
                "variant features are on/off, never alternates"
            );
            features.iter().map(|f| f.feature_tag).collect()
        };
        // the default style adds nothing
        assert!(tags(&StyleProperties::default()).is_empty());
        let small_caps = StyleProperties {
            font_variant_caps: FontVariantCaps::SmallCaps,
            ..StyleProperties::default()
        };
        assert_eq!(tags(&small_caps), vec![u32::from_be_bytes(*b"smcp")]);
        let all_small = StyleProperties {
            font_variant_caps: FontVariantCaps::AllSmallCaps,
            ..StyleProperties::default()
        };
        assert_eq!(
            tags(&all_small),
            vec![u32::from_be_bytes(*b"c2sc"), u32::from_be_bytes(*b"smcp")]
        );
        let combined = StyleProperties {
            font_variant_ligatures: FontVariantLigatures::Discretionary,
            font_variant_numeric: FontVariantNumeric::TabularNums,
            font_variant_caps: FontVariantCaps::TitlingCaps,
            ..StyleProperties::default()
        };
        assert_eq!(
            tags(&combined),
            vec![
                u32::from_be_bytes(*b"dlig"),
                u32::from_be_bytes(*b"titl"),
                u32::from_be_bytes(*b"tnum"),
            ],
            "ligature, caps and numeric features are all emitted, in that order"
        );
    }
    #[test]
    fn add_variant_features_is_additive_and_never_panics_for_any_variant() {
        let ligatures = [
            FontVariantLigatures::Normal,
            FontVariantLigatures::None,
            FontVariantLigatures::Common,
            FontVariantLigatures::NoCommon,
            FontVariantLigatures::Discretionary,
            FontVariantLigatures::NoDiscretionary,
            FontVariantLigatures::Historical,
            FontVariantLigatures::NoHistorical,
            FontVariantLigatures::Contextual,
            FontVariantLigatures::NoContextual,
        ];
        let caps = [
            FontVariantCaps::Normal,
            FontVariantCaps::SmallCaps,
            FontVariantCaps::AllSmallCaps,
            FontVariantCaps::PetiteCaps,
            FontVariantCaps::AllPetiteCaps,
            FontVariantCaps::Unicase,
            FontVariantCaps::TitlingCaps,
        ];
        let numeric = [
            FontVariantNumeric::Normal,
            FontVariantNumeric::LiningNums,
            FontVariantNumeric::OldstyleNums,
            FontVariantNumeric::ProportionalNums,
            FontVariantNumeric::TabularNums,
            FontVariantNumeric::DiagonalFractions,
            FontVariantNumeric::StackedFractions,
            FontVariantNumeric::Ordinal,
            FontVariantNumeric::SlashedZero,
        ];
        // a pre-existing feature must survive: the helper appends, never clears
        let sentinel = FeatureInfo {
            feature_tag: u32::from_be_bytes(*b"kern"),
            alternate: Some(7),
        };
        for l in ligatures {
            for c in caps {
                for n in numeric {
                    let style = StyleProperties {
                        font_variant_ligatures: l,
                        font_variant_caps: c,
                        font_variant_numeric: n,
                        ..StyleProperties::default()
                    };
                    let mut features = vec![sentinel];
                    add_variant_features(&style, &mut features);
                    assert_eq!(features[0].feature_tag, sentinel.feature_tag);
                    assert_eq!(features[0].alternate, Some(7));
                    // at most 2 (caps) + 1 (ligature) + 1 (numeric) new tags
                    assert!(features.len() <= 5, "{l:?}/{c:?}/{n:?} emitted too many features");
                    for f in &features[1..] {
                        assert!(f.feature_tag.to_be_bytes().iter().all(u8::is_ascii_graphic));
                    }
                }
            }
        }
    }
    // -----------------------------------------------------------------
    // to_opentype_lang_tag (other, feature-gated)
    // -----------------------------------------------------------------
    #[cfg(feature = "text_layout_hyphenation")]
    #[test]
    fn lang_tags_are_four_byte_uppercase_padded_tags() {
        use hyphenation::Language as HL;
        // A representative spread across the mapping table, including the two
        // arms that intentionally share a tag.
        let sample = [
            HL::EnglishUS,
            HL::EnglishGB,
            HL::German1901,
            HL::German1996,
            HL::French,
            HL::Russian,
            HL::Finnish,
            HL::FinnishScholastic,
            HL::Latin,
            HL::LatinClassic,
            HL::Welsh,
            HL::Thai,
        ];
        for lang in sample {
            let tag = to_opentype_lang_tag(lang);
            let bytes = tag.to_be_bytes();
            for b in bytes {
                assert!(
                    b.is_ascii_uppercase() || b == b' ',
                    "{lang:?} -> {tag:#010x} is not an uppercase, space-padded tag"
                );
            }
            assert_ne!(tag, 0);
        }
        assert_eq!(to_opentype_lang_tag(HL::EnglishUS), u32::from_be_bytes(*b"ENU "));
        assert_eq!(to_opentype_lang_tag(HL::EnglishGB), u32::from_be_bytes(*b"ENG "));
        assert_eq!(to_opentype_lang_tag(HL::German1996), u32::from_be_bytes(*b"DEU "));
        assert_eq!(to_opentype_lang_tag(HL::French), u32::from_be_bytes(*b"FRA "));
        assert_eq!(to_opentype_lang_tag(HL::Russian), u32::from_be_bytes(*b"RUS "));
        // documented aliases: both German orthographies and both Finnish variants
        assert_eq!(
            to_opentype_lang_tag(HL::German1901),
            to_opentype_lang_tag(HL::German1996)
        );
        assert_eq!(
            to_opentype_lang_tag(HL::Finnish),
            to_opentype_lang_tag(HL::FinnishScholastic)
        );
    }
    // -----------------------------------------------------------------
    // FontRef trait surface: delegation invariants
    // -----------------------------------------------------------------
    #[test]
    fn font_ref_trait_getters_delegate_to_the_inner_parsed_font() {
        let parsed = mock();
        let font_ref = crate::parsed_font_to_font_ref(mock());
        assert_eq!(font_ref.num_glyphs(), parsed.num_glyphs);
        assert_eq!(font_ref.get_space_width(), parsed.get_space_width());
        assert_eq!(font_ref.get_font_metrics().units_per_em, parsed.font_metrics.units_per_em);
        assert!(font_ref.has_glyph('a' as u32) == parsed.has_glyph('a' as u32));
        assert!(!font_ref.has_glyph(0x0011_0000), "an invalid scalar value has no glyph");
        let gid = parsed.lookup_glyph_index('a' as u32).expect("'a' must be mapped");
        let via_ref = font_ref.get_glyph_size(gid, 16.0).expect("gid decodes");
        let via_parsed = parsed.get_glyph_size(gid, 16.0).expect("gid decodes");
        assert_eq!(via_ref.width, via_parsed.width);
        assert_eq!(via_ref.height, via_parsed.height);
        assert_eq!(
            font_ref.get_hyphen_glyph_and_advance(16.0).map(|(g, _)| g),
            parsed.get_hyphen_glyph_and_advance(16.0).map(|(g, _)| g)
        );
        assert_eq!(
            font_ref.get_kashida_glyph_and_advance(16.0).map(|(g, _)| g),
            parsed.get_kashida_glyph_and_advance(16.0).map(|(g, _)| g)
        );
        // shallow_clone shares the same underlying face
        let cloned = font_ref.shallow_clone();
        assert_eq!(cloned.get_hash(), font_ref.get_hash());
    }
}