use parley::fontique::{Collection, FallbackKey, FamilyId, FontInfo, GenericFamily, Script};
use waterui_text::FontCollection;
use crate::renderer::HydrolysisRenderer;
#[derive(Default)]
pub(super) struct ResourceFontFamilies {
generic: Vec<FamilyId>,
hani_simplified: Vec<FamilyId>,
hani_traditional: Vec<FamilyId>,
hani_japanese: Vec<FamilyId>,
hani_korean: Vec<FamilyId>,
arabic: Vec<FamilyId>,
hebrew: Vec<FamilyId>,
}
fn extend_family_ids(target: &mut Vec<FamilyId>, families: &[(FamilyId, Vec<FontInfo>)]) {
target.extend(families.iter().map(|(family_id, _)| *family_id));
}
fn set_fallbacks(collection: &mut Collection, key: impl Into<FallbackKey>, families: &[FamilyId]) {
if families.is_empty() {
return;
}
assert!(
collection.set_fallbacks(key, families.iter().copied()),
"hydrolysis font loader attempted to install an untracked script fallback"
);
}
impl ResourceFontFamilies {
pub(super) fn classify(&mut self, name: &str, families: &[(FamilyId, Vec<FontInfo>)]) {
let key = name.to_ascii_lowercase().replace(' ', "");
if key.contains("roboto") {
extend_family_ids(&mut self.generic, families);
} else if key.contains("notosanscjksc") {
extend_family_ids(&mut self.hani_simplified, families);
} else if key.contains("notosanscjktc") {
extend_family_ids(&mut self.hani_traditional, families);
} else if key.contains("notosanscjkjp") {
extend_family_ids(&mut self.hani_japanese, families);
} else if key.contains("notosanscjkkr") {
extend_family_ids(&mut self.hani_korean, families);
} else if key.contains("notosansarabic") {
extend_family_ids(&mut self.arabic, families);
} else if key.contains("notosanshebrew") {
extend_family_ids(&mut self.hebrew, families);
}
}
pub(super) fn install(&self, collection: &mut Collection) {
if !self.generic.is_empty() {
collection.set_generic_families(GenericFamily::SansSerif, self.generic.iter().copied());
collection
.set_generic_families(GenericFamily::UiSansSerif, self.generic.iter().copied());
collection.set_generic_families(GenericFamily::SystemUi, self.generic.iter().copied());
}
let hani = Script::from_str_unchecked("Hani");
set_fallbacks(collection, hani, &self.hani_simplified);
for locale in ["zh", "zh-CN", "zh-SG"] {
set_fallbacks(collection, (hani, locale), &self.hani_simplified);
}
for locale in ["zh-Hant", "zh-TW", "zh-HK", "zh-MO"] {
set_fallbacks(collection, (hani, locale), &self.hani_traditional);
}
set_fallbacks(collection, (hani, "ja"), &self.hani_japanese);
set_fallbacks(collection, (hani, "ko"), &self.hani_korean);
set_fallbacks(collection, Script::from_str_unchecked("Arab"), &self.arabic);
set_fallbacks(collection, Script::from_str_unchecked("Hebr"), &self.hebrew);
}
}
#[cfg(any(test, feature = "testing"))]
const TEST_FONTS: &[(&str, &[u8])] = &[
(
"Roboto-Regular.ttf",
include_bytes!("../../test-fonts/Roboto-Regular.ttf"),
),
(
"Roboto-Medium.ttf",
include_bytes!("../../test-fonts/Roboto-Medium.ttf"),
),
(
"Roboto-Bold.ttf",
include_bytes!("../../test-fonts/Roboto-Bold.ttf"),
),
(
"Roboto-Italic.ttf",
include_bytes!("../../test-fonts/Roboto-Italic.ttf"),
),
];
#[cfg(any(test, feature = "testing"))]
pub(crate) fn deterministic_test_fonts() -> parley::FontContext {
use parley::fontique::{Blob, CollectionOptions};
use std::sync::Arc;
let mut font_cx = parley::FontContext {
collection: Collection::new(CollectionOptions {
system_fonts: true,
..CollectionOptions::default()
}),
source_cache: parley::fontique::SourceCache::default(),
};
let mut resource_fonts = ResourceFontFamilies::default();
for (name, bytes) in TEST_FONTS {
let families = font_cx
.collection
.register_fonts(Blob::new(Arc::new(*bytes)), None);
resource_fonts.classify(name, &families);
}
resource_fonts.install(&mut font_cx.collection);
font_cx
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn native_resource_fonts() -> parley::FontContext {
use parley::fontique::Blob;
use std::sync::Arc;
let mut roots = Vec::new();
if let Ok(current_dir) = std::env::current_dir() {
roots.push(current_dir.join("resources").join("fonts"));
}
if let Ok(exe) = std::env::current_exe()
&& let Some(exe_dir) = exe.parent()
{
roots.push(exe_dir.join("resources").join("fonts"));
if let Some(contents_dir) = exe_dir.parent()
&& contents_dir
.file_name()
.is_some_and(|name| name == "Contents")
{
roots.push(
contents_dir
.join("Resources")
.join("resources")
.join("fonts"),
);
}
}
let mut font_cx = parley::FontContext::new();
let mut resource_fonts = ResourceFontFamilies::default();
for root in roots {
if !root.exists() {
continue;
}
let entries = std::fs::read_dir(&root).unwrap_or_else(|error| {
panic!(
"hydrolysis native font loader failed to read `{}`: {error}",
root.display()
)
});
for entry in entries {
let entry = entry.unwrap_or_else(|error| {
panic!(
"hydrolysis native font loader failed to read an entry in `{}`: {error}",
root.display()
)
});
let path = entry.path();
let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else {
continue;
};
if !extension.eq_ignore_ascii_case("ttf") && !extension.eq_ignore_ascii_case("otf") {
continue;
}
let font_data = std::fs::read(&path).unwrap_or_else(|error| {
panic!(
"hydrolysis native font loader failed to read `{}`: {error}",
path.display()
)
});
let families = font_cx
.collection
.register_fonts(Blob::new(Arc::new(font_data)), None);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_else(|| {
panic!(
"hydrolysis native font loader found a font path without UTF-8 file name: `{}`",
path.display()
)
});
resource_fonts.classify(file_name, &families);
tracing::debug!(
target: "waterui::hydrolysis::fonts",
path = %path.display(),
families = families.len(),
"registered native Hydrolysis font"
);
}
}
resource_fonts.install(&mut font_cx.collection);
font_cx
}
pub(super) fn seed_renderer(renderer: &mut HydrolysisRenderer, fonts: &FontCollection) {
*renderer.state_mut().text_fonts_mut() = fonts.use_fonts(|fonts| fonts.clone());
}
#[cfg(test)]
mod tests {
use parley::PositionedLayoutItem;
use waterui_core::Environment;
use waterui_core::layout::HorizontalAlignment;
use waterui_text::styled::StyledStr;
use super::{TEST_FONTS, deterministic_test_fonts};
use crate::renderer::{TextMeasureService, resolve_text_layout_input};
const SAMPLES: &[(&str, &str)] = &[
("Latin", "Ada Lovelace"),
("Cyrillic", "Ольга Ладыженская"),
("Han", "山田 太郎"),
("Hangul", "안녕하세요"),
("Arabic", "مرحبا بالعالم"),
("Hebrew", "שלום עולם"),
("Thai", "สวัสดี"),
("Devanagari", "नमस्ते"),
];
fn test_host_service() -> TextMeasureService {
let mut service = TextMeasureService::new();
*service.fonts_mut() = deterministic_test_fonts();
service
}
fn shaped(service: &TextMeasureService, text: &'static str) -> (usize, usize, bool) {
let mut env = Environment::new();
crate::testing::install_theme(&mut env);
let input =
resolve_text_layout_input(&StyledStr::from(text), HorizontalAlignment::Leading, &env);
let layout = service.shape(&input, None);
let mut glyphs = 0;
let mut missing = 0;
let mut all_bundled = true;
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
continue;
};
let face = run.run().font().data.data();
all_bundled &= TEST_FONTS.iter().any(|(_, bundled)| face == *bundled);
for glyph in run.glyphs() {
glyphs += 1;
missing += usize::from(glyph.id == 0);
}
}
}
(glyphs, missing, all_bundled)
}
#[test]
fn no_script_shapes_to_a_missing_glyph() {
let service = test_host_service();
for (script, text) in SAMPLES {
let (glyphs, missing, _) = shaped(&service, text);
assert!(glyphs > 0, "{script} sample `{text}` produced no glyphs");
assert_eq!(
missing, 0,
"{missing} of {glyphs} glyphs in the {script} sample `{text}` are `.notdef`, \
which is the tofu box: the collection found no face covering the script"
);
}
}
#[test]
fn the_scripts_roboto_covers_still_shape_through_roboto() {
let service = test_host_service();
for (script, text) in &SAMPLES[..2] {
let (glyphs, _, all_bundled) = shaped(&service, text);
assert!(glyphs > 0, "{script} sample `{text}` produced no glyphs");
assert!(
all_bundled,
"the {script} sample `{text}` reached a system face; the bundled Roboto \
covers it and must be matched first, or every metric in the suite \
becomes host-dependent"
);
}
}
#[test]
fn a_script_roboto_lacks_is_answered_by_a_platform_face() {
let service = test_host_service();
for (script, text) in &SAMPLES[2..] {
let (_, _, all_bundled) = shaped(&service, text);
assert!(
!all_bundled,
"the {script} sample `{text}` claims to shape through the bundled Roboto, \
which has no glyph for it"
);
}
}
}