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 font_family_config: fret_core::TextFontFamilyConfig,
15 locale_bcp47: Option<String>,
17
18 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 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 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 "Segoe UI",
321 "Tahoma",
322 "Microsoft YaHei UI",
324 "Microsoft YaHei",
325 "Yu Gothic UI",
326 "Meiryo UI",
327 "Meiryo",
328 "Nirmala UI",
329 "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 "SF Pro Text",
340 ".SF NS Text",
341 "Helvetica Neue",
342 "PingFang SC",
344 "PingFang TC",
345 "Hiragino Sans",
346 "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 "Noto Sans",
356 "DejaVu Sans",
357 "Liberation Sans",
358 "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 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 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}