Skip to main content

fret_render_text/
parley_shaper.rs

1use fret_core::text::TextLeadingDistribution;
2use fret_core::{
3    FontId, TextInputRef, TextLineHeightPolicy, TextShapingStyle, TextSlant, TextSpan, TextStyle,
4};
5use parley::FontContext;
6use parley::Layout;
7use parley::LayoutContext;
8use parley::fontique::{FamilyId, GenericFamily};
9use parley::style::{
10    FontFeature, FontSettings, FontStyle, FontVariation, FontWeight as ParleyFontWeight,
11    OverflowWrap, StyleProperty, TextStyle as ParleyTextStyle, TextWrapMode, WordBreakStrength,
12};
13use std::borrow::Cow;
14use std::ops::Range;
15use std::sync::Arc;
16
17use crate::FontCatalogEntryMetadata;
18use crate::parley_font_db::ParleyFontDbState;
19pub use crate::parley_font_db::ParleyShaperFontDbDiagnosticsSnapshot;
20
21#[derive(Debug, Clone, Copy)]
22pub struct FontEnvironmentBlobRef<'a> {
23    hash: u64,
24    bytes: &'a [u8],
25}
26
27impl<'a> FontEnvironmentBlobRef<'a> {
28    pub(crate) fn new(hash: u64, bytes: &'a [u8]) -> Self {
29        Self { hash, bytes }
30    }
31
32    pub fn hash(&self) -> u64 {
33        self.hash
34    }
35
36    pub fn len(&self) -> u64 {
37        self.bytes.len() as u64
38    }
39
40    pub fn is_empty(&self) -> bool {
41        self.bytes.is_empty()
42    }
43
44    pub fn bytes(&self) -> &'a [u8] {
45        self.bytes
46    }
47}
48
49fn env_disables_system_fonts() -> bool {
50    let Ok(raw) = std::env::var("FRET_TEXT_SYSTEM_FONTS") else {
51        return false;
52    };
53    let v = raw.trim().to_ascii_lowercase();
54    matches!(v.as_str(), "0" | "false" | "no" | "off")
55}
56
57fn min_line_height_for_metrics(ascent: f32, descent: f32) -> f32 {
58    let ascent = normalize_ascent(ascent);
59    let descent_mag = if descent.is_sign_negative() {
60        (-descent).max(0.0)
61    } else {
62        descent.max(0.0)
63    };
64    ascent + descent_mag
65}
66
67fn normalize_ascent(ascent: f32) -> f32 {
68    if ascent.is_sign_negative() {
69        (-ascent).max(0.0)
70    } else {
71        ascent.max(0.0)
72    }
73}
74
75fn normalize_descent(descent: f32) -> f32 {
76    if descent.is_sign_negative() {
77        (-descent).max(0.0)
78    } else {
79        descent.max(0.0)
80    }
81}
82
83fn requested_line_height_logical_px(style: &TextStyle) -> Option<f32> {
84    if let Some(px) = style.line_height {
85        return Some(px.0.max(0.0));
86    }
87    let em = style.line_height_em?;
88    if !em.is_finite() || em <= 0.0 {
89        return None;
90    }
91    Some((style.size.0 * em).max(0.0))
92}
93
94fn requested_line_height_logical_px_with_strut(style: &TextStyle) -> Option<f32> {
95    if let Some(px) = requested_line_height_logical_px(style) {
96        return Some(px);
97    }
98
99    let strut = style.strut_style.as_ref()?;
100
101    if let Some(px) = strut.line_height {
102        return Some(px.0.max(0.0));
103    }
104
105    let em = strut.line_height_em?;
106    if !em.is_finite() || em <= 0.0 {
107        return None;
108    }
109
110    let size = strut.size.unwrap_or(style.size).0;
111    Some((size * em).max(0.0))
112}
113
114fn leading_distribution_top_factor(
115    dist: TextLeadingDistribution,
116    ascent_px: f32,
117    descent_px: f32,
118) -> f32 {
119    match dist {
120        TextLeadingDistribution::Even => 0.5,
121        TextLeadingDistribution::Proportional => {
122            let ascent_px = normalize_ascent(ascent_px);
123            let descent_px = normalize_descent(descent_px);
124            let total = ascent_px + descent_px;
125            if total > 0.0 {
126                (ascent_px / total).clamp(0.0, 1.0)
127            } else {
128                0.5
129            }
130        }
131    }
132}
133
134fn baseline_for_fixed_line_box(
135    ascent_px: f32,
136    descent_px: f32,
137    line_height_px: f32,
138    dist: TextLeadingDistribution,
139) -> f32 {
140    let ascent_px = normalize_ascent(ascent_px);
141    let descent_px = normalize_descent(descent_px);
142    let line_height_px = line_height_px.max(0.0);
143    let extra_leading_px = (line_height_px - ascent_px - descent_px).max(0.0);
144    let padding_top_px =
145        extra_leading_px * leading_distribution_top_factor(dist, ascent_px, descent_px);
146    (padding_top_px + ascent_px).clamp(0.0, line_height_px.max(0.0))
147}
148
149fn effective_leading_distribution(style: &TextStyle) -> TextLeadingDistribution {
150    style
151        .strut_style
152        .as_ref()
153        .and_then(|s| s.leading_distribution)
154        .unwrap_or(style.leading_distribution)
155}
156
157fn style_for_strut_metrics(style: &TextStyle) -> Option<TextStyle> {
158    let strut = style.strut_style.as_ref()?;
159    if strut.font.is_none() && strut.size.is_none() {
160        return None;
161    }
162
163    let mut out = style.clone();
164    if let Some(font) = strut.font.clone() {
165        out.font = font;
166    }
167    if let Some(size) = strut.size {
168        out.size = size;
169    }
170    Some(out)
171}
172
173#[derive(Debug, Clone, PartialEq)]
174pub struct ParleyGlyph {
175    id: u32,
176    x: f32,
177    y: f32,
178    advance: f32,
179    font: GlyphFontData,
180    font_size: f32,
181    normalized_coords: Arc<[i16]>,
182    synthesis: FontSynthesis,
183    text_range: Range<usize>,
184    is_rtl: bool,
185}
186
187impl ParleyGlyph {
188    pub fn id(&self) -> u32 {
189        self.id
190    }
191
192    pub fn advance(&self) -> f32 {
193        self.advance
194    }
195
196    pub fn x(&self) -> f32 {
197        self.x
198    }
199
200    pub fn y(&self) -> f32 {
201        self.y
202    }
203
204    pub fn font(&self) -> &GlyphFontData {
205        &self.font
206    }
207
208    pub fn font_size(&self) -> f32 {
209        self.font_size
210    }
211
212    pub fn normalized_coords(&self) -> &Arc<[i16]> {
213        &self.normalized_coords
214    }
215
216    pub fn synthesis(&self) -> FontSynthesis {
217        self.synthesis
218    }
219
220    pub fn text_range(&self) -> Range<usize> {
221        self.text_range.clone()
222    }
223
224    pub fn is_rtl(&self) -> bool {
225        self.is_rtl
226    }
227
228    pub(crate) fn set_is_rtl(&mut self, is_rtl: bool) {
229        self.is_rtl = is_rtl;
230    }
231
232    pub(crate) fn set_text_range(&mut self, text_range: Range<usize>) {
233        self.text_range = text_range;
234    }
235
236    pub(crate) fn set_x(&mut self, x: f32) {
237        self.x = x;
238    }
239
240    pub(crate) fn set_y(&mut self, y: f32) {
241        self.y = y;
242    }
243}
244
245#[derive(Debug, Clone, PartialEq)]
246pub struct GlyphFontData {
247    inner: parley::FontData,
248}
249
250impl GlyphFontData {
251    fn from_parley(inner: parley::FontData) -> Self {
252        Self { inner }
253    }
254
255    pub fn data_id(&self) -> u64 {
256        self.inner.data.id()
257    }
258
259    pub fn face_index(&self) -> u32 {
260        self.inner.index
261    }
262
263    pub fn bytes(&self) -> &[u8] {
264        self.inner.data.data()
265    }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
269pub struct FontSynthesis {
270    embolden: bool,
271    /// Faux italic/oblique skew in degrees, clamped for stable cache identity.
272    skew_degrees: i8,
273}
274
275impl FontSynthesis {
276    pub fn new(embolden: bool, skew_degrees: i8) -> Self {
277        Self {
278            embolden,
279            skew_degrees,
280        }
281    }
282
283    pub fn embolden(&self) -> bool {
284        self.embolden
285    }
286
287    pub fn skew_degrees(&self) -> i8 {
288        self.skew_degrees
289    }
290
291    fn from_parley(synthesis: parley::fontique::Synthesis) -> Self {
292        Self::new(
293            synthesis.embolden(),
294            synthesis
295                .skew()
296                .unwrap_or(0.0)
297                .clamp(i8::MIN as f32, i8::MAX as f32) as i8,
298        )
299    }
300}
301
302#[derive(Debug, Clone, PartialEq)]
303pub struct ShapedCluster {
304    text_range: Range<usize>,
305    x0: f32,
306    x1: f32,
307    is_rtl: bool,
308}
309
310impl ShapedCluster {
311    pub(crate) fn new(text_range: Range<usize>, x0: f32, x1: f32, is_rtl: bool) -> Self {
312        Self {
313            text_range,
314            x0,
315            x1,
316            is_rtl,
317        }
318    }
319
320    pub fn text_range(&self) -> Range<usize> {
321        self.text_range.clone()
322    }
323
324    pub fn x0(&self) -> f32 {
325        self.x0
326    }
327
328    pub fn x1(&self) -> f32 {
329        self.x1
330    }
331
332    pub fn is_rtl(&self) -> bool {
333        self.is_rtl
334    }
335}
336
337#[derive(Debug, Clone, PartialEq)]
338pub struct ShapedLineLayout {
339    width: f32,
340    ascent: f32,
341    descent: f32,
342    ink_ascent: f32,
343    ink_descent: f32,
344    baseline: f32,
345    line_height: f32,
346    glyphs: Vec<ParleyGlyph>,
347    clusters: Vec<ShapedCluster>,
348}
349
350impl ShapedLineLayout {
351    #[allow(clippy::too_many_arguments)]
352    pub(crate) fn new(
353        width: f32,
354        ascent: f32,
355        descent: f32,
356        ink_ascent: f32,
357        ink_descent: f32,
358        baseline: f32,
359        line_height: f32,
360        glyphs: Vec<ParleyGlyph>,
361        clusters: Vec<ShapedCluster>,
362    ) -> Self {
363        Self {
364            width,
365            ascent,
366            descent,
367            ink_ascent,
368            ink_descent,
369            baseline,
370            line_height,
371            glyphs,
372            clusters,
373        }
374    }
375
376    pub fn clusters(&self) -> &[ShapedCluster] {
377        &self.clusters
378    }
379
380    pub fn take_clusters(&mut self) -> Vec<ShapedCluster> {
381        std::mem::take(&mut self.clusters)
382    }
383
384    pub fn width(&self) -> f32 {
385        self.width
386    }
387
388    pub(crate) fn set_width(&mut self, width: f32) {
389        self.width = width;
390    }
391
392    pub fn ascent(&self) -> f32 {
393        self.ascent
394    }
395
396    pub fn descent(&self) -> f32 {
397        self.descent
398    }
399
400    pub fn ink_ascent(&self) -> f32 {
401        self.ink_ascent
402    }
403
404    pub fn ink_descent(&self) -> f32 {
405        self.ink_descent
406    }
407
408    pub fn baseline(&self) -> f32 {
409        self.baseline
410    }
411
412    pub fn line_height(&self) -> f32 {
413        self.line_height
414    }
415
416    pub fn glyphs(&self) -> &[ParleyGlyph] {
417        &self.glyphs
418    }
419
420    pub fn glyphs_mut(&mut self) -> &mut [ParleyGlyph] {
421        &mut self.glyphs
422    }
423
424    pub fn take_glyphs(&mut self) -> Vec<ParleyGlyph> {
425        std::mem::take(&mut self.glyphs)
426    }
427}
428
429#[derive(Default)]
430pub struct ParleyShaper {
431    fcx: FontContext,
432    lcx: LayoutContext<[u8; 4]>,
433    layout: Layout<[u8; 4]>,
434    default_locale: Option<String>,
435    common_fallback_stack_suffix: String,
436    font_db: ParleyFontDbState,
437}
438
439impl ParleyShaper {
440    pub fn font_db_diagnostics_snapshot(&self) -> ParleyShaperFontDbDiagnosticsSnapshot {
441        self.font_db.diagnostics_snapshot()
442    }
443}
444
445impl ParleyShaper {
446    pub fn new() -> Self {
447        let mut out = Self::default();
448        if env_disables_system_fonts() {
449            out.disable_system_fonts();
450        }
451        out
452    }
453
454    #[cfg(test)]
455    fn record_registered_font_blob_bytes_for_tests(&mut self, bytes: Vec<u8>) {
456        self.font_db
457            .record_registered_font_blob_bytes_for_tests(bytes);
458    }
459
460    #[cfg(test)]
461    fn registered_font_blob_lengths_for_tests(&self) -> Vec<usize> {
462        self.font_db.registered_font_blob_lengths_for_tests()
463    }
464
465    #[cfg(test)]
466    fn registered_font_blob_total_bytes_for_tests(&self) -> usize {
467        self.font_db.registered_font_blob_total_bytes_for_tests()
468    }
469
470    pub fn system_fonts_enabled(&self) -> bool {
471        self.font_db.system_fonts_enabled()
472    }
473
474    pub fn set_default_locale(&mut self, locale_bcp47: Option<String>) -> bool {
475        if self.default_locale == locale_bcp47 {
476            return false;
477        }
478        self.default_locale = locale_bcp47;
479        true
480    }
481
482    pub fn set_common_fallback_stack_suffix(&mut self, suffix: String) -> bool {
483        if self.common_fallback_stack_suffix == suffix {
484            return false;
485        }
486        self.common_fallback_stack_suffix = suffix;
487        true
488    }
489
490    pub fn common_fallback_stack_suffix(&self) -> &str {
491        &self.common_fallback_stack_suffix
492    }
493
494    fn disable_system_fonts(&mut self) {
495        self.fcx.collection =
496            parley::fontique::Collection::new(parley::fontique::CollectionOptions {
497                shared: false,
498                system_fonts: false,
499            });
500        self.fcx.source_cache = parley::fontique::SourceCache::default();
501        self.font_db.disable_system_fonts();
502    }
503
504    #[doc(hidden)]
505    pub fn new_without_system_fonts() -> Self {
506        let mut out = Self::default();
507        out.disable_system_fonts();
508        out
509    }
510
511    pub fn all_font_names(&mut self) -> Vec<String> {
512        self.font_db.all_font_names(&mut self.fcx)
513    }
514
515    pub fn all_font_catalog_entries(&mut self) -> Vec<FontCatalogEntryMetadata> {
516        self.font_db.all_font_catalog_entries(&mut self.fcx)
517    }
518
519    pub fn resolve_family_id(&mut self, name: &str) -> Option<FamilyId> {
520        self.font_db.resolve_family_id(&mut self.fcx, name)
521    }
522
523    pub fn family_name_for_id(&mut self, id: FamilyId) -> Option<String> {
524        self.font_db.family_name_for_id(&mut self.fcx, id)
525    }
526
527    pub fn generic_family_ids(&mut self, generic: GenericFamily) -> Vec<FamilyId> {
528        self.font_db.generic_family_ids(&mut self.fcx, generic)
529    }
530
531    pub fn set_generic_family_ids(&mut self, generic: GenericFamily, ids: &[FamilyId]) -> bool {
532        self.font_db
533            .set_generic_family_ids(&mut self.fcx, generic, ids)
534    }
535
536    pub fn add_fonts(&mut self, fonts: impl IntoIterator<Item = Vec<u8>>) -> usize {
537        self.font_db.add_fonts(&mut self.fcx, fonts)
538    }
539
540    pub fn for_each_font_environment_blob(&mut self, f: impl FnMut(FontEnvironmentBlobRef<'_>)) {
541        self.font_db
542            .for_each_font_environment_blob(&mut self.fcx, f);
543    }
544
545    pub fn system_font_rescan_seed(&self) -> Option<crate::SystemFontRescanSeed> {
546        self.font_db.system_font_rescan_seed()
547    }
548
549    pub fn apply_system_font_rescan_result(
550        &mut self,
551        result: crate::SystemFontRescanResult,
552    ) -> bool {
553        self.font_db
554            .apply_system_font_rescan_result(&mut self.fcx, result)
555    }
556
557    #[cfg(test)]
558    fn current_font_environment_fingerprint(&mut self) -> u64 {
559        self.font_db
560            .current_font_environment_fingerprint(&mut self.fcx)
561    }
562
563    fn base_line_metrics_cache_key(&self, style: &TextStyle, scale: f32) -> u64 {
564        self.font_db.base_line_metrics_cache_key(
565            self.default_locale.as_deref(),
566            &self.common_fallback_stack_suffix,
567            style,
568            scale,
569        )
570    }
571
572    fn base_ascent_descent_px_for_style(
573        &mut self,
574        style: &TextStyle,
575        scale: f32,
576    ) -> Option<(f32, f32)> {
577        let mut metrics_style = style.clone();
578        metrics_style.line_height = None;
579        metrics_style.line_height_em = None;
580        metrics_style.line_height_policy = TextLineHeightPolicy::ExpandToFit;
581        metrics_style.leading_distribution = TextLeadingDistribution::Even;
582        metrics_style.strut_style = None;
583
584        let key = self.base_line_metrics_cache_key(&metrics_style, scale);
585        if let Some(hit) = self.font_db.base_line_metrics(key) {
586            return Some(hit);
587        }
588
589        let line = self.shape_single_line_metrics(TextInputRef::plain("Hg", &metrics_style), scale);
590        let ascent = normalize_ascent(line.ascent);
591        let descent = normalize_descent(line.descent);
592        self.font_db
593            .insert_base_line_metrics(key, (ascent, descent));
594        Some((ascent, descent))
595    }
596
597    pub fn shape_single_line(&mut self, input: TextInputRef<'_>, scale: f32) -> ShapedLineLayout {
598        let (text, base_style, spans) = match input {
599            TextInputRef::Plain { text, style } => (text, style, &[][..]),
600            TextInputRef::Attributed { text, base, spans } => (text, base, spans),
601        };
602
603        let requested_line_height_px =
604            requested_line_height_logical_px_with_strut(base_style).map(|v| (v * scale).max(0.0));
605        let strut_forces_fixed = base_style.strut_style.as_ref().is_some_and(|s| s.force);
606        let fixed_line_box = strut_forces_fixed
607            || (base_style.line_height_policy == TextLineHeightPolicy::FixedFromStyle
608                && requested_line_height_px.is_some());
609        let fixed_ascent_descent = if fixed_line_box {
610            let style_for_metrics = style_for_strut_metrics(base_style);
611            let style_for_metrics = style_for_metrics.as_ref().unwrap_or(base_style);
612            self.base_ascent_descent_px_for_style(style_for_metrics, scale)
613        } else {
614            None
615        };
616
617        if text.is_empty() {
618            let fallback = self.shape_single_line(TextInputRef::plain(" ", base_style), scale);
619            return ShapedLineLayout {
620                width: 0.0,
621                ascent: fallback.ascent,
622                descent: fallback.descent,
623                ink_ascent: fallback.ink_ascent,
624                ink_descent: fallback.ink_descent,
625                baseline: fallback.baseline,
626                line_height: fallback.line_height,
627                glyphs: Vec::new(),
628                clusters: Vec::new(),
629            };
630        }
631
632        let root_style = ParleyTextStyle::default();
633        let mut builder = self
634            .lcx
635            .tree_builder(&mut self.fcx, scale, true, &root_style);
636
637        builder.push_style_span(base_parley_style(
638            base_style,
639            self.default_locale.as_deref(),
640            &self.common_fallback_stack_suffix,
641        ));
642
643        if let Some(span_ranges) = resolve_span_ranges(text, spans) {
644            for (range, span) in span_ranges {
645                let chunk = &text[range.clone()];
646                if let Some(props) = shaping_properties_for_span(
647                    base_style,
648                    span,
649                    &self.common_fallback_stack_suffix,
650                ) {
651                    builder.push_style_modification_span(props.iter());
652                    builder.push_text(chunk);
653                    builder.pop_style_span();
654                } else {
655                    builder.push_text(chunk);
656                }
657            }
658        } else {
659            builder.push_text(text);
660        }
661
662        builder.pop_style_span();
663        let _built_text = builder.build_into(&mut self.layout);
664        self.layout.break_all_lines(None);
665
666        let Some(line) = self.layout.lines().next() else {
667            return ShapedLineLayout {
668                width: 0.0,
669                ascent: 0.0,
670                descent: 0.0,
671                ink_ascent: 0.0,
672                ink_descent: 0.0,
673                baseline: 0.0,
674                line_height: 0.0,
675                glyphs: Vec::new(),
676                clusters: Vec::new(),
677            };
678        };
679
680        let metrics = *line.metrics();
681        let ink_ascent = normalize_ascent(metrics.ascent);
682        let ink_descent = normalize_descent(metrics.descent);
683        let leading_distribution = effective_leading_distribution(base_style);
684        let (ascent, descent, line_height, baseline) = if base_style.line_height_policy
685            == TextLineHeightPolicy::FixedFromStyle
686            || strut_forces_fixed
687        {
688            let (ascent, descent) = fixed_ascent_descent.unwrap_or((
689                normalize_ascent(metrics.ascent),
690                normalize_descent(metrics.descent),
691            ));
692            let fixed_line_height_px = requested_line_height_px
693                .unwrap_or_else(|| min_line_height_for_metrics(ascent, descent));
694            (
695                ascent,
696                descent,
697                fixed_line_height_px,
698                baseline_for_fixed_line_box(
699                    ascent,
700                    descent,
701                    fixed_line_height_px,
702                    leading_distribution,
703                ),
704            )
705        } else {
706            let ascent = normalize_ascent(metrics.ascent);
707            let descent = normalize_descent(metrics.descent);
708            let base_line_height = metrics.line_height.max(0.0);
709            let mut line_height = metrics.line_height.max(0.0);
710            line_height = line_height.max(min_line_height_for_metrics(ascent, descent));
711            if let Some(requested) = requested_line_height_px {
712                line_height = line_height.max(requested.max(0.0));
713            }
714            let extra = (line_height - base_line_height).max(0.0);
715            let top_factor = leading_distribution_top_factor(leading_distribution, ascent, descent);
716            let baseline =
717                (metrics.baseline.max(0.0) + (extra * top_factor)).clamp(0.0, line_height.max(0.0));
718            (ascent, descent, line_height, baseline)
719        };
720
721        let mut glyphs: Vec<ParleyGlyph> = Vec::new();
722        let mut clusters: Vec<ShapedCluster> = Vec::new();
723
724        // Note: This ignores inline boxes; our current text surface doesn't emit them.
725        let mut run_x = metrics.offset;
726        for run in line.runs() {
727            let font = run.font();
728            let font_data = GlyphFontData::from_parley(font.clone());
729            let font_size = run.font_size();
730            let normalized_coords: Arc<[i16]> = Arc::from(run.normalized_coords());
731            let synthesis = FontSynthesis::from_parley(run.synthesis());
732
733            for cluster in run.visual_clusters() {
734                let cluster_range = cluster.text_range();
735                let cluster_x0 = run_x;
736
737                let mut glyph_x = cluster_x0;
738                for mut g in cluster.glyphs() {
739                    g.x += glyph_x;
740                    glyph_x += g.advance;
741
742                    glyphs.push(ParleyGlyph {
743                        id: g.id,
744                        x: g.x,
745                        y: g.y,
746                        advance: g.advance,
747                        font: font_data.clone(),
748                        font_size,
749                        normalized_coords: normalized_coords.clone(),
750                        synthesis,
751                        text_range: cluster_range.clone(),
752                        is_rtl: cluster.is_rtl(),
753                    });
754                }
755
756                run_x = cluster_x0 + cluster.advance();
757                clusters.push(ShapedCluster::new(
758                    cluster_range,
759                    cluster_x0,
760                    run_x,
761                    cluster.is_rtl(),
762                ));
763            }
764        }
765
766        ShapedLineLayout {
767            width: metrics.advance,
768            ascent,
769            descent,
770            ink_ascent,
771            ink_descent,
772            baseline,
773            line_height,
774            glyphs,
775            clusters,
776        }
777    }
778
779    pub fn shape_paragraph_word_wrap(
780        &mut self,
781        input: TextInputRef<'_>,
782        max_width_px: f32,
783        scale: f32,
784    ) -> Vec<(Range<usize>, ShapedLineLayout)> {
785        self.shape_paragraph_with_wrap(
786            input,
787            Some(max_width_px),
788            WordBreakStrength::Normal,
789            // `TextWrap::Word` is intended to wrap at whitespace/word boundaries. Avoid breaking
790            // within a single token; use `TextWrap::Grapheme` when mid-token wrapping is desired
791            // (paths/URLs/code identifiers, CJK-heavy editor surfaces).
792            OverflowWrap::Normal,
793            TextWrapMode::Wrap,
794            scale,
795            false,
796        )
797    }
798
799    pub fn shape_paragraph_word_break_wrap(
800        &mut self,
801        input: TextInputRef<'_>,
802        max_width_px: f32,
803        scale: f32,
804    ) -> Vec<(Range<usize>, ShapedLineLayout)> {
805        self.shape_paragraph_with_wrap(
806            input,
807            Some(max_width_px),
808            WordBreakStrength::Normal,
809            OverflowWrap::BreakWord,
810            TextWrapMode::Wrap,
811            scale,
812            false,
813        )
814    }
815
816    pub fn shape_paragraph_word_wrap_metrics(
817        &mut self,
818        input: TextInputRef<'_>,
819        max_width_px: f32,
820        scale: f32,
821    ) -> Vec<(Range<usize>, ShapedLineLayout)> {
822        self.shape_paragraph_with_wrap(
823            input,
824            Some(max_width_px),
825            WordBreakStrength::Normal,
826            // See `shape_paragraph_word_wrap`.
827            OverflowWrap::Normal,
828            TextWrapMode::Wrap,
829            scale,
830            true,
831        )
832    }
833
834    pub fn shape_paragraph_word_break_wrap_metrics(
835        &mut self,
836        input: TextInputRef<'_>,
837        max_width_px: f32,
838        scale: f32,
839    ) -> Vec<(Range<usize>, ShapedLineLayout)> {
840        self.shape_paragraph_with_wrap(
841            input,
842            Some(max_width_px),
843            WordBreakStrength::Normal,
844            OverflowWrap::BreakWord,
845            TextWrapMode::Wrap,
846            scale,
847            true,
848        )
849    }
850
851    pub fn shape_single_line_metrics(
852        &mut self,
853        input: TextInputRef<'_>,
854        scale: f32,
855    ) -> ShapedLineLayout {
856        let (text, base_style, spans) = match input {
857            TextInputRef::Plain { text, style } => (text, style, &[][..]),
858            TextInputRef::Attributed { text, base, spans } => (text, base, spans),
859        };
860
861        let requested_line_height_px =
862            requested_line_height_logical_px_with_strut(base_style).map(|v| (v * scale).max(0.0));
863        let strut_forces_fixed = base_style.strut_style.as_ref().is_some_and(|s| s.force);
864        let fixed_line_box = strut_forces_fixed
865            || (base_style.line_height_policy == TextLineHeightPolicy::FixedFromStyle
866                && requested_line_height_px.is_some());
867        let fixed_ascent_descent = if fixed_line_box {
868            let style_for_metrics = style_for_strut_metrics(base_style);
869            let style_for_metrics = style_for_metrics.as_ref().unwrap_or(base_style);
870            self.base_ascent_descent_px_for_style(style_for_metrics, scale)
871        } else {
872            None
873        };
874
875        if text.is_empty() {
876            let fallback =
877                self.shape_single_line_metrics(TextInputRef::plain(" ", base_style), scale);
878            return ShapedLineLayout {
879                width: 0.0,
880                ascent: fallback.ascent,
881                descent: fallback.descent,
882                ink_ascent: fallback.ink_ascent,
883                ink_descent: fallback.ink_descent,
884                baseline: fallback.baseline,
885                line_height: fallback.line_height,
886                glyphs: Vec::new(),
887                clusters: Vec::new(),
888            };
889        }
890
891        let root_style = ParleyTextStyle::default();
892        let mut builder = self
893            .lcx
894            .tree_builder(&mut self.fcx, scale, true, &root_style);
895
896        builder.push_style_span(base_parley_style(
897            base_style,
898            self.default_locale.as_deref(),
899            &self.common_fallback_stack_suffix,
900        ));
901
902        if let Some(span_ranges) = resolve_span_ranges(text, spans) {
903            for (range, span) in span_ranges {
904                let chunk = &text[range.clone()];
905                if let Some(props) = shaping_properties_for_span(
906                    base_style,
907                    span,
908                    &self.common_fallback_stack_suffix,
909                ) {
910                    builder.push_style_modification_span(props.iter());
911                    builder.push_text(chunk);
912                    builder.pop_style_span();
913                } else {
914                    builder.push_text(chunk);
915                }
916            }
917        } else {
918            builder.push_text(text);
919        }
920
921        builder.pop_style_span();
922        let _built_text = builder.build_into(&mut self.layout);
923        self.layout.break_all_lines(None);
924
925        let Some(line) = self.layout.lines().next() else {
926            return ShapedLineLayout {
927                width: 0.0,
928                ascent: 0.0,
929                descent: 0.0,
930                ink_ascent: 0.0,
931                ink_descent: 0.0,
932                baseline: 0.0,
933                line_height: 0.0,
934                glyphs: Vec::new(),
935                clusters: Vec::new(),
936            };
937        };
938
939        let metrics = *line.metrics();
940        let ink_ascent = normalize_ascent(metrics.ascent);
941        let ink_descent = normalize_descent(metrics.descent);
942        let leading_distribution = effective_leading_distribution(base_style);
943        let (ascent, descent, line_height, baseline) = if base_style.line_height_policy
944            == TextLineHeightPolicy::FixedFromStyle
945            || strut_forces_fixed
946        {
947            let (ascent, descent) = fixed_ascent_descent.unwrap_or((
948                normalize_ascent(metrics.ascent),
949                normalize_descent(metrics.descent),
950            ));
951            let fixed_line_height_px = requested_line_height_px
952                .unwrap_or_else(|| min_line_height_for_metrics(ascent, descent));
953            (
954                ascent,
955                descent,
956                fixed_line_height_px,
957                baseline_for_fixed_line_box(
958                    ascent,
959                    descent,
960                    fixed_line_height_px,
961                    leading_distribution,
962                ),
963            )
964        } else {
965            let ascent = normalize_ascent(metrics.ascent);
966            let descent = normalize_descent(metrics.descent);
967            let base_line_height = metrics.line_height.max(0.0);
968            let mut line_height = metrics.line_height.max(0.0);
969            line_height = line_height.max(min_line_height_for_metrics(ascent, descent));
970            if let Some(requested) = requested_line_height_px {
971                line_height = line_height.max(requested.max(0.0));
972            }
973            let extra = (line_height - base_line_height).max(0.0);
974            let top_factor = leading_distribution_top_factor(leading_distribution, ascent, descent);
975            let baseline =
976                (metrics.baseline.max(0.0) + (extra * top_factor)).clamp(0.0, line_height.max(0.0));
977            (ascent, descent, line_height, baseline)
978        };
979
980        let mut clusters: Vec<ShapedCluster> = Vec::new();
981
982        let mut run_x = metrics.offset;
983        for run in line.runs() {
984            for cluster in run.visual_clusters() {
985                let cluster_range = cluster.text_range();
986                let cluster_x0 = run_x;
987                run_x = cluster_x0 + cluster.advance();
988                clusters.push(ShapedCluster::new(
989                    cluster_range,
990                    cluster_x0,
991                    run_x,
992                    cluster.is_rtl(),
993                ));
994            }
995        }
996
997        ShapedLineLayout {
998            width: metrics.advance,
999            ascent,
1000            descent,
1001            ink_ascent,
1002            ink_descent,
1003            baseline,
1004            line_height,
1005            glyphs: Vec::new(),
1006            clusters,
1007        }
1008    }
1009
1010    #[allow(clippy::too_many_arguments)]
1011    fn shape_paragraph_with_wrap(
1012        &mut self,
1013        input: TextInputRef<'_>,
1014        max_width_px: Option<f32>,
1015        word_break: WordBreakStrength,
1016        overflow_wrap: OverflowWrap,
1017        text_wrap_mode: TextWrapMode,
1018        scale: f32,
1019        metrics_only: bool,
1020    ) -> Vec<(Range<usize>, ShapedLineLayout)> {
1021        let (text, base_style, spans) = match input {
1022            TextInputRef::Plain { text, style } => (text, style, &[][..]),
1023            TextInputRef::Attributed { text, base, spans } => (text, base, spans),
1024        };
1025
1026        if text.is_empty() {
1027            let fallback = if metrics_only {
1028                self.shape_single_line_metrics(TextInputRef::plain(" ", base_style), scale)
1029            } else {
1030                self.shape_single_line(TextInputRef::plain(" ", base_style), scale)
1031            };
1032            return vec![(
1033                0..0,
1034                ShapedLineLayout {
1035                    width: 0.0,
1036                    ascent: fallback.ascent,
1037                    descent: fallback.descent,
1038                    ink_ascent: fallback.ink_ascent,
1039                    ink_descent: fallback.ink_descent,
1040                    baseline: fallback.baseline,
1041                    line_height: fallback.line_height,
1042                    glyphs: Vec::new(),
1043                    clusters: Vec::new(),
1044                },
1045            )];
1046        }
1047
1048        let root_style = ParleyTextStyle {
1049            word_break,
1050            overflow_wrap,
1051            text_wrap_mode,
1052            ..Default::default()
1053        };
1054
1055        let mut builder = self
1056            .lcx
1057            .tree_builder(&mut self.fcx, scale, true, &root_style);
1058
1059        let mut base = base_parley_style(
1060            base_style,
1061            self.default_locale.as_deref(),
1062            &self.common_fallback_stack_suffix,
1063        );
1064        base.word_break = word_break;
1065        base.overflow_wrap = overflow_wrap;
1066        base.text_wrap_mode = text_wrap_mode;
1067        builder.push_style_span(base);
1068
1069        let base_mods = shaping_properties_for_base_style(base_style);
1070        if let Some(props) = base_mods.as_ref() {
1071            builder.push_style_modification_span(props.iter());
1072        }
1073
1074        if let Some(span_ranges) = resolve_span_ranges(text, spans) {
1075            for (range, span) in span_ranges {
1076                let chunk = &text[range.clone()];
1077                if let Some(props) = shaping_properties_for_span(
1078                    base_style,
1079                    span,
1080                    &self.common_fallback_stack_suffix,
1081                ) {
1082                    builder.push_style_modification_span(props.iter());
1083                    builder.push_text(chunk);
1084                    builder.pop_style_span();
1085                } else {
1086                    builder.push_text(chunk);
1087                }
1088            }
1089        } else {
1090            builder.push_text(text);
1091        }
1092
1093        if base_mods.is_some() {
1094            builder.pop_style_span();
1095        }
1096        builder.pop_style_span();
1097        let _built_text = builder.build_into(&mut self.layout);
1098        self.layout.break_all_lines(max_width_px);
1099
1100        let mut out: Vec<(Range<usize>, ShapedLineLayout)> = Vec::new();
1101
1102        let requested_line_height_px =
1103            requested_line_height_logical_px_with_strut(base_style).map(|v| (v * scale).max(0.0));
1104        let strut_forces_fixed = base_style.strut_style.as_ref().is_some_and(|s| s.force);
1105        let fixed_line_box = strut_forces_fixed
1106            || (base_style.line_height_policy == TextLineHeightPolicy::FixedFromStyle
1107                && requested_line_height_px.is_some());
1108        let fixed_ascent_descent = if fixed_line_box {
1109            let style_for_metrics = style_for_strut_metrics(base_style);
1110            let style_for_metrics = style_for_metrics.as_ref().unwrap_or(base_style);
1111            self.base_ascent_descent_px_for_style(style_for_metrics, scale)
1112        } else {
1113            None
1114        };
1115
1116        for line in self.layout.lines() {
1117            let line_range = line.text_range();
1118            let line_start = line_range.start;
1119            let metrics = *line.metrics();
1120
1121            let ink_ascent = normalize_ascent(metrics.ascent);
1122            let ink_descent = normalize_descent(metrics.descent);
1123            let leading_distribution = effective_leading_distribution(base_style);
1124            let (ascent, descent, line_height, baseline) = if fixed_line_box {
1125                let (ascent, descent) = fixed_ascent_descent.unwrap_or((
1126                    normalize_ascent(metrics.ascent),
1127                    normalize_descent(metrics.descent),
1128                ));
1129                let fixed_line_height_px = requested_line_height_px
1130                    .unwrap_or_else(|| min_line_height_for_metrics(ascent, descent));
1131                (
1132                    ascent,
1133                    descent,
1134                    fixed_line_height_px,
1135                    baseline_for_fixed_line_box(
1136                        ascent,
1137                        descent,
1138                        fixed_line_height_px,
1139                        leading_distribution,
1140                    ),
1141                )
1142            } else {
1143                let ascent = normalize_ascent(metrics.ascent);
1144                let descent = normalize_descent(metrics.descent);
1145                let base_line_height = metrics.line_height.max(0.0);
1146                let mut line_height = metrics.line_height.max(0.0);
1147                line_height = line_height.max(min_line_height_for_metrics(ascent, descent));
1148                if let Some(requested) = requested_line_height_px {
1149                    line_height = line_height.max(requested.max(0.0));
1150                }
1151                let extra = (line_height - base_line_height).max(0.0);
1152                let top_factor =
1153                    leading_distribution_top_factor(leading_distribution, ascent, descent);
1154                let baseline = (metrics.baseline.max(0.0) + (extra * top_factor))
1155                    .clamp(0.0, line_height.max(0.0));
1156                (ascent, descent, line_height, baseline)
1157            };
1158
1159            let mut glyphs: Vec<ParleyGlyph> = Vec::new();
1160            let mut clusters: Vec<ShapedCluster> = Vec::new();
1161
1162            let mut run_x = metrics.offset;
1163            for run in line.runs() {
1164                let font = run.font();
1165                let font_data = GlyphFontData::from_parley(font.clone());
1166                let font_size = run.font_size();
1167                let normalized_coords: Arc<[i16]> = Arc::from(run.normalized_coords());
1168                let synthesis = FontSynthesis::from_parley(run.synthesis());
1169
1170                for cluster in run.visual_clusters() {
1171                    let cluster_range = cluster.text_range();
1172                    let cluster_x0 = run_x;
1173
1174                    let adjusted_range = (cluster_range.start.saturating_sub(line_start))
1175                        ..(cluster_range.end.saturating_sub(line_start));
1176
1177                    if !metrics_only {
1178                        let mut glyph_x = cluster_x0;
1179                        for mut g in cluster.glyphs() {
1180                            g.x += glyph_x;
1181                            glyph_x += g.advance;
1182
1183                            glyphs.push(ParleyGlyph {
1184                                id: g.id,
1185                                x: g.x,
1186                                y: g.y,
1187                                advance: g.advance,
1188                                font: font_data.clone(),
1189                                font_size,
1190                                normalized_coords: normalized_coords.clone(),
1191                                synthesis,
1192                                text_range: adjusted_range.clone(),
1193                                is_rtl: cluster.is_rtl(),
1194                            });
1195                        }
1196                    }
1197
1198                    run_x = cluster_x0 + cluster.advance();
1199                    clusters.push(ShapedCluster::new(
1200                        adjusted_range,
1201                        cluster_x0,
1202                        run_x,
1203                        cluster.is_rtl(),
1204                    ));
1205                }
1206            }
1207
1208            out.push((
1209                line_range.clone(),
1210                ShapedLineLayout {
1211                    width: metrics.advance,
1212                    ascent,
1213                    descent,
1214                    ink_ascent,
1215                    ink_descent,
1216                    baseline,
1217                    line_height,
1218                    glyphs,
1219                    clusters,
1220                },
1221            ));
1222        }
1223
1224        if out.is_empty() {
1225            out.push((
1226                0..text.len(),
1227                if metrics_only {
1228                    self.shape_single_line_metrics(input, scale)
1229                } else {
1230                    self.shape_single_line(input, scale)
1231                },
1232            ));
1233        }
1234
1235        out
1236    }
1237}
1238
1239fn resolve_span_ranges<'a>(
1240    text: &'a str,
1241    spans: &'a [TextSpan],
1242) -> Option<Vec<(Range<usize>, &'a TextSpan)>> {
1243    if spans.is_empty() {
1244        return None;
1245    }
1246
1247    let mut out: Vec<(Range<usize>, &'a TextSpan)> = Vec::with_capacity(spans.len());
1248    let mut offset: usize = 0;
1249
1250    for span in spans {
1251        let end = offset.saturating_add(span.len);
1252        if end > text.len() {
1253            return None;
1254        }
1255        if !text.is_char_boundary(offset) || !text.is_char_boundary(end) {
1256            return None;
1257        }
1258        if span.len != 0 {
1259            out.push((offset..end, span));
1260        }
1261        offset = end;
1262    }
1263
1264    if offset != text.len() {
1265        return None;
1266    }
1267
1268    Some(out)
1269}
1270
1271fn base_parley_style<'a>(
1272    style: &TextStyle,
1273    locale: Option<&'a str>,
1274    common_fallback_stack_suffix: &str,
1275) -> ParleyTextStyle<'a, [u8; 4]> {
1276    let stack = font_stack_for_font_id(&style.font, common_fallback_stack_suffix);
1277    ParleyTextStyle {
1278        font_size: style.size.0,
1279        font_weight: ParleyFontWeight::new(style.weight.0 as f32),
1280        font_style: font_style_for_slant(style.slant),
1281        letter_spacing: style.letter_spacing_em.unwrap_or(0.0).clamp(-4.0, 4.0) * style.size.0,
1282        locale,
1283        font_stack: parley::style::FontStack::Source(Cow::Owned(stack)),
1284        ..Default::default()
1285    }
1286}
1287
1288fn font_stack_for_font_id(font: &FontId, common_fallback_stack_suffix: &str) -> String {
1289    match font {
1290        FontId::Ui => "sans-serif".to_string(),
1291        FontId::Serif => "serif".to_string(),
1292        FontId::Monospace => "monospace".to_string(),
1293        FontId::Family(name) => {
1294            if common_fallback_stack_suffix.is_empty() {
1295                return name.clone();
1296            }
1297            format!("{name}, {common_fallback_stack_suffix}")
1298        }
1299    }
1300}
1301
1302fn font_style_for_slant(slant: TextSlant) -> FontStyle {
1303    match slant {
1304        TextSlant::Normal => FontStyle::Normal,
1305        TextSlant::Italic => FontStyle::Italic,
1306        TextSlant::Oblique => FontStyle::Oblique(None),
1307    }
1308}
1309
1310fn shaping_properties_for_span(
1311    base: &TextStyle,
1312    span: &TextSpan,
1313    common_fallback_stack_suffix: &str,
1314) -> Option<Vec<StyleProperty<'static, [u8; 4]>>> {
1315    let TextShapingStyle {
1316        font,
1317        weight,
1318        slant,
1319        letter_spacing_em,
1320        features,
1321        axes,
1322    } = &span.shaping;
1323
1324    let mut out: Vec<StyleProperty<'static, [u8; 4]>> = Vec::new();
1325
1326    if let Some(font) = font {
1327        let stack = font_stack_for_font_id(font, common_fallback_stack_suffix);
1328        out.push(StyleProperty::FontStack(parley::style::FontStack::Source(
1329            Cow::Owned(stack),
1330        )));
1331    }
1332
1333    let mut effective_weight = *weight;
1334    let mut axes_for_variations: Vec<fret_core::TextFontAxisSetting> = Vec::new();
1335    if !axes.is_empty() {
1336        // `wght` overlaps with the `FontWeight` attribute path. Prefer expressing it as
1337        // `FontWeight` so fontique synthesis participates consistently (and avoid duplicate
1338        // tag resolution ambiguity in the underlying shaping stack).
1339        let mut wght_axis_override: Option<f32> = None;
1340        for axis in axes {
1341            if axis.tag.trim().eq_ignore_ascii_case("wght") && axis.value.is_finite() {
1342                wght_axis_override = Some(axis.value);
1343                continue;
1344            }
1345            axes_for_variations.push(axis.clone());
1346        }
1347        if effective_weight.is_none()
1348            && let Some(wght) = wght_axis_override
1349        {
1350            let wght = wght.clamp(1.0, 1000.0).round() as u16;
1351            effective_weight = Some(fret_core::FontWeight(wght));
1352        }
1353    }
1354
1355    if !axes_for_variations.is_empty() {
1356        let variations = font_variations_for_axes(&axes_for_variations);
1357        if !variations.is_empty() {
1358            out.push(StyleProperty::FontVariations(FontSettings::List(
1359                Cow::Owned(variations),
1360            )));
1361        }
1362    }
1363    if !features.is_empty() {
1364        let features = font_features_for_settings(features);
1365        if !features.is_empty() {
1366            out.push(StyleProperty::FontFeatures(FontSettings::List(Cow::Owned(
1367                features,
1368            ))));
1369        }
1370    }
1371    if let Some(weight) = effective_weight {
1372        out.push(StyleProperty::FontWeight(ParleyFontWeight::new(
1373            weight.0 as f32,
1374        )));
1375    }
1376    if let Some(slant) = slant {
1377        out.push(StyleProperty::FontStyle(font_style_for_slant(*slant)));
1378    }
1379    if let Some(letter_spacing_em) = letter_spacing_em {
1380        out.push(StyleProperty::LetterSpacing(
1381            letter_spacing_em.clamp(-4.0, 4.0) * base.size.0,
1382        ));
1383    }
1384
1385    (!out.is_empty()).then_some(out)
1386}
1387
1388fn shaping_properties_for_base_style(
1389    style: &TextStyle,
1390) -> Option<Vec<StyleProperty<'static, [u8; 4]>>> {
1391    let mut out: Vec<StyleProperty<'static, [u8; 4]>> = Vec::new();
1392
1393    if !style.axes.is_empty() {
1394        // `wght` overlaps with the `FontWeight` attribute path. Prefer expressing it as
1395        // `FontWeight` so fontique synthesis participates consistently (and avoid duplicate
1396        // tag resolution ambiguity in the underlying shaping stack).
1397        let mut wght_axis_override: Option<f32> = None;
1398        let axes_for_variations = style
1399            .axes
1400            .iter()
1401            .filter_map(|a| {
1402                if a.tag.trim().eq_ignore_ascii_case("wght") && a.value.is_finite() {
1403                    wght_axis_override = Some(a.value);
1404                    return None;
1405                }
1406                Some(a.clone())
1407            })
1408            .collect::<Vec<_>>();
1409        if !axes_for_variations.is_empty() {
1410            let variations = font_variations_for_axes(&axes_for_variations);
1411            if !variations.is_empty() {
1412                out.push(StyleProperty::FontVariations(FontSettings::List(
1413                    Cow::Owned(variations),
1414                )));
1415            }
1416        }
1417
1418        if let Some(wght) = wght_axis_override {
1419            let wght = wght.clamp(1.0, 1000.0).round() as u16;
1420            out.push(StyleProperty::FontWeight(ParleyFontWeight::new(
1421                wght as f32,
1422            )));
1423        }
1424    }
1425
1426    if !style.features.is_empty() {
1427        let features = font_features_for_settings(&style.features);
1428        if !features.is_empty() {
1429            out.push(StyleProperty::FontFeatures(FontSettings::List(Cow::Owned(
1430                features,
1431            ))));
1432        }
1433    }
1434
1435    (!out.is_empty()).then_some(out)
1436}
1437
1438fn font_variations_for_axes(axes: &[fret_core::TextFontAxisSetting]) -> Vec<FontVariation> {
1439    use std::collections::BTreeMap;
1440
1441    let mut by_tag: BTreeMap<u32, FontVariation> = BTreeMap::new();
1442    for axis in axes {
1443        let tag = axis.tag.trim();
1444        if tag.is_empty() {
1445            continue;
1446        }
1447        let bytes = tag.as_bytes();
1448        if bytes.len() != 4 {
1449            continue;
1450        }
1451        if !axis.value.is_finite() {
1452            continue;
1453        }
1454
1455        let mut tag_bytes = [0u8; 4];
1456        tag_bytes.copy_from_slice(bytes);
1457        let tuple = (tag_bytes, axis.value);
1458        let setting = FontVariation::from(&tuple);
1459        by_tag.insert(setting.tag, setting);
1460    }
1461
1462    by_tag.into_values().collect::<Vec<_>>()
1463}
1464
1465fn font_features_for_settings(features: &[fret_core::TextFontFeatureSetting]) -> Vec<FontFeature> {
1466    use std::collections::BTreeMap;
1467
1468    let mut by_tag: BTreeMap<u32, FontFeature> = BTreeMap::new();
1469    for feature in features {
1470        let tag = feature.tag.trim();
1471        if tag.is_empty() {
1472            continue;
1473        }
1474        let bytes = tag.as_bytes();
1475        if bytes.len() != 4 || !bytes.iter().all(u8::is_ascii) {
1476            continue;
1477        }
1478
1479        let value = feature.value.min(u32::from(u16::MAX)) as u16;
1480        let mut tag_bytes = [0u8; 4];
1481        tag_bytes.copy_from_slice(bytes);
1482        let tuple = (tag_bytes, value);
1483        let setting = FontFeature::from(&tuple);
1484        by_tag.insert(setting.tag, setting);
1485    }
1486
1487    by_tag.into_values().collect::<Vec<_>>()
1488}
1489
1490pub fn run_system_font_rescan(seed: crate::SystemFontRescanSeed) -> crate::SystemFontRescanResult {
1491    crate::parley_font_db::run_system_font_rescan(seed)
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496    use super::*;
1497    use fret_core::{FontId, FontWeight, Px, TextFontFeatureSetting, TextSpan, TextStyle};
1498    use std::sync::{Mutex, OnceLock};
1499
1500    fn env_lock() -> &'static Mutex<()> {
1501        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1502        LOCK.get_or_init(|| Mutex::new(()))
1503    }
1504
1505    fn shaper_with_bundled_fonts() -> ParleyShaper {
1506        let mut shaper = ParleyShaper::new_without_system_fonts();
1507        let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
1508            fret_fonts::bootstrap_profile()
1509                .faces
1510                .iter()
1511                .chain(fret_fonts_emoji::default_profile().faces.iter())
1512                .chain(fret_fonts_cjk::default_profile().faces.iter()),
1513        ));
1514        assert!(added > 0, "expected bundled fonts to load");
1515        shaper
1516    }
1517
1518    #[test]
1519    fn shaping_properties_map_wght_axis_to_font_weight() {
1520        let base = TextStyle {
1521            font: FontId::family("Roboto Flex"),
1522            size: Px(16.0),
1523            weight: FontWeight(400),
1524            ..Default::default()
1525        };
1526
1527        let span = TextSpan {
1528            len: 1,
1529            shaping: TextShapingStyle::default().with_axis("wght", 900.0),
1530            paint: Default::default(),
1531        };
1532
1533        let props =
1534            shaping_properties_for_span(&base, &span, "").expect("expected shaping properties");
1535
1536        assert!(
1537            props
1538                .iter()
1539                .any(|p| matches!(p, StyleProperty::FontWeight(_))),
1540            "expected `wght` axis to map to FontWeight"
1541        );
1542        assert!(
1543            !props
1544                .iter()
1545                .any(|p| matches!(p, StyleProperty::FontVariations(_))),
1546            "expected `wght` axis to be removed from FontVariations"
1547        );
1548    }
1549
1550    #[test]
1551    fn base_style_maps_wght_axis_to_font_weight() {
1552        let base = TextStyle {
1553            font: FontId::family("Roboto Flex"),
1554            size: Px(16.0),
1555            weight: FontWeight(400),
1556            axes: vec![fret_core::TextFontAxisSetting {
1557                tag: "wght".into(),
1558                value: 900.0,
1559            }],
1560            ..Default::default()
1561        };
1562
1563        let props =
1564            shaping_properties_for_base_style(&base).expect("expected base shaping properties");
1565
1566        assert!(
1567            props
1568                .iter()
1569                .any(|p| matches!(p, StyleProperty::FontWeight(_))),
1570            "expected base `wght` axis to map to FontWeight"
1571        );
1572        assert!(
1573            !props
1574                .iter()
1575                .any(|p| matches!(p, StyleProperty::FontVariations(_))),
1576            "expected base `wght` axis to be removed from FontVariations"
1577        );
1578    }
1579
1580    #[test]
1581    fn shaping_properties_emit_font_features_when_present() {
1582        let base = TextStyle {
1583            font: FontId::family("Roboto Flex"),
1584            size: Px(16.0),
1585            weight: FontWeight(400),
1586            ..Default::default()
1587        };
1588
1589        let span = TextSpan {
1590            len: 1,
1591            shaping: TextShapingStyle::default()
1592                .with_feature("liga", 0)
1593                .with_feature("liga", 1)
1594                .with_feature(" lig ", 42)
1595                .with_feature("", 1)
1596                .with_feature("calt", 0),
1597            paint: Default::default(),
1598        };
1599
1600        let props =
1601            shaping_properties_for_span(&base, &span, "").expect("expected shaping properties");
1602
1603        fn tag_u32(tag: &[u8; 4]) -> u32 {
1604            (tag[0] as u32) << 24 | (tag[1] as u32) << 16 | (tag[2] as u32) << 8 | tag[3] as u32
1605        }
1606
1607        let mut features: Vec<FontFeature> = Vec::new();
1608        for p in &props {
1609            if let StyleProperty::FontFeatures(FontSettings::List(settings)) = p {
1610                features.extend(settings.iter().copied());
1611            }
1612        }
1613
1614        assert!(!features.is_empty(), "expected FontFeatures to be emitted");
1615
1616        let liga_tag = tag_u32(b"liga");
1617        let calt_tag = tag_u32(b"calt");
1618
1619        let tags = features
1620            .iter()
1621            .map(|f| f.tag)
1622            .collect::<std::collections::BTreeSet<_>>();
1623        assert_eq!(
1624            tags,
1625            std::collections::BTreeSet::from([calt_tag, liga_tag]),
1626            "expected invalid tags to be ignored and duplicates to be coalesced"
1627        );
1628
1629        let liga: Vec<FontFeature> = features
1630            .iter()
1631            .cloned()
1632            .filter(|f| f.tag == liga_tag)
1633            .collect();
1634        assert_eq!(liga.len(), 1, "expected duplicate tags to be coalesced");
1635        assert_eq!(liga[0].value, 1, "expected last-writer-wins for `liga`");
1636
1637        let calt: Vec<FontFeature> = features
1638            .iter()
1639            .cloned()
1640            .filter(|f| f.tag == calt_tag)
1641            .collect();
1642        assert_eq!(calt.len(), 1);
1643        assert_eq!(calt[0].value, 0);
1644    }
1645
1646    #[test]
1647    fn base_text_style_font_features_are_emitted_for_plain_text_builder() {
1648        fn tag_u32(tag: &[u8; 4]) -> u32 {
1649            (tag[0] as u32) << 24 | (tag[1] as u32) << 16 | (tag[2] as u32) << 8 | tag[3] as u32
1650        }
1651
1652        let style = TextStyle {
1653            font: FontId::family("Inter"),
1654            size: Px(16.0),
1655            ..Default::default()
1656        };
1657
1658        let mut tnum_style = style.clone();
1659        tnum_style.features.push(TextFontFeatureSetting {
1660            tag: "tnum".into(),
1661            value: 1,
1662        });
1663
1664        let props = shaping_properties_for_base_style(&tnum_style).expect("expected base props");
1665
1666        let mut features: Vec<FontFeature> = Vec::new();
1667        for p in &props {
1668            if let StyleProperty::FontFeatures(FontSettings::List(settings)) = p {
1669                features.extend(settings.iter().copied());
1670            }
1671        }
1672
1673        let tnum_tag = tag_u32(b"tnum");
1674        assert!(
1675            features.iter().any(|f| f.tag == tnum_tag && f.value == 1),
1676            "expected `tnum=1` to be emitted via base-style shaping properties"
1677        );
1678    }
1679
1680    #[test]
1681    fn font_catalog_caches_invalidate_after_add_fonts() {
1682        let mut shaper = ParleyShaper::new_without_system_fonts();
1683
1684        let names0 = shaper.all_font_names();
1685        assert!(
1686            !names0.iter().any(|n| n.eq_ignore_ascii_case("Inter")),
1687            "expected Inter to be absent before adding bundled fonts"
1688        );
1689
1690        let entries0 = shaper.all_font_catalog_entries();
1691        assert!(
1692            !entries0
1693                .iter()
1694                .any(|e| e.family().eq_ignore_ascii_case("Inter")),
1695            "expected catalog entries to be empty of Inter before adding bundled fonts"
1696        );
1697
1698        let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
1699            fret_fonts::bootstrap_profile().faces.iter(),
1700        ));
1701        assert!(added > 0, "expected bundled fonts to load");
1702
1703        let names1 = shaper.all_font_names();
1704        assert!(
1705            names1.iter().any(|n| n.eq_ignore_ascii_case("Inter")),
1706            "expected Inter to be present after adding bundled fonts"
1707        );
1708        assert_eq!(
1709            names1,
1710            shaper.all_font_names(),
1711            "expected repeated catalog reads to be stable"
1712        );
1713
1714        let entries1 = shaper.all_font_catalog_entries();
1715        assert!(
1716            entries1
1717                .iter()
1718                .any(|e| e.family().eq_ignore_ascii_case("Inter")),
1719            "expected catalog entries to include Inter after adding bundled fonts"
1720        );
1721        assert_eq!(
1722            entries1,
1723            shaper.all_font_catalog_entries(),
1724            "expected repeated catalog reads to be stable"
1725        );
1726    }
1727
1728    #[test]
1729    fn font_catalog_cached_reads_do_not_rebuild_entries_until_invalidated() {
1730        let mut shaper = ParleyShaper::new_without_system_fonts();
1731        let snapshot0 = shaper.font_db_diagnostics_snapshot();
1732        assert_eq!(
1733            snapshot0.catalog_entries_build_count(),
1734            0,
1735            "expected no catalog builds before first enumeration"
1736        );
1737
1738        let entries0 = shaper.all_font_catalog_entries();
1739        assert!(
1740            entries0.is_empty(),
1741            "expected bundled-only empty catalog before adding fonts"
1742        );
1743        let snapshot1 = shaper.font_db_diagnostics_snapshot();
1744        assert_eq!(
1745            snapshot1.catalog_entries_build_count(),
1746            1,
1747            "expected first cold enumeration to build the catalog exactly once"
1748        );
1749        assert!(
1750            snapshot1.all_font_catalog_entries_cache_present(),
1751            "expected catalog cache to be populated after first enumeration"
1752        );
1753
1754        for _ in 0..16 {
1755            assert_eq!(
1756                entries0,
1757                shaper.all_font_catalog_entries(),
1758                "expected cached catalog enumeration to stay stable"
1759            );
1760        }
1761        let snapshot2 = shaper.font_db_diagnostics_snapshot();
1762        assert_eq!(
1763            snapshot2.catalog_entries_build_count(),
1764            1,
1765            "expected repeated cached enumerations not to rebuild the catalog"
1766        );
1767
1768        let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
1769            fret_fonts::bootstrap_profile().faces.iter(),
1770        ));
1771        assert!(added > 0, "expected bundled fonts to load");
1772        let snapshot3 = shaper.font_db_diagnostics_snapshot();
1773        assert!(
1774            !snapshot3.all_font_catalog_entries_cache_present(),
1775            "expected add_fonts to invalidate the catalog cache"
1776        );
1777        assert_eq!(
1778            snapshot3.catalog_entries_build_count(),
1779            1,
1780            "expected invalidation alone not to rebuild the catalog"
1781        );
1782
1783        let entries1 = shaper.all_font_catalog_entries();
1784        assert!(
1785            entries1
1786                .iter()
1787                .any(|e| e.family().eq_ignore_ascii_case("Inter")),
1788            "expected rebuilt catalog to include bundled fonts after invalidation"
1789        );
1790        let snapshot4 = shaper.font_db_diagnostics_snapshot();
1791        assert_eq!(
1792            snapshot4.catalog_entries_build_count(),
1793            2,
1794            "expected the first post-invalidation enumeration to rebuild exactly once"
1795        );
1796    }
1797
1798    #[test]
1799    fn registered_font_blobs_dedup_and_lru_eviction_by_count() {
1800        let _guard = env_lock().lock().unwrap();
1801        let prev_max_count = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT").ok();
1802        let prev_max_bytes = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES").ok();
1803        unsafe {
1804            std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", "2");
1805            std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", "1048576");
1806        }
1807
1808        let mut shaper = ParleyShaper::new_without_system_fonts();
1809        shaper.record_registered_font_blob_bytes_for_tests(vec![1u8; 1]); // A
1810        shaper.record_registered_font_blob_bytes_for_tests(vec![2u8; 2]); // B
1811        shaper.record_registered_font_blob_bytes_for_tests(vec![1u8; 1]); // A again (touch)
1812        shaper.record_registered_font_blob_bytes_for_tests(vec![3u8; 3]); // C -> evict B
1813
1814        assert_eq!(shaper.registered_font_blob_lengths_for_tests(), vec![1, 3]);
1815        assert_eq!(shaper.registered_font_blob_total_bytes_for_tests(), 4);
1816
1817        unsafe {
1818            match prev_max_count {
1819                Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", v),
1820                None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT"),
1821            }
1822            match prev_max_bytes {
1823                Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", v),
1824                None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES"),
1825            }
1826        }
1827    }
1828
1829    #[test]
1830    fn registered_font_blobs_eviction_by_bytes_budget() {
1831        let _guard = env_lock().lock().unwrap();
1832        let prev_max_count = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT").ok();
1833        let prev_max_bytes = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES").ok();
1834        unsafe {
1835            std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", "4096");
1836            std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", "3");
1837        }
1838
1839        let mut shaper = ParleyShaper::new_without_system_fonts();
1840        shaper.record_registered_font_blob_bytes_for_tests(vec![1u8; 2]);
1841        shaper.record_registered_font_blob_bytes_for_tests(vec![2u8; 2]);
1842
1843        assert_eq!(shaper.registered_font_blob_lengths_for_tests(), vec![2]);
1844        assert_eq!(shaper.registered_font_blob_total_bytes_for_tests(), 2);
1845
1846        unsafe {
1847            match prev_max_count {
1848                Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", v),
1849                None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT"),
1850            }
1851            match prev_max_bytes {
1852                Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", v),
1853                None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES"),
1854            }
1855        }
1856    }
1857
1858    #[test]
1859    fn rescan_is_noop_when_system_fonts_disabled() {
1860        let shaper = ParleyShaper::new_without_system_fonts();
1861        assert!(shaper.system_font_rescan_seed().is_none());
1862    }
1863
1864    #[cfg(not(target_arch = "wasm32"))]
1865    #[test]
1866    fn rescan_apply_returns_false_when_environment_is_unchanged() {
1867        let mut shaper = ParleyShaper::new();
1868        let fingerprint_before = shaper.current_font_environment_fingerprint();
1869        let seed = shaper
1870            .system_font_rescan_seed()
1871            .expect("expected system font rescan to be available");
1872        let result = seed.run();
1873
1874        assert_eq!(
1875            result.environment_fingerprint, fingerprint_before,
1876            "expected background rescan to observe the same environment before apply"
1877        );
1878        assert!(
1879            !shaper.apply_system_font_rescan_result(result),
1880            "expected apply to short-circuit when the environment is unchanged"
1881        );
1882        assert_eq!(
1883            shaper.current_font_environment_fingerprint(),
1884            fingerprint_before,
1885            "expected no-op apply to preserve the current font environment"
1886        );
1887    }
1888
1889    #[test]
1890    fn shapes_basic_single_line() {
1891        let mut shaper = ParleyShaper::new();
1892        let style = TextStyle {
1893            font: FontId::default(),
1894            size: Px(16.0),
1895            ..Default::default()
1896        };
1897        let input = TextInputRef::plain("hello", &style);
1898
1899        let layout = shaper.shape_single_line(input, 1.0);
1900        assert!(layout.width() >= 0.0);
1901        assert!(!layout.glyphs().is_empty());
1902        assert!(!layout.clusters().is_empty());
1903    }
1904
1905    #[test]
1906    fn clamps_line_height_to_font_extents() {
1907        let mut shaper = ParleyShaper::new_without_system_fonts();
1908        shaper.add_fonts(fret_fonts::test_support::face_blobs(
1909            fret_fonts::default_profile().faces.iter(),
1910        ));
1911
1912        let style = TextStyle {
1913            font: FontId::default(),
1914            size: Px(16.0),
1915            line_height: Some(Px(1.0)),
1916            ..Default::default()
1917        };
1918        let input = TextInputRef::plain("Hello", &style);
1919
1920        let layout = shaper.shape_single_line(input, 1.0);
1921        let min = min_line_height_for_metrics(layout.ascent, layout.descent);
1922        assert!(
1923            layout.line_height + 0.001 >= min,
1924            "line_height={} ascent={} descent={} min={}",
1925            layout.line_height,
1926            layout.ascent,
1927            layout.descent,
1928            min
1929        );
1930    }
1931
1932    #[test]
1933    fn normalizes_descent_to_positive_magnitude() {
1934        let mut shaper = ParleyShaper::new_without_system_fonts();
1935        shaper.add_fonts(fret_fonts::test_support::face_blobs(
1936            fret_fonts::default_profile().faces.iter(),
1937        ));
1938
1939        let style = TextStyle {
1940            font: FontId::default(),
1941            size: Px(16.0),
1942            ..Default::default()
1943        };
1944        let input = TextInputRef::plain("Hello", &style);
1945
1946        let layout = shaper.shape_single_line_metrics(input, 1.0);
1947        assert!(
1948            layout.descent >= -0.001,
1949            "expected descent to be non-negative; descent={}",
1950            layout.descent
1951        );
1952        assert!(
1953            layout.line_height + 0.001 >= layout.ascent + layout.descent,
1954            "expected line_height >= ascent+descent; line_height={} ascent={} descent={}",
1955            layout.line_height,
1956            layout.ascent,
1957            layout.descent
1958        );
1959    }
1960
1961    #[test]
1962    fn fixed_line_box_policy_keeps_line_height_stable_across_fallback_fonts() {
1963        let mut shaper = shaper_with_bundled_fonts();
1964
1965        let style = TextStyle {
1966            font: FontId::default(),
1967            size: Px(16.0),
1968            line_height: Some(Px(18.0)),
1969            line_height_policy: TextLineHeightPolicy::FixedFromStyle,
1970            ..Default::default()
1971        };
1972
1973        let latin = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &style), 1.0);
1974        let emoji = shaper.shape_single_line_metrics(TextInputRef::plain("😀", &style), 1.0);
1975        let cjk = shaper.shape_single_line_metrics(TextInputRef::plain("你好", &style), 1.0);
1976
1977        for (name, line) in [("latin", latin), ("emoji", emoji), ("cjk", cjk)] {
1978            assert!(
1979                (line.line_height - 18.0).abs() < 0.01,
1980                "expected fixed line_height=18px; {name} line_height={}",
1981                line.line_height
1982            );
1983        }
1984    }
1985
1986    #[test]
1987    fn respects_explicit_line_height_override() {
1988        let mut shaper = ParleyShaper::new_without_system_fonts();
1989        shaper.add_fonts(fret_fonts::test_support::face_blobs(
1990            fret_fonts::default_profile().faces.iter(),
1991        ));
1992
1993        let style = TextStyle {
1994            font: FontId::default(),
1995            size: Px(16.0),
1996            line_height: Some(Px(40.0)),
1997            ..Default::default()
1998        };
1999        let input = TextInputRef::plain("Hello", &style);
2000
2001        let layout = shaper.shape_single_line(input, 1.0);
2002        assert!(layout.line_height + 0.001 >= 40.0);
2003    }
2004
2005    #[test]
2006    fn explicit_line_height_increases_baseline_via_half_leading() {
2007        let mut shaper = ParleyShaper::new_without_system_fonts();
2008        shaper.add_fonts(fret_fonts::test_support::face_blobs(
2009            fret_fonts::default_profile().faces.iter(),
2010        ));
2011
2012        let base = TextStyle {
2013            font: FontId::default(),
2014            size: Px(14.0),
2015            line_height: None,
2016            ..Default::default()
2017        };
2018        let tall = TextStyle {
2019            line_height: Some(Px(20.0)),
2020            ..base.clone()
2021        };
2022
2023        let a = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &base), 1.0);
2024        let b = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &tall), 1.0);
2025
2026        assert!(
2027            b.baseline > a.baseline + 0.1,
2028            "expected baseline to increase when line_height expands (half-leading); a.baseline={} b.baseline={} a.line_height={} b.line_height={}",
2029            a.baseline,
2030            b.baseline,
2031            a.line_height,
2032            b.line_height
2033        );
2034        assert!(
2035            b.baseline <= b.line_height + 0.001,
2036            "expected baseline to remain within the line box; baseline={} line_height={}",
2037            b.baseline,
2038            b.line_height
2039        );
2040    }
2041
2042    #[test]
2043    fn proportional_leading_distribution_increases_baseline_shift() {
2044        let mut shaper = shaper_with_bundled_fonts();
2045
2046        let base = TextStyle {
2047            font: FontId::family("Inter"),
2048            size: Px(14.0),
2049            line_height: Some(Px(40.0)),
2050            line_height_policy: TextLineHeightPolicy::FixedFromStyle,
2051            ..Default::default()
2052        };
2053
2054        let even = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &base), 1.0);
2055
2056        let proportional_style = TextStyle {
2057            leading_distribution: TextLeadingDistribution::Proportional,
2058            ..base
2059        };
2060        let proportional = shaper
2061            .shape_single_line_metrics(TextInputRef::plain("Hello", &proportional_style), 1.0);
2062        let factor_even = leading_distribution_top_factor(
2063            TextLeadingDistribution::Even,
2064            even.ascent,
2065            even.descent,
2066        );
2067        let factor_prop = leading_distribution_top_factor(
2068            TextLeadingDistribution::Proportional,
2069            proportional.ascent,
2070            proportional.descent,
2071        );
2072
2073        assert!(
2074            proportional.baseline > even.baseline + 0.01,
2075            "expected proportional leading to bias extra leading upward; even.baseline={} proportional.baseline={} line_height={} even(ascent={},descent={},factor={}) proportional(ascent={},descent={},factor={})",
2076            even.baseline,
2077            proportional.baseline,
2078            proportional.line_height,
2079            even.ascent,
2080            even.descent,
2081            factor_even,
2082            proportional.ascent,
2083            proportional.descent,
2084            factor_prop
2085        );
2086        assert!(
2087            proportional.baseline <= proportional.line_height + 0.001,
2088            "expected baseline to remain within the line box; baseline={} line_height={}",
2089            proportional.baseline,
2090            proportional.line_height
2091        );
2092    }
2093
2094    #[test]
2095    fn fixed_line_box_baseline_normalizes_negative_descent() {
2096        let ascent = 9.0_f32;
2097        let descent = 3.0_f32;
2098        let line_height = 16.0_f32;
2099
2100        let baseline_pos = baseline_for_fixed_line_box(
2101            ascent,
2102            descent,
2103            line_height,
2104            TextLeadingDistribution::Even,
2105        );
2106        let baseline_neg = baseline_for_fixed_line_box(
2107            ascent,
2108            -descent,
2109            line_height,
2110            TextLeadingDistribution::Even,
2111        );
2112
2113        assert!(
2114            (baseline_pos - baseline_neg).abs() < 0.0001,
2115            "expected negative descent to be normalized; baseline_pos={baseline_pos} baseline_neg={baseline_neg}"
2116        );
2117        assert!(
2118            baseline_pos > 0.0 && baseline_pos < line_height,
2119            "expected baseline to remain within the line box; baseline={baseline_pos} line_height={line_height}"
2120        );
2121    }
2122
2123    #[test]
2124    fn strut_force_keeps_metrics_stable_without_explicit_line_height() {
2125        let mut shaper = shaper_with_bundled_fonts();
2126
2127        let style = TextStyle {
2128            font: FontId::family("Inter"),
2129            size: Px(16.0),
2130            strut_style: Some(fret_core::TextStrutStyle {
2131                force: true,
2132                ..Default::default()
2133            }),
2134            ..Default::default()
2135        };
2136
2137        let latin = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &style), 1.0);
2138        let latin_line_height = latin.line_height;
2139        let latin_baseline = latin.baseline;
2140        let emoji = shaper.shape_single_line_metrics(TextInputRef::plain("😀", &style), 1.0);
2141        let cjk = shaper.shape_single_line_metrics(TextInputRef::plain("你好", &style), 1.0);
2142
2143        for (name, line) in [("latin", latin), ("emoji", emoji), ("cjk", cjk)] {
2144            assert!(
2145                (line.line_height - latin_line_height).abs() < 0.01,
2146                "expected strut-forced line height to be stable; {name} line_height={} latin={}",
2147                line.line_height,
2148                latin_line_height
2149            );
2150            assert!(
2151                (line.baseline - latin_baseline).abs() < 0.01,
2152                "expected strut-forced baseline to be stable; {name} baseline={} latin={}",
2153                line.baseline,
2154                latin_baseline
2155            );
2156        }
2157    }
2158
2159    #[test]
2160    fn font_catalog_monospace_probe_can_be_disabled() {
2161        let lock = env_lock().lock().unwrap();
2162        unsafe {
2163            std::env::set_var("FRET_TEXT_FONT_CATALOG_MONOSPACE_PROBE", "0");
2164        }
2165
2166        let mut shaper = ParleyShaper::new();
2167        let entries = shaper.all_font_catalog_entries();
2168        assert!(
2169            entries.iter().all(|e| !e.is_monospace_candidate()),
2170            "expected monospace candidates to be suppressed when probe is disabled"
2171        );
2172
2173        unsafe {
2174            std::env::remove_var("FRET_TEXT_FONT_CATALOG_MONOSPACE_PROBE");
2175        }
2176        drop(lock);
2177    }
2178}