diff --git a/components/fonts/platform/freetype/ohos/font_list.rs b/components/fonts/platform/freetype/ohos/font_list.rs index ee14200f0d1..1d210e1f3db 100644 --- a/components/fonts/platform/freetype/ohos/font_list.rs +++ b/components/fonts/platform/freetype/ohos/font_list.rs @@ -1,13 +1,16 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ - +use std::collections::HashMap; +use std::ffi::OsStr; use std::fs::File; use std::io::Read; -use std::path::Path; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::{fs, io}; -use base::text::{is_cjk, UnicodeBlock, UnicodeBlockMethod}; -use log::warn; +use base::text::{UnicodeBlock, UnicodeBlockMethod}; +use log::{debug, error, warn}; use malloc_size_of_derive::MallocSizeOf; use serde::{Deserialize, Serialize}; use style::values::computed::font::GenericFontFamily; @@ -15,15 +18,26 @@ use style::values::computed::{ FontStretch as StyleFontStretch, FontStyle as StyleFontStyle, FontWeight as StyleFontWeight, }; use style::Atom; +use unicode_script::Script; use crate::{ - FallbackFontSelectionOptions, FontTemplate, FontTemplateDescriptor, LowercaseFontFamilyName, + EmojiPresentationPreference, FallbackFontSelectionOptions, FontTemplate, + FontTemplateDescriptor, LowercaseFontFamilyName, }; lazy_static::lazy_static! { static ref FONT_LIST: FontList = FontList::new(); } +/// When testing the ohos font code on linux, we can pass the fonts directory of the SDK +/// via an environment variable. +#[cfg(ohos_mock)] +static OHOS_FONTS_DIR: &'static str = env!("OHOS_SDK_FONTS_DIR"); + +/// On OpenHarmony devices the fonts are always located here. +#[cfg(not(ohos_mock))] +static OHOS_FONTS_DIR: &'static str = "/system/fonts"; + /// An identifier for a local font on OpenHarmony systems. #[derive(Clone, Debug, Deserialize, Eq, Hash, MallocSizeOf, PartialEq, Serialize)] pub struct LocalFontIdentifier { @@ -46,12 +60,35 @@ impl LocalFontIdentifier { } } -struct Font { - filename: String, - weight: Option, - style: Option, +#[allow(unused)] +#[derive(Clone, Copy, Debug, Default)] +// HarmonyOS only comes in Condensed and Normal variants +enum FontWidth { + Condensed, + #[default] + Normal, } +impl From for StyleFontStretch { + fn from(value: FontWidth) -> Self { + match value { + FontWidth::Condensed => Self::CONDENSED, + FontWidth::Normal => Self::NORMAL, + } + } +} + +#[derive(Debug, Default)] +struct Font { + // `LocalFontIdentifier` uses `Atom` for string interning and requires a String or str, so we + // already require a String here, instead of using a PathBuf. + filepath: String, + weight: Option, + style: Option, + width: FontWidth, +} + +#[derive(Debug)] struct FontFamily { name: String, fonts: Vec, @@ -68,23 +105,287 @@ struct FontList { aliases: Vec, } -impl FontList { - fn new() -> FontList { - // We don't support parsing `/system/etc/fontconfig.json` yet. - FontList { - families: Self::fallback_font_families(), - aliases: Vec::new(), +fn enumerate_font_files() -> io::Result> { + let mut font_list = vec![]; + for elem in fs::read_dir(OHOS_FONTS_DIR)? { + if let Ok(e) = elem { + if e.file_type().unwrap().is_file() { + let name = e.file_name(); + let raw_name = name.as_bytes(); + if raw_name.ends_with(b".ttf".as_ref()) || raw_name.ends_with(b".ttc".as_ref()) { + debug!("Found font {}", e.file_name().to_str().unwrap()); + font_list.push(e.path()) + } + } + } + } + Ok(font_list) +} + +fn detect_hos_font_style(font_modifiers: &[&str]) -> Option { + if font_modifiers.contains(&"Italic") { + Some("italic".to_string()) + } else { + None + } +} + +// Note: The weights here are taken from the `alias` section of the fontconfig.json +fn detect_hos_font_weight_alias(font_modifiers: &[&str]) -> Option { + if font_modifiers.contains(&"Light") { + Some(100) + } else if font_modifiers.contains(&"Regular") { + Some(400) + } else if font_modifiers.contains(&"Medium") { + Some(700) + } else if font_modifiers.contains(&"Bold") { + Some(900) + } else { + None + } +} + +fn noto_weight_alias(alias: &str) -> Option { + match alias.to_ascii_lowercase().as_str() { + "thin" => Some(100), + "extralight" => Some(200), + "light" => Some(300), + "regular" => Some(400), + "medium" => Some(500), + "semibold" => Some(600), + "bold" => Some(700), + "extrabold" => Some(800), + "black" => Some(900), + _unknown_alias => { + warn!("Unknown weight alias `{alias}` encountered."); + None + }, + } +} + +fn detect_hos_font_width(font_modifiers: &[&str]) -> FontWidth { + if font_modifiers.contains(&"Condensed") { + FontWidth::Condensed + } else { + FontWidth::Normal + } +} + +/// Split a Noto font filename into the family name with spaces +/// +/// E.g. `NotoSansTeluguUI` -> `Noto Sans Telugu UI` +/// Or for older OH 4.1 fonts: `NotoSans_JP_Bold` -> `Noto Sans JP Bold` +fn split_noto_font_name(name: &str) -> Vec { + let mut name_components = vec![]; + let mut current_word = String::new(); + let mut chars = name.chars(); + // To not split acronyms like `UI` or `CJK`, we only start a new word if the previous + // char was not uppercase. + let mut previous_char_was_uppercase = true; + if let Some(first) = chars.next() { + current_word.push(first); + for c in chars { + if c.is_uppercase() { + if !previous_char_was_uppercase { + name_components.push(current_word.clone()); + current_word = String::new(); + } + previous_char_was_uppercase = true; + current_word.push(c) + } else if c == '_' { + name_components.push(current_word.clone()); + current_word = String::new(); + previous_char_was_uppercase = true; + // Skip the underscore itself + } else { + previous_char_was_uppercase = false; + current_word.push(c) + } + } + } + if current_word.len() > 0 { + name_components.push(current_word); + } + name_components +} + +/// Parse the font file names to determine the available FontFamilies +/// +/// Note: For OH 5.0+ this function is intended to only be a fallback path, if parsing the +/// `fontconfig.json` fails for some reason. Beta 1 of OH 5.0 still has a bug in the fontconfig.json +/// though, so the "normal path" is currently unimplemented. +fn parse_font_filenames(font_files: Vec) -> Vec { + let harmonyos_prefix = "HarmonyOS_Sans"; + + let weight_aliases = ["Light", "Regular", "Medium", "Bold"]; + let style_modifiers = ["Italic"]; + let width_modifiers = ["Condensed"]; + + let mut families: HashMap> = HashMap::new(); + + let font_files: Vec = font_files + .into_iter() + .filter(|file_path| { + if let Some(extension) = file_path.extension() { + // whitelist of extensions we expect to be fonts + let valid_font_extensions = + [OsStr::new("ttf"), OsStr::new("ttc"), OsStr::new("otf")]; + if valid_font_extensions.contains(&extension) { + return true; + } + } + false + }) + .collect(); + + let harmony_os_fonts = font_files.iter().filter_map(|file_path| { + let stem = file_path.file_stem()?.to_str()?; + let stem_no_prefix = stem.strip_prefix(harmonyos_prefix)?; + let name_components: Vec<&str> = stem_no_prefix.split('_').collect(); + let style = detect_hos_font_style(&name_components); + let weight = detect_hos_font_weight_alias(&name_components); + let width = detect_hos_font_width(&name_components); + + let mut name_components = name_components; + // If we remove all the modifiers, we are left with the family name + name_components.retain(|component| { + !weight_aliases.contains(component) && + !style_modifiers.contains(component) && + !width_modifiers.contains(component) && + !component.is_empty() + }); + name_components.insert(0, "HarmonyOS Sans"); + let family_name = name_components.join(" "); + let font = Font { + filepath: file_path.to_str()?.to_string(), + weight, + style, + width, + }; + Some((family_name, font)) + }); + + let noto_fonts = font_files.iter().filter_map(|file_path| { + let stem = file_path.file_stem()?.to_str()?; + // Filter out non-noto fonts + if !stem.starts_with("Noto") { + return None; + } + // Strip the weight alias from the filename, e.g. `-Regular` or `_Regular`. + // We use `rsplit_once()`, since there is e.g. `NotoSansPhags-Pa-Regular.ttf`, where the + // Pa is part of the font family name and not a modifier. + // There seem to be no more than one modifier at once per font filename. + let (base, weight) = if let Some((stripped_base, weight_suffix)) = + stem.rsplit_once("-").or_else(|| stem.rsplit_once("_")) + { + (stripped_base, noto_weight_alias(weight_suffix)) + } else { + (stem, None) + }; + // Do some special post-processing for `NotoSansPhags-Pa-Regular.ttf` and any friends. + let base = if base.contains("-") { + if !base.ends_with("-Pa") { + warn!("Unknown `-` pattern in Noto font filename: {base}"); + } + // Note: We assume here that the following character is uppercase, so that + // the word splitting later functions correctly. + base.replace("-", "") + } else { + base.to_string() + }; + // Remove suffixes `[wght]` or `[wdth,wght]`. These suffixes seem to be mutually exclusive + // with the weight alias suffixes from before. + let base_name = base + .strip_suffix("[wght]") + .or_else(|| base.strip_suffix("[wdth,wght]")) + .unwrap_or(base.as_str()); + let family_name = split_noto_font_name(base_name).join(" "); + let font = Font { + filepath: file_path.to_str()?.to_string(), + weight, + ..Default::default() + }; + Some((family_name, font)) + }); + + let all_families = harmony_os_fonts.chain(noto_fonts); + + for (family_name, font) in all_families { + if let Some(font_list) = families.get_mut(&family_name) { + font_list.push(font); + } else { + families.insert(family_name, vec![font]); } } - // Fonts expected to exist in OpenHarmony devices. - // Used until parsing of the fontconfig.json file is implemented. + let families = families + .into_iter() + .map(|(name, fonts)| FontFamily { name, fonts }) + .collect(); + families +} + +impl FontList { + fn new() -> FontList { + FontList { + families: Self::detect_installed_font_families(), + aliases: Self::fallback_font_aliases(), + } + } + + /// Detect available fonts or fallback to a hardcoded list + fn detect_installed_font_families() -> Vec { + let mut families = enumerate_font_files() + .inspect_err(|e| error!("Failed to enumerate font files due to `{e:?}`")) + .and_then(|font_files| Ok(parse_font_filenames(font_files))) + .unwrap_or_else(|_| FontList::fallback_font_families()); + families.extend(Self::hardcoded_font_families()); + families + } + + /// A List of hardcoded fonts, added in addition to both detected and fallback font families. + /// + /// There are only two emoji fonts, and their filenames are stable, so we just hardcode + /// their paths, instead of attempting to parse the family name from the filepaths. + fn hardcoded_font_families() -> Vec { + let hardcoded_fonts = vec![ + FontFamily { + name: "HMOS Color Emoji".to_string(), + fonts: vec![Font { + filepath: FontList::font_absolute_path("HMOSColorEmojiCompat.ttf".into()), + ..Default::default() + }], + }, + FontFamily { + name: "HMOS Color Emoji Flags".to_string(), + fonts: vec![Font { + filepath: FontList::font_absolute_path("HMOSColorEmojiFlags.ttf".into()), + ..Default::default() + }], + }, + ]; + if log::log_enabled!(log::Level::Warn) { + for family in hardcoded_fonts.iter() { + for font in &family.fonts { + let path = Path::new(&font.filepath); + if !path.exists() { + warn!( + "Hardcoded Emoji Font {} was not found at `{}`", + family.name, font.filepath + ) + } + } + } + } + hardcoded_fonts + } + fn fallback_font_families() -> Vec { + warn!("Falling back to hardcoded fallback font families..."); let alternatives = [ - ("HarmonyOS Sans", "HarmonyOS_Sans_SC_Regular.ttf"), - ("sans-serif", "HarmonyOS_Sans_SC_Regular.ttf"), - // Todo: It's unclear what font should be used, but we need to use something - ("serif", "HarmonyOS_Sans_SC_Regular.ttf"), + ("HarmonyOS Sans", "HarmonyOS_Sans.ttf"), + ("HarmonyOS Sans SC", "HarmonyOS_Sans_SC.ttf"), + ("serif", "NotoSerif[wdth,wght].ttf"), ]; alternatives @@ -93,23 +394,53 @@ impl FontList { .map(|item| FontFamily { name: item.0.into(), fonts: vec![Font { - filename: item.1.into(), - weight: None, - style: None, + filepath: item.1.into(), + ..Default::default() }], }) .collect() } - // OHOS fonts are located in /system/fonts fn font_absolute_path(filename: &str) -> String { if filename.starts_with("/") { String::from(filename) } else { - format!("/system/fonts/{}", filename) + format!("{OHOS_FONTS_DIR}/{filename}") } } + fn fallback_font_aliases() -> Vec { + let aliases = vec![ + // Note: ideally the aliases should be read from fontconfig.json + FontAlias { + from: "serif".to_string(), + to: "Noto Serif".to_string(), + weight: None, + }, + FontAlias { + from: "sans-serif".to_string(), + to: "HarmonyOS Sans".to_string(), + weight: None, + }, + FontAlias { + from: "monospace".to_string(), + to: "Noto Sans Mono".to_string(), + weight: Some(400), + }, + FontAlias { + from: "HarmonyOS-Sans-Condensed".to_string(), + to: "HarmonyOS Sans Condensed".to_string(), + weight: None, + }, + FontAlias { + from: "HarmonyOS-Sans-Digit".to_string(), + to: "HarmonyOS Sans Digit".to_string(), + weight: None, + }, + ]; + aliases + } + fn find_family(&self, name: &str) -> Option<&FontFamily> { self.families .iter() @@ -142,9 +473,9 @@ where { let mut produce_font = |font: &Font| { let local_font_identifier = LocalFontIdentifier { - path: Atom::from(FontList::font_absolute_path(&font.filename)), + path: Atom::from(font.filepath.clone()), }; - let stretch = StyleFontStretch::NORMAL; + let stretch = font.width.into(); let weight = font .weight .map(|w| StyleFontWeight::from_float(w as f32)) @@ -155,7 +486,7 @@ where Some(value) => { warn!( "unknown value \"{value}\" for \"style\" attribute in the font {}", - font.filename + font.filepath ); StyleFontStyle::NORMAL }, @@ -192,6 +523,16 @@ where pub fn fallback_font_families(options: FallbackFontSelectionOptions) -> Vec<&'static str> { let mut families = vec![]; + if options.presentation_preference == EmojiPresentationPreference::Emoji { + families.push("HMOS Color Emoji"); + families.push("HMOS Color Emoji Flags"); + } + + if Script::from(options.character) == Script::Han { + families.push("HarmonyOS Sans SC"); + families.push("HarmonyOS Sans TC"); + } + if let Some(block) = options.character.block() { match block { UnicodeBlock::Hebrew => { @@ -221,20 +562,116 @@ pub fn fallback_font_families(options: FallbackFontSelectionOptions) -> Vec<&'st UnicodeBlock::Ethiopic | UnicodeBlock::EthiopicSupplement => { families.push("Noto Sans Ethiopic"); }, - - _ => { - if is_cjk(options.character) { - families.push("Noto Sans JP"); - families.push("Noto Sans KR"); - } + UnicodeBlock::HangulCompatibilityJamo | + UnicodeBlock::HangulJamo | + UnicodeBlock::HangulJamoExtendedA | + UnicodeBlock::HangulJamoExtendedB | + UnicodeBlock::HangulSyllables => { + families.push("Noto Sans KR"); }, + UnicodeBlock::Hiragana | + UnicodeBlock::Katakana | + UnicodeBlock::KatakanaPhoneticExtensions => { + families.push("Noto Sans JP"); + }, + _ => {}, } } families.push("HarmonyOS Sans"); + families.push("Noto Sans"); + families.push("Noto Sans Symbols"); + families.push("Noto Sans Symbols 2"); families } -pub fn default_system_generic_font_family(_generic: GenericFontFamily) -> LowercaseFontFamilyName { - "HarmonyOS Sans".into() +pub fn default_system_generic_font_family(generic: GenericFontFamily) -> LowercaseFontFamilyName { + let default_font = "HarmonyOS Sans".into(); + match generic { + GenericFontFamily::Monospace => { + if let Some(alias) = FONT_LIST.find_alias("monospace") { + alias.from.clone().into() + } else { + default_font + } + }, + _ => default_font, + } +} + +#[cfg(test)] +mod test { + use std::path::PathBuf; + + #[test] + fn split_noto_font_name_test() { + use super::split_noto_font_name; + assert_eq!( + split_noto_font_name("NotoSansSinhala"), + vec!["Noto", "Sans", "Sinhala"] + ); + assert_eq!( + split_noto_font_name("NotoSansTamilUI"), + vec!["Noto", "Sans", "Tamil", "UI"] + ); + assert_eq!( + split_noto_font_name("NotoSerifCJK"), + vec!["Noto", "Serif", "CJK"] + ); + } + + #[test] + fn test_parse_font_filenames() { + use super::parse_font_filenames; + let families = parse_font_filenames(vec![PathBuf::from("NotoSansCJK-Regular.ttc")]); + assert_eq!(families.len(), 1); + let family = families.first().unwrap(); + assert_eq!(family.name, "Noto Sans CJK".to_string()); + + let families = parse_font_filenames(vec![ + PathBuf::from("NotoSerifGeorgian[wdth,wght].ttf"), + PathBuf::from("HarmonyOS_Sans_Naskh_Arabic_UI.ttf"), + PathBuf::from("HarmonyOS_Sans_Condensed.ttf"), + PathBuf::from("HarmonyOS_Sans_Condensed_Italic.ttf"), + PathBuf::from("NotoSansDevanagariUI-Bold.ttf"), + PathBuf::from("NotoSansDevanagariUI-Medium.ttf"), + PathBuf::from("NotoSansDevanagariUI-Regular.ttf"), + PathBuf::from("NotoSansDevanagariUI-SemiBold.ttf"), + ]); + assert_eq!(families.len(), 4); + } + + #[test] + fn test_parse_noto_sans_phags_pa() { + use super::parse_font_filenames; + + let families = parse_font_filenames(vec![PathBuf::from("NotoSansPhags-Pa-Regular.ttf")]); + let family = families.first().unwrap(); + assert_eq!(family.name, "Noto Sans Phags Pa"); + } + + #[test] + fn test_old_noto_sans() { + use super::parse_font_filenames; + + let families = parse_font_filenames(vec![ + PathBuf::from("NotoSans_JP_Regular.otf"), + PathBuf::from("NotoSans_KR_Regular.otf"), + PathBuf::from("NotoSans_JP_Bold.otf"), + ]); + assert_eq!(families.len(), 2, "actual families: {families:?}"); + let first_family = families.first().unwrap(); + let second_family = families.last().unwrap(); + // We don't have a requirement on the order of the family names, + // we just want to test existence. + let names = [first_family.name.as_str(), second_family.name.as_str()]; + assert!(names.contains(&"Noto Sans JP")); + assert!(names.contains(&"Noto Sans KR")); + } + + #[test] + fn print_detected_families() { + let list = super::FontList::detect_installed_font_families(); + println!("The fallback FontList is: {list:?}"); + } } diff --git a/components/fonts/platform/mod.rs b/components/fonts/platform/mod.rs index 6aebc107f66..642b6c46a80 100644 --- a/components/fonts/platform/mod.rs +++ b/components/fonts/platform/mod.rs @@ -33,7 +33,7 @@ mod freetype { pub mod font; - #[cfg(all(target_os = "linux", not(target_env = "ohos")))] + #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(ohos_mock)))] pub mod font_list; #[cfg(target_os = "android")] mod android { @@ -42,11 +42,11 @@ mod freetype { } #[cfg(target_os = "android")] pub use self::android::font_list; - #[cfg(target_env = "ohos")] + #[cfg(any(target_env = "ohos", ohos_mock))] mod ohos { pub mod font_list; } - #[cfg(target_env = "ohos")] + #[cfg(any(target_env = "ohos", ohos_mock))] pub use self::ohos::font_list; pub mod library_handle;