diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113..dd67c68 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1835,6 +1835,23 @@ fn cjk_fallback_follows_the_language() { assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); } +#[test] +fn malayalam_falls_back_to_a_font_that_forms_its_conjuncts() { + // Run against the installed fonts: on macOS the first file mapping Malayalam is Arial + // Unicode MS, which leaves ക്ഷ as three glyphs. + let db = designcraft_fonts::FontDb::global(); + if !["Malayalam Sangam MN", "Malayalam MN", "Nirmala UI", "Noto Sans Malayalam"].iter().any(|f| db.has_family(f)) { + return; + } + let text = "ക്ഷ"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + d.settings.glyph_fallback = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<_> = all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face.family.clone(), g.gid)).collect(); + assert_eq!(glyphs.len(), 1, "one conjunct: {glyphs:?}"); + assert_ne!(glyphs[0].1, 0); +} + #[test] fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() { use designcraft_fonts::testing::font_with; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e1..294cf34 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -435,6 +435,29 @@ const KOREAN_FALLBACKS: &[&str] = &[ "Source Han Sans K", ]; +/// Fonts made for Malayalam (macOS, Windows, Noto, Linux). A font that only maps the characters +/// (Arial Unicode MS, first by path on macOS) sets them without conjuncts or vowel signs. +const MALAYALAM_FALLBACKS: &[&str] = &[ + "Malayalam Sangam MN", + "Malayalam MN", + "Nirmala UI", + "Kartika", + "Noto Sans Malayalam", + "Noto Serif Malayalam", + "Manjari", + "Rachana", + "Meera", + "Lohit Malayalam", +]; + +/// The fallback chain of a script that only fonts made for it set well, whatever the language. +fn script_chain(c: char) -> Option<&'static [&'static str]> { + match c as u32 { + 0x0D00..=0x0D7F => Some(MALAYALAM_FALLBACKS), + _ => None, + } +} + /// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order /// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. fn japanese_chain() -> &'static [&'static str] { @@ -1375,12 +1398,12 @@ impl ScopedFonts<'_> { } /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when - /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the - /// first face (fallback family first, then load order, the document's fonts last) that covers - /// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face - /// covers a character. + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]), Malayalam + /// the fonts made for it (see [`script_chain`]); then the first face (fallback family first, + /// then load order, the document's fonts last) that covers `c`. On native, system fonts are + /// cataloged and loaded lazily the first time no loaded face covers a character. pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { - if let Some(chain) = cjk_chain(c, language) + if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c)) && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) { return Some(f); diff --git a/crates/fonts/src/tests_fallback.rs b/crates/fonts/src/tests_fallback.rs index 4d81f9a..d9e27ac 100644 --- a/crates/fonts/src/tests_fallback.rs +++ b/crates/fonts/src/tests_fallback.rs @@ -127,3 +127,33 @@ fn chain_fonts_load_from_the_font_folders_when_first_needed() { assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK"))); assert!(!db.is_loaded("AppleMyungjo")); } + +const KA: char = 'ക'; +const VIRAMA: char = '\u{0D4D}'; +const AA: char = '\u{0D3E}'; + +#[test] +fn malayalam_takes_a_font_made_for_it() { + // Arial Unicode MS maps Malayalam but can't shape it (no conjuncts); loaded first, it won. + let db = db_with(&[("Arial Unicode MS", &[KA, VIRAMA, AA]), ("Malayalam Sangam MN", &[KA, VIRAMA, AA])]); + for c in [KA, VIRAMA, AA] { + for language in [None, Some("en-US"), Some("ml")] { + assert_eq!(family(&db, c, language).as_deref(), Some("Malayalam Sangam MN"), "{c:?} {language:?}"); + } + } + // Past the Malayalam fonts, the language-blind search as before. + let sangam = db.face("Malayalam Sangam MN", "Regular"); + assert_eq!(db.fallback_for(KA, sangam.id(), None).map(|f| f.family.clone()).as_deref(), Some("Arial Unicode MS")); +} + +#[test] +fn malayalam_fonts_load_from_the_font_folders_when_first_needed() { + let dir = std::env::temp_dir().join(format!("dc-malayalam-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("noto.ttf"), font_with("Noto Sans Malayalam", &[KA]).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + db.add_font(font_with("Arial Unicode MS", &[KA]).unwrap()); + assert!(!db.is_loaded("Noto Sans Malayalam")); + assert_eq!(family(&db, KA, None).as_deref(), Some("Noto Sans Malayalam")); +}