Skip to main content

azul_layout/text3/
default.rs

1//! Default / concrete implementations of the text3 trait abstractions.
2//!
3//! This module bridges the generic text3 layout engine and the concrete
4//! `FontRef` / `ParsedFont` types.  It provides:
5//!
6//! - `ParsedFontTrait` implementation for `FontRef`
7//! - Font loading via `PathLoader`
8//! - The core `shape_text_internal` shaping function
9
10use std::{path::Path, sync::Arc};
11
12use allsorts::{
13    gpos,
14    gsub::{self, Feature, FeatureInfo, FeatureMask, FeatureMaskExt},
15};
16use azul_core::geom::LogicalSize;
17use azul_css::props::basic::FontRef;
18
19use crate::{
20    font::parsed::ParsedFont,
21    text3::{
22        cache::{
23            BidiDirection, BidiLevel, FontManager, FontSelector, FontVariantCaps,
24            FontVariantLigatures, FontVariantNumeric, Glyph, GlyphOrientation, GlyphSource,
25            LayoutError, LayoutFontMetrics, ParsedFontTrait, Point, ShallowClone, StyleProperties,
26            TextCombineUpright, TextDecoration, TextOrientation, VerticalMetrics, WritingMode,
27        },
28        script::Script,
29    },
30};
31
32/// Creates a `FontRef` from font bytes by parsing them into a `ParsedFont`.
33///
34/// This is a bridge function that:
35///
36/// 1. Parses the bytes into a `ParsedFont`
37/// 2. Wraps it in a `FontRef` with proper reference counting
38///
39/// # Arguments
40///
41/// - `font_bytes` - The raw font file data
42/// - `font_index` - Index of the font in a font collection (0 for single fonts)
43/// - `parse_outlines` - Whether to parse glyph outlines (expensive, usually false for layout)
44#[must_use] pub fn font_ref_from_bytes(
45    font_bytes: &[u8],
46    font_index: usize,
47    parse_outlines: bool,
48) -> Option<FontRef> {
49    // Parse the font bytes into ParsedFont
50    let mut warnings = Vec::new();
51    let parsed_font = ParsedFont::from_bytes(font_bytes, font_index, &mut warnings)?;
52
53    Some(crate::parsed_font_to_font_ref(parsed_font))
54}
55
56/// A `FontLoader` that parses font data from a byte slice.
57///
58/// It is designed to be used in conjunction with a mechanism that reads font files
59/// from paths into memory. This loader simply handles the parsing aspect.
60#[derive(Copy, Debug, Default, Clone)]
61pub struct PathLoader;
62
63impl PathLoader {
64    /// Creates a new `PathLoader`.
65    #[must_use] pub const fn new() -> Self {
66        Self
67    }
68
69    /// Read a font from disk and parse via the lazy-LocaGlyf path.
70    /// Convenience wrapper for callers that have a path but no
71    /// `Arc<FontBytes>` yet — uses a heap read (`Owned`) since a
72    /// loose path won't go through the fontconfig dedup cache.
73    pub(crate) fn load_from_path(self, path: &Path, font_index: usize) -> Result<FontRef, LayoutError> {
74        let font_bytes = std::fs::read(path).map_err(|_| {
75            LayoutError::FontNotFound(FontSelector {
76                family: path.to_string_lossy().into_owned(),
77                weight: rust_fontconfig::FcWeight::Normal,
78                style: crate::text3::cache::FontStyle::Normal,
79                unicode_ranges: Vec::new(),
80            })
81        })?;
82        let arc_owned = Arc::<[u8]>::from(font_bytes);
83        let bytes = Arc::new(rust_fontconfig::FontBytes::Owned(arc_owned));
84        self.load_font_shared(bytes, font_index)
85    }
86
87    /// Lazy-friendly loader: takes an `Arc<FontBytes>` (typically
88    /// from [`rust_fontconfig::FcFontCache::get_font_bytes`]) and
89    /// uses the [`ParsedFont::from_bytes_shared`] constructor so
90    /// `LocaGlyf::load` is deferred until the first glyph decode.
91    ///
92    /// This is the only loader on the production path —
93    /// `load_fonts_from_disk` calls this via the closure passed
94    /// into `FontManager::load_missing_for_chains`. Fonts that
95    /// never get rasterized (common — every face of a `.ttc` gets a
96    /// `FontId`, but pages only hit a couple of them) skip their
97    /// per-face loca+glyf materialisation entirely; with
98    /// `FontBytes::Mmapped` the unread pages also never count
99    /// toward RSS.
100    /// # Errors
101    ///
102    /// Returns a `LayoutError` if the font cannot be loaded.
103    pub fn load_font_shared(
104        &self,
105        font_bytes: Arc<rust_fontconfig::FontBytes>,
106        font_index: usize,
107    ) -> Result<FontRef, LayoutError> {
108        let mut warnings = Vec::new();
109        let parsed_font = ParsedFont::from_bytes_shared(font_bytes, font_index, &mut warnings)
110            .ok_or_else(|| {
111                LayoutError::ShapingError("Failed to parse font with allsorts".to_string())
112            })?;
113        Ok(crate::parsed_font_to_font_ref(parsed_font))
114    }
115}
116
117impl FontManager<FontRef> {
118    /// Evict the cached `LocaGlyf` for every face that hasn't had a
119    /// `get_or_decode_glyph` call within the last `idle` duration.
120    /// Only `LocaGlyfState::Deferred` faces (the production lazy
121    /// path) can be evicted — they keep their source `Arc<[u8]>` so
122    /// the next glyph access re-parses cheaply. `LocaGlyfState::Loaded`
123    /// faces from the eager path stay put.
124    ///
125    /// Returns the number of faces evicted. Embedders can call this
126    /// from a memory-pressure hook or on a timer; servo-shot
127    /// exposes it via `--azul-evict-after-each` for measurement.
128    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
129    pub fn evict_unused(&self, idle: std::time::Duration) -> usize {
130        use crate::font::parsed::ParsedFont;
131        let Ok(parsed) = self.parsed_fonts.lock() else {
132            return 0;
133        };
134        // We compare against the same monotonic clock the font's
135        // `last_used` is sampled from. `last_used == 0` means
136        // "never touched" -> eligible. Otherwise we only evict if
137        // `now_nanos - last_used >= idle.as_nanos()`.
138        let cutoff = idle.as_nanos() as u64;
139        let now_nanos = crate::font::parsed::monotonic_now_nanos();
140        let mut evicted = 0usize;
141        for font_ref in parsed.values() {
142            let font: &ParsedFont = crate::font_ref_to_parsed_font(font_ref);
143            let last = font.last_used_nanos();
144            // Untouched faces are eligible immediately. Touched
145            // faces need to be `idle` past their last use.
146            let stale = last == 0 || now_nanos.saturating_sub(last) >= cutoff;
147            if stale && font.evict_loca_glyf() {
148                evicted += 1;
149            }
150        }
151        evicted
152    }
153}
154
155
156// ParsedFontTrait Implementation for FontRef
157
158// Implement ShallowClone for FontRef
159impl ShallowClone for FontRef {
160    fn shallow_clone(&self) -> Self {
161        // FontRef::clone increments the reference count
162        self.clone()
163    }
164}
165
166// Use crate::font_ref_to_parsed_font instead of a local duplicate
167
168impl ParsedFontTrait for FontRef {
169    // +spec:block-formatting-context:21ec9a - bidi direction handled during text shaping for vertical writing modes
170    fn shape_text(
171        &self,
172        text: &str,
173        script: Script,
174        language: crate::text3::script::Language,
175        direction: BidiDirection,
176        style: &StyleProperties,
177    ) -> Result<Vec<Glyph>, LayoutError> {
178        // Delegate to the inner ParsedFont's shape_text, passing self as font_ref
179        let parsed = crate::font_ref_to_parsed_font(self);
180        parsed.shape_text_for_font_ref(self, text, script, language, direction, style)
181    }
182
183    fn get_hash(&self) -> u64 {
184        crate::font_ref_to_parsed_font(self).hash
185    }
186
187    fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
188        crate::font_ref_to_parsed_font(self).get_glyph_size(glyph_id, font_size)
189    }
190
191    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
192        crate::font_ref_to_parsed_font(self).get_hyphen_glyph_and_advance(font_size)
193    }
194
195    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
196        crate::font_ref_to_parsed_font(self).get_kashida_glyph_and_advance(font_size)
197    }
198
199    fn has_glyph(&self, codepoint: u32) -> bool {
200        crate::font_ref_to_parsed_font(self).has_glyph(codepoint)
201    }
202
203    fn get_vertical_metrics(&self, glyph_id: u16) -> Option<VerticalMetrics> {
204        crate::font_ref_to_parsed_font(self).get_vertical_metrics(glyph_id)
205    }
206
207    fn get_font_metrics(&self) -> LayoutFontMetrics {
208        crate::font_ref_to_parsed_font(self).font_metrics
209    }
210
211    fn num_glyphs(&self) -> u16 {
212        crate::font_ref_to_parsed_font(self).num_glyphs
213    }
214
215    fn get_space_width(&self) -> Option<usize> {
216        crate::font_ref_to_parsed_font(self).get_space_width()
217    }
218}
219
220/// Extension trait for `FontRef` to provide access to font bytes and metrics
221///
222/// This trait provides methods that require access to the inner `ParsedFont` data.
223pub trait FontRefExt {
224    /// Get the original font bytes. Returns an empty slice when the
225    /// underlying `ParsedFont` was created without retaining its
226    /// source bytes (the default since the lazy-font-loading refactor).
227    /// Callers that need the bytes for PDF embedding must construct
228    /// the `ParsedFont` via `ParsedFont::with_source_bytes`.
229    fn get_bytes(&self) -> &[u8];
230    /// Get the full font metrics (PDF-style metrics from HEAD, HHEA, OS/2 tables)
231    fn get_full_font_metrics(&self) -> azul_css::props::basic::FontMetrics;
232}
233
234impl FontRefExt for FontRef {
235    fn get_bytes(&self) -> &[u8] {
236        crate::font_ref_to_parsed_font(self)
237            .original_bytes
238            .as_ref()
239            .map_or(&[], |b| b.as_slice())
240    }
241
242    fn get_full_font_metrics(&self) -> azul_css::props::basic::FontMetrics {
243        use azul_css::{OptionI16, OptionU16, OptionU32};
244
245        let parsed = crate::font_ref_to_parsed_font(self);
246        let pdf = &parsed.pdf_font_metrics;
247
248        // PdfFontMetrics only has a subset of fields; fill others with defaults
249        azul_css::props::basic::FontMetrics {
250            // OS/2 version 1 fields (u32 - align 4, placed first)
251            ul_code_page_range1: OptionU32::None,
252            ul_code_page_range2: OptionU32::None,
253
254            // OS/2 table (u32 fields)
255            ul_unicode_range1: 0,   // Not in PdfFontMetrics
256            ul_unicode_range2: 0,   // Not in PdfFontMetrics
257            ul_unicode_range3: 0,   // Not in PdfFontMetrics
258            ul_unicode_range4: 0,   // Not in PdfFontMetrics
259            ach_vend_id: 0,         // Not in PdfFontMetrics
260
261            // OS/2 version 0 fields (optional)
262            s_typo_ascender: OptionI16::None,
263            s_typo_descender: OptionI16::None,
264            s_typo_line_gap: OptionI16::None,
265            us_win_ascent: OptionU16::None,
266            us_win_descent: OptionU16::None,
267
268            // OS/2 version 2 fields (optional)
269            sx_height: OptionI16::None,
270            s_cap_height: OptionI16::None,
271            us_default_char: OptionU16::None,
272            us_break_char: OptionU16::None,
273            us_max_context: OptionU16::None,
274
275            // OS/2 version 3 fields (optional)
276            us_lower_optical_point_size: OptionU16::None,
277            us_upper_optical_point_size: OptionU16::None,
278
279            // HEAD table fields
280            units_per_em: pdf.units_per_em,
281            font_flags: pdf.font_flags,
282            x_min: pdf.x_min,
283            y_min: pdf.y_min,
284            x_max: pdf.x_max,
285            y_max: pdf.y_max,
286
287            // HHEA table fields
288            ascender: pdf.ascender,
289            descender: pdf.descender,
290            line_gap: pdf.line_gap,
291            advance_width_max: pdf.advance_width_max,
292            min_left_side_bearing: 0,  // Not in PdfFontMetrics
293            min_right_side_bearing: 0, // Not in PdfFontMetrics
294            x_max_extent: 0,           // Not in PdfFontMetrics
295            caret_slope_rise: pdf.caret_slope_rise,
296            caret_slope_run: pdf.caret_slope_run,
297            caret_offset: 0,  // Not in PdfFontMetrics
298            num_h_metrics: 0, // Not in PdfFontMetrics
299
300            // OS/2 table fields
301            x_avg_char_width: pdf.x_avg_char_width,
302            us_weight_class: pdf.us_weight_class,
303            us_width_class: pdf.us_width_class,
304            fs_type: 0,                // Not in PdfFontMetrics
305            y_subscript_x_size: 0,     // Not in PdfFontMetrics
306            y_subscript_y_size: 0,     // Not in PdfFontMetrics
307            y_subscript_x_offset: 0,   // Not in PdfFontMetrics
308            y_subscript_y_offset: 0,   // Not in PdfFontMetrics
309            y_superscript_x_size: 0,   // Not in PdfFontMetrics
310            y_superscript_y_size: 0,   // Not in PdfFontMetrics
311            y_superscript_x_offset: 0, // Not in PdfFontMetrics
312            y_superscript_y_offset: 0, // Not in PdfFontMetrics
313            y_strikeout_size: pdf.y_strikeout_size,
314            y_strikeout_position: pdf.y_strikeout_position,
315            s_family_class: 0, // Not in PdfFontMetrics
316            fs_selection: 0,        // Not in PdfFontMetrics
317            us_first_char_index: 0, // Not in PdfFontMetrics
318            us_last_char_index: 0,  // Not in PdfFontMetrics
319
320            // Panose (align 1 - last)
321            panose: azul_css::props::basic::Panose::zero(),
322        }
323    }
324}
325
326// ParsedFont helper method for FontRef
327//
328// This allows ParsedFont to create glyphs that use FontRef
329//
330// FontRef is just a C-style Arc wrapper around ParsedFont, so we delegate to
331// the common shaping implementation and convert the font reference type.
332
333impl ParsedFont {
334    /// Internal helper that shapes text and returns Glyph
335    /// Delegates to `shape_text_internal` and converts the font reference.
336    fn shape_text_for_font_ref(
337        &self,
338        _font_ref: &FontRef,
339        text: &str,
340        script: Script,
341        language: crate::text3::script::Language,
342        direction: BidiDirection,
343        style: &StyleProperties,
344    ) -> Result<Vec<Glyph>, LayoutError> {
345        // `shape_text_internal` already stamps each glyph with `font_hash`
346        // and `font_metrics` derived from `self`, which is the same
347        // `ParsedFont` backing `_font_ref`, so no per-glyph rewrite is needed.
348        shape_text_internal(self, text, script, language, direction, style)
349    }
350
351    const fn get_hash(&self) -> u64 {
352        self.hash
353    }
354
355    fn get_glyph_size(&self, glyph_id: u16, font_size_px: f32) -> Option<LogicalSize> {
356        self.get_or_decode_glyph(glyph_id).map(|record| {
357            let units_per_em = f32::from(self.font_metrics.units_per_em);
358            let scale_factor = if units_per_em > 0.0 {
359                font_size_px / units_per_em
360            } else {
361                FALLBACK_SCALE
362            };
363
364            // max_x, max_y, min_x, min_y in font units
365            let bbox = &record.bounding_box;
366
367            LogicalSize {
368                width: f32::from(bbox.max_x - bbox.min_x) * scale_factor,
369                height: f32::from(bbox.max_y - bbox.min_y) * scale_factor,
370            }
371        })
372    }
373
374    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
375        let glyph_id = self.lookup_glyph_index('-' as u32)?;
376        let advance_units = self.get_horizontal_advance(glyph_id);
377        let scale_factor = if self.font_metrics.units_per_em > 0 {
378            font_size / f32::from(self.font_metrics.units_per_em)
379        } else {
380            return None;
381        };
382        let scaled_advance = f32::from(advance_units) * scale_factor;
383        Some((glyph_id, scaled_advance))
384    }
385
386    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
387        // U+0640 is the Arabic Tatweel character, used for kashida justification.
388        let glyph_id = self.lookup_glyph_index('\u{0640}' as u32)?;
389        let advance_units = self.get_horizontal_advance(glyph_id);
390        let scale_factor = if self.font_metrics.units_per_em > 0 {
391            font_size / f32::from(self.font_metrics.units_per_em)
392        } else {
393            return None;
394        };
395        let scaled_advance = f32::from(advance_units) * scale_factor;
396        Some((glyph_id, scaled_advance))
397    }
398}
399
400/// Fallback scale factor when `units_per_em` is zero (corrupt/broken font).
401const FALLBACK_SCALE: f32 = 0.01;
402
403// Helper Functions
404
405/// Builds a `FeatureMask` with the appropriate OpenType features for a given script.
406/// This ensures proper text shaping for complex scripts like Arabic, Devanagari, etc.
407///
408/// The function includes:
409/// - Common features for all scripts (ligatures, contextual alternates, etc.)
410/// - Script-specific features (positional forms for Arabic, conjuncts for Indic, etc.)
411///
412/// This is designed to be stable and explicit - we control exactly which features
413/// are enabled rather than relying on allsorts' defaults which may change.
414#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
415fn build_feature_mask_for_script(script: Script) -> FeatureMask {
416    use Script::{Arabic, Devanagari, Bengali, Gujarati, Gurmukhi, Kannada, Malayalam, Oriya, Tamil, Telugu, Myanmar, Khmer, Thai, Hebrew, Hangul, Ethiopic, Latin, Greek, Cyrillic, Georgian, Hiragana, Katakana, Mandarin, Sinhala};
417
418    // Start with common features that apply to most scripts
419    let mut mask = FeatureMask::default_mask(); // Includes: CALT, CCMP, CLIG, LIGA, LOCL, RLIG
420
421    // Add script-specific features
422    match script {
423        // Arabic and related scripts - require positional forms
424        Arabic => {
425            mask |= Feature::INIT; // Initial forms (at start of word)
426            mask |= Feature::MEDI; // Medial forms (middle of word)
427            mask |= Feature::FINA; // Final forms (end of word)
428            mask |= Feature::ISOL; // Isolated forms (standalone)
429                                       // Note: RLIG (required ligatures) already in default for
430                                       // lam-alef ligatures
431        }
432
433        // Indic scripts - require complex conjunct formation and reordering
434        Devanagari | Bengali | Gujarati | Gurmukhi | Kannada | Malayalam | Oriya | Tamil
435        | Telugu => {
436            mask |= Feature::NUKT; // Nukta forms
437            mask |= Feature::AKHN; // Akhand ligatures
438            mask |= Feature::RPHF; // Reph form
439            mask |= Feature::RKRF; // Rakar form
440            mask |= Feature::PREF; // Pre-base forms
441            mask |= Feature::BLWF; // Below-base forms
442            mask |= Feature::ABVF; // Above-base forms
443            mask |= Feature::HALF; // Half forms
444            mask |= Feature::PSTF; // Post-base forms
445            mask |= Feature::VATU; // Vattu variants
446            mask |= Feature::CJCT; // Conjunct forms
447        }
448
449        // Myanmar (Burmese) - has complex reordering
450        Myanmar => {
451            mask |= Feature::PREF; // Pre-base forms
452            mask |= Feature::BLWF; // Below-base forms
453            mask |= Feature::PSTF; // Post-base forms
454        }
455
456        // Khmer - has complex reordering and stacking
457        Khmer => {
458            mask |= Feature::PREF; // Pre-base forms
459            mask |= Feature::BLWF; // Below-base forms
460            mask |= Feature::ABVF; // Above-base forms
461            mask |= Feature::PSTF; // Post-base forms
462        }
463
464        // Thai - has tone marks and vowel reordering
465        Thai => {
466            // Thai mostly uses default features, but may have some special marks
467            // The default mask is sufficient for most Thai fonts
468        }
469
470        // Hebrew - may have contextual forms but less complex than Arabic
471        Hebrew => {
472            // Hebrew fonts may use contextual alternates already in default
473            // Some fonts have special features but they're rare
474        }
475
476        // Hangul (Korean) - has complex syllable composition
477        Hangul => {
478            // Note: Hangul jamo features (LJMO, VJMO, TJMO) are not available in allsorts'
479            // FeatureMask Most modern Hangul fonts work correctly with the default
480            // features as syllable composition is usually handled at a lower level
481        }
482
483        // Ethiopic - has syllabic script with some ligatures
484        Ethiopic => {
485            // Default features are usually sufficient
486            // LIGA and CLIG already in default mask
487        }
488
489        // Latin, Greek, Cyrillic - standard features are sufficient
490        Latin | Greek | Cyrillic => {
491            // Default mask includes all needed features:
492            // - LIGA: standard ligatures (fi, fl, etc.)
493            // - CLIG: contextual ligatures
494            // - CALT: contextual alternates
495            // - CCMP: mark composition
496        }
497
498        // Georgian - uses standard features
499        Georgian => {
500            // Default features sufficient
501        }
502
503        // CJK scripts (Hiragana, Katakana, Mandarin/Hani)
504        Hiragana | Katakana | Mandarin => {
505            // CJK fonts may use vertical alternates, but those are controlled
506            // by writing-mode, not GSUB features in the horizontal direction.
507            // Default features are sufficient.
508        }
509
510        // Sinhala - Indic-derived but simpler
511        Sinhala => {
512            mask |= Feature::AKHN; // Akhand ligatures
513            mask |= Feature::RPHF; // Reph form
514            mask |= Feature::VATU; // Vattu variants
515        }
516    }
517
518    mask
519}
520
521/// Maps the layout engine's `Script` enum to an OpenType script tag `u32`.
522#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
523const fn to_opentype_script_tag(script: Script) -> u32 {
524    use Script::{Arabic, Bengali, Cyrillic, Devanagari, Ethiopic, Georgian, Greek, Gujarati, Gurmukhi, Hangul, Hebrew, Hiragana, Kannada, Katakana, Khmer, Latin, Malayalam, Mandarin, Myanmar, Oriya, Sinhala, Tamil, Telugu, Thai};
525    // Tags from https://docs.microsoft.com/en-us/typography/opentype/spec/scripttags
526    match script {
527        Arabic => u32::from_be_bytes(*b"arab"),
528        Bengali => u32::from_be_bytes(*b"beng"),
529        Cyrillic => u32::from_be_bytes(*b"cyrl"),
530        Devanagari => u32::from_be_bytes(*b"deva"),
531        Ethiopic => u32::from_be_bytes(*b"ethi"),
532        Georgian => u32::from_be_bytes(*b"geor"),
533        Greek => u32::from_be_bytes(*b"grek"),
534        Gujarati => u32::from_be_bytes(*b"gujr"),
535        Gurmukhi => u32::from_be_bytes(*b"guru"),
536        Hangul => u32::from_be_bytes(*b"hang"),
537        Hebrew => u32::from_be_bytes(*b"hebr"),
538        // OpenType does not define a separate Hiragana script tag;
539        // both Hiragana and Katakana intentionally use "kana".
540        Hiragana => u32::from_be_bytes(*b"kana"),
541        Kannada => u32::from_be_bytes(*b"knda"),
542        Katakana => u32::from_be_bytes(*b"kana"),
543        Khmer => u32::from_be_bytes(*b"khmr"),
544        Latin => u32::from_be_bytes(*b"latn"),
545        Malayalam => u32::from_be_bytes(*b"mlym"),
546        Mandarin => u32::from_be_bytes(*b"hani"),
547        Myanmar => u32::from_be_bytes(*b"mymr"),
548        Oriya => u32::from_be_bytes(*b"orya"),
549        Sinhala => u32::from_be_bytes(*b"sinh"),
550        Tamil => u32::from_be_bytes(*b"taml"),
551        Telugu => u32::from_be_bytes(*b"telu"),
552        Thai => u32::from_be_bytes(*b"thai"),
553    }
554}
555
556/// Parses a CSS-style font-feature-settings string like `"liga"`, `"liga=0"`, or `"ss01"`.
557/// Returns an OpenType tag and a value.
558fn parse_font_feature(feature_str: &str) -> Option<(u32, u32)> {
559    let mut parts = feature_str.split('=');
560    let tag_str = parts.next()?.trim();
561    let value_str = parts.next().unwrap_or("1").trim(); // Default to 1 (on) if no value
562
563    // OpenType feature tags must be 4 characters long.
564    if tag_str.len() > 4 {
565        return None;
566    }
567    // Pad with spaces if necessary
568    let padded_tag_str = format!("{tag_str:<4}");
569
570    let tag = u32::from_be_bytes(padded_tag_str.as_bytes().try_into().ok()?);
571    let value = value_str.parse::<u32>().ok()?;
572
573    Some((tag, value))
574}
575
576/// A helper to add OpenType features based on CSS `font-variant-*` properties.
577fn add_variant_features(style: &StyleProperties, features: &mut Vec<FeatureInfo>) {
578    // Helper to add a feature that is simply "on".
579    let mut add_on = |tag_str: &[u8; 4]| {
580        features.push(FeatureInfo {
581            feature_tag: u32::from_be_bytes(*tag_str),
582            alternate: None,
583        });
584    };
585
586    // Note on disabling features: The CSS properties `font-variant-ligatures: none` or
587    // `no-common-ligatures` are meant to disable features that may be on by default for a
588    // given script. The `allsorts` API for applying custom features is additive and does not
589    // currently support disabling default features. This implementation only handles enabling
590    // non-default features.
591
592    // Ligatures
593    match style.font_variant_ligatures {
594        FontVariantLigatures::Discretionary => add_on(b"dlig"),
595        FontVariantLigatures::Historical => add_on(b"hlig"),
596        FontVariantLigatures::Contextual => add_on(b"calt"),
597        _ => {} // Other cases are either default-on or require disabling.
598    }
599
600    // Caps
601    match style.font_variant_caps {
602        FontVariantCaps::SmallCaps => add_on(b"smcp"),
603        FontVariantCaps::AllSmallCaps => {
604            add_on(b"c2sc");
605            add_on(b"smcp");
606        }
607        FontVariantCaps::PetiteCaps => add_on(b"pcap"),
608        FontVariantCaps::AllPetiteCaps => {
609            add_on(b"c2pc");
610            add_on(b"pcap");
611        }
612        FontVariantCaps::Unicase => add_on(b"unic"),
613        FontVariantCaps::TitlingCaps => add_on(b"titl"),
614        FontVariantCaps::Normal => {}
615    }
616
617    // Numeric
618    match style.font_variant_numeric {
619        FontVariantNumeric::LiningNums => add_on(b"lnum"),
620        FontVariantNumeric::OldstyleNums => add_on(b"onum"),
621        FontVariantNumeric::ProportionalNums => add_on(b"pnum"),
622        FontVariantNumeric::TabularNums => add_on(b"tnum"),
623        FontVariantNumeric::DiagonalFractions => add_on(b"frac"),
624        FontVariantNumeric::StackedFractions => add_on(b"afrc"),
625        FontVariantNumeric::Ordinal => add_on(b"ordn"),
626        FontVariantNumeric::SlashedZero => add_on(b"zero"),
627        FontVariantNumeric::Normal => {}
628    }
629}
630
631/// Maps the `hyphenation::Language` enum to an OpenType language tag `u32`.
632#[cfg(feature = "text_layout_hyphenation")]
633#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
634const fn to_opentype_lang_tag(lang: hyphenation::Language) -> u32 {
635    use hyphenation::Language::{Afrikaans, Albanian, Armenian, Assamese, Basque, Belarusian, Bengali, Bulgarian, Catalan, Chinese, Coptic, Croatian, Czech, Danish, Dutch, EnglishGB, EnglishUS, Esperanto, Estonian, Ethiopic, Finnish, FinnishScholastic, French, Friulan, Galician, Georgian, German1901, German1996, GermanSwiss, GreekAncient, GreekMono, GreekPoly, Gujarati, Hindi, Hungarian, Icelandic, Indonesian, Interlingua, Irish, Italian, Kannada, Kurmanji, Latin, LatinClassic, LatinLiturgical, Latvian, Lithuanian, Macedonian, Malayalam, Marathi, Mongolian, NorwegianBokmal, NorwegianNynorsk, Occitan, Oriya, Pali, Panjabi, Piedmontese, Polish, Portuguese, Romanian, Romansh, Russian, Sanskrit, SerbianCyrillic, SerbocroatianCyrillic, SerbocroatianLatin, SlavonicChurch, Slovak, Slovenian, Spanish, Swedish, Tamil, Telugu, Thai, Turkish, Turkmen, Ukrainian, Uppersorbian, Welsh};
636    // A complete list of language tags can be found at:
637    // https://docs.microsoft.com/en-us/typography/opentype/spec/languagetags
638    let tag_bytes = match lang {
639        Afrikaans => *b"AFK ",
640        Albanian => *b"SQI ",
641        Armenian => *b"HYE ",
642        Assamese => *b"ASM ",
643        Basque => *b"EUQ ",
644        Belarusian => *b"BEL ",
645        Bengali => *b"BEN ",
646        Bulgarian => *b"BGR ",
647        Catalan => *b"CAT ",
648        Chinese => *b"ZHS ",
649        Coptic => *b"COP ",
650        Croatian => *b"HRV ",
651        Czech => *b"CSY ",
652        Danish => *b"DAN ",
653        Dutch => *b"NLD ",
654        EnglishGB => *b"ENG ",
655        EnglishUS => *b"ENU ",
656        Esperanto => *b"ESP ",
657        Estonian => *b"ETI ",
658        Ethiopic => *b"ETH ",
659        Finnish => *b"FIN ",
660        FinnishScholastic => *b"FIN ",
661        French => *b"FRA ",
662        Friulan => *b"FRL ",
663        Galician => *b"GLC ",
664        Georgian => *b"KAT ",
665        German1901 => *b"DEU ",
666        German1996 => *b"DEU ",
667        GermanSwiss => *b"DES ",
668        GreekAncient => *b"GRC ",
669        GreekMono => *b"ELL ",
670        GreekPoly => *b"ELL ",
671        Gujarati => *b"GUJ ",
672        Hindi => *b"HIN ",
673        Hungarian => *b"HUN ",
674        Icelandic => *b"ISL ",
675        Indonesian => *b"IND ",
676        Interlingua => *b"INA ",
677        Irish => *b"IRI ",
678        Italian => *b"ITA ",
679        Kannada => *b"KAN ",
680        Kurmanji => *b"KUR ",
681        Latin => *b"LAT ",
682        LatinClassic => *b"LAT ",
683        LatinLiturgical => *b"LAT ",
684        Latvian => *b"LVI ",
685        Lithuanian => *b"LTH ",
686        Macedonian => *b"MKD ",
687        Malayalam => *b"MAL ",
688        Marathi => *b"MAR ",
689        Mongolian => *b"MNG ",
690        NorwegianBokmal => *b"NOR ",
691        NorwegianNynorsk => *b"NYN ",
692        Occitan => *b"OCI ",
693        Oriya => *b"ORI ",
694        Pali => *b"PLI ",
695        Panjabi => *b"PAN ",
696        Piedmontese => *b"PMS ",
697        Polish => *b"PLK ",
698        Portuguese => *b"PTG ",
699        Romanian => *b"ROM ",
700        Romansh => *b"RMC ",
701        Russian => *b"RUS ",
702        Sanskrit => *b"SAN ",
703        SerbianCyrillic => *b"SRB ",
704        SerbocroatianCyrillic => *b"SHC ",
705        SerbocroatianLatin => *b"SHL ",
706        SlavonicChurch => *b"CSL ",
707        Slovak => *b"SKY ",
708        Slovenian => *b"SLV ",
709        Spanish => *b"ESP ",
710        Swedish => *b"SVE ",
711        Tamil => *b"TAM ",
712        Telugu => *b"TEL ",
713        Thai => *b"THA ",
714        Turkish => *b"TRK ",
715        Turkmen => *b"TUK ",
716        Ukrainian => *b"UKR ",
717        Uppersorbian => *b"HSB ",
718        Welsh => *b"CYM ",
719    };
720    u32::from_be_bytes(tag_bytes)
721}
722
723/// Internal shaping implementation - the single source of truth for text shaping.
724/// Both `FontRef` and `ParsedFont` use this function.
725#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // bounded layout/render numeric cast
726#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
727#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
728fn shape_text_internal(
729    parsed_font: &ParsedFont,
730    text: &str,
731    script: Script,
732    language: crate::text3::script::Language,
733    direction: BidiDirection,
734    style: &StyleProperties,
735) -> Result<Vec<Glyph>, LayoutError> {
736    let script_tag = to_opentype_script_tag(script);
737    #[cfg(feature = "text_layout_hyphenation")]
738    let lang_tag = to_opentype_lang_tag(language);
739    #[cfg(not(feature = "text_layout_hyphenation"))]
740    let lang_tag = 0u32;
741
742    // +spec:text-alignment-spacing:4357e6 - non-zero letter-spacing should disable optional ligatures; allsorts API is additive-only so default liga cannot be disabled here
743    // +spec:text-alignment-spacing:24d624 - cursive script letter-spacing behavior is advisory (outside CSS scope per spec note)
744    let mut user_features: Vec<FeatureInfo> = style
745        .font_features
746        .iter()
747        .filter_map(|s| parse_font_feature(s))
748        .map(|(tag, value)| FeatureInfo {
749            feature_tag: tag,
750            alternate: if value > 1 {
751                Some(value as usize)
752            } else {
753                None
754            },
755        })
756        .collect();
757    add_variant_features(style, &mut user_features);
758
759    let opt_gdef = parsed_font.opt_gdef_table.as_deref();
760
761    let mut raw_glyphs: Vec<gsub::RawGlyph<()>> = Vec::new();
762    {
763        let mut ci = 0usize;
764        while ci < text.len() {
765            let Some(ch) = text[ci..].chars().next() else {
766                break;
767            };
768            let glyph_index = parsed_font.lookup_glyph_index(ch as u32).unwrap_or(0);
769            // NOTE: `liga_component_pos` MUST be left at allsorts' managed default (0)
770            // here. It is a GPOS ligature-COMPONENT index, and mark-to-mark /
771            // mark-to-ligature attachment (gpos::forall_mark_mark_glyph_pairs,
772            // gpos::markligpos) is gated on equality of this field between glyphs.
773            // Overloading it with the source byte offset (as an earlier version did)
774            // made every glyph carry a distinct value, silently disabling mkmk stacking
775            // and mis-selecting ligature-component anchors. Source byte offsets are
776            // instead reconstructed after shaping from each glyph's `unicodes` (see the
777            // read-back loop below), which also removes the old u16 byte-offset cap that
778            // dropped every glyph past byte 65535.
779            raw_glyphs.push(gsub::RawGlyph {
780                unicodes: tinyvec::tiny_vec![[char; 1] => ch],
781                glyph_index,
782                liga_component_pos: 0,
783                glyph_origin: gsub::GlyphOrigin::Char(ch),
784                flags: gsub::RawGlyphFlags::empty(),
785                extra_data: (),
786                variation: None,
787            });
788            ci += ch.len_utf8();
789        }
790    }
791
792    if let Some(gsub) = parsed_font.gsub() {
793        // Always start from the script's default feature mask (LIGA, CLIG, CALT,
794        // CCMP, LOCL, RLIG + script-specific shaping features) and additively layer
795        // any user-supplied features on top. gsub::apply applies the mask AND the
796        // custom features, so these are NOT mutually exclusive. The previous code
797        // replaced the mask with `empty()` whenever ANY font-feature/font-variant
798        // was set, which silently disabled default ligatures/contextual-alternates
799        // for Latin-family text (ScriptType::Default only re-adds CCMP|RLIG|LOCL).
800        let feature_mask = build_feature_mask_for_script(script);
801        let custom_features: &[FeatureInfo] = user_features.as_slice();
802
803        let dotted_circle_index = parsed_font
804            .lookup_glyph_index(allsorts::DOTTED_CIRCLE as u32)
805            .unwrap_or(0);
806        gsub::apply(
807            dotted_circle_index,
808            gsub,
809            opt_gdef,
810            script_tag,
811            Some(lang_tag),
812            feature_mask,
813            custom_features,
814            None,
815            parsed_font.num_glyphs(),
816            &mut raw_glyphs,
817        )
818        .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
819    }
820
821    let mut infos = gpos::Info::init_from_glyphs(opt_gdef, raw_glyphs);
822
823    if let Some(gpos) = parsed_font.gpos() {
824        let kern_table = parsed_font
825            .opt_kern_table
826            .as_ref()
827            .map(|kt| kt.as_borrowed());
828        let apply_kerning = true; // Always enable GPOS kern feature (not just when legacy kern table exists)
829        gpos::apply(
830            gpos,
831            opt_gdef,
832            kern_table,
833            apply_kerning,
834            FeatureMask::empty(),
835            &user_features,
836            None,
837            script_tag,
838            Some(lang_tag),
839            &mut infos,
840        )
841        .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
842    } else {
843        // No GPOS table: apply the legacy `kern` table (and fallback mark
844        // positioning) directly, so fonts that ship only a legacy `kern` table
845        // still kern — matching CoreText/HarfBuzz behavior. Without this,
846        // GPOS-less fonts got zero kerning.
847        let kern_table = parsed_font
848            .opt_kern_table
849            .as_ref()
850            .map(|kt| kt.as_borrowed());
851        gpos::apply_fallback(kern_table, script_tag, &mut infos)
852            .map_err(|e| LayoutError::ShapingError(e.to_string()))?;
853    }
854
855    let font_size = style.font_size_px;
856    let scale_factor = if parsed_font.font_metrics.units_per_em > 0 {
857        font_size / f32::from(parsed_font.font_metrics.units_per_em)
858    } else {
859        FALLBACK_SCALE
860    };
861
862    let font_hash = parsed_font.get_hash();
863    let font_metrics = LayoutFontMetrics {
864        ascent: parsed_font.font_metrics.ascent,
865        descent: parsed_font.font_metrics.descent,
866        line_gap: parsed_font.font_metrics.line_gap,
867        units_per_em: parsed_font.font_metrics.units_per_em,
868        x_height: parsed_font.font_metrics.x_height,
869        cap_height: parsed_font.font_metrics.cap_height,
870    };
871    let style_arc = Arc::new(style.clone());
872    let bidi_level = BidiLevel::new(u8::from(direction.is_rtl()));
873
874    let mut shaped_glyphs = Vec::new();
875    // Reconstruct source byte spans by walking the source text in logical order,
876    // consuming each glyph's `unicodes`. This replaces the removed liga_component_pos
877    // byte-offset overload. A ligature glyph carries ALL of its component chars in
878    // `unicodes`, so its span covers every merged component (fixing the ligature
879    // logical_byte_len that previously reported only the first component's length).
880    // Multiple-substitution duplicates (MULTI_SUBST_DUP) and unicode-less inserted
881    // glyphs share the current cursor position and do not advance it.
882    let mut byte_cursor = 0usize;
883    for info in &infos {
884        let uni_len: usize = info.glyph.unicodes.iter().map(|c| c.len_utf8()).sum();
885        let (byte_index, byte_len) = if info.glyph.multi_subst_dup() || uni_len == 0 {
886            (byte_cursor.min(text.len()), 0)
887        } else {
888            let start = byte_cursor.min(text.len());
889            byte_cursor = (byte_cursor + uni_len).min(text.len());
890            (start, uni_len)
891        };
892        let cluster = byte_index as u32;
893        let source_char = info
894            .glyph
895            .unicodes
896            .first()
897            .copied()
898            .or_else(|| text.get(byte_index..).and_then(|s| s.chars().next()))
899            .unwrap_or('\u{FFFD}');
900
901        let base_advance = parsed_font.get_horizontal_advance(info.glyph.glyph_index);
902        // Use hinted advance width when available (matches FreeType/Chrome behavior).
903        // Hinting grid-fits at an INTEGER ppem, so rescale the result back to the exact
904        // (fractional) font size to keep the advance on the SAME size basis as the GPOS
905        // offsets and kerning below (which use the unrounded scale_factor). Otherwise a
906        // run at e.g. 14.4px would pair a 14px-basis advance with 14.4px-basis positioning.
907        let ppem = font_size.round().max(1.0) as u16;
908        let advance = parsed_font
909            .get_hinted_advance_px(info.glyph.glyph_index, ppem)
910            .map_or_else(
911                || f32::from(base_advance) * scale_factor,
912                |hinted| hinted * font_size / f32::from(ppem),
913            );
914        let kerning = f32::from(info.kerning) * scale_factor;
915
916        let (offset_x_units, offset_y_units) =
917            if let gpos::Placement::Distance(x, y) = info.placement {
918                (x, y)
919            } else {
920                (0, 0)
921            };
922        let offset_x = offset_x_units as f32 * scale_factor;
923        let offset_y = offset_y_units as f32 * scale_factor;
924
925        let vert = parsed_font.get_vertical_metrics(info.glyph.glyph_index);
926        let glyph = Glyph {
927            glyph_id: info.glyph.glyph_index,
928            codepoint: source_char,
929            font_hash,
930            font_metrics,
931            style: Arc::clone(&style_arc),
932            source: GlyphSource::Char,
933            logical_byte_index: byte_index,
934            logical_byte_len: byte_len,
935            content_index: 0,
936            cluster,
937            advance,
938            kerning,
939            offset: Point {
940                x: offset_x,
941                y: offset_y,
942            },
943            vertical_advance: vert.as_ref().map_or(0.0, |v| v.advance * font_size),
944            vertical_origin_y: vert.as_ref().map_or(0.0, |v| v.origin_y * font_size),
945            vertical_bearing: vert
946                .map_or(Point { x: 0.0, y: 0.0 }, |v| Point { x: v.bearing_x * font_size, y: v.bearing_y * font_size }),
947            orientation: GlyphOrientation::Horizontal,
948            script,
949            bidi_level,
950        };
951        shaped_glyphs.push(glyph);
952    }
953
954    Ok(shaped_glyphs)
955}
956
957/// Public helper function to shape text for `ParsedFont`, returning Glyph
958/// This is used by the `ParsedFontTrait` implementation for `ParsedFont`
959/// # Errors
960///
961/// Returns a `LayoutError` if the text cannot be shaped.
962pub fn shape_text_for_parsed_font(
963    parsed_font: &ParsedFont,
964    text: &str,
965    script: Script,
966    language: crate::text3::script::Language,
967    direction: BidiDirection,
968    style: &StyleProperties,
969) -> Result<Vec<Glyph>, LayoutError> {
970    // Delegate to the single internal implementation
971    shape_text_internal(parsed_font, text, script, language, direction, style)
972}