Skip to main content

fret_render_text/
fallback_policy.rs

1use crate::parley_shaper::ParleyShaper;
2use parley::fontique::FamilyId as ParleyFamilyId;
3use std::{collections::HashSet, sync::OnceLock};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum CommonFallbackMode {
7    PreferSystemFallback,
8    PreferCommonFallback,
9}
10
11#[derive(Debug, Clone)]
12pub struct TextFallbackPolicyV1 {
13    /// Last applied config inputs (runner-owned, portable).
14    font_family_config: fret_core::TextFontFamilyConfig,
15    /// Last applied shaping locale (BCP47).
16    locale_bcp47: Option<String>,
17
18    /// Derived, renderer-internal policy state.
19    generic_common_fallback_mode: CommonFallbackMode,
20    named_common_fallback_mode: CommonFallbackMode,
21    common_fallback_candidates: Vec<String>,
22    common_fallback_stack_suffix: String,
23
24    /// Fingerprint of the effective fallback policy, intended for diagnostics + cache invalidation.
25    fallback_policy_key: u64,
26}
27
28impl TextFallbackPolicyV1 {
29    pub fn new(shaper: &ParleyShaper) -> Self {
30        let mut out = Self {
31            font_family_config: fret_core::TextFontFamilyConfig::default(),
32            locale_bcp47: None,
33            generic_common_fallback_mode: CommonFallbackMode::PreferSystemFallback,
34            named_common_fallback_mode: CommonFallbackMode::PreferSystemFallback,
35            common_fallback_candidates: Vec::new(),
36            common_fallback_stack_suffix: String::new(),
37            // Non-zero by default so callers can treat `0` as "unknown/uninitialized" if desired.
38            fallback_policy_key: 1,
39        };
40        out.refresh_derived(shaper);
41        out.recompute_key(shaper);
42        out
43    }
44
45    pub fn font_family_config(&self) -> &fret_core::TextFontFamilyConfig {
46        &self.font_family_config
47    }
48
49    pub fn set_font_family_config(&mut self, font_family_config: fret_core::TextFontFamilyConfig) {
50        self.font_family_config = font_family_config;
51    }
52
53    pub fn locale_bcp47(&self) -> Option<&str> {
54        self.locale_bcp47.as_deref()
55    }
56
57    pub fn set_locale_bcp47(&mut self, locale_bcp47: Option<String>) {
58        self.locale_bcp47 = locale_bcp47;
59    }
60
61    pub fn common_fallback_mode(&self) -> CommonFallbackMode {
62        self.named_common_fallback_mode
63    }
64
65    pub fn generic_common_fallback_mode(&self) -> CommonFallbackMode {
66        self.generic_common_fallback_mode
67    }
68
69    pub fn prefer_common_fallback(&self) -> bool {
70        self.named_common_fallback_mode == CommonFallbackMode::PreferCommonFallback
71    }
72
73    pub fn prefer_common_fallback_for_generics(&self) -> bool {
74        self.generic_common_fallback_mode == CommonFallbackMode::PreferCommonFallback
75    }
76
77    pub fn uses_common_fallback_for_font(&self, font: &fret_core::FontId) -> bool {
78        match font {
79            fret_core::FontId::Family(_) => self.prefer_common_fallback(),
80            _ => self.prefer_common_fallback_for_generics(),
81        }
82    }
83
84    pub fn common_fallback_candidates(&self) -> &[String] {
85        self.common_fallback_candidates.as_slice()
86    }
87
88    pub fn common_fallback_stack_suffix(&self) -> &str {
89        self.common_fallback_stack_suffix.as_str()
90    }
91
92    pub(crate) fn set_common_fallback_stack_suffix(
93        &mut self,
94        common_fallback_stack_suffix: String,
95    ) {
96        self.common_fallback_stack_suffix = common_fallback_stack_suffix;
97    }
98
99    pub fn fallback_policy_key(&self) -> u64 {
100        self.fallback_policy_key
101    }
102
103    fn platform_default_common_fallback_modes(
104        shaper: &ParleyShaper,
105    ) -> (CommonFallbackMode, CommonFallbackMode) {
106        #[cfg(target_arch = "wasm32")]
107        {
108            let _ = shaper;
109            (
110                CommonFallbackMode::PreferCommonFallback,
111                CommonFallbackMode::PreferCommonFallback,
112            )
113        }
114        #[cfg(not(target_arch = "wasm32"))]
115        {
116            if shaper.system_fonts_enabled() {
117                (
118                    CommonFallbackMode::PreferCommonFallback,
119                    CommonFallbackMode::PreferSystemFallback,
120                )
121            } else {
122                (
123                    CommonFallbackMode::PreferCommonFallback,
124                    CommonFallbackMode::PreferCommonFallback,
125                )
126            }
127        }
128    }
129
130    pub fn refresh_derived(&mut self, shaper: &ParleyShaper) {
131        let (generic_mode, named_mode) = match self.font_family_config.common_fallback_injection {
132            fret_core::TextCommonFallbackInjection::PlatformDefault => {
133                Self::platform_default_common_fallback_modes(shaper)
134            }
135            fret_core::TextCommonFallbackInjection::None => (
136                CommonFallbackMode::PreferSystemFallback,
137                CommonFallbackMode::PreferSystemFallback,
138            ),
139            fret_core::TextCommonFallbackInjection::CommonFallback => (
140                CommonFallbackMode::PreferCommonFallback,
141                CommonFallbackMode::PreferCommonFallback,
142            ),
143        };
144        self.generic_common_fallback_mode = generic_mode;
145        self.named_common_fallback_mode = named_mode;
146
147        self.common_fallback_candidates =
148            if self.prefer_common_fallback() || self.prefer_common_fallback_for_generics() {
149                effective_common_fallback_candidates(
150                    &self.font_family_config.common_fallback,
151                    default_common_fallback_families(shaper),
152                )
153            } else {
154                Vec::new()
155            };
156
157        self.common_fallback_stack_suffix = match self.named_common_fallback_mode {
158            CommonFallbackMode::PreferSystemFallback => String::new(),
159            CommonFallbackMode::PreferCommonFallback => self.common_fallback_candidates.join(", "),
160        };
161    }
162
163    pub fn recompute_key(&mut self, shaper: &ParleyShaper) {
164        let mut hasher = blake3::Hasher::new();
165        update_key_bytes(&mut hasher, "schema", b"fret.text.fallback_policy.v1");
166        update_key_u8(
167            &mut hasher,
168            "system_fonts_enabled",
169            u8::from(shaper.system_fonts_enabled()),
170        );
171        update_key_u8(
172            &mut hasher,
173            "generic_common_fallback_mode",
174            match self.generic_common_fallback_mode {
175                CommonFallbackMode::PreferSystemFallback => 0,
176                CommonFallbackMode::PreferCommonFallback => 1,
177            },
178        );
179        update_key_u8(
180            &mut hasher,
181            "named_common_fallback_mode",
182            match self.named_common_fallback_mode {
183                CommonFallbackMode::PreferSystemFallback => 0,
184                CommonFallbackMode::PreferCommonFallback => 1,
185            },
186        );
187        update_key_optional_lower_string(&mut hasher, "locale_bcp47", self.locale_bcp47.as_deref());
188        update_key_u8(
189            &mut hasher,
190            "common_fallback_injection",
191            match self.font_family_config.common_fallback_injection {
192                fret_core::TextCommonFallbackInjection::PlatformDefault => 0,
193                fret_core::TextCommonFallbackInjection::None => 1,
194                fret_core::TextCommonFallbackInjection::CommonFallback => 2,
195            },
196        );
197        update_key_normalized_string_list(
198            &mut hasher,
199            "configured_ui_sans_families",
200            &self.font_family_config.ui_sans,
201        );
202        update_key_normalized_string_list(
203            &mut hasher,
204            "configured_ui_serif_families",
205            &self.font_family_config.ui_serif,
206        );
207        update_key_normalized_string_list(
208            &mut hasher,
209            "configured_ui_mono_families",
210            &self.font_family_config.ui_mono,
211        );
212        update_key_normalized_string_list(
213            &mut hasher,
214            "configured_common_fallback_families",
215            &self.font_family_config.common_fallback,
216        );
217        update_key_normalized_static_str_list(
218            &mut hasher,
219            "default_common_fallback_families",
220            default_common_fallback_families(shaper),
221        );
222        update_key_lower_string(
223            &mut hasher,
224            "common_fallback_stack_suffix",
225            shaper.common_fallback_stack_suffix(),
226        );
227
228        let digest = hasher.finalize();
229        let mut key_bytes = [0u8; 8];
230        key_bytes.copy_from_slice(&digest.as_bytes()[..8]);
231        let key = u64::from_le_bytes(key_bytes);
232        self.fallback_policy_key = if key == 0 { 1 } else { key };
233    }
234
235    pub fn diagnostics_snapshot(
236        &self,
237        frame_id: fret_core::FrameId,
238        font_stack_key: u64,
239        font_db_revision: u64,
240        shaper: &ParleyShaper,
241    ) -> fret_core::RendererTextFallbackPolicySnapshot {
242        fret_core::RendererTextFallbackPolicySnapshot {
243            frame_id,
244            font_stack_key,
245            font_db_revision,
246            fallback_policy_key: self.fallback_policy_key,
247            system_fonts_enabled: shaper.system_fonts_enabled(),
248            locale_bcp47: self.locale_bcp47.clone(),
249            common_fallback_injection: self.font_family_config.common_fallback_injection,
250            prefer_common_fallback: self.prefer_common_fallback(),
251            prefer_common_fallback_for_generics: self.prefer_common_fallback_for_generics(),
252            configured_ui_sans_families: self.font_family_config.ui_sans.clone(),
253            configured_ui_serif_families: self.font_family_config.ui_serif.clone(),
254            configured_ui_mono_families: self.font_family_config.ui_mono.clone(),
255            configured_common_fallback_families: self.font_family_config.common_fallback.clone(),
256            default_ui_sans_candidates: default_sans_candidates(shaper)
257                .iter()
258                .map(|family| (*family).to_string())
259                .collect(),
260            default_ui_serif_candidates: default_serif_candidates(shaper)
261                .iter()
262                .map(|family| (*family).to_string())
263                .collect(),
264            default_ui_mono_candidates: default_monospace_candidates(shaper)
265                .iter()
266                .map(|family| (*family).to_string())
267                .collect(),
268            default_common_fallback_families: default_common_fallback_families(shaper)
269                .iter()
270                .map(|family| (*family).to_string())
271                .collect(),
272            common_fallback_stack_suffix: shaper.common_fallback_stack_suffix().to_string(),
273            common_fallback_candidates: self.common_fallback_candidates.clone(),
274            bundled_profile_contract: bundled_profile_contract_snapshot(),
275        }
276    }
277}
278
279#[cfg_attr(not(any(test, target_arch = "wasm32")), allow(dead_code))]
280fn merged_static_family_lists(lists: &[&[&'static str]]) -> Box<[&'static str]> {
281    let mut seen_lower: HashSet<String> = HashSet::new();
282    let mut families: Vec<&'static str> = Vec::new();
283    for list in lists {
284        for &family in *list {
285            let trimmed = family.trim();
286            if trimmed.is_empty() {
287                continue;
288            }
289            let key = trimmed.to_ascii_lowercase();
290            if seen_lower.insert(key) {
291                families.push(trimmed);
292            }
293        }
294    }
295    families.into_boxed_slice()
296}
297
298#[cfg(target_arch = "wasm32")]
299fn bundled_only_default_common_fallback_families() -> &'static [&'static str] {
300    static FAMILIES: OnceLock<Box<[&'static str]>> = OnceLock::new();
301    FAMILIES.get_or_init(|| {
302        let profile = fret_fonts::default_profile();
303        merged_static_family_lists(&[profile.ui_sans_families, profile.common_fallback_families])
304    })
305}
306
307#[cfg(not(target_arch = "wasm32"))]
308fn bundled_only_default_common_fallback_families() -> &'static [&'static str] {
309    static FAMILIES: OnceLock<Box<[&'static str]>> = OnceLock::new();
310    FAMILIES.get_or_init(|| {
311        let profile = fret_fonts::default_profile();
312        merged_static_family_lists(&[profile.ui_sans_families, profile.common_fallback_families])
313    })
314}
315
316#[cfg(target_os = "windows")]
317fn platform_default_common_fallback_families() -> &'static [&'static str] {
318    &[
319        // UI
320        "Segoe UI",
321        "Tahoma",
322        // CJK
323        "Microsoft YaHei UI",
324        "Microsoft YaHei",
325        "Yu Gothic UI",
326        "Meiryo UI",
327        "Meiryo",
328        "Nirmala UI",
329        // Emoji
330        "Segoe UI Emoji",
331        "Segoe UI Symbol",
332    ]
333}
334
335#[cfg(target_os = "macos")]
336fn platform_default_common_fallback_families() -> &'static [&'static str] {
337    &[
338        // UI
339        "SF Pro Text",
340        ".SF NS Text",
341        "Helvetica Neue",
342        // CJK
343        "PingFang SC",
344        "PingFang TC",
345        "Hiragino Sans",
346        // Emoji
347        "Apple Color Emoji",
348    ]
349}
350
351#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
352fn platform_default_common_fallback_families() -> &'static [&'static str] {
353    &[
354        // UI
355        "Noto Sans",
356        "DejaVu Sans",
357        "Liberation Sans",
358        // CJK
359        "Noto Sans CJK JP",
360        "Noto Sans CJK TC",
361    ]
362}
363
364#[cfg(not(any(
365    target_arch = "wasm32",
366    target_os = "windows",
367    target_os = "macos",
368    all(unix, not(any(target_os = "macos", target_os = "android")))
369)))]
370fn platform_default_common_fallback_families() -> &'static [&'static str] {
371    &[]
372}
373
374#[cfg(not(target_arch = "wasm32"))]
375fn native_default_common_fallback_families() -> &'static [&'static str] {
376    static FAMILIES: OnceLock<Box<[&'static str]>> = OnceLock::new();
377    FAMILIES.get_or_init(|| {
378        merged_static_family_lists(&[
379            platform_default_common_fallback_families(),
380            fret_fonts::default_profile().common_fallback_families,
381        ])
382    })
383}
384
385pub(crate) fn default_common_fallback_families(shaper: &ParleyShaper) -> &'static [&'static str] {
386    // Bundled-only mode should be explicit and deterministic on both wasm and native.
387    if !shaper.system_fonts_enabled() {
388        return bundled_only_default_common_fallback_families();
389    }
390
391    #[cfg(target_arch = "wasm32")]
392    {
393        let _ = shaper;
394        bundled_only_default_common_fallback_families()
395    }
396    #[cfg(target_os = "windows")]
397    {
398        native_default_common_fallback_families()
399    }
400    #[cfg(target_os = "macos")]
401    {
402        native_default_common_fallback_families()
403    }
404    #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
405    {
406        native_default_common_fallback_families()
407    }
408    #[cfg(not(any(
409        target_arch = "wasm32",
410        target_os = "windows",
411        target_os = "macos",
412        all(unix, not(any(target_os = "macos", target_os = "android")))
413    )))]
414    {
415        let _ = shaper;
416        &[]
417    }
418}
419
420pub(crate) fn first_available_family_id(
421    shaper: &mut ParleyShaper,
422    candidates: &[&str],
423) -> Option<ParleyFamilyId> {
424    for &name in candidates {
425        if let Some(id) = shaper.resolve_family_id(name) {
426            return Some(id);
427        }
428    }
429    None
430}
431
432pub fn common_fallback_stack_suffix(
433    common_fallback_config: &[String],
434    defaults: &'static [&'static str],
435) -> String {
436    effective_common_fallback_candidates(common_fallback_config, defaults).join(", ")
437}
438
439pub(crate) fn effective_common_fallback_candidates(
440    common_fallback_config: &[String],
441    defaults: &'static [&'static str],
442) -> Vec<String> {
443    let mut seen_lower: HashSet<String> = HashSet::new();
444    let mut families: Vec<String> = Vec::new();
445
446    let mut push = |name: &str| {
447        let trimmed = name.trim();
448        if trimmed.is_empty() {
449            return;
450        }
451        let key = trimmed.to_ascii_lowercase();
452        if seen_lower.insert(key) {
453            families.push(trimmed.to_string());
454        }
455    };
456
457    for family in common_fallback_config {
458        push(family);
459    }
460    for &family in defaults {
461        push(family);
462    }
463
464    families
465}
466
467pub(crate) fn common_fallback_stack_suffix_max_families() -> usize {
468    static MAX: OnceLock<usize> = OnceLock::new();
469    *MAX.get_or_init(|| {
470        // Keep the explicit per-style fallback list bounded to avoid pathological slowdowns when
471        // users copy-paste huge fallback stacks.
472        std::env::var("FRET_TEXT_COMMON_FALLBACK_MAX_FAMILIES")
473            .ok()
474            .and_then(|v| v.parse::<usize>().ok())
475            .unwrap_or(64)
476            .clamp(1, 256)
477    })
478}
479
480fn update_key_bytes(hasher: &mut blake3::Hasher, field: &str, bytes: &[u8]) {
481    hasher.update(field.as_bytes());
482    hasher.update(&[0]);
483    hasher.update(&(bytes.len() as u64).to_le_bytes());
484    hasher.update(bytes);
485}
486
487fn update_key_u8(hasher: &mut blake3::Hasher, field: &str, value: u8) {
488    update_key_bytes(hasher, field, &[value]);
489}
490
491fn update_key_u64(hasher: &mut blake3::Hasher, field: &str, value: u64) {
492    update_key_bytes(hasher, field, &value.to_le_bytes());
493}
494
495fn update_key_lower_string(hasher: &mut blake3::Hasher, field: &str, value: &str) {
496    update_key_bytes(hasher, field, value.trim().to_ascii_lowercase().as_bytes());
497}
498
499fn update_key_optional_lower_string(hasher: &mut blake3::Hasher, field: &str, value: Option<&str>) {
500    match value {
501        Some(value) => {
502            update_key_u8(hasher, &format!("{field}.present"), 1);
503            update_key_lower_string(hasher, field, value);
504        }
505        None => update_key_u8(hasher, &format!("{field}.present"), 0),
506    }
507}
508
509fn normalized_lower_strings(candidates: &[String]) -> Vec<String> {
510    let mut out: Vec<String> = Vec::new();
511    for candidate in candidates {
512        let trimmed = candidate.trim();
513        if trimmed.is_empty() {
514            continue;
515        }
516        out.push(trimmed.to_ascii_lowercase());
517    }
518    out
519}
520
521fn update_key_normalized_string_list(
522    hasher: &mut blake3::Hasher,
523    field: &str,
524    candidates: &[String],
525) {
526    let normalized = normalized_lower_strings(candidates);
527    update_key_u64(hasher, &format!("{field}.count"), normalized.len() as u64);
528    for (ix, candidate) in normalized.iter().enumerate() {
529        update_key_bytes(hasher, &format!("{field}[{ix}]"), candidate.as_bytes());
530    }
531}
532
533fn update_key_normalized_static_str_list(
534    hasher: &mut blake3::Hasher,
535    field: &str,
536    candidates: &[&'static str],
537) {
538    update_key_u64(hasher, &format!("{field}.count"), candidates.len() as u64);
539    for (ix, candidate) in candidates.iter().enumerate() {
540        update_key_lower_string(hasher, &format!("{field}[{ix}]"), candidate);
541    }
542}
543
544pub(crate) fn default_sans_candidates(shaper: &ParleyShaper) -> &'static [&'static str] {
545    if !shaper.system_fonts_enabled() {
546        return fret_fonts::default_profile().ui_sans_families;
547    }
548    #[cfg(target_os = "windows")]
549    {
550        &["Segoe UI", "Tahoma", "Arial"]
551    }
552    #[cfg(target_os = "macos")]
553    {
554        &["SF Pro Text", ".SF NS Text", "Helvetica Neue", "Helvetica"]
555    }
556    #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
557    {
558        &["Noto Sans", "DejaVu Sans", "Liberation Sans"]
559    }
560    #[cfg(not(any(
561        target_os = "windows",
562        target_os = "macos",
563        all(unix, not(any(target_os = "macos", target_os = "android")))
564    )))]
565    {
566        let _ = shaper;
567        &[]
568    }
569}
570
571pub(crate) fn default_monospace_candidates(shaper: &ParleyShaper) -> &'static [&'static str] {
572    if !shaper.system_fonts_enabled() {
573        return fret_fonts::default_profile().ui_mono_families;
574    }
575    #[cfg(target_os = "windows")]
576    {
577        &["Cascadia Mono", "Consolas", "Courier New"]
578    }
579    #[cfg(target_os = "macos")]
580    {
581        &["SF Mono", "Menlo", "Monaco"]
582    }
583    #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
584    {
585        &["Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono"]
586    }
587    #[cfg(not(any(
588        target_os = "windows",
589        target_os = "macos",
590        all(unix, not(any(target_os = "macos", target_os = "android")))
591    )))]
592    {
593        let _ = shaper;
594        &[]
595    }
596}
597
598pub(crate) fn default_serif_candidates(shaper: &ParleyShaper) -> &'static [&'static str] {
599    if !shaper.system_fonts_enabled() {
600        return fret_fonts::default_profile().ui_serif_families;
601    }
602    #[cfg(target_os = "windows")]
603    {
604        &["Times New Roman", "Georgia"]
605    }
606    #[cfg(target_os = "macos")]
607    {
608        &["New York", "Times New Roman", "Times"]
609    }
610    #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
611    {
612        &["DejaVu Serif", "Noto Serif", "Liberation Serif"]
613    }
614    #[cfg(not(any(
615        target_os = "windows",
616        target_os = "macos",
617        all(unix, not(any(target_os = "macos", target_os = "android")))
618    )))]
619    {
620        let _ = shaper;
621        &[]
622    }
623}
624
625fn bundled_profile_contract_snapshot() -> fret_core::RendererBundledFontProfileSnapshot {
626    let profile = fret_fonts::default_profile();
627
628    fn role_name(role: fret_fonts::BundledFontRole) -> &'static str {
629        match role {
630            fret_fonts::BundledFontRole::UiSans => "ui_sans",
631            fret_fonts::BundledFontRole::UiSerif => "ui_serif",
632            fret_fonts::BundledFontRole::UiMonospace => "ui_monospace",
633            fret_fonts::BundledFontRole::EmojiFallback => "emoji_fallback",
634            fret_fonts::BundledFontRole::CjkFallback => "cjk_fallback",
635        }
636    }
637
638    fn generic_name(family: fret_fonts::BundledGenericFamily) -> &'static str {
639        match family {
640            fret_fonts::BundledGenericFamily::Sans => "sans",
641            fret_fonts::BundledGenericFamily::Serif => "serif",
642            fret_fonts::BundledGenericFamily::Monospace => "monospace",
643        }
644    }
645
646    fret_core::RendererBundledFontProfileSnapshot {
647        name: profile.name.to_string(),
648        provided_roles: profile
649            .provided_roles
650            .iter()
651            .map(|role| role_name(*role).to_string())
652            .collect(),
653        expected_family_names: profile
654            .expected_family_names
655            .iter()
656            .map(|family| (*family).to_string())
657            .collect(),
658        guaranteed_generic_families: profile
659            .guaranteed_generic_families
660            .iter()
661            .map(|family| generic_name(*family).to_string())
662            .collect(),
663        ui_sans_families: profile
664            .ui_sans_families
665            .iter()
666            .map(|family| (*family).to_string())
667            .collect(),
668        ui_serif_families: profile
669            .ui_serif_families
670            .iter()
671            .map(|family| (*family).to_string())
672            .collect(),
673        ui_mono_families: profile
674            .ui_mono_families
675            .iter()
676            .map(|family| (*family).to_string())
677            .collect(),
678        common_fallback_families: profile
679            .common_fallback_families
680            .iter()
681            .map(|family| (*family).to_string())
682            .collect(),
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::parley_shaper::ParleyShaper;
690
691    fn expected_bundled_only_default_common_fallback() -> Vec<String> {
692        let profile = fret_fonts::default_profile();
693        let mut seen = std::collections::HashSet::<String>::new();
694        let mut out = Vec::new();
695        for family in profile
696            .ui_sans_families
697            .iter()
698            .chain(profile.common_fallback_families.iter())
699        {
700            let key = family.to_ascii_lowercase();
701            if seen.insert(key) {
702                out.push((*family).to_string());
703            }
704        }
705        out
706    }
707
708    fn build_policy(
709        shaper: &mut ParleyShaper,
710        config: fret_core::TextFontFamilyConfig,
711        locale: Option<&str>,
712    ) -> TextFallbackPolicyV1 {
713        let _ = shaper.set_default_locale(locale.map(str::to_string));
714        let mut policy = TextFallbackPolicyV1::new(shaper);
715        policy.set_font_family_config(config);
716        policy.set_locale_bcp47(locale.map(str::to_string));
717        policy.refresh_derived(shaper);
718        let _ = shaper
719            .set_common_fallback_stack_suffix(policy.common_fallback_stack_suffix().to_string());
720        policy.recompute_key(shaper);
721        policy
722    }
723
724    #[test]
725    fn merged_static_family_lists_preserves_order_and_dedupes_case_insensitively() {
726        let families = merged_static_family_lists(&[
727            &["Inter", "Noto Sans CJK SC"],
728            &["inter", "Noto Color Emoji", "Noto Sans CJK SC"],
729        ]);
730        assert_eq!(
731            families.as_ref(),
732            &["Inter", "Noto Sans CJK SC", "Noto Color Emoji"]
733        );
734    }
735
736    #[test]
737    fn bundled_only_default_candidates_use_profile_families() {
738        let shaper = ParleyShaper::new_without_system_fonts();
739        let profile = fret_fonts::default_profile();
740
741        assert_eq!(default_sans_candidates(&shaper), profile.ui_sans_families);
742        assert_eq!(default_serif_candidates(&shaper), profile.ui_serif_families);
743        assert_eq!(
744            default_monospace_candidates(&shaper),
745            profile.ui_mono_families
746        );
747        assert_eq!(
748            default_common_fallback_families(&shaper)
749                .iter()
750                .map(|family| (*family).to_string())
751                .collect::<Vec<_>>(),
752            expected_bundled_only_default_common_fallback()
753        );
754    }
755
756    #[test]
757    fn fallback_policy_key_changes_when_locale_changes() {
758        let mut shaper = ParleyShaper::new_without_system_fonts();
759        let config = fret_core::TextFontFamilyConfig {
760            common_fallback_injection: fret_core::TextCommonFallbackInjection::CommonFallback,
761            common_fallback: vec!["Noto Sans CJK SC".to_string()],
762            ..Default::default()
763        };
764
765        let en = build_policy(&mut shaper, config.clone(), Some("en-US"));
766        let zh = build_policy(&mut shaper, config, Some("zh-CN"));
767
768        assert_ne!(
769            en.fallback_policy_key(),
770            zh.fallback_policy_key(),
771            "expected locale changes to participate in the fallback policy fingerprint"
772        );
773    }
774
775    #[test]
776    fn fallback_policy_key_changes_when_injection_mode_changes() {
777        let mut shaper = ParleyShaper::new_without_system_fonts();
778        let platform_default = build_policy(
779            &mut shaper,
780            fret_core::TextFontFamilyConfig {
781                common_fallback_injection: fret_core::TextCommonFallbackInjection::PlatformDefault,
782                common_fallback: vec!["Noto Sans CJK SC".to_string()],
783                ..Default::default()
784            },
785            Some("en-US"),
786        );
787        let common_fallback = build_policy(
788            &mut shaper,
789            fret_core::TextFontFamilyConfig {
790                common_fallback_injection: fret_core::TextCommonFallbackInjection::CommonFallback,
791                common_fallback: vec!["Noto Sans CJK SC".to_string()],
792                ..Default::default()
793            },
794            Some("en-US"),
795        );
796
797        assert_ne!(
798            platform_default.fallback_policy_key(),
799            common_fallback.fallback_policy_key(),
800            "expected injection-mode changes to participate in the fallback policy fingerprint"
801        );
802    }
803
804    #[test]
805    fn fallback_policy_key_changes_when_system_font_availability_changes() {
806        let config = fret_core::TextFontFamilyConfig {
807            common_fallback_injection: fret_core::TextCommonFallbackInjection::PlatformDefault,
808            common_fallback: vec!["Noto Sans CJK SC".to_string()],
809            ..Default::default()
810        };
811
812        let mut system_shaper = ParleyShaper::default();
813        let system_policy = build_policy(&mut system_shaper, config.clone(), Some("en-US"));
814
815        let mut bundled_only_shaper = ParleyShaper::new_without_system_fonts();
816        let bundled_only_policy = build_policy(&mut bundled_only_shaper, config, Some("en-US"));
817
818        assert_ne!(
819            system_policy.fallback_policy_key(),
820            bundled_only_policy.fallback_policy_key(),
821            "expected system-font availability changes to participate in the fallback policy fingerprint"
822        );
823    }
824
825    #[cfg(not(target_arch = "wasm32"))]
826    #[test]
827    fn platform_default_prefers_common_fallback_for_generics_but_not_named_stacks_on_native() {
828        let mut shaper = ParleyShaper::default();
829        let policy = build_policy(
830            &mut shaper,
831            fret_core::TextFontFamilyConfig {
832                common_fallback_injection: fret_core::TextCommonFallbackInjection::PlatformDefault,
833                ..Default::default()
834            },
835            Some("en-US"),
836        );
837
838        assert!(
839            policy.prefer_common_fallback_for_generics(),
840            "expected native PlatformDefault to keep generic UI stacks on a no-tofu baseline"
841        );
842        assert!(
843            !policy.prefer_common_fallback(),
844            "expected native PlatformDefault to keep named-family stacks on the system-fallback lane"
845        );
846        assert!(
847            !policy.common_fallback_candidates().is_empty(),
848            "expected native PlatformDefault to derive a generic common fallback candidate list"
849        );
850        assert_eq!(
851            policy.common_fallback_stack_suffix(),
852            "",
853            "expected native PlatformDefault to keep named stacks free of an explicit common-fallback suffix"
854        );
855    }
856
857    #[test]
858    fn diagnostics_snapshot_reports_profile_contract_and_defaults_in_bundled_only_mode() {
859        let mut shaper = ParleyShaper::new_without_system_fonts();
860        let config = fret_core::TextFontFamilyConfig {
861            common_fallback_injection: fret_core::TextCommonFallbackInjection::CommonFallback,
862            ui_sans: vec!["Inter".to_string()],
863            ui_mono: vec!["JetBrains Mono".to_string()],
864            common_fallback: vec!["Noto Sans Arabic".to_string()],
865            ..Default::default()
866        };
867        let policy = build_policy(&mut shaper, config.clone(), Some("en-US"));
868        let snapshot = policy.diagnostics_snapshot(fret_core::FrameId(7), 11, 13, &shaper);
869        let profile = fret_fonts::default_profile();
870
871        assert!(!snapshot.system_fonts_enabled);
872        assert!(snapshot.prefer_common_fallback);
873        assert!(snapshot.prefer_common_fallback_for_generics);
874        assert_eq!(
875            snapshot.common_fallback_injection,
876            config.common_fallback_injection
877        );
878        assert_eq!(snapshot.configured_ui_sans_families, config.ui_sans);
879        assert_eq!(snapshot.configured_ui_serif_families, config.ui_serif);
880        assert_eq!(snapshot.configured_ui_mono_families, config.ui_mono);
881        assert_eq!(
882            snapshot.configured_common_fallback_families,
883            config.common_fallback
884        );
885        assert_eq!(
886            snapshot.default_ui_sans_candidates,
887            profile
888                .ui_sans_families
889                .iter()
890                .map(|family| (*family).to_string())
891                .collect::<Vec<_>>()
892        );
893        assert_eq!(
894            snapshot.default_ui_serif_candidates,
895            profile
896                .ui_serif_families
897                .iter()
898                .map(|family| (*family).to_string())
899                .collect::<Vec<_>>()
900        );
901        assert_eq!(
902            snapshot.default_ui_mono_candidates,
903            profile
904                .ui_mono_families
905                .iter()
906                .map(|family| (*family).to_string())
907                .collect::<Vec<_>>()
908        );
909        assert_eq!(
910            snapshot.default_common_fallback_families,
911            expected_bundled_only_default_common_fallback()
912        );
913        assert_eq!(snapshot.bundled_profile_contract.name, profile.name);
914        assert_eq!(
915            snapshot.bundled_profile_contract.ui_sans_families,
916            profile
917                .ui_sans_families
918                .iter()
919                .map(|family| (*family).to_string())
920                .collect::<Vec<_>>()
921        );
922        assert_eq!(
923            snapshot.bundled_profile_contract.ui_mono_families,
924            profile
925                .ui_mono_families
926                .iter()
927                .map(|family| (*family).to_string())
928                .collect::<Vec<_>>()
929        );
930        assert_eq!(
931            snapshot.bundled_profile_contract.common_fallback_families,
932            profile
933                .common_fallback_families
934                .iter()
935                .map(|family| (*family).to_string())
936                .collect::<Vec<_>>()
937        );
938    }
939}