From 08f633ae489bcdadcbbb27f94c72eec65f58ffe5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:45:42 +0000 Subject: [PATCH] refactor(fonts): split fontdb.rs into fontdb/ --- crates/engine/src/tests/file_guard.rs | 2 +- crates/fonts/src/fontdb.rs | 2201 +------------------------ crates/fonts/src/fontdb/db.rs | 244 +++ crates/fonts/src/fontdb/face.rs | 530 ++++++ crates/fonts/src/fontdb/fallback.rs | 179 ++ crates/fonts/src/fontdb/names.rs | 118 ++ crates/fonts/src/fontdb/outline.rs | 62 + crates/fonts/src/fontdb/scoped.rs | 499 ++++++ crates/fonts/src/fontdb/system.rs | 630 +++++++ 9 files changed, 2292 insertions(+), 2173 deletions(-) create mode 100644 crates/fonts/src/fontdb/db.rs create mode 100644 crates/fonts/src/fontdb/face.rs create mode 100644 crates/fonts/src/fontdb/fallback.rs create mode 100644 crates/fonts/src/fontdb/names.rs create mode 100644 crates/fonts/src/fontdb/outline.rs create mode 100644 crates/fonts/src/fontdb/scoped.rs create mode 100644 crates/fonts/src/fontdb/system.rs diff --git a/crates/engine/src/tests/file_guard.rs b/crates/engine/src/tests/file_guard.rs index 9f2af3c32..326968329 100644 --- a/crates/engine/src/tests/file_guard.rs +++ b/crates/engine/src/tests/file_guard.rs @@ -31,7 +31,7 @@ const ALLOWED: &[(&str, &str)] = &[ "`OutputRoot` checks the folder against the write root before it creates anything and then only writes inside it. Its reads are of the font files that draw the document's text (installed fonts and the `Document Fonts` folder), never of a path a command names", ), ( - "fonts/src/fontdb.rs", + "fonts/src/fontdb/system.rs", "the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either", ), ( diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f867a9ce5..d0f0054f8 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -14,51 +14,6 @@ use skrifa::{GlyphId, MetadataProvider}; use crate::group::{FamilyInfo, FontGroup}; -/// The family used when a requested family is unknown (the UI sans). -pub const FALLBACK_FAMILY: &str = "Source Sans 3"; - -/// The bundled fonts (OFL), as font file bytes. Japanese fonts are not bundled: they come from -/// the optional craft-fonts build input ([`crate::japanese_document_fonts`]). -pub fn bundled() -> &'static [&'static [u8]] { - BUNDLED -} - -static BUNDLED: &[&[u8]] = &[ - include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"), - include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"), - include_bytes!("../../../assets/fonts/SourceSans3-Bold.ttf"), - include_bytes!("../../../assets/fonts/SourceSans3-It.ttf"), - include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"), - include_bytes!("../../../assets/fonts/SourceSerif4-It.ttf"), - include_bytes!("../../../assets/fonts/SourceSerif4-Semibold.ttf"), - include_bytes!("../../../assets/fonts/SourceSerif4-Bold.ttf"), - include_bytes!("../../../assets/fonts/SourceSerif4-BoldIt.ttf"), - include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), - include_bytes!("../../../assets/fonts/Inter-Medium.ttf"), - include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"), - include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), -]; - -#[derive(Clone)] -enum FontBytes { - Static(&'static [u8]), - Owned(Arc>), -} - -/// Where a face's font came from. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum FontSource { - /// Compiled into DesignCraft. - Bundled, - /// Font data handed to [`FontDb::add_font`], with no file behind it. - Memory, - /// An installed font file (the system's or the user's font folders, or one the platform - /// lists outside them). - Installed(std::path::PathBuf), - /// A font file in a document's `Document Fonts` folder ([`FontDb::load_document_fonts`]). - Document(std::path::PathBuf), -} - impl FontSource { /// The font file, if the face was loaded from one. pub fn path(&self) -> Option<&std::path::Path> { @@ -69,82 +24,6 @@ impl FontSource { } } -/// The folder beside a document whose fonts the document brings with it (as File › Package -/// writes it). -pub const DOCUMENT_FONTS_FOLDER: &str = "Document Fonts"; -/// Caps on what a `Document Fonts` folder (untrusted files) can make [`FontDb::load_document_fonts`] -/// read: font files read, bytes per file and bytes in all. -pub const MAX_DOCUMENT_FONT_FILES: usize = 200; -pub const MAX_DOCUMENT_FONT_BYTES: u64 = 256 << 20; -pub const MAX_DOCUMENT_FONTS_TOTAL: u64 = 512 << 20; - -/// What [`FontDb::load_document_fonts`] did: the scope the document looks its fonts up in (0 when -/// the folder gave none), the faces in it, and a note for each file skipped. -#[derive(Debug, Default)] -pub struct DocumentFonts { - pub scope: u32, - pub faces: usize, - pub skipped: Vec, -} - -/// One loaded font face. -pub struct FontFace { - id: u32, - /// Typographic family name (e.g. "Source Sans 3"). - pub family: String, - /// Typographic style name (e.g. "Semibold", "Italic"). - pub style: String, - /// The WWS family and style names (name IDs 21 and 22) when they differ from the typographic - /// ones: InDesign names an optical-size face by them ("Kepler Std Display" / "Light"). - pub wws: Option<(String, String)>, - /// usWeightClass-style weight (400 = regular). - pub weight: f32, - pub italic: bool, - /// Where the font came from (a file to copy when packaging, a document's own font). - pub source: FontSource, - bytes: FontBytes, - index: u32, - pub upem: f64, - /// Ascender in font units (positive = up). - pub ascent: f64, - /// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the - /// Ascent first baseline offset. - pub typo_ascent: f64, - /// Descender in font units (positive = down). - pub descent: f64, - /// Cap height and x height in font units (without plausible OS/2 values, the cap height is the - /// top of the H, and either is estimated from the ascent): the Cap Height and x Height first - /// baseline offsets. - pub cap_height: f64, - pub x_height: f64, - pub shaper: harfrust::ShaperData, - /// Variable fonts: the named instance's axis settings (user units; empty = default instance), - /// the normalised location and the shaper's view of it. - pub coords: Vec<([u8; 4], f32)>, - location: Location, - pub instance: Option, - /// Basic Multilingual Plane coverage bitset, built on first use. - bmp: std::sync::OnceLock>, - /// The ideographic em box, read on first use. - em: std::sync::OnceLock<((f64, f64), bool)>, - /// The ideographic character face box, read on first use. - icf: std::sync::OnceLock<(f64, f64)>, - /// The font menu group and native family name, read on first use. - group: std::sync::OnceLock<(FontGroup, Option)>, - family_aliases: Vec, - style_aliases: Vec, - /// Format, class and PANOSE numbers (the font menus), read on first use. - kind: std::sync::OnceLock, - /// The PostScript name, read on first use. - postscript: std::sync::OnceLock>, -} - -/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of -/// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made -/// parallel composition scale negatively). -#[derive(Clone, Copy)] -pub struct FaceRef(&'static FontFace); - impl FaceRef { /// The handle for a loaded face (memoised per face). pub fn of(face: &Arc) -> FaceRef { @@ -194,227 +73,6 @@ impl std::fmt::Debug for FontFace { } } -impl FontFace { - pub fn data(&self) -> &[u8] { - match &self.bytes { - FontBytes::Static(b) => b, - FontBytes::Owned(v) => v.as_slice(), - } - } - pub fn skrifa(&self) -> Option> { - skrifa::FontRef::from_index(self.data(), self.index).ok() - } - /// The face's format, class and PANOSE numbers, as the font menus show and filter them. - pub fn kind(&self) -> crate::kind::FaceKind { - *self.kind.get_or_init(|| { - let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default(); - // Nothing in the tables or the name: look at the letters. - if kind.class == crate::kind::FontClass::Other - && let Some(serifs) = has_serifs(self) - { - kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif }; - } - kind - }) - } - /// When the font file was added (created, else last modified): the font menus' Show Recently - /// Added. `None` for fonts without a file. - pub fn added(&self) -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - let meta = std::fs::metadata(self.source.path()?).ok()?; - meta.created().or_else(|_| meta.modified()).ok() - } - #[cfg(target_arch = "wasm32")] - { - None - } - } - pub fn hb(&self) -> Option> { - harfrust::FontRef::from_index(self.data(), self.index).ok() - } - /// Where in the design space glyphs, metrics and outlines come from. - pub fn location(&self) -> LocationRef<'_> { - (&self.location).into() - } - /// Is this a named instance of a variable font? - pub fn is_variable(&self) -> bool { - !self.coords.is_empty() - } - /// Face index within the font file (collections); 0 for plain fonts. - pub fn index(&self) -> u32 { - self.index - } - /// Unique id of this face within the process. - pub fn id(&self) -> u32 { - self.id - } - /// System placeholder fonts draw generic boxes rather than the requested character. - pub fn is_placeholder(&self) -> bool { - matches!(norm(self.family.trim_start_matches('.')).as_str(), "lastresort") - } - - /// Does the face map `c` to a real glyph? - pub fn covers(&self, c: char) -> bool { - if self.is_placeholder() { - return false; - } - let cp = c as u32; - if cp < 0x1_0000 { - let bits = self.bmp.get_or_init(|| { - let mut b = vec![0u64; 1024].into_boxed_slice(); - if let Some(f) = self.skrifa() { - for (cp, gid) in f.charmap().mappings() { - if cp < 0x1_0000 && gid.to_u32() != 0 { - b[(cp / 64) as usize] |= 1 << (cp % 64); - } - } - } - b - }); - return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0; - } - self.skrifa().is_some_and(|f| f.charmap().map(c).is_some_and(|gid| gid.to_u32() != 0)) - } - /// Units per em. - pub fn units_per_em(&self) -> f64 { - self.upem - } - /// (ascent, descent) in font units, both positive. - pub fn vertical_metrics(&self) -> (f64, f64) { - (self.ascent, self.descent) - } - /// Every mapped character and its glyph id, sorted by code point (the Glyphs panel). - pub fn chars(&self) -> Vec<(char, u32)> { - let Some(f) = self.skrifa() else { return vec![] }; - let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect(); - v.sort_unstable_by_key(|x| x.0); - v.dedup_by_key(|x| x.0); - v - } - /// Advance width of glyph `gid` in font units. - pub fn advance(&self, gid: u32) -> f64 { - self.skrifa() - .and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid))) - .map(|a| a as f64) - .unwrap_or(self.upem * 0.5) - } - /// Vertical advance (down the line) of glyph `gid` in font units: `vmtx`, else one em. - pub fn v_advance(&self, gid: u32) -> f64 { - crate::vertical::VMetrics::new(self).advance(GlyphId::new(gid)) - } - /// Height of glyph `gid`'s vertical origin above its baseline in font units: `VORG`, else its - /// top plus its `vmtx` top side bearing, else the em box top. Across, the origin is at half the - /// horizontal advance. Upright glyphs in vertical text hang from it on the line's centre. - pub fn v_origin(&self, gid: u32) -> f64 { - crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) - } - /// The ideographic em box (top, bottom) in font units, y up, one em tall (Japanese fonts: - /// typically 880, -120): `BASE` `idtp`/`ideo`; else, for a CJK font, from the OS/2 typo - /// ascender when the typo ascender and descender span one em; else centred on half the cap - /// height (OS/2, else the `H` glyph's top); else 0.88 em above the baseline. - pub fn em_box(&self) -> (f64, f64) { - self.em.get_or_init(|| crate::vertical::em_box(self)).0 - } - /// [`Self::em_box`] when the font's tables give it, `None` for the 0.88 em fallback. - pub fn declared_em_box(&self) -> Option<(f64, f64)> { - let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self)); - declared.then_some(b) - } - /// The ideographic character face (ICF) box (top, bottom) in font units, y up: `BASE` - /// `icft`/`icfb`, else the em box inset by 5% of the em at the top and bottom. - pub fn icf_box(&self) -> (f64, f64) { - *self.icf.get_or_init(|| crate::vertical::icf_box(self)) - } - /// The font menu group the font is for and, for a CJK group, its family name in that language - /// (see [`crate::FamilyInfo`]); read from the font once. - pub fn menu_group(&self) -> (FontGroup, Option<&str>) { - let (g, n) = self.group.get_or_init(|| self.skrifa().map(|f| crate::group::classify(&f, &self.family)).unwrap_or_default()); - (*g, n.as_deref()) - } - /// The PostScript name (`name` ID 6), read from the font once. None for a variable font's - /// named instance: the file's name belongs to its default instance. - pub fn postscript_name(&self) -> Option<&str> { - self.postscript - .get_or_init(|| if self.is_variable() { None } else { self.skrifa().and_then(|f| name(&f, &[StringId::POSTSCRIPT_NAME])) }) - .as_deref() - } - /// Glyph id for `c` (0 = .notdef). - pub fn glyph_for(&self, c: char) -> u32 { - self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) - } - - /// GDEF identifies marks even when shaping merges their source cluster with a base. - pub fn glyph_is_mark(&self, gid: u32) -> bool { - use skrifa::raw::TableProvider; - self.skrifa() - .and_then(|f| f.gdef().ok()) - .and_then(|gdef| gdef.glyph_class_def()?.ok()) - .is_some_and(|classes| classes.get(GlyphId::new(gid)) == 3) - } - - /// Does the font's licence forbid passing it on? OS/2 `fsType` Restricted License embedding - /// (bit 1) without a less restrictive bit (Preview & Print, Editable). Fonts without an OS/2 - /// table aren't restricted. - pub fn restricted_licence(&self) -> bool { - use skrifa::raw::TableProvider; - let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()); - fs_type & 0x0002 != 0 && fs_type & 0x000C == 0 - } - - /// Does the font's licence allow embedding bitmaps only? OS/2 `fsType` bit 9: the outlines - /// may not be passed on, so the font file can't be packaged whole. - pub fn bitmap_only(&self) -> bool { - use skrifa::raw::TableProvider; - self.skrifa().and_then(|f| f.os2().ok()).is_some_and(|t| t.fs_type() & 0x0200 != 0) - } -} - -/// A font file's length and modification time. -#[cfg(not(target_arch = "wasm32"))] -type FileStamp = (u64, Option); - -#[cfg(not(target_arch = "wasm32"))] -fn file_stamp(path: &std::path::Path) -> Option { - std::fs::metadata(path).ok().map(|m| (m.len(), m.modified().ok())) -} - -/// What [`FontDb::reload_fonts`] did: the faces cataloged by the rescan, and the installed font -/// files that changed or were removed since they were read (their faces are read again, from the -/// new file, the next time they are looked up). -#[derive(Debug, Default, PartialEq)] -pub struct ReloadedFonts { - pub cataloged: usize, - pub changed: Vec, -} - -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug)] -struct CatalogEntry { - family: String, - style: String, - wws: Option<(String, String)>, - path: std::path::PathBuf, - /// The font menu group and native family name, read by the scan. - group: FontGroup, - native: Option, - aliases: Vec, - postscript: Option, -} - -/// What the scan reads of one face in a font file: its names and font menu group. -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug, PartialEq)] -struct ScannedFace { - family: String, - style: String, - wws: Option<(String, String)>, - group: FontGroup, - native: Option, - aliases: Vec, - postscript: Option, -} - /// Process-wide font database: the fonts every document shares (bundled, installed, added), and /// each open document's own fonts under its scope (see [`FontDb::scoped`]). pub struct FontDb { @@ -453,1727 +111,43 @@ pub struct FontDb { fallback_snapshot_hook: Mutex>, } -#[cfg(not(target_arch = "wasm32"))] -#[derive(Default)] -struct SysFallback { - enabled: bool, - /// Rescan generation: an older in-flight lookup must not restore a cleared miss. - generation: u64, - misses: std::collections::HashSet, - /// CJK chain families already looked up in the catalog (loaded, or not installed). - chain_tried: std::collections::HashSet<&'static str>, -} - -#[cfg(all(test, target_os = "linux"))] -struct FallbackSnapshotHook { - reached: std::sync::mpsc::SyncSender<()>, - resume: Arc, -} - -/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. -#[cfg(not(target_arch = "wasm32"))] -const SYSTEM_FALLBACKS: &[&str] = &[ - "Helvetica Neue", - "Arial", - "Segoe UI", - "Noto Sans", - "DejaVu Sans", - "PingFang SC", - "Hiragino Sans", - "Hiragino Kaku Gothic ProN", - "Apple SD Gothic Neo", - "Heiti SC", - "STHeiti", - "Microsoft YaHei", - "Yu Gothic", - "Malgun Gothic", - "Noto Sans CJK SC", - "Noto Sans CJK JP", - "Arial Unicode MS", - "Apple Symbols", - "Segoe UI Symbol", - "Noto Sans Symbols", - "Noto Sans Symbols2", - "Noto Emoji", - "Segoe UI Emoji", - "Apple Color Emoji", - "Noto Color Emoji", -]; - -/// CJK fallback chains: the families tried first (user-added, else installed) for characters of -/// each language that the primary font lacks. Each lists macOS, then Windows, then Linux / -/// cross-platform families, serif first; a platform only has its own installed. Japanese tries -/// the craft-fonts Japanese faces before these ([`japanese_chain`]). -const JAPANESE_FALLBACKS: &[&str] = &[ - "Hiragino Mincho ProN", - "YuMincho", - "Hiragino Sans", - "Yu Mincho", - "MS Mincho", - "Yu Gothic", - "Noto Serif CJK JP", - "Source Han Serif JP", - "Noto Serif JP", - "Noto Sans CJK JP", - "Source Han Sans JP", -]; -const SIMPLIFIED_CHINESE_FALLBACKS: &[&str] = &[ - "Songti SC", - "STSong", - "PingFang SC", - "SimSun", - "Microsoft YaHei", - "Noto Serif CJK SC", - "Source Han Serif SC", - "Noto Serif SC", - "Noto Sans CJK SC", - "Source Han Sans SC", -]; -const TRADITIONAL_CHINESE_FALLBACKS: &[&str] = &[ - "Songti TC", - "PingFang TC", - "PMingLiU", - "MingLiU", - "Microsoft JhengHei", - "Noto Serif CJK TC", - "Source Han Serif TC", - "Noto Serif TC", - "Noto Sans CJK TC", - "Source Han Sans TC", -]; -const KOREAN_FALLBACKS: &[&str] = &[ - "AppleMyungjo", - "Apple SD Gothic Neo", - "Batang", - "Malgun Gothic", - "Noto Serif CJK KR", - "Source Han Serif K", - "Noto Serif KR", - "Noto Sans CJK KR", - "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] { - static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); - CHAIN.get_or_init(|| { - let mut chain: Vec<&'static str> = Vec::new(); - for f in crate::japanese_document_fonts() { - if !chain.contains(&f.family) { - chain.push(f.family); - } - } - chain.extend_from_slice(JAPANESE_FALLBACKS); - chain - }) -} - -/// The CJK fallback chain for `c` in `language` (a BCP 47 tag). The script decides where it is -/// unambiguous (Hangul is Korean, kana Japanese, Bopomofo Traditional Chinese); ideographs and CJK -/// punctuation follow the language, and have no chain without a CJK one. -fn cjk_chain(c: char, language: Option<&str>) -> Option<&'static [&'static str]> { - match c as u32 { - 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => return Some(KOREAN_FALLBACKS), - 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => return Some(japanese_chain()), - 0x3100..=0x312F | 0x31A0..=0x31BF => return Some(TRADITIONAL_CHINESE_FALLBACKS), - // Radicals, CJK symbols and punctuation, kanbun, strokes, enclosed and compatibility - // characters, ideographs, compatibility and vertical forms, full-width forms. - 0x2E80..=0x2FDF - | 0x3000..=0x303F - | 0x3190..=0x319F - | 0x31C0..=0x31EF - | 0x3200..=0x33FF - | 0x3400..=0x4DBF - | 0x4E00..=0x9FFF - | 0xF900..=0xFAFF - | 0xFE10..=0xFE1F - | 0xFE30..=0xFE4F - | 0xFF00..=0xFFEF - | 0x20000..=0x3FFFF => {} - _ => return None, - } - // The language decides, whatever the region (`ja-JP`, `ko-KR`). - let language = language?; - match language.split('-').next()? { - "ja" => Some(japanese_chain()), - "ko" => Some(KOREAN_FALLBACKS), - "zh" if language.starts_with("zh-Hant") => Some(TRADITIONAL_CHINESE_FALLBACKS), - "zh" => Some(SIMPLIFIED_CHINESE_FALLBACKS), - _ => None, - } -} - -static NEXT_ID: AtomicU32 = AtomicU32::new(1); -/// Scopes are never reused: a closed document's scope finds no fonts ever after. -#[cfg(not(target_arch = "wasm32"))] -static NEXT_SCOPE: AtomicU32 = AtomicU32::new(1); -const OUTLINE_CACHE_MAX: usize = 50_000; - -fn aliases(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Vec { - let mut names: Vec = - ids.iter().flat_map(|id| font.localized_strings(*id)).take(256).map(|name| name.to_string()).filter(|name| !name.trim().is_empty()).collect(); - names.sort(); - names.dedup(); - names -} - -fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { - ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) -} - -/// A face's family and style names (the default instance's style for a variable font). -/// Is a face of `family` and `style` among `faces`? -fn has_face(faces: &[Arc], family: &str, style: &str) -> bool { - faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style)) -} - -/// A face's WWS family and style names (name IDs 21 and 22), if it has a WWS family and it differs -/// from `family`/`style`. -fn wws_names(f: &skrifa::FontRef<'_>, family: &str, style: &str) -> Option<(String, String)> { - let wf = name(f, &[StringId::WWS_FAMILY_NAME])?; - let ws = name(f, &[StringId::WWS_SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); - (!wf.eq_ignore_ascii_case(family) || !ws.eq_ignore_ascii_case(style)).then_some((wf, ws)) -} - -/// The family and style a face is known by: its typographic names, or its WWS names (if it has -/// them) when `by_wws`. -fn known_as<'a>(family: &'a str, style: &'a str, wws: Option<&'a (String, String)>, by_wws: bool) -> Option<(&'a str, &'a str)> { - if by_wws { wws.map(|w| (w.0.as_str(), w.1.as_str())) } else { Some((family, style)) } -} - -/// Is `family` the WWS family name of `f`? -fn has_wws_family(f: &FontFace, family: &str) -> bool { - f.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family)) -} - -/// Is `family` the typographic or the WWS family name of `c`? -#[cfg(not(target_arch = "wasm32"))] -fn in_catalog_family(c: &CatalogEntry, family: &str) -> bool { - c.family.eq_ignore_ascii_case(family) || c.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family)) -} - -fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { - let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; - let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); - Some((family, style)) -} - -/// The tables glyph outlines are drawn from. -#[cfg(not(target_arch = "wasm32"))] -const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; - -/// The names and font menu group of every face in the font file at `path`, reading only its table -/// directories and the tables that name and classify it (`name`, `OS/2`, `meta`; `cmap` only for a -/// font that declares no language or code page): a scan opens hundreds of font files, many of them -/// megabytes long. A variable font is cataloged under its default style; its named instances -/// appear once it loads. Faces without glyph outlines (none of [`OUTLINE_TABLES`]) are left out. -#[cfg(not(target_arch = "wasm32"))] -fn file_face_names(path: &std::path::Path) -> Vec { - use std::io::{Read, Seek, SeekFrom}; - /// Caps on what a (possibly damaged) file can make the scan read. - const MAX_FACES: u32 = 256; - const MAX_NAME_TABLE: u32 = 1 << 20; - const MAX_SMALL_TABLE: u32 = 64 << 10; - const MAX_CMAP: u32 = 4 << 20; - let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; - let mut read_at = |offset: u64, len: usize| -> Option> { - let mut buf = vec![0; len]; - file.seek(SeekFrom::Start(offset)).ok()?; - file.read_exact(&mut buf).ok()?; - Some(buf) - }; - let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); - let Some(head) = read_at(0, 12) else { return vec![] }; - // A collection lists where each face's table directory starts. - let starts: Vec = if head.starts_with(b"ttcf") { - let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize; - read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default() - } else { - vec![0] - }; - starts - .into_iter() - .filter_map(|start| { - let dir = read_at(start.into(), 12)?; - let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; - let records = read_at(u64::from(start) + 12, tables * 16)?; - // Faces drawn from other tables (Apple's `hvgl`, bitmaps) would draw nothing. - if !records.as_chunks::<16>().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) { - return None; - } - // Table `tag`, unless missing or over `max` bytes. - let mut table = |tag: &[u8; 4], max: u32| -> Option<([u8; 4], Vec)> { - let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?; - let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); - if len > max { - return None; - } - Some((*tag, read_at(offset.into(), len as usize)?)) - }; - // A font holding just these tables, to read them as the font itself would. - let mut found = vec![table(b"name", MAX_NAME_TABLE)?]; - found.extend(table(b"OS/2", MAX_SMALL_TABLE)); - found.extend(table(b"meta", MAX_SMALL_TABLE)); - let font = crate::group::sfnt(found.clone())?; - let f = skrifa::FontRef::new(&font).ok()?; - let (family, style) = face_names(&f)?; - let (group, native) = match crate::group::declared_group(&f, &family) { - Some(g) => (g, crate::group::classify(&f, &family).1), - None => { - found.extend(table(b"cmap", MAX_CMAP)); - let font = crate::group::sfnt(found)?; - crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) - } - }; - let postscript = name(&f, &[StringId::POSTSCRIPT_NAME]); - let wws = wws_names(&f, &family, &style); - Some(ScannedFace { - family, - style, - wws, - group, - native, - aliases: aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]), - postscript, - }) - }) - .collect() -} - -/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. -/// Empty on wasm. -pub fn system_font_dirs() -> Vec { - let mut dirs: Vec = Vec::new(); - if cfg!(target_arch = "wasm32") { - return dirs; - } - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - if cfg!(target_os = "macos") { - dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); - if let Some(h) = &home { - dirs.push(h.join("Library/Fonts")); - } - // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font. - for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { - let Ok(entries) = std::fs::read_dir(parent) else { continue }; - for e in entries.flatten() { - if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { - dirs.push(e.path()); - } - } - } - // Microsoft Office bundles its fonts (Calibri, Cambria, …) inside each app; read them in - // place from the first installed one (they share the same set). - let office = ["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"] - .iter() - .map(|app| std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts"))) - .find(|p| p.is_dir()); - dirs.extend(office); - } else if cfg!(windows) { - let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); - dirs.push(root.join("Fonts")); - if let Some(l) = std::env::var_os("LOCALAPPDATA") { - dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); - } - } else { - let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from); - dirs.extend(unix_font_dirs(home.as_deref(), xdg_data_home.as_deref())); - } - dirs -} - -fn unix_font_dirs(home: Option<&std::path::Path>, xdg_data_home: Option<&std::path::Path>) -> Vec { - let mut dirs: Vec = ["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into).into(); - if let Some(home) = home { - dirs.push(home.join(".fonts")); - } - let data_home = - xdg_data_home.filter(|path| path.is_absolute()).map(std::path::Path::to_path_buf).or_else(|| home.map(|path| path.join(".local/share"))); - dirs.extend(data_home.map(|path| path.join("fonts"))); - dirs -} - /// The font files outside the font folders that the platform has available, which /// [`FontDb::global`]'s scan reads too: on macOS, those CoreText's font manager lists, such as the /// fonts font services and font managers register from their own folders. None elsewhere. #[cfg(target_os = "macos")] use crate::mac_fonts::available_font_files as platform_font_files; -#[cfg(not(target_os = "macos"))] -fn platform_font_files() -> Vec { - Vec::new() -} -/// The files in `listed` that aren't in `scanned` (canonical paths), each once and added to -/// `scanned`; paths that don't lead to a file (gone, unreadable folder) are left out. +mod db; +mod face; +mod fallback; +mod names; +mod outline; +mod scoped; +mod system; + +#[cfg(test)] +pub(crate) use face::last_resort_face; +pub use face::{ + DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, + MAX_DOCUMENT_FONTS_TOTAL, bundled, +}; +#[cfg(test)] +use face::{FontBytes, make_face}; +pub(crate) use face::{measured_top, plausible}; +#[cfg(all(test, target_os = "linux"))] +use fallback::FallbackSnapshotHook; #[cfg(not(target_arch = "wasm32"))] -fn unscanned_font_files(listed: Vec, scanned: &mut std::collections::HashSet) -> Vec { - listed - .into_iter() - .filter(|p| { - let Ok(canonical) = std::fs::canonicalize(p) else { return false }; - canonical.is_file() && scanned.insert(canonical) - }) - .collect() -} - -/// One face found in a font file: index, family, style and (variable fonts) the named instance's -/// axis settings. -type Found = (u32, String, String, Vec<([u8; 4], f32)>); - -/// Parse every face in `data` (a font file or collection); a variable font yields one face per -/// named instance (InDesign lists them as styles). -fn enumerate_faces(data: &[u8]) -> Vec { - /// A collection's count is input: what a damaged one can make the loop try. - const MAX_FACES: u32 = 1024; - let count = match FileRef::new(data) { - Ok(FileRef::Font(_)) => 1, - Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES), - Err(_) => 0, - }; - let mut out = Vec::new(); - for i in 0..count { - let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; - let Some((family, style)) = face_names(&f) else { continue }; - let axes = f.axes(); - let mut named = Vec::new(); - for ni in f.named_instances().iter() { - let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; - if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { - continue; - } - let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); - named.push((i, style, coords)); - } - if named.is_empty() { - out.push((i, family, style, Vec::new())); - } else { - out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); - } - } - out -} - -/// The compiled-in Source Sans 3 Regular, for a database that somehow has no faces at all. -pub(crate) fn last_resort_face() -> Arc { - static FACE: std::sync::OnceLock> = std::sync::OnceLock::new(); - FACE.get_or_init(|| { - // The font is compiled in (`include_bytes!`), so parsing it can't depend on input; the - // `last_resort_face_parses` test proves it on every run. - #[allow(clippy::expect_used)] - let face = make_face(FontBytes::Static(BUNDLED[0]), FontSource::Bundled, 0, FALLBACK_FAMILY.into(), "Regular".into(), Vec::new()) - .expect("the compiled-in Source Sans 3 Regular parses"); - Arc::new(face) - }) - .clone() -} - -fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { - let data: &[u8] = match &bytes { - FontBytes::Static(b) => b, - FontBytes::Owned(v) => v.as_slice(), - }; - let f = skrifa::FontRef::from_index(data, index).ok()?; - let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect(); - let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) }; - let m = f.metrics(Size::unscaled(), &location); - let upem = m.units_per_em.max(1) as f64; - let a = f.attributes(); - let wws = wws_names(&f, &family, &style); - let hb = harfrust::FontRef::from_index(data, index).ok()?; - let shaper = harfrust::ShaperData::new(&hb); - let instance = (!coords.is_empty()) - .then(|| harfrust::ShaperInstance::from_variations(&hb, settings.iter().map(|(t, v)| harfrust::Variation { tag: *t, value: *v }))); - let axis = |tag: &[u8; 4]| coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v); - let weight = axis(b"wght").unwrap_or(if coords.is_empty() { a.weight.value() } else { style_weight(&style) }); - let italic = axis(b"ital") - .map(|v| v >= 0.5) - .or(axis(b"slnt").map(|v| v.abs() > 0.1)) - .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); - let family_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]); - let style_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]); - Some(FontFace { - id: NEXT_ID.fetch_add(1, Ordering::Relaxed), - family, - style, - wws, - weight, - italic, - upem, - ascent: m.ascent as f64, - typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64), - descent: -(m.descent as f64), - // Fonts with an OS/2 table older than version 2 don't declare these: measure them from - // flat-topped letters before falling back to a share of the ascent, which can be far off - // (drop caps are sized from the cap height). - cap_height: plausible(m.cap_height.map(f64::from), upem) - .or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem)) - .unwrap_or(m.ascent as f64 * 0.72), - x_height: plausible(m.x_height.map(f64::from), upem) - .or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem)) - .unwrap_or(m.ascent as f64 * 0.5), - shaper, - coords, - location, - instance, - source, - bytes, - index, - bmp: std::sync::OnceLock::new(), - em: std::sync::OnceLock::new(), - family_aliases, - style_aliases, - icf: std::sync::OnceLock::new(), - group: std::sync::OnceLock::new(), - kind: std::sync::OnceLock::new(), - postscript: std::sync::OnceLock::new(), - }) -} - -/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em. -pub(crate) fn plausible(v: Option, upem: f64) -> Option { - v.filter(|v| *v > 0.0 && *v <= 4.0 * upem) -} - -/// The top of the first of `chars` the font has a non-empty outline for, in font units: the -/// highest point of its unhinted outline at `location`, if [`plausible`]. For flat-topped letters -/// that is the cap height (`H`) or x-height (`x`). -pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option { - struct Top(f32); - impl OutlinePen for Top { - fn move_to(&mut self, _x: f32, y: f32) { - self.0 = self.0.max(y); - } - fn line_to(&mut self, _x: f32, y: f32) { - self.0 = self.0.max(y); - } - fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) { - self.0 = self.0.max(y); - } - fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) { - self.0 = self.0.max(y); - } - fn close(&mut self) {} - } - let cmap = f.charmap(); - let glyphs = f.outline_glyphs(); - chars.iter().find_map(|&c| { - let g = glyphs.get(cmap.map(c)?)?; - let mut pen = Top(f32::NEG_INFINITY); - g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?; - plausible(Some(f64::from(pen.0)), upem) - }) -} - -/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and -/// the value is [`plausible`]. -fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option { - use skrifa::raw::TableProvider; - let mut v = f.os2().ok()?.s_typo_ascender() as f64; - if !location.coords().is_empty() - && let Ok(mvar) = f.mvar() - { - v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64()); - } - plausible(Some(v), upem) -} - -/// `family` without what layout apps add to a family name, if it has any: InDesign's `$ID/` -/// prefix (`$ID/Arial` → `Arial`) and the font-format suffix appended when a family is installed -/// in several formats (`Minion Pro (OTF)` → `Minion Pro`). -fn plain_family(family: &str) -> Option<&str> { - let unprefixed = family.trim_start().strip_prefix("$ID/").map(str::trim).filter(|f| !f.is_empty()); - without_format_suffix(unprefixed.unwrap_or(family)).or(unprefixed) -} - -/// `family` without the font-format suffix layout apps append when a family is installed in -/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. -fn without_format_suffix(family: &str) -> Option<&str> { - const FORMATS: [&str; 5] = ["OTF", "TT", "TTF", "TTC", "T1"]; - let (base, suffix) = family.trim_end().strip_suffix(')')?.rsplit_once('(')?; - let base = base.trim_end(); - (!base.is_empty() && FORMATS.iter().any(|f| f.eq_ignore_ascii_case(suffix.trim()))).then_some(base) -} - -/// The names `family` is looked up under when no font has it as written: without surrounding -/// spaces, then without an `$ID/` prefix or a format suffix (see [`plain_family`]). -fn alternate_names(family: &str) -> impl Iterator { - let trimmed = family.trim(); - (trimmed != family && !trimmed.is_empty()).then_some(trimmed).into_iter().chain(plain_family(trimmed)) -} - -/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). -pub fn base_style(style: &str) -> &str { - style.split('{').next().unwrap_or(style).trim() -} - -/// Do two style names name the same face? Spacing, punctuation and case don't count, as when a face -/// is looked up (InDesign documents can carry `Regular ` with a trailing space). -pub fn same_style(a: &str, b: &str) -> bool { - norm(a) == norm(b) -} - -fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() -} - -/// Weight implied by a style name. -fn style_weight(style: &str) -> f32 { - let s = norm(style); - const TABLE: &[(&str, f32)] = &[ - ("extralight", 200.0), - ("ultralight", 200.0), - ("semibold", 600.0), - ("demibold", 600.0), - ("extrabold", 800.0), - ("ultrabold", 800.0), - ("hairline", 100.0), - ("thin", 100.0), - ("light", 300.0), - ("medium", 500.0), - ("bold", 700.0), - ("black", 900.0), - ("heavy", 900.0), - ]; - TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) -} - -fn style_italic(style: &str) -> bool { - let s = norm(style); - s.contains("italic") || s.contains("oblique") || s == "it" -} - -impl FontDb { - /// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first), - /// followed by Noto Sans Hebrew Regular and Noto Sans Devanagari Regular for document text. - /// The optional craft-fonts faces are absent without `CRAFT_FONTS_DIR`; system font scans - /// read `font_dirs`. Japanese fallback and UI ordering remain independent of these additions. - pub fn with_font_dirs(font_dirs: Vec) -> Self { - let mut faces = Vec::new(); - let craft = crate::japanese_document_fonts() - .into_iter() - .chain( - ["Noto Sans Hebrew", "Noto Sans Devanagari"] - .into_iter() - .filter_map(|family| crate::CRAFT_FONTS.iter().find(|f| f.family == family && f.style == "Regular")), - ) - .map(|f| f.bytes); - for data in BUNDLED.iter().copied().chain(craft) { - for (i, family, style, coords) in enumerate_faces(data) { - if let Some(f) = make_face(FontBytes::Static(data), FontSource::Bundled, i, family, style, coords) { - faces.push(Arc::new(f)); - } - } - } - Self { - faces: RwLock::new(faces), - scopes: RwLock::new(HashMap::new()), - #[cfg(not(target_arch = "wasm32"))] - document_files: Mutex::new(HashMap::new()), - instances: RwLock::new(HashMap::new()), - outlines: Mutex::new(HashMap::new()), - #[cfg(not(target_arch = "wasm32"))] - catalog: RwLock::new(Vec::new()), - #[cfg(not(target_arch = "wasm32"))] - installed_files: Mutex::new(HashMap::new()), - font_dirs, - font_files: Vec::new, - #[cfg(not(target_arch = "wasm32"))] - cataloged: std::sync::OnceLock::new(), - #[cfg(not(target_arch = "wasm32"))] - sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), - #[cfg(all(test, target_os = "linux"))] - fallback_snapshot_hook: Mutex::new(None), - } - } - - /// Enable or disable the lazy system-font fallback for characters the loaded fonts lack - /// (native only; on by default). - pub fn set_system_fallback(&self, on: bool) { - #[cfg(not(target_arch = "wasm32"))] - { - self.sys.lock().unwrap_or_else(PoisonError::into_inner).enabled = on; - } - #[cfg(target_arch = "wasm32")] - let _ = on; - } - - /// Process-wide database: the bundled fonts, then the installed system fonts (the font - /// folders' and the platform's other font files, see [`platform_font_files`]), cataloged the - /// first time a lookup by family name needs them (or ahead of time by - /// [`FontDb::scan_in_background`]). - pub fn global() -> &'static FontDb { - static DB: std::sync::OnceLock = std::sync::OnceLock::new(); - DB.get_or_init(|| FontDb { font_files: platform_font_files, ..FontDb::with_font_dirs(system_font_dirs()) }) - } - - fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { - self.faces.read().unwrap_or_else(PoisonError::into_inner) - } - - /// The system font catalog, scanned first if it hasn't been yet. - #[cfg(not(target_arch = "wasm32"))] - fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec> { - self.ensure_catalog(); - self.catalog.read().unwrap_or_else(PoisonError::into_inner) - } - - /// What the document with font scope `scope` sees: its own fonts ahead of the shared ones. - /// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts. - pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> { - let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(PoisonError::into_inner).get(&scope).cloned() }; - ScopedFonts { db: self, own } - } - - /// The shared fonts' family names (see [`ScopedFonts::families`]). - pub fn families(&self) -> Vec { - self.scoped(0).families() - } - - /// The shared fonts' styles of `family` (see [`ScopedFonts::styles`]). - pub fn styles(&self, family: &str) -> Vec { - self.scoped(0).styles(family) - } - - /// The shared fonts' weight and italic of a style of `family` (see [`ScopedFonts::traits_of`]). - pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { - self.scoped(0).traits_of(family, style) - } - - /// Style linking among the shared fonts (see [`ScopedFonts::linked_style`]). - pub fn linked_style(&self, family: &str, current: &str, bold: Option, italic: Option) -> Option { - self.scoped(0).linked_style(family, current, bold, italic) - } - - /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or - /// every face was already present). - pub fn add_font(&self, bytes: Vec) -> usize { - self.add_faces(bytes, FontSource::Memory) - } - - /// Add the faces of a font file from `source` to the shared fonts, skipping the family and - /// style already loaded. - fn add_faces(&self, bytes: Vec, source: FontSource) -> usize { - let faces = self.new_faces(bytes, source); - self.insert_faces(faces) - } - - /// The faces of a font file from `source` whose family and style aren't loaded yet. - pub(crate) fn new_faces(&self, bytes: Vec, source: FontSource) -> Vec { - let data = Arc::new(bytes); - enumerate_faces(&data) - .into_iter() - .filter(|(_, family, style, _)| !has_face(&self.read_faces(), family, style)) - .filter_map(|(i, family, style, coords)| make_face(FontBytes::Owned(data.clone()), source.clone(), i, family, style, coords)) - .collect() - } - - /// Add `faces` to the shared fonts all at once, so a lookup sees all of them or none (not a - /// family's bold without its regular), skipping a family and style another thread has added - /// meanwhile. Returns the number added. - fn insert_faces(&self, new: Vec) -> usize { - let mut faces = self.faces.write().unwrap_or_else(PoisonError::into_inner); - let mut added = 0; - for f in new { - if !has_face(&faces, &f.family, &f.style) { - faces.push(Arc::new(f)); - added += 1; - } - } - added - } - - /// Catalog the installed fonts unless that has been done: the first caller scans the font - /// folders, any other waits for that scan to finish. - #[cfg(not(target_arch = "wasm32"))] - fn ensure_catalog(&self) { - self.cataloged.get_or_init(|| { - self.scan_font_dirs(); - }); - } - - /// Whether the installed fonts are cataloged: a lookup by family name won't wait for the - /// scan (always true on wasm, which has none). See [`FontDb::scan_in_background`]. - pub fn system_fonts_cataloged(&self) -> bool { - #[cfg(target_arch = "wasm32")] - return true; - #[cfg(not(target_arch = "wasm32"))] - self.cataloged.get().is_some() - } - - /// Catalog the installed fonts on a background thread, so the first lookup by family name - /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are - /// cataloged, and on wasm. - pub fn scan_in_background(&'static self) { - #[cfg(not(target_arch = "wasm32"))] - if self.cataloged.get().is_none() { - // A failed spawn leaves the scan to the first lookup that needs it. - let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog()); - } - } - - /// Scan the font folders again (fonts installed or removed since), cataloging the faces - /// found (native only). Font data is loaded when a cataloged family is first resolved. - /// Returns the number of faces cataloged. - #[cfg(not(target_arch = "wasm32"))] - pub fn load_system_fonts(&self) -> usize { - // The first scan, or a rescan once it is done (never both at once). - let mut first = None; - self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); - let count = first.unwrap_or_else(|| self.scan_font_dirs()); - // Retry characters and CJK chain families that newly installed fonts can now cover. - // Reset outside scan_font_dirs: a chain lookup can hold sys during the initial scan. - let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); - sys.generation = sys.generation.wrapping_add(1); - sys.misses.clear(); - sys.chain_tried.clear(); - count - } - - /// Fonts installed, changed or removed since they were read (#328): scan the font folders - /// again (see [`FontDb::load_system_fonts`]), then let go of the shared faces whose installed - /// file changed (length or modification time) or is gone, so the next lookup reads the file as - /// it is now, or finds the font missing. What was composed with the old faces must be composed - /// again. Faces are never freed (see [`FaceRef`]): each reload of a changed file keeps the old - /// copy. Bundled, added and document fonts are left alone. Nothing to do on wasm. - pub fn reload_fonts(&self) -> ReloadedFonts { - #[cfg(not(target_arch = "wasm32"))] - { - // Rescan first: a lookup after the faces are dropped finds the new files. - let cataloged = self.load_system_fonts(); - let changed: Vec = { - let mut files = self.installed_files.lock().unwrap_or_else(PoisonError::into_inner); - let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect(); - changed.sort(); - for p in &changed { - files.remove(p); - } - changed - }; - if !changed.is_empty() { - let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p)); - let mut dropped = std::collections::HashSet::new(); - self.faces.write().unwrap_or_else(PoisonError::into_inner).retain(|f| { - let keep = !stale(f); - if !keep { - dropped.insert(f.id); - } - keep - }); - self.instances.write().unwrap_or_else(PoisonError::into_inner).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); - self.outlines.lock().unwrap_or_else(PoisonError::into_inner).retain(|(face, _), _| !dropped.contains(face)); - log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len()); - } - ReloadedFonts { cataloged, changed } - } - #[cfg(target_arch = "wasm32")] - ReloadedFonts::default() - } - - /// Read an installed font file, noting its length and modification time as they were before - /// the read (a change during it is then found by the next reload). - #[cfg(not(target_arch = "wasm32"))] - fn read_installed(&self, path: &std::path::Path) -> Option> { - let stamp = file_stamp(path)?; - let data = std::fs::read(path).ok()?; - self.installed_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), stamp); - Some(data) - } - - #[cfg(not(target_arch = "wasm32"))] - fn scan_font_dirs(&self) -> usize { - let mut found = Vec::new(); - let mut stack = self.font_dirs.clone(); - // Each folder once, however links lead back to it. - let mut visited = std::collections::HashSet::new(); - // The font files read, by canonical path, so the platform's list adds only the others. - let mut scanned = std::collections::HashSet::new(); - while let Some(d) = stack.pop() { - if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { - continue; - } - let Ok(rd) = std::fs::read_dir(&d) else { continue }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - continue; - } - let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); - if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { - continue; - } - scanned.insert(std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone())); - for f in file_face_names(&p) { - found.push(CatalogEntry { - family: f.family, - style: f.style, - wws: f.wws, - path: p.clone(), - group: f.group, - native: f.native, - aliases: f.aliases, - postscript: f.postscript, - }); - } - } - } - // Font services name their files as they like (no extension, say): the face names read - // decide what is a font. - for p in unscanned_font_files((self.font_files)(), &mut scanned) { - for f in file_face_names(&p) { - found.push(CatalogEntry { - family: f.family, - style: f.style, - wws: f.wws, - path: p.clone(), - group: f.group, - native: f.native, - aliases: f.aliases, - postscript: f.postscript, - }); - } - } - let n = found.len(); - log::debug!("cataloged {n} system font faces"); - *self.catalog.write().unwrap_or_else(PoisonError::into_inner) = found; - n - } - - /// Load the files of the installed `family`. Returns whether any face was added. - #[cfg(not(target_arch = "wasm32"))] - fn load_cataloged(&self, family: &str) -> bool { - let paths: Vec = { - let cat = self.read_catalog(); - let mut p: Vec<_> = cat.iter().filter(|c| in_catalog_family(c, family)).map(|c| c.path.clone()).collect(); - p.sort(); - p.dedup(); - p - }; - // The whole family arrives at once (see `insert_faces`). - let mut faces = Vec::new(); - for p in paths { - if let Some(data) = self.read_installed(&p) { - faces.extend(self.new_faces(data, FontSource::Installed(p))); - } - } - self.insert_faces(faces) > 0 - } - - /// Load the font files (.ttf, .otf, .ttc, .otc; not in subfolders) of a document's `Document - /// Fonts` folder under a new scope, for that document alone: looked up through - /// [`FontDb::scoped`], they come before the shared faces of the same family and style, until - /// [`FontDb::close_scope`]. A file loaded before and unchanged since (same length and - /// modification time) isn't read again: its faces are shared. The files are untrusted: past - /// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past - /// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A - /// missing folder loads nothing (scope 0). - /// - /// The folder is named by a document, so this crate does not trust it: `allow` (`Ok`, or why a - /// path may not be read) is asked about - /// the folder and about every font file in it before either is opened, and a path it refuses - /// is skipped with its message (the engine passes its automation-root check; a host with no - /// roots passes `&|_| Ok(())`). - #[cfg(not(target_arch = "wasm32"))] - pub fn load_document_fonts(&self, folder: &std::path::Path, allow: &dyn Fn(&std::path::Path) -> Result<(), String>) -> DocumentFonts { - let mut out = DocumentFonts::default(); - if let Err(why) = allow(folder) { - out.skipped.push(why); - return out; - } - let Ok(entries) = std::fs::read_dir(folder) else { return out }; - let mut files: Vec = Vec::new(); - for path in entries.flatten().map(|e| e.path()) { - let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); - if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { - continue; - } - // Each file is vetted before it is touched at all, a link out of the folder included. - match allow(&path) { - Ok(()) if path.is_file() => files.push(path), - Ok(()) => {} - Err(why) => out.skipped.push(why), - } - } - files.sort(); - let mut total = 0u64; - let mut own: Vec> = Vec::new(); - for (n, path) in files.iter().enumerate() { - if n == MAX_DOCUMENT_FONT_FILES { - out.skipped.push(format!("{} more font files not loaded (at most {MAX_DOCUMENT_FONT_FILES} are)", files.len() - n)); - break; - } - let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); - // The listed length skips oversized files unread. - let meta = std::fs::metadata(path).ok(); - let listed = meta.as_ref().map_or(0, std::fs::Metadata::len); - let faces = if listed > MAX_DOCUMENT_FONT_BYTES { - Err(too_large()) - } else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL { - Err(over_total()) - } else { - let modified = meta.and_then(|m| m.modified().ok()); - self.document_file(path, listed, modified, MAX_DOCUMENT_FONTS_TOTAL.saturating_sub(total)) - }; - match faces { - Err(r) => out.skipped.push(format!("{name}: {r}")), - Ok((faces, len)) => { - total = total.saturating_add(len); - // A family and style the folder already gave stays with its first file. - for f in faces { - if !own.iter().any(|o| o.family.eq_ignore_ascii_case(&f.family) && o.style.eq_ignore_ascii_case(&f.style)) { - own.push(f); - } - } - } - } - } - for s in &out.skipped { - log::warn!("{}: {s}", folder.display()); - } - if !own.is_empty() { - out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed); - out.faces = own.len(); - self.scopes.write().unwrap_or_else(PoisonError::into_inner).insert(out.scope, own.into()); - } - out - } - - /// The faces of the document font file at `path` (`listed` bytes long, modified at - /// `modified`): those read before if it hasn't changed since, else read now, within `room` - /// bytes of the folder's total → (faces, bytes), or why the file is skipped. - #[cfg(not(target_arch = "wasm32"))] - fn document_file( - &self, - path: &std::path::Path, - listed: u64, - modified: Option, - room: u64, - ) -> Result<(Vec>, u64), String> { - use std::io::Read; - if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(PoisonError::into_inner).get(path) - && (*len, *m) == (listed, modified) - { - return Ok((faces.clone(), listed)); - } - // One byte past the limit catches a file that grew since it was listed. - let mut data = Vec::new(); - std::fs::File::open(path) - .and_then(|f| f.take(MAX_DOCUMENT_FONT_BYTES + 1).read_to_end(&mut data)) - .map_err(|e| format!("can't be read ({e})"))?; - let len = data.len() as u64; - if len > MAX_DOCUMENT_FONT_BYTES { - return Err(too_large()); - } - if listed.max(len) > room { - return Err(over_total()); - } - let data = Arc::new(data); - let faces: Vec> = enumerate_faces(&data) - .into_iter() - .filter_map(|(i, family, style, coords)| { - make_face(FontBytes::Owned(data.clone()), FontSource::Document(path.to_path_buf()), i, family, style, coords) - }) - .map(Arc::new) - .collect(); - if faces.is_empty() { - return Err("not a font DesignCraft can read".into()); - } - self.document_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (listed, modified, faces.clone())); - Ok((faces, len)) - } - - /// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their - /// files stay read, for the document opening again. - pub fn close_scope(&self, scope: u32) { - self.scopes.write().unwrap_or_else(PoisonError::into_inner).remove(&scope); - } - - /// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]). - pub fn face(&self, family: &str, style: &str) -> Arc { - self.scoped(0).face(family, style) - } - - /// The shared face with PostScript name `name` (see [`ScopedFonts::postscript_face`]). - pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { - self.scoped(0).postscript_face(name) - } - - /// Is `family` among the shared fonts (loaded, or installed on the system)? - pub fn has_family(&self, family: &str) -> bool { - self.scoped(0).has_family(family) - } - - #[cfg(not(target_arch = "wasm32"))] - fn is_loaded(&self, family: &str) -> bool { - self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family) || has_wws_family(f, family)) - } - - /// A shared variable font's axes (see [`ScopedFonts::axes`]). - pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { - self.scoped(0).axes(family, style) - } - - /// A shared face other than `exclude` that covers `c` (see [`ScopedFonts::fallback_for`]). - pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { - self.scoped(0).fallback_for(c, exclude, language) - } - - /// Load a system font covering `c` (preferred fallback families first, then any cataloged - /// file under 40 MB). Returns true if one was loaded. Misses are remembered. - #[cfg(not(target_arch = "wasm32"))] - fn system_fallback(&self, c: char) -> bool { - if c.is_control() || c.is_whitespace() { - return false; - } - let generation = { - let sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); - if !sys.enabled || sys.misses.contains(&c) { - return false; - } - sys.generation - }; - let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); - let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); - for fam in SYSTEM_FALLBACKS { - if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { - self.load_cataloged(fam); - if covered(self) { - return true; - } - } - } - let mut paths: Vec = self.catalog.read().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.path.clone()).collect(); - paths.sort(); - paths.dedup(); - #[cfg(all(test, target_os = "linux"))] - { - let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(PoisonError::into_inner).take(); - if let Some(hook) = hook { - let _ = hook.reached.send(()); - hook.resume.wait(); - } - } - for p in paths { - if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { - continue; - } - let Some(data) = self.read_installed(&p) else { continue }; - let hit = - enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); - if hit && self.add_faces(data, FontSource::Installed(p)) > 0 && covered(self) { - return true; - } - } - let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); - if sys.generation == generation { - sys.misses.insert(c); - } - false - } - - /// The box drawn for `face`'s missing glyph (.notdef) when the font's own has no outline, in - /// font units, y-down: a hollow rectangle across the glyph's advance, cap height tall. `None` - /// when the font draws its own. - pub fn missing_box(&self, face: &FontFace) -> Option { - if !font_outline(face, 0).elements().is_empty() { - return None; - } - let adv = Some(face.advance(0)).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(face.upem * 0.5); - let top = Some(face.cap_height).filter(|h| h.is_finite() && *h > 0.0).unwrap_or(face.upem * 0.7); - let outer = kurbo::Rect::new(adv * 0.1, -top, adv * 0.9, 0.0); - let t = (face.upem * 0.05).min(outer.width() / 4.0).min(top / 4.0); - let inner = outer.inset(-t); - let mut p = BezPath::new(); - // The inner rectangle runs the other way round, so filling leaves it empty. - p.move_to((outer.x0, outer.y0)); - p.line_to((outer.x1, outer.y0)); - p.line_to((outer.x1, outer.y1)); - p.line_to((outer.x0, outer.y1)); - p.close_path(); - p.move_to((inner.x0, inner.y0)); - p.line_to((inner.x0, inner.y1)); - p.line_to((inner.x1, inner.y1)); - p.line_to((inner.x1, inner.y0)); - p.close_path(); - Some(p) - } - - /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). A `.notdef` - /// without an outline is drawn as [`FontDb::missing_box`], so a missing character never - /// disappears. - pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { - let key = (face.id, gid); - if let Some(p) = self.outlines.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { - return p.clone(); - } - let drawn = font_outline(face, gid); - let p = Arc::new(if gid == 0 && drawn.elements().is_empty() { self.missing_box(face).unwrap_or(drawn) } else { drawn }); - let mut cache = self.outlines.lock().unwrap_or_else(PoisonError::into_inner); - if cache.len() >= OUTLINE_CACHE_MAX { - cache.clear(); - } - cache.insert(key, p.clone()); - p - } -} - -/// One document's view of the [`FontDb`] ([`FontDb::scoped`]): the fonts of its `Document Fonts` -/// folder, then the shared fonts (bundled, installed, added). Other documents' fonts aren't in it. -#[derive(Clone)] -pub struct ScopedFonts<'a> { - db: &'a FontDb, - /// The document's own faces, in the folder's file order (none without a scope). - own: Option]>>, -} - -impl ScopedFonts<'_> { - fn own(&self) -> &[Arc] { - self.own.as_deref().unwrap_or(&[]) - } - - /// Resolve localized and legacy family names without confusing replacement fonts with - /// installed originals. Document-local names take precedence over shared fonts. - /// - /// In each scope (the document's faces, loaded faces, the catalog) an exact typographic name - /// wins, then a WWS family name (an optical-size face's, see `find`), which names its faces as - /// it is, then the aliases. An optical-size face's legacy family name (name ID 1) is usually - /// its WWS name, so the aliases must not claim it first. - pub fn canonical_family(&self, family: &str) -> Option { - let is = |name: &str| name.eq_ignore_ascii_case(family); - let in_faces = |faces: &[Arc]| -> Option { - if let Some(f) = faces.iter().find(|f| is(&f.family)) { - return Some(f.family.clone()); - } - if let Some(w) = faces.iter().find_map(|f| f.wws.as_ref().filter(|w| is(&w.0))) { - return Some(w.0.clone()); - } - faces.iter().find(|f| f.family_aliases.iter().any(|alias| is(alias))).map(|f| f.family.clone()) - }; - if let Some(name) = in_faces(self.own()).or_else(|| in_faces(&self.db.read_faces())) { - return Some(name); - } - #[cfg(not(target_arch = "wasm32"))] - { - let catalog = self.db.read_catalog(); - if let Some(c) = catalog.iter().find(|c| is(&c.family)) { - return Some(c.family.clone()); - } - if let Some(w) = catalog.iter().find_map(|c| c.wws.as_ref().filter(|w| is(&w.0))) { - return Some(w.0.clone()); - } - if let Some(c) = catalog.iter().find(|c| c.aliases.iter().any(|alias| is(alias))) { - return Some(c.family.clone()); - } - } - None - } - - /// Whether the requested style exists, including localized names and axis instances. - pub fn has_style(&self, family: &str, style: &str) -> bool { - if !self.has_family(family) { - return false; - } - let face = self.face(family, style); - norm(&face.style) == norm(style) - || (face.coords.is_empty() && face.style_aliases.iter().any(|alias| norm(alias) == norm(style))) - // An optical-size face asked for by its WWS names. - || face.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family) && norm(&w.1) == norm(style)) - } - - /// Prefer known metric-compatible substitutes, then an appropriate script family. This - /// never changes `has_family`: a usable substitute is still a missing original font. - pub fn substitute_family(&self, family: &str) -> String { - let lower = family.to_lowercase(); - let candidates: &[&str] = match lower.as_str() { - "calibri" | "calibri light" => &["Carlito", "Liberation Sans"], - "cambria" | "cambria math" => &["Caladea", "Liberation Serif"], - "arial" | "helvetica" => &["Liberation Sans", "Arial", "Helvetica"], - "times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times"], - "courier new" | "courier" => &["Liberation Mono", "Courier New", "Courier"], - _ if lower.contains("arabic") || lower.contains("naskh") => &["Amiri", "Noto Naskh Arabic", "Geeza Pro"], - _ if family.contains('楷') => &["Kaiti SC", "STKaiti", "KaiTi", "Songti SC"], - _ if family.contains("仿宋") => &["STFangsong", "FangSong", "Songti SC"], - _ if family.contains('宋') || family.contains("明朝") => &["Songti SC", "Noto Serif CJK SC", "SimSun"], - _ if family.contains('黑') || family.contains("雅黑") => &["PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei"], - _ if family.chars().any(|c| ('\u{3400}'..='\u{9fff}').contains(&c)) => &["Songti SC", "Noto Serif CJK SC", "PingFang SC"], - _ => &[], - }; - candidates.iter().find_map(|name| self.canonical_family(name)).unwrap_or_else(|| FALLBACK_FAMILY.to_string()) - } - - /// A replacement suggestion must cover the text being replaced, not just its main script. - /// Ties retain the preferred substitute; this does not install or redefine any font. - pub fn replacement_face(&self, family: &str, style: &str, characters: &[char]) -> Arc { - let mut best = self.face(family, style); - let coverage = |face: &FontFace| characters.iter().filter(|&&c| c.is_whitespace() || c.is_control() || face.covers(c)).count(); - let mut score = coverage(&best); - let candidates: &[&str] = if characters.iter().any(|c| ('\u{0600}'..='\u{08ff}').contains(c)) { - &["Amiri", "Noto Naskh Arabic", "Al Bayan", "Damascus", "Geeza Pro", "Arial"] - } else if characters.iter().any(|c| ('\u{3400}'..='\u{9fff}').contains(c)) { - &["Songti SC", "Kaiti SC", "Noto Serif CJK SC", "PingFang SC", "Noto Sans CJK SC"] - } else { - &["Source Serif 4", "Source Sans 3", "Inter"] - }; - for candidate in candidates { - if score == characters.len() { - break; - } - if !self.has_family(candidate) { - continue; - } - let face = self.face(candidate, style); - let next = coverage(&face); - if next > score { - best = face; - score = next; - } - } - best - } - - /// Family names available (the document's, loaded and cataloged system fonts), sorted and - /// deduplicated. - pub fn families(&self) -> Vec { - let mut v: Vec = self.own().iter().chain(self.db.read_faces().iter()).map(|f| f.family.clone()).collect(); - #[cfg(not(target_arch = "wasm32"))] - v.extend(self.db.read_catalog().iter().map(|c| c.family.clone())); - v.sort_by_key(|a| a.to_lowercase()); - v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v - } - - /// The available families as the font menus list them (see [`ScopedFonts::families`]), each - /// with its group and native name, unsorted ([`crate::sort_for_menu`] orders them). - pub fn family_infos(&self) -> Vec { - let mut out: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for f in self.own().iter().chain(self.db.read_faces().iter()) { - if seen.insert(f.family.to_lowercase()) { - let (group, native) = f.menu_group(); - out.push(FamilyInfo { family: f.family.clone(), group, native: native.map(str::to_string) }); - } - } - #[cfg(not(target_arch = "wasm32"))] - for c in self.db.read_catalog().iter() { - if seen.insert(c.family.to_lowercase()) { - out.push(FamilyInfo { family: c.family.clone(), group: c.group, native: c.native.clone() }); - } - } - out - } - - /// Style names available for `family` (Regular first, then by weight). - pub fn styles(&self, family: &str) -> Vec { - self.style_traits(family).into_iter().map(|t| t.2).collect() - } - - /// Weight and italic of a style of `family`: from the font when it is known, else from the - /// style's name. - pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { - let base = base_style(style); - self.style_traits(family) - .into_iter() - .find(|t| t.2.eq_ignore_ascii_case(base)) - .map_or((style_weight(base), style_italic(base)), |(italic, weight, _)| (weight, italic)) - } - - /// Style linking: the style of `family` that sets bold (`bold`) and italic (`italic`) as asked - /// and keeps the other from `current` (Bold from Italic is Bold Italic). `None` when the - /// family has no such style. - pub fn linked_style(&self, family: &str, current: &str, bold: Option, italic: Option) -> Option { - let (weight, cur_italic) = self.traits_of(family, current); - let want_italic = italic.unwrap_or(cur_italic); - let want_weight = match bold { - Some(true) => 700.0, - Some(false) => 400.0, - None => weight, - }; - // Width words (Condensed, Extended…) of the current style are kept when the family allows. - const WIDTHS: &[&str] = &["condensed", "compressed", "narrow", "extended", "expanded", "wide"]; - let widths = |s: &str| { - let n = norm(s); - WIDTHS.iter().filter(|w| n.contains(*w)).count() - }; - let cur_widths = widths(current); - self.style_traits(family) - .into_iter() - .filter(|(i, w, _)| *i == want_italic && bold.is_none_or(|b| b == (*w >= 600.0))) - .min_by(|a, b| { - let key = |t: &(bool, f32, String)| ((t.1 - want_weight).abs(), usize::from(widths(&t.2) != cur_widths), t.2.len()); - let (ka, kb) = (key(a), key(b)); - ka.0.total_cmp(&kb.0).then(ka.1.cmp(&kb.1)).then(ka.2.cmp(&kb.2)) - }) - .map(|t| t.2) - } - - /// `(italic, weight, name)` of each style of `family` (the document's, loaded, or installed), - /// Regular first, then by weight. - fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { - let v = self.exact_style_traits(family); - if !v.is_empty() { - return v; - } - alternate_names(family).map(|f| self.exact_style_traits(f)).find(|v| !v.is_empty()).unwrap_or_default() - } - - fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { - let canonical = self.canonical_family(family); - let family = canonical.as_deref().unwrap_or(family); - let v = self.named_style_traits(family, false); - // No face of that typographic family: the family may be a WWS name (see `find`). - if v.is_empty() { self.named_style_traits(family, true) } else { v } - } - - /// [`ScopedFonts::exact_style_traits`] by the faces' typographic names, or by their WWS names - /// when `by_wws`. - fn named_style_traits(&self, family: &str, by_wws: bool) -> Vec<(bool, f32, String)> { - let mut v: Vec<(bool, f32, String)> = self - .own() - .iter() - .chain(self.db.read_faces().iter()) - .filter(|f| !f.style.contains('{')) - .filter_map(|f| known_as(&f.family, &f.style, f.wws.as_ref(), by_wws).map(|n| (f, n))) - .filter(|(_, (fam, _))| fam.eq_ignore_ascii_case(family)) - .map(|(f, (_, style))| (f.italic, f.weight, style.to_string())) - .collect(); - #[cfg(not(target_arch = "wasm32"))] - for c in self.db.read_catalog().iter() { - if let Some((fam, style)) = known_as(&c.family, &c.style, c.wws.as_ref(), by_wws) - && fam.eq_ignore_ascii_case(family) - && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) - { - v.push((style_italic(style), style_weight(style), style.to_string())); - } - } - v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); - v.dedup_by(|a, b| a.2 == b.2); - v - } - - /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A - /// family that isn't found as written is looked up without surrounding spaces, then without - /// InDesign's `$ID/` prefix or a format suffix (`Minion Pro (OTF)` → `Minion Pro`). - pub fn face(&self, family: &str, style: &str) -> Arc { - let canonical = self - .canonical_family(family) - .or_else(|| alternate_names(family).find_map(|f| self.canonical_family(f))) - .unwrap_or_else(|| self.substitute_family(alternate_names(family).last().unwrap_or(family))); - if let Some(f) = self.find_or_load(&canonical, style) { - return f; - } - self.find(FALLBACK_FAMILY, style) - .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) - .or_else(|| self.db.read_faces().first().cloned()) - .unwrap_or_else(last_resort_face) - } - - fn find_or_load(&self, family: &str, style: &str) -> Option> { - if let Some(f) = self.find(family, style) { - return Some(f); - } - #[cfg(not(target_arch = "wasm32"))] - if self.db.load_cataloged(family) { - return self.find(family, style); - } - None - } - - /// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular - /// face (else its first), for the font menus' filters, which look at every family. A face - /// already loaded is asked; an installed family that isn't loaded is read from its file, which - /// is then let go rather than kept loaded. None for an unknown family or an unreadable file. - pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option)> { - let styles = self.styles(family); - let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone(); - if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) { - let f = self.find(family, &style)?; - return Some((f.kind(), f.added())); - } - #[cfg(not(target_arch = "wasm32"))] - { - let path = { - let cat = self.db.read_catalog(); - let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family); - let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family)); - entry.map(|c| c.path.clone())? - }; - if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES { - return None; - } - let data = Arc::new(std::fs::read(&path).ok()?); - let found = enumerate_faces(&data); - let pick = found - .iter() - .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style)) - .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))? - .clone(); - let (index, fam, st, coords) = pick; - let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?; - Some((face.kind(), face.added())) - } - #[cfg(target_arch = "wasm32")] - None - } - - /// The family and style of the face whose PostScript name is `name`, ignoring case (the - /// document's, loaded, or installed on the system, not loading it). SVGs exported from - /// Illustrator name fonts this way (`font-family="'MyriadPro-Regular'"`). - pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { - let named = |ps: Option<&str>| ps.is_some_and(|ps| ps.eq_ignore_ascii_case(name)); - if let Some(f) = self.own().iter().chain(self.db.read_faces().iter()).find(|f| named(f.postscript_name())) { - return Some((f.family.clone(), f.style.clone())); - } - #[cfg(not(target_arch = "wasm32"))] - if let Some(c) = self.db.read_catalog().iter().find(|c| named(c.postscript.as_deref())) { - return Some((c.family.clone(), c.style.clone())); - } - None - } - - /// Is `family` available (the document's, loaded, or installed on the system), as named or - /// under an alternate name (see [`ScopedFonts::face`])? - pub fn has_family(&self, family: &str) -> bool { - self.canonical_family(family).is_some() || alternate_names(family).any(|f| self.canonical_family(f).is_some()) - } - - /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. - pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { - let face = self.face(family, base_style(style)); - let Some(f) = face.skrifa() else { return vec![] }; - f.axes() - .iter() - .map(|a| { - let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string(); - let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone()); - (tag, name, a.min_value(), a.default_value(), a.max_value()) - }) - .collect() - } - - /// Glyph outline in font units, y-down (flipped), cached per (face, glyph); see - /// [`FontDb::outline`]. - pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { - self.db.outline(face, gid) - } - - /// See [`FontDb::missing_box`]. - pub fn missing_box(&self, face: &FontFace) -> Option { - self.db.missing_box(face) - } - - /// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use. - fn instance(&self, family: &str, style: &str) -> Option> { - let open = style.find('{')?; - let base = self.find(family, style[..open].trim())?; - if base.skrifa().is_none_or(|f| f.axes().is_empty()) { - return Some(base); - } - let key = (base.id, style.to_string()); - if let Some(f) = self.db.instances.read().unwrap_or_else(PoisonError::into_inner).get(&key) { - return Some(f.clone()); - } - let mut coords = base.coords.clone(); - for kv in style[open + 1..].trim_end_matches('}').split(',') { - let (k, v) = kv.split_once(':')?; - let k = k.trim().as_bytes(); - let v: f32 = v.trim().parse().ok()?; - if k.len() != 4 { - return None; - } - let tag = [k[0], k[1], k[2], k[3]]; - match coords.iter_mut().find(|(t, _)| *t == tag) { - Some(c) => c.1 = v, - None => coords.push((tag, v)), - } - } - let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?; - Some(self.db.instances.write().unwrap_or_else(PoisonError::into_inner).entry(key).or_insert_with(|| Arc::new(f)).clone()) - } - - fn find(&self, family: &str, style: &str) -> Option> { - if style.contains('{') { - return self.instance(family, style); - } - let faces = self.db.read_faces(); - // The document's fonts first: they stand in for other fonts of the same name. - let named = |f: &&Arc| f.family.eq_ignore_ascii_case(family); - let own: Vec<&Arc> = self.own().iter().filter(named).collect(); - let mut cands: Vec<&Arc> = if own.is_empty() { faces.iter().filter(named).collect() } else { own }; - // No face of that typographic family: the family may be a WWS name, whose faces carry - // their WWS style names. - let by_wws = cands.is_empty(); - if by_wws { - let aliased = |f: &&Arc| has_wws_family(f, family); - let own: Vec<&Arc> = self.own().iter().filter(aliased).collect(); - cands = if own.is_empty() { faces.iter().filter(aliased).collect() } else { own }; - } - if cands.is_empty() { - return None; - } - let style_of = |f: &FontFace| -> String { norm(f.wws.as_ref().filter(|_| by_wws).map_or(&f.style, |w| &w.1)) }; - let ns = norm(style); - // A face's own style name first: another face's legacy subfamily ("Black" faces named - // "Regular" in their RIBBI family) or localized name must not shadow it. - if let Some(f) = cands - .iter() - .find(|f| style_of(f) == ns) - .or_else(|| cands.iter().find(|f| f.coords.is_empty() && f.style_aliases.iter().any(|alias| norm(alias) == ns))) - { - return Some((*f).clone()); - } - let (tw, ti) = (style_weight(style), style_italic(style)); - cands - .iter() - .min_by(|a, b| { - let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; - let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; - sa.total_cmp(&sb) - }) - .map(|f| (*f).clone()) - } - - /// 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`]), 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).or_else(|| script_chain(c)) - && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) - { - return Some(f); - } - if let Some(f) = self.loaded_fallback(c, exclude) { - return Some(f); - } - #[cfg(not(target_arch = "wasm32"))] - if self.db.system_fallback(c) { - return self.loaded_fallback(c, exclude); - } - None - } - - /// The face of `family` (the document's first, regular first) that covers `c`, loading the - /// installed family the first time a chain asks for it (native, while the system fallback is - /// on). - fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option> { - #[cfg(not(target_arch = "wasm32"))] - if !self.db.is_loaded(family) { - // Held while loading, so a parallel lookup waits for the font instead of passing it by. - let mut sys = self.db.sys.lock().unwrap_or_else(PoisonError::into_inner); - if sys.enabled && sys.chain_tried.insert(family) { - self.db.load_cataloged(family); - } - } - let faces = self.db.read_faces(); - let mut order: Vec<&Arc> = - self.own().iter().chain(faces.iter()).filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect(); - order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); - order.into_iter().find(|f| f.covers(c)).cloned() - } - - fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { - let faces = self.db.read_faces(); - let mut order: Vec<&Arc> = faces.iter().chain(self.own().iter()).filter(|f| f.id != exclude).collect(); - order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); - order.into_iter().find(|f| f.covers(c)).cloned() - } -} - +use fallback::SysFallback; +pub use names::{base_style, same_style}; +use outline::FlipPen; +pub use scoped::ScopedFonts; +#[cfg(all(not(target_arch = "wasm32"), test))] +use system::file_face_names; +#[cfg(all(test, not(any(target_os = "macos", windows))))] +use system::unix_font_dirs; #[cfg(not(target_arch = "wasm32"))] -fn too_large() -> String { - format!("larger than {} MB", MAX_DOCUMENT_FONT_BYTES >> 20) -} - -#[cfg(not(target_arch = "wasm32"))] -fn over_total() -> String { - format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) -} - -/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when -/// it has neither letter. -fn has_serifs(face: &FontFace) -> Option { - let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?; - let path = font_outline(face, gid); - let bbox = kurbo::Shape::bounding_box(&path); - // An empty or degenerate outline (NaN included) says nothing. - if bbox.height().is_nan() || bbox.height() <= 1.0 { - return None; - } - let mut edges = Vec::new(); - let mut last = None; - let mut start = None; - kurbo::flatten(&path, 1.0, |el| match el { - kurbo::PathEl::MoveTo(p) => { - last = Some(p); - start = Some(p); - } - kurbo::PathEl::LineTo(p) => { - if let Some(a) = last { - edges.push((a, p)); - } - last = Some(p); - } - kurbo::PathEl::ClosePath => { - if let (Some(a), Some(b)) = (last, start) { - edges.push((a, b)); - } - last = start; - } - _ => {} - }); - // The outline is y-down: the foot is near bbox.y1 (the baseline). - let width_at = |y: f64| { - let xs: Vec = edges - .iter() - .filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y)) - .map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)) - .collect(); - let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); - (hi > lo).then_some(hi - lo) - }; - let foot = width_at(bbox.y1 - bbox.height() * 0.02)?; - let stem = width_at(bbox.y0 + bbox.height() * 0.5)?; - Some(foot > stem * 1.6) -} - -/// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped). -fn font_outline(face: &FontFace, gid: u32) -> BezPath { - let mut pen = FlipPen(BezPath::new()); - if let Some(f) = face.skrifa() - && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) - { - let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); - } - pen.0 -} - -struct FlipPen(BezPath); +use system::{CatalogEntry, FileStamp}; +pub use system::{ReloadedFonts, system_font_dirs}; impl OutlinePen for FlipPen { fn move_to(&mut self, x: f32, y: f32) { @@ -2195,120 +169,3 @@ impl OutlinePen for FlipPen { #[cfg(test)] mod tests; - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod native_gui_document_fonts_tests { - use super::*; - - /// The production font scope contains the canonical bundled and Japanese faces in their existing order, including bundled Hebrew/Devanagari faces, without system or injected fonts. - #[test] - fn native_gui_canonical_document_faces_load_without_system_or_added_fonts() { - if crate::CRAFT_FONTS.is_empty() { - assert!(std::env::var_os("CRAFT_FONTS_REQUIRED").is_none(), "required canonical fonts were not embedded"); - return; // Optional-input builds retain their supported Latin-only database. - } - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - let scope = db.scoped(0); - let japanese = crate::japanese_document_fonts(); - let previous: Vec<_> = BUNDLED - .iter() - .copied() - .chain(japanese.iter().map(|f| f.bytes)) - .flat_map(enumerate_faces) - .map(|(_, family, style, _)| (family, style)) - .collect(); - let loaded: Vec<_> = db.read_faces().iter().map(|f| (f.family.clone(), f.style.clone())).collect(); - assert!(loaded.len() >= previous.len(), "existing bundled/Japanese faces disappeared"); - assert_eq!(&loaded[..previous.len()], previous.as_slice(), "preserve all Latin and Japanese face ordering"); - for family in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { - let face = db.face(family, "Regular"); - assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled)); - } - assert!(!japanese.is_empty(), "the required central manifest includes the existing Japanese faces"); - assert!(japanese[0].family.contains("Mincho")); - assert_eq!(crate::japanese_ui_fonts(false)[0].family, "BIZ UDPGothic"); - assert_eq!(crate::japanese_ui_fonts(true)[0].style, "Bold"); - let latin = db.face(crate::DEFAULT_FAMILY, "Regular"); - for language in [Some("ja"), None] { - assert!(db.fallback_for('語', latin.id(), language).unwrap().family.contains("Mincho"), "Japanese fallback changed: {language:?}"); - } - let mut admitted = Vec::new(); - for (family, text) in [("Noto Sans Hebrew", "שלום עולם"), ("Noto Sans Devanagari", "नमस्ते दुनिया")] { - let embedded: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.family == family && f.style == "Regular").collect(); - assert_eq!(embedded.len(), 1, "exactly one canonical {family} Regular input"); - assert!(scope.has_family(family), "production database did not register {family}"); - assert!(scope.has_style(family, "Regular"), "{family} Regular"); - assert!(!scope.has_style(family, "Unavailable Native GUI Style")); - assert!(db.families().iter().any(|f| f == family), "family catalog omits {family}"); - assert!(scope.family_infos().iter().any(|f| f.family == family), "menu catalog omits {family}"); - let face = scope.face(family, "Regular"); - assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled)); - assert_eq!(face.data(), embedded[0].bytes, "neither OS discovery nor an injected face may satisfy this contract"); - for c in text.chars().filter(|c| !c.is_whitespace()) { - assert_ne!(face.glyph_for(c), 0, "{family} lacks U+{:04X} ({c})", u32::from(c)); - let fallback = db.fallback_for(c, latin.id(), None).unwrap(); - assert_eq!((fallback.family.as_str(), fallback.style.as_str(), &fallback.source), (family, "Regular", &FontSource::Bundled)); - assert_eq!(fallback.data(), embedded[0].bytes, "document fallback must use the canonical {family} face for {c}"); - assert_ne!(fallback.glyph_for(c), 0); - } - let replacement = scope.face(family, "Unavailable Native GUI Style"); - assert_eq!((replacement.family.as_str(), replacement.style.as_str()), (family, "Regular"), "nearest-style policy stays truthful"); - admitted.push((family.to_string(), "Regular".to_string())); - } - assert_eq!(&loaded[previous.len()..], admitted.as_slice(), "only the two deliberate Regular tuples follow the existing document faces"); - assert!(!scope.has_family("Unavailable Native GUI Family")); - assert_eq!(scope.face("Unavailable Native GUI Family", "Regular").family, FALLBACK_FAMILY); - } -} - -#[cfg(test)] -mod tests_heights { - use super::*; - use crate::testing::{font_mapping, with_table_u16}; - - fn face_of(bytes: Vec) -> FontFace { - make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap() - } - - /// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared). - fn undeclared(font: Vec) -> Vec { - with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap() - } - - /// The top of Source Sans 3's glyph for `c` from its bounding box (font units). - fn bbox_top(c: char) -> f64 { - let f = skrifa::FontRef::new(BUNDLED[0]).unwrap(); - f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64 - } - - /// A font that doesn't declare its cap height and x-height gets them measured from its - /// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that - /// declares them keeps them. - #[test] - fn undeclared_heights_are_measured_from_outlines() { - let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')]; - for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] { - let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); - let (cap, x) = (bbox_top(cap), bbox_top(x)); - assert!(cap > x && x > 0.0); - assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height); - assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height); - } - let declared = face_of(font_mapping("Heights", &glyphs).unwrap()); - assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values"); - // Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height. - let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap()); - assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height)); - } - - /// Without any of the letters, or with only empty outlines, the heights fall back to a share - /// of the ascent. - #[test] - fn heights_without_measurable_letters_fall_back_to_the_ascent() { - for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] { - let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); - assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}"); - } - } -} diff --git a/crates/fonts/src/fontdb/db.rs b/crates/fonts/src/fontdb/db.rs new file mode 100644 index 000000000..30b2b4b28 --- /dev/null +++ b/crates/fonts/src/fontdb/db.rs @@ -0,0 +1,244 @@ +use super::face::{BUNDLED, FontBytes, FontFace, FontSource, enumerate_faces, make_face}; +#[cfg(not(target_arch = "wasm32"))] +use super::fallback::SysFallback; +use super::names::has_face; +#[cfg(not(target_arch = "wasm32"))] +use super::names::has_wws_family; +use super::outline::font_outline; +use super::scoped::ScopedFonts; +#[cfg(not(target_arch = "wasm32"))] +use super::system::CatalogEntry; +#[cfg(not(target_os = "macos"))] +use super::system::platform_font_files; +use super::system::system_font_dirs; +use super::*; + +pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1); + +/// Scopes are never reused: a closed document's scope finds no fonts ever after. +#[cfg(not(target_arch = "wasm32"))] +pub(super) static NEXT_SCOPE: AtomicU32 = AtomicU32::new(1); + +const OUTLINE_CACHE_MAX: usize = 50_000; + +impl FontDb { + /// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first), + /// followed by Noto Sans Hebrew Regular and Noto Sans Devanagari Regular for document text. + /// The optional craft-fonts faces are absent without `CRAFT_FONTS_DIR`; system font scans + /// read `font_dirs`. Japanese fallback and UI ordering remain independent of these additions. + pub fn with_font_dirs(font_dirs: Vec) -> Self { + let mut faces = Vec::new(); + let craft = crate::japanese_document_fonts() + .into_iter() + .chain( + ["Noto Sans Hebrew", "Noto Sans Devanagari"] + .into_iter() + .filter_map(|family| crate::CRAFT_FONTS.iter().find(|f| f.family == family && f.style == "Regular")), + ) + .map(|f| f.bytes); + for data in BUNDLED.iter().copied().chain(craft) { + for (i, family, style, coords) in enumerate_faces(data) { + if let Some(f) = make_face(FontBytes::Static(data), FontSource::Bundled, i, family, style, coords) { + faces.push(Arc::new(f)); + } + } + } + Self { + faces: RwLock::new(faces), + scopes: RwLock::new(HashMap::new()), + #[cfg(not(target_arch = "wasm32"))] + document_files: Mutex::new(HashMap::new()), + instances: RwLock::new(HashMap::new()), + outlines: Mutex::new(HashMap::new()), + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock::new(Vec::new()), + #[cfg(not(target_arch = "wasm32"))] + installed_files: Mutex::new(HashMap::new()), + font_dirs, + font_files: Vec::new, + #[cfg(not(target_arch = "wasm32"))] + cataloged: std::sync::OnceLock::new(), + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), + #[cfg(all(test, target_os = "linux"))] + fallback_snapshot_hook: Mutex::new(None), + } + } + + /// Enable or disable the lazy system-font fallback for characters the loaded fonts lack + /// (native only; on by default). + pub fn set_system_fallback(&self, on: bool) { + #[cfg(not(target_arch = "wasm32"))] + { + self.sys.lock().unwrap_or_else(PoisonError::into_inner).enabled = on; + } + #[cfg(target_arch = "wasm32")] + let _ = on; + } + + /// Process-wide database: the bundled fonts, then the installed system fonts (the font + /// folders' and the platform's other font files, see [`platform_font_files`]), cataloged the + /// first time a lookup by family name needs them (or ahead of time by + /// [`FontDb::scan_in_background`]). + pub fn global() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(|| FontDb { font_files: platform_font_files, ..FontDb::with_font_dirs(system_font_dirs()) }) + } + + pub(super) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { + self.faces.read().unwrap_or_else(PoisonError::into_inner) + } + + /// The system font catalog, scanned first if it hasn't been yet. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec> { + self.ensure_catalog(); + self.catalog.read().unwrap_or_else(PoisonError::into_inner) + } + + /// What the document with font scope `scope` sees: its own fonts ahead of the shared ones. + /// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts. + pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> { + let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(PoisonError::into_inner).get(&scope).cloned() }; + ScopedFonts { db: self, own } + } + + /// The shared fonts' family names (see [`ScopedFonts::families`]). + pub fn families(&self) -> Vec { + self.scoped(0).families() + } + + /// The shared fonts' styles of `family` (see [`ScopedFonts::styles`]). + pub fn styles(&self, family: &str) -> Vec { + self.scoped(0).styles(family) + } + + /// The shared fonts' weight and italic of a style of `family` (see [`ScopedFonts::traits_of`]). + pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { + self.scoped(0).traits_of(family, style) + } + + /// Style linking among the shared fonts (see [`ScopedFonts::linked_style`]). + pub fn linked_style(&self, family: &str, current: &str, bold: Option, italic: Option) -> Option { + self.scoped(0).linked_style(family, current, bold, italic) + } + + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or + /// every face was already present). + pub fn add_font(&self, bytes: Vec) -> usize { + self.add_faces(bytes, FontSource::Memory) + } + + /// Add the faces of a font file from `source` to the shared fonts, skipping the family and + /// style already loaded. + pub(super) fn add_faces(&self, bytes: Vec, source: FontSource) -> usize { + let faces = self.new_faces(bytes, source); + self.insert_faces(faces) + } + + /// The faces of a font file from `source` whose family and style aren't loaded yet. + pub(crate) fn new_faces(&self, bytes: Vec, source: FontSource) -> Vec { + let data = Arc::new(bytes); + enumerate_faces(&data) + .into_iter() + .filter(|(_, family, style, _)| !has_face(&self.read_faces(), family, style)) + .filter_map(|(i, family, style, coords)| make_face(FontBytes::Owned(data.clone()), source.clone(), i, family, style, coords)) + .collect() + } + + /// Add `faces` to the shared fonts all at once, so a lookup sees all of them or none (not a + /// family's bold without its regular), skipping a family and style another thread has added + /// meanwhile. Returns the number added. + pub(super) fn insert_faces(&self, new: Vec) -> usize { + let mut faces = self.faces.write().unwrap_or_else(PoisonError::into_inner); + let mut added = 0; + for f in new { + if !has_face(&faces, &f.family, &f.style) { + faces.push(Arc::new(f)); + added += 1; + } + } + added + } + + /// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their + /// files stay read, for the document opening again. + pub fn close_scope(&self, scope: u32) { + self.scopes.write().unwrap_or_else(PoisonError::into_inner).remove(&scope); + } + + /// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]). + pub fn face(&self, family: &str, style: &str) -> Arc { + self.scoped(0).face(family, style) + } + + /// The shared face with PostScript name `name` (see [`ScopedFonts::postscript_face`]). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + self.scoped(0).postscript_face(name) + } + + /// Is `family` among the shared fonts (loaded, or installed on the system)? + pub fn has_family(&self, family: &str) -> bool { + self.scoped(0).has_family(family) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn is_loaded(&self, family: &str) -> bool { + self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family) || has_wws_family(f, family)) + } + + /// A shared variable font's axes (see [`ScopedFonts::axes`]). + pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { + self.scoped(0).axes(family, style) + } + + /// A shared face other than `exclude` that covers `c` (see [`ScopedFonts::fallback_for`]). + pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { + self.scoped(0).fallback_for(c, exclude, language) + } + + /// The box drawn for `face`'s missing glyph (.notdef) when the font's own has no outline, in + /// font units, y-down: a hollow rectangle across the glyph's advance, cap height tall. `None` + /// when the font draws its own. + pub fn missing_box(&self, face: &FontFace) -> Option { + if !font_outline(face, 0).elements().is_empty() { + return None; + } + let adv = Some(face.advance(0)).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(face.upem * 0.5); + let top = Some(face.cap_height).filter(|h| h.is_finite() && *h > 0.0).unwrap_or(face.upem * 0.7); + let outer = kurbo::Rect::new(adv * 0.1, -top, adv * 0.9, 0.0); + let t = (face.upem * 0.05).min(outer.width() / 4.0).min(top / 4.0); + let inner = outer.inset(-t); + let mut p = BezPath::new(); + // The inner rectangle runs the other way round, so filling leaves it empty. + p.move_to((outer.x0, outer.y0)); + p.line_to((outer.x1, outer.y0)); + p.line_to((outer.x1, outer.y1)); + p.line_to((outer.x0, outer.y1)); + p.close_path(); + p.move_to((inner.x0, inner.y0)); + p.line_to((inner.x0, inner.y1)); + p.line_to((inner.x1, inner.y1)); + p.line_to((inner.x1, inner.y0)); + p.close_path(); + Some(p) + } + + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). A `.notdef` + /// without an outline is drawn as [`FontDb::missing_box`], so a missing character never + /// disappears. + pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { + let key = (face.id, gid); + if let Some(p) = self.outlines.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { + return p.clone(); + } + let drawn = font_outline(face, gid); + let p = Arc::new(if gid == 0 && drawn.elements().is_empty() { self.missing_box(face).unwrap_or(drawn) } else { drawn }); + let mut cache = self.outlines.lock().unwrap_or_else(PoisonError::into_inner); + if cache.len() >= OUTLINE_CACHE_MAX { + cache.clear(); + } + cache.insert(key, p.clone()); + p + } +} diff --git a/crates/fonts/src/fontdb/face.rs b/crates/fonts/src/fontdb/face.rs new file mode 100644 index 000000000..2e662b16f --- /dev/null +++ b/crates/fonts/src/fontdb/face.rs @@ -0,0 +1,530 @@ +use super::db::NEXT_ID; +use super::names::{aliases, face_names, name, norm, style_italic, style_weight, wws_names}; +use super::outline::has_serifs; +use super::*; + +/// The family used when a requested family is unknown (the UI sans). +pub const FALLBACK_FAMILY: &str = "Source Sans 3"; + +/// The bundled fonts (OFL), as font file bytes. Japanese fonts are not bundled: they come from +/// the optional craft-fonts build input ([`crate::japanese_document_fonts`]). +pub fn bundled() -> &'static [&'static [u8]] { + BUNDLED +} + +pub(super) static BUNDLED: &[&[u8]] = &[ + include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"), + include_bytes!("../../../../assets/fonts/SourceSans3-Semibold.ttf"), + include_bytes!("../../../../assets/fonts/SourceSans3-Bold.ttf"), + include_bytes!("../../../../assets/fonts/SourceSans3-It.ttf"), + include_bytes!("../../../../assets/fonts/SourceSerif4-Regular.ttf"), + include_bytes!("../../../../assets/fonts/SourceSerif4-It.ttf"), + include_bytes!("../../../../assets/fonts/SourceSerif4-Semibold.ttf"), + include_bytes!("../../../../assets/fonts/SourceSerif4-Bold.ttf"), + include_bytes!("../../../../assets/fonts/SourceSerif4-BoldIt.ttf"), + include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"), + include_bytes!("../../../../assets/fonts/Inter-Medium.ttf"), + include_bytes!("../../../../assets/fonts/Inter-SemiBold.ttf"), + include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"), +]; + +#[derive(Clone)] +pub(super) enum FontBytes { + Static(&'static [u8]), + Owned(Arc>), +} + +/// Where a face's font came from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FontSource { + /// Compiled into DesignCraft. + Bundled, + /// Font data handed to [`FontDb::add_font`], with no file behind it. + Memory, + /// An installed font file (the system's or the user's font folders, or one the platform + /// lists outside them). + Installed(std::path::PathBuf), + /// A font file in a document's `Document Fonts` folder ([`FontDb::load_document_fonts`]). + Document(std::path::PathBuf), +} + +/// The folder beside a document whose fonts the document brings with it (as File › Package +/// writes it). +pub const DOCUMENT_FONTS_FOLDER: &str = "Document Fonts"; + +/// Caps on what a `Document Fonts` folder (untrusted files) can make [`FontDb::load_document_fonts`] +/// read: font files read, bytes per file and bytes in all. +pub const MAX_DOCUMENT_FONT_FILES: usize = 200; + +pub const MAX_DOCUMENT_FONT_BYTES: u64 = 256 << 20; + +pub const MAX_DOCUMENT_FONTS_TOTAL: u64 = 512 << 20; + +/// What [`FontDb::load_document_fonts`] did: the scope the document looks its fonts up in (0 when +/// the folder gave none), the faces in it, and a note for each file skipped. +#[derive(Debug, Default)] +pub struct DocumentFonts { + pub scope: u32, + pub faces: usize, + pub skipped: Vec, +} + +/// One loaded font face. +pub struct FontFace { + pub(super) id: u32, + /// Typographic family name (e.g. "Source Sans 3"). + pub family: String, + /// Typographic style name (e.g. "Semibold", "Italic"). + pub style: String, + /// The WWS family and style names (name IDs 21 and 22) when they differ from the typographic + /// ones: InDesign names an optical-size face by them ("Kepler Std Display" / "Light"). + pub wws: Option<(String, String)>, + /// usWeightClass-style weight (400 = regular). + pub weight: f32, + pub italic: bool, + /// Where the font came from (a file to copy when packaging, a document's own font). + pub source: FontSource, + pub(super) bytes: FontBytes, + pub(super) index: u32, + pub upem: f64, + /// Ascender in font units (positive = up). + pub ascent: f64, + /// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the + /// Ascent first baseline offset. + pub typo_ascent: f64, + /// Descender in font units (positive = down). + pub descent: f64, + /// Cap height and x height in font units (without plausible OS/2 values, the cap height is the + /// top of the H, and either is estimated from the ascent): the Cap Height and x Height first + /// baseline offsets. + pub cap_height: f64, + pub x_height: f64, + pub shaper: harfrust::ShaperData, + /// Variable fonts: the named instance's axis settings (user units; empty = default instance), + /// the normalised location and the shaper's view of it. + pub coords: Vec<([u8; 4], f32)>, + location: Location, + pub instance: Option, + /// Basic Multilingual Plane coverage bitset, built on first use. + bmp: std::sync::OnceLock>, + /// The ideographic em box, read on first use. + em: std::sync::OnceLock<((f64, f64), bool)>, + /// The ideographic character face box, read on first use. + icf: std::sync::OnceLock<(f64, f64)>, + /// The font menu group and native family name, read on first use. + group: std::sync::OnceLock<(FontGroup, Option)>, + pub(super) family_aliases: Vec, + pub(super) style_aliases: Vec, + /// Format, class and PANOSE numbers (the font menus), read on first use. + kind: std::sync::OnceLock, + /// The PostScript name, read on first use. + postscript: std::sync::OnceLock>, +} + +/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of +/// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made +/// parallel composition scale negatively). +#[derive(Clone, Copy)] +pub struct FaceRef(pub(crate) &'static FontFace); + +impl FontFace { + pub fn data(&self) -> &[u8] { + match &self.bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + } + } + + pub fn skrifa(&self) -> Option> { + skrifa::FontRef::from_index(self.data(), self.index).ok() + } + + /// The face's format, class and PANOSE numbers, as the font menus show and filter them. + pub fn kind(&self) -> crate::kind::FaceKind { + *self.kind.get_or_init(|| { + let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default(); + // Nothing in the tables or the name: look at the letters. + if kind.class == crate::kind::FontClass::Other + && let Some(serifs) = has_serifs(self) + { + kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif }; + } + kind + }) + } + + pub fn hb(&self) -> Option> { + harfrust::FontRef::from_index(self.data(), self.index).ok() + } + + /// Where in the design space glyphs, metrics and outlines come from. + pub fn location(&self) -> LocationRef<'_> { + (&self.location).into() + } + + /// Is this a named instance of a variable font? + pub fn is_variable(&self) -> bool { + !self.coords.is_empty() + } + + /// Face index within the font file (collections); 0 for plain fonts. + pub fn index(&self) -> u32 { + self.index + } + + /// Unique id of this face within the process. + pub fn id(&self) -> u32 { + self.id + } + + /// System placeholder fonts draw generic boxes rather than the requested character. + pub fn is_placeholder(&self) -> bool { + matches!(norm(self.family.trim_start_matches('.')).as_str(), "lastresort") + } + + /// Does the face map `c` to a real glyph? + pub fn covers(&self, c: char) -> bool { + if self.is_placeholder() { + return false; + } + let cp = c as u32; + if cp < 0x1_0000 { + let bits = self.bmp.get_or_init(|| { + let mut b = vec![0u64; 1024].into_boxed_slice(); + if let Some(f) = self.skrifa() { + for (cp, gid) in f.charmap().mappings() { + if cp < 0x1_0000 && gid.to_u32() != 0 { + b[(cp / 64) as usize] |= 1 << (cp % 64); + } + } + } + b + }); + return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0; + } + self.skrifa().is_some_and(|f| f.charmap().map(c).is_some_and(|gid| gid.to_u32() != 0)) + } + + /// Units per em. + pub fn units_per_em(&self) -> f64 { + self.upem + } + + /// (ascent, descent) in font units, both positive. + pub fn vertical_metrics(&self) -> (f64, f64) { + (self.ascent, self.descent) + } + + /// Every mapped character and its glyph id, sorted by code point (the Glyphs panel). + pub fn chars(&self) -> Vec<(char, u32)> { + let Some(f) = self.skrifa() else { return vec![] }; + let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect(); + v.sort_unstable_by_key(|x| x.0); + v.dedup_by_key(|x| x.0); + v + } + + /// Advance width of glyph `gid` in font units. + pub fn advance(&self, gid: u32) -> f64 { + self.skrifa() + .and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid))) + .map(|a| a as f64) + .unwrap_or(self.upem * 0.5) + } + + /// Vertical advance (down the line) of glyph `gid` in font units: `vmtx`, else one em. + pub fn v_advance(&self, gid: u32) -> f64 { + crate::vertical::VMetrics::new(self).advance(GlyphId::new(gid)) + } + + /// Height of glyph `gid`'s vertical origin above its baseline in font units: `VORG`, else its + /// top plus its `vmtx` top side bearing, else the em box top. Across, the origin is at half the + /// horizontal advance. Upright glyphs in vertical text hang from it on the line's centre. + pub fn v_origin(&self, gid: u32) -> f64 { + crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) + } + + /// The ideographic em box (top, bottom) in font units, y up, one em tall (Japanese fonts: + /// typically 880, -120): `BASE` `idtp`/`ideo`; else, for a CJK font, from the OS/2 typo + /// ascender when the typo ascender and descender span one em; else centred on half the cap + /// height (OS/2, else the `H` glyph's top); else 0.88 em above the baseline. + pub fn em_box(&self) -> (f64, f64) { + self.em.get_or_init(|| crate::vertical::em_box(self)).0 + } + + /// [`Self::em_box`] when the font's tables give it, `None` for the 0.88 em fallback. + pub fn declared_em_box(&self) -> Option<(f64, f64)> { + let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self)); + declared.then_some(b) + } + + /// The ideographic character face (ICF) box (top, bottom) in font units, y up: `BASE` + /// `icft`/`icfb`, else the em box inset by 5% of the em at the top and bottom. + pub fn icf_box(&self) -> (f64, f64) { + *self.icf.get_or_init(|| crate::vertical::icf_box(self)) + } + + /// The font menu group the font is for and, for a CJK group, its family name in that language + /// (see [`crate::FamilyInfo`]); read from the font once. + pub fn menu_group(&self) -> (FontGroup, Option<&str>) { + let (g, n) = self.group.get_or_init(|| self.skrifa().map(|f| crate::group::classify(&f, &self.family)).unwrap_or_default()); + (*g, n.as_deref()) + } + + /// The PostScript name (`name` ID 6), read from the font once. None for a variable font's + /// named instance: the file's name belongs to its default instance. + pub fn postscript_name(&self) -> Option<&str> { + self.postscript + .get_or_init(|| if self.is_variable() { None } else { self.skrifa().and_then(|f| name(&f, &[StringId::POSTSCRIPT_NAME])) }) + .as_deref() + } + + /// Glyph id for `c` (0 = .notdef). + pub fn glyph_for(&self, c: char) -> u32 { + self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) + } + + /// GDEF identifies marks even when shaping merges their source cluster with a base. + pub fn glyph_is_mark(&self, gid: u32) -> bool { + use skrifa::raw::TableProvider; + self.skrifa() + .and_then(|f| f.gdef().ok()) + .and_then(|gdef| gdef.glyph_class_def()?.ok()) + .is_some_and(|classes| classes.get(GlyphId::new(gid)) == 3) + } + + /// Does the font's licence forbid passing it on? OS/2 `fsType` Restricted License embedding + /// (bit 1) without a less restrictive bit (Preview & Print, Editable). Fonts without an OS/2 + /// table aren't restricted. + pub fn restricted_licence(&self) -> bool { + use skrifa::raw::TableProvider; + let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()); + fs_type & 0x0002 != 0 && fs_type & 0x000C == 0 + } + + /// Does the font's licence allow embedding bitmaps only? OS/2 `fsType` bit 9: the outlines + /// may not be passed on, so the font file can't be packaged whole. + pub fn bitmap_only(&self) -> bool { + use skrifa::raw::TableProvider; + self.skrifa().and_then(|f| f.os2().ok()).is_some_and(|t| t.fs_type() & 0x0200 != 0) + } +} + +/// One face found in a font file: index, family, style and (variable fonts) the named instance's +/// axis settings. +pub(super) type Found = (u32, String, String, Vec<([u8; 4], f32)>); + +/// Parse every face in `data` (a font file or collection); a variable font yields one face per +/// named instance (InDesign lists them as styles). +pub(super) fn enumerate_faces(data: &[u8]) -> Vec { + /// A collection's count is input: what a damaged one can make the loop try. + const MAX_FACES: u32 = 1024; + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES), + Err(_) => 0, + }; + let mut out = Vec::new(); + for i in 0..count { + let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; + let Some((family, style)) = face_names(&f) else { continue }; + let axes = f.axes(); + let mut named = Vec::new(); + for ni in f.named_instances().iter() { + let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; + if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { + continue; + } + let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); + named.push((i, style, coords)); + } + if named.is_empty() { + out.push((i, family, style, Vec::new())); + } else { + out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); + } + } + out +} + +/// The compiled-in Source Sans 3 Regular, for a database that somehow has no faces at all. +pub(crate) fn last_resort_face() -> Arc { + static FACE: std::sync::OnceLock> = std::sync::OnceLock::new(); + FACE.get_or_init(|| { + // The font is compiled in (`include_bytes!`), so parsing it can't depend on input; the + // `last_resort_face_parses` test proves it on every run. + #[allow(clippy::expect_used)] + let face = make_face(FontBytes::Static(BUNDLED[0]), FontSource::Bundled, 0, FALLBACK_FAMILY.into(), "Regular".into(), Vec::new()) + .expect("the compiled-in Source Sans 3 Regular parses"); + Arc::new(face) + }) + .clone() +} + +pub(super) fn make_face( + bytes: FontBytes, + source: FontSource, + index: u32, + family: String, + style: String, + coords: Vec<([u8; 4], f32)>, +) -> Option { + let data: &[u8] = match &bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + }; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect(); + let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) }; + let m = f.metrics(Size::unscaled(), &location); + let upem = m.units_per_em.max(1) as f64; + let a = f.attributes(); + let wws = wws_names(&f, &family, &style); + let hb = harfrust::FontRef::from_index(data, index).ok()?; + let shaper = harfrust::ShaperData::new(&hb); + let instance = (!coords.is_empty()) + .then(|| harfrust::ShaperInstance::from_variations(&hb, settings.iter().map(|(t, v)| harfrust::Variation { tag: *t, value: *v }))); + let axis = |tag: &[u8; 4]| coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v); + let weight = axis(b"wght").unwrap_or(if coords.is_empty() { a.weight.value() } else { style_weight(&style) }); + let italic = axis(b"ital") + .map(|v| v >= 0.5) + .or(axis(b"slnt").map(|v| v.abs() > 0.1)) + .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); + let family_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]); + let style_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]); + Some(FontFace { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), + family, + style, + wws, + weight, + italic, + upem, + ascent: m.ascent as f64, + typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64), + descent: -(m.descent as f64), + // Fonts with an OS/2 table older than version 2 don't declare these: measure them from + // flat-topped letters before falling back to a share of the ascent, which can be far off + // (drop caps are sized from the cap height). + cap_height: plausible(m.cap_height.map(f64::from), upem) + .or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem)) + .unwrap_or(m.ascent as f64 * 0.72), + x_height: plausible(m.x_height.map(f64::from), upem) + .or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem)) + .unwrap_or(m.ascent as f64 * 0.5), + shaper, + coords, + location, + instance, + source, + bytes, + index, + bmp: std::sync::OnceLock::new(), + em: std::sync::OnceLock::new(), + family_aliases, + style_aliases, + icf: std::sync::OnceLock::new(), + group: std::sync::OnceLock::new(), + kind: std::sync::OnceLock::new(), + postscript: std::sync::OnceLock::new(), + }) +} + +/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em. +pub(crate) fn plausible(v: Option, upem: f64) -> Option { + v.filter(|v| *v > 0.0 && *v <= 4.0 * upem) +} + +/// The top of the first of `chars` the font has a non-empty outline for, in font units: the +/// highest point of its unhinted outline at `location`, if [`plausible`]. For flat-topped letters +/// that is the cap height (`H`) or x-height (`x`). +pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option { + struct Top(f32); + impl OutlinePen for Top { + fn move_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn line_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn close(&mut self) {} + } + let cmap = f.charmap(); + let glyphs = f.outline_glyphs(); + chars.iter().find_map(|&c| { + let g = glyphs.get(cmap.map(c)?)?; + let mut pen = Top(f32::NEG_INFINITY); + g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?; + plausible(Some(f64::from(pen.0)), upem) + }) +} + +/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and +/// the value is [`plausible`]. +fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option { + use skrifa::raw::TableProvider; + let mut v = f.os2().ok()?.s_typo_ascender() as f64; + if !location.coords().is_empty() + && let Ok(mvar) = f.mvar() + { + v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64()); + } + plausible(Some(v), upem) +} + +#[cfg(test)] +mod tests_heights { + use super::*; + use crate::testing::{font_mapping, with_table_u16}; + + fn face_of(bytes: Vec) -> FontFace { + make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap() + } + + /// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared). + fn undeclared(font: Vec) -> Vec { + with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap() + } + + /// The top of Source Sans 3's glyph for `c` from its bounding box (font units). + fn bbox_top(c: char) -> f64 { + let f = skrifa::FontRef::new(BUNDLED[0]).unwrap(); + f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64 + } + + /// A font that doesn't declare its cap height and x-height gets them measured from its + /// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that + /// declares them keeps them. + #[test] + fn undeclared_heights_are_measured_from_outlines() { + let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')]; + for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + let (cap, x) = (bbox_top(cap), bbox_top(x)); + assert!(cap > x && x > 0.0); + assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height); + assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height); + } + let declared = face_of(font_mapping("Heights", &glyphs).unwrap()); + assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values"); + // Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height. + let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap()); + assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height)); + } + + /// Without any of the letters, or with only empty outlines, the heights fall back to a share + /// of the ascent. + #[test] + fn heights_without_measurable_letters_fall_back_to_the_ascent() { + for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}"); + } + } +} diff --git a/crates/fonts/src/fontdb/fallback.rs b/crates/fonts/src/fontdb/fallback.rs new file mode 100644 index 000000000..7e29fa4b3 --- /dev/null +++ b/crates/fonts/src/fontdb/fallback.rs @@ -0,0 +1,179 @@ +#[cfg(all(test, target_os = "linux"))] +use std::sync::Arc; + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +pub(super) struct SysFallback { + pub(super) enabled: bool, + /// Rescan generation: an older in-flight lookup must not restore a cleared miss. + pub(super) generation: u64, + pub(super) misses: std::collections::HashSet, + /// CJK chain families already looked up in the catalog (loaded, or not installed). + pub(super) chain_tried: std::collections::HashSet<&'static str>, +} + +#[cfg(all(test, target_os = "linux"))] +pub(super) struct FallbackSnapshotHook { + pub(super) reached: std::sync::mpsc::SyncSender<()>, + pub(super) resume: Arc, +} + +/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. +#[cfg(not(target_arch = "wasm32"))] +pub(super) const SYSTEM_FALLBACKS: &[&str] = &[ + "Helvetica Neue", + "Arial", + "Segoe UI", + "Noto Sans", + "DejaVu Sans", + "PingFang SC", + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Apple SD Gothic Neo", + "Heiti SC", + "STHeiti", + "Microsoft YaHei", + "Yu Gothic", + "Malgun Gothic", + "Noto Sans CJK SC", + "Noto Sans CJK JP", + "Arial Unicode MS", + "Apple Symbols", + "Segoe UI Symbol", + "Noto Sans Symbols", + "Noto Sans Symbols2", + "Noto Emoji", + "Segoe UI Emoji", + "Apple Color Emoji", + "Noto Color Emoji", +]; + +/// CJK fallback chains: the families tried first (user-added, else installed) for characters of +/// each language that the primary font lacks. Each lists macOS, then Windows, then Linux / +/// cross-platform families, serif first; a platform only has its own installed. Japanese tries +/// the craft-fonts Japanese faces before these ([`japanese_chain`]). +const JAPANESE_FALLBACKS: &[&str] = &[ + "Hiragino Mincho ProN", + "YuMincho", + "Hiragino Sans", + "Yu Mincho", + "MS Mincho", + "Yu Gothic", + "Noto Serif CJK JP", + "Source Han Serif JP", + "Noto Serif JP", + "Noto Sans CJK JP", + "Source Han Sans JP", +]; + +const SIMPLIFIED_CHINESE_FALLBACKS: &[&str] = &[ + "Songti SC", + "STSong", + "PingFang SC", + "SimSun", + "Microsoft YaHei", + "Noto Serif CJK SC", + "Source Han Serif SC", + "Noto Serif SC", + "Noto Sans CJK SC", + "Source Han Sans SC", +]; + +const TRADITIONAL_CHINESE_FALLBACKS: &[&str] = &[ + "Songti TC", + "PingFang TC", + "PMingLiU", + "MingLiU", + "Microsoft JhengHei", + "Noto Serif CJK TC", + "Source Han Serif TC", + "Noto Serif TC", + "Noto Sans CJK TC", + "Source Han Sans TC", +]; + +const KOREAN_FALLBACKS: &[&str] = &[ + "AppleMyungjo", + "Apple SD Gothic Neo", + "Batang", + "Malgun Gothic", + "Noto Serif CJK KR", + "Source Han Serif K", + "Noto Serif KR", + "Noto Sans CJK KR", + "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. +pub(super) 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] { + static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + CHAIN.get_or_init(|| { + let mut chain: Vec<&'static str> = Vec::new(); + for f in crate::japanese_document_fonts() { + if !chain.contains(&f.family) { + chain.push(f.family); + } + } + chain.extend_from_slice(JAPANESE_FALLBACKS); + chain + }) +} + +/// The CJK fallback chain for `c` in `language` (a BCP 47 tag). The script decides where it is +/// unambiguous (Hangul is Korean, kana Japanese, Bopomofo Traditional Chinese); ideographs and CJK +/// punctuation follow the language, and have no chain without a CJK one. +pub(super) fn cjk_chain(c: char, language: Option<&str>) -> Option<&'static [&'static str]> { + match c as u32 { + 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => return Some(KOREAN_FALLBACKS), + 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => return Some(japanese_chain()), + 0x3100..=0x312F | 0x31A0..=0x31BF => return Some(TRADITIONAL_CHINESE_FALLBACKS), + // Radicals, CJK symbols and punctuation, kanbun, strokes, enclosed and compatibility + // characters, ideographs, compatibility and vertical forms, full-width forms. + 0x2E80..=0x2FDF + | 0x3000..=0x303F + | 0x3190..=0x319F + | 0x31C0..=0x31EF + | 0x3200..=0x33FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xF900..=0xFAFF + | 0xFE10..=0xFE1F + | 0xFE30..=0xFE4F + | 0xFF00..=0xFFEF + | 0x20000..=0x3FFFF => {} + _ => return None, + } + // The language decides, whatever the region (`ja-JP`, `ko-KR`). + let language = language?; + match language.split('-').next()? { + "ja" => Some(japanese_chain()), + "ko" => Some(KOREAN_FALLBACKS), + "zh" if language.starts_with("zh-Hant") => Some(TRADITIONAL_CHINESE_FALLBACKS), + "zh" => Some(SIMPLIFIED_CHINESE_FALLBACKS), + _ => None, + } +} diff --git a/crates/fonts/src/fontdb/names.rs b/crates/fonts/src/fontdb/names.rs new file mode 100644 index 000000000..ce80bcbe7 --- /dev/null +++ b/crates/fonts/src/fontdb/names.rs @@ -0,0 +1,118 @@ +use super::face::FontFace; +#[cfg(not(target_arch = "wasm32"))] +use super::system::CatalogEntry; +use super::*; + +pub(super) fn aliases(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Vec { + let mut names: Vec = + ids.iter().flat_map(|id| font.localized_strings(*id)).take(256).map(|name| name.to_string()).filter(|name| !name.trim().is_empty()).collect(); + names.sort(); + names.dedup(); + names +} + +pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { + ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} + +/// A face's family and style names (the default instance's style for a variable font). +/// Is a face of `family` and `style` among `faces`? +pub(super) fn has_face(faces: &[Arc], family: &str, style: &str) -> bool { + faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style)) +} + +/// A face's WWS family and style names (name IDs 21 and 22), if it has a WWS family and it differs +/// from `family`/`style`. +pub(super) fn wws_names(f: &skrifa::FontRef<'_>, family: &str, style: &str) -> Option<(String, String)> { + let wf = name(f, &[StringId::WWS_FAMILY_NAME])?; + let ws = name(f, &[StringId::WWS_SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + (!wf.eq_ignore_ascii_case(family) || !ws.eq_ignore_ascii_case(style)).then_some((wf, ws)) +} + +/// The family and style a face is known by: its typographic names, or its WWS names (if it has +/// them) when `by_wws`. +pub(super) fn known_as<'a>(family: &'a str, style: &'a str, wws: Option<&'a (String, String)>, by_wws: bool) -> Option<(&'a str, &'a str)> { + if by_wws { wws.map(|w| (w.0.as_str(), w.1.as_str())) } else { Some((family, style)) } +} + +/// Is `family` the WWS family name of `f`? +pub(super) fn has_wws_family(f: &FontFace, family: &str) -> bool { + f.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family)) +} + +/// Is `family` the typographic or the WWS family name of `c`? +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn in_catalog_family(c: &CatalogEntry, family: &str) -> bool { + c.family.eq_ignore_ascii_case(family) || c.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family)) +} + +pub(super) fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { + let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((family, style)) +} + +/// `family` without what layout apps add to a family name, if it has any: InDesign's `$ID/` +/// prefix (`$ID/Arial` → `Arial`) and the font-format suffix appended when a family is installed +/// in several formats (`Minion Pro (OTF)` → `Minion Pro`). +fn plain_family(family: &str) -> Option<&str> { + let unprefixed = family.trim_start().strip_prefix("$ID/").map(str::trim).filter(|f| !f.is_empty()); + without_format_suffix(unprefixed.unwrap_or(family)).or(unprefixed) +} + +/// `family` without the font-format suffix layout apps append when a family is installed in +/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. +fn without_format_suffix(family: &str) -> Option<&str> { + const FORMATS: [&str; 5] = ["OTF", "TT", "TTF", "TTC", "T1"]; + let (base, suffix) = family.trim_end().strip_suffix(')')?.rsplit_once('(')?; + let base = base.trim_end(); + (!base.is_empty() && FORMATS.iter().any(|f| f.eq_ignore_ascii_case(suffix.trim()))).then_some(base) +} + +/// The names `family` is looked up under when no font has it as written: without surrounding +/// spaces, then without an `$ID/` prefix or a format suffix (see [`plain_family`]). +pub(super) fn alternate_names(family: &str) -> impl Iterator { + let trimmed = family.trim(); + (trimmed != family && !trimmed.is_empty()).then_some(trimmed).into_iter().chain(plain_family(trimmed)) +} + +/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). +pub fn base_style(style: &str) -> &str { + style.split('{').next().unwrap_or(style).trim() +} + +/// Do two style names name the same face? Spacing, punctuation and case don't count, as when a face +/// is looked up (InDesign documents can carry `Regular ` with a trailing space). +pub fn same_style(a: &str, b: &str) -> bool { + norm(a) == norm(b) +} + +pub(super) fn norm(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() +} + +/// Weight implied by a style name. +pub(super) fn style_weight(style: &str) -> f32 { + let s = norm(style); + const TABLE: &[(&str, f32)] = &[ + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) +} + +pub(super) fn style_italic(style: &str) -> bool { + let s = norm(style); + s.contains("italic") || s.contains("oblique") || s == "it" +} diff --git a/crates/fonts/src/fontdb/outline.rs b/crates/fonts/src/fontdb/outline.rs new file mode 100644 index 000000000..0f82c4aac --- /dev/null +++ b/crates/fonts/src/fontdb/outline.rs @@ -0,0 +1,62 @@ +use super::face::FontFace; +use super::*; + +/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when +/// it has neither letter. +pub(super) fn has_serifs(face: &FontFace) -> Option { + let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?; + let path = font_outline(face, gid); + let bbox = kurbo::Shape::bounding_box(&path); + // An empty or degenerate outline (NaN included) says nothing. + if bbox.height().is_nan() || bbox.height() <= 1.0 { + return None; + } + let mut edges = Vec::new(); + let mut last = None; + let mut start = None; + kurbo::flatten(&path, 1.0, |el| match el { + kurbo::PathEl::MoveTo(p) => { + last = Some(p); + start = Some(p); + } + kurbo::PathEl::LineTo(p) => { + if let Some(a) = last { + edges.push((a, p)); + } + last = Some(p); + } + kurbo::PathEl::ClosePath => { + if let (Some(a), Some(b)) = (last, start) { + edges.push((a, b)); + } + last = start; + } + _ => {} + }); + // The outline is y-down: the foot is near bbox.y1 (the baseline). + let width_at = |y: f64| { + let xs: Vec = edges + .iter() + .filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y)) + .map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)) + .collect(); + let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + (hi > lo).then_some(hi - lo) + }; + let foot = width_at(bbox.y1 - bbox.height() * 0.02)?; + let stem = width_at(bbox.y0 + bbox.height() * 0.5)?; + Some(foot > stem * 1.6) +} + +/// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped). +pub(super) fn font_outline(face: &FontFace, gid: u32) -> BezPath { + let mut pen = FlipPen(BezPath::new()); + if let Some(f) = face.skrifa() + && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) + { + let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); + } + pen.0 +} + +pub(super) struct FlipPen(pub(super) BezPath); diff --git a/crates/fonts/src/fontdb/scoped.rs b/crates/fonts/src/fontdb/scoped.rs new file mode 100644 index 000000000..8fba9ff6e --- /dev/null +++ b/crates/fonts/src/fontdb/scoped.rs @@ -0,0 +1,499 @@ +#[cfg(test)] +use super::face::{BUNDLED, FontSource, enumerate_faces}; +use super::face::{FALLBACK_FAMILY, FontFace, last_resort_face, make_face}; +use super::fallback::{cjk_chain, script_chain}; +use super::names::{alternate_names, base_style, has_wws_family, known_as, norm, style_italic, style_weight}; +use super::*; + +/// One document's view of the [`FontDb`] ([`FontDb::scoped`]): the fonts of its `Document Fonts` +/// folder, then the shared fonts (bundled, installed, added). Other documents' fonts aren't in it. +#[derive(Clone)] +pub struct ScopedFonts<'a> { + pub(super) db: &'a FontDb, + /// The document's own faces, in the folder's file order (none without a scope). + pub(super) own: Option]>>, +} + +impl ScopedFonts<'_> { + pub(super) fn own(&self) -> &[Arc] { + self.own.as_deref().unwrap_or(&[]) + } + + /// Resolve localized and legacy family names without confusing replacement fonts with + /// installed originals. Document-local names take precedence over shared fonts. + /// + /// In each scope (the document's faces, loaded faces, the catalog) an exact typographic name + /// wins, then a WWS family name (an optical-size face's, see `find`), which names its faces as + /// it is, then the aliases. An optical-size face's legacy family name (name ID 1) is usually + /// its WWS name, so the aliases must not claim it first. + pub fn canonical_family(&self, family: &str) -> Option { + let is = |name: &str| name.eq_ignore_ascii_case(family); + let in_faces = |faces: &[Arc]| -> Option { + if let Some(f) = faces.iter().find(|f| is(&f.family)) { + return Some(f.family.clone()); + } + if let Some(w) = faces.iter().find_map(|f| f.wws.as_ref().filter(|w| is(&w.0))) { + return Some(w.0.clone()); + } + faces.iter().find(|f| f.family_aliases.iter().any(|alias| is(alias))).map(|f| f.family.clone()) + }; + if let Some(name) = in_faces(self.own()).or_else(|| in_faces(&self.db.read_faces())) { + return Some(name); + } + #[cfg(not(target_arch = "wasm32"))] + { + let catalog = self.db.read_catalog(); + if let Some(c) = catalog.iter().find(|c| is(&c.family)) { + return Some(c.family.clone()); + } + if let Some(w) = catalog.iter().find_map(|c| c.wws.as_ref().filter(|w| is(&w.0))) { + return Some(w.0.clone()); + } + if let Some(c) = catalog.iter().find(|c| c.aliases.iter().any(|alias| is(alias))) { + return Some(c.family.clone()); + } + } + None + } + + /// Whether the requested style exists, including localized names and axis instances. + pub fn has_style(&self, family: &str, style: &str) -> bool { + if !self.has_family(family) { + return false; + } + let face = self.face(family, style); + norm(&face.style) == norm(style) + || (face.coords.is_empty() && face.style_aliases.iter().any(|alias| norm(alias) == norm(style))) + // An optical-size face asked for by its WWS names. + || face.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family) && norm(&w.1) == norm(style)) + } + + /// Prefer known metric-compatible substitutes, then an appropriate script family. This + /// never changes `has_family`: a usable substitute is still a missing original font. + pub fn substitute_family(&self, family: &str) -> String { + let lower = family.to_lowercase(); + let candidates: &[&str] = match lower.as_str() { + "calibri" | "calibri light" => &["Carlito", "Liberation Sans"], + "cambria" | "cambria math" => &["Caladea", "Liberation Serif"], + "arial" | "helvetica" => &["Liberation Sans", "Arial", "Helvetica"], + "times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times"], + "courier new" | "courier" => &["Liberation Mono", "Courier New", "Courier"], + _ if lower.contains("arabic") || lower.contains("naskh") => &["Amiri", "Noto Naskh Arabic", "Geeza Pro"], + _ if family.contains('楷') => &["Kaiti SC", "STKaiti", "KaiTi", "Songti SC"], + _ if family.contains("仿宋") => &["STFangsong", "FangSong", "Songti SC"], + _ if family.contains('宋') || family.contains("明朝") => &["Songti SC", "Noto Serif CJK SC", "SimSun"], + _ if family.contains('黑') || family.contains("雅黑") => &["PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei"], + _ if family.chars().any(|c| ('\u{3400}'..='\u{9fff}').contains(&c)) => &["Songti SC", "Noto Serif CJK SC", "PingFang SC"], + _ => &[], + }; + candidates.iter().find_map(|name| self.canonical_family(name)).unwrap_or_else(|| FALLBACK_FAMILY.to_string()) + } + + /// A replacement suggestion must cover the text being replaced, not just its main script. + /// Ties retain the preferred substitute; this does not install or redefine any font. + pub fn replacement_face(&self, family: &str, style: &str, characters: &[char]) -> Arc { + let mut best = self.face(family, style); + let coverage = |face: &FontFace| characters.iter().filter(|&&c| c.is_whitespace() || c.is_control() || face.covers(c)).count(); + let mut score = coverage(&best); + let candidates: &[&str] = if characters.iter().any(|c| ('\u{0600}'..='\u{08ff}').contains(c)) { + &["Amiri", "Noto Naskh Arabic", "Al Bayan", "Damascus", "Geeza Pro", "Arial"] + } else if characters.iter().any(|c| ('\u{3400}'..='\u{9fff}').contains(c)) { + &["Songti SC", "Kaiti SC", "Noto Serif CJK SC", "PingFang SC", "Noto Sans CJK SC"] + } else { + &["Source Serif 4", "Source Sans 3", "Inter"] + }; + for candidate in candidates { + if score == characters.len() { + break; + } + if !self.has_family(candidate) { + continue; + } + let face = self.face(candidate, style); + let next = coverage(&face); + if next > score { + best = face; + score = next; + } + } + best + } + + /// Family names available (the document's, loaded and cataloged system fonts), sorted and + /// deduplicated. + pub fn families(&self) -> Vec { + let mut v: Vec = self.own().iter().chain(self.db.read_faces().iter()).map(|f| f.family.clone()).collect(); + #[cfg(not(target_arch = "wasm32"))] + v.extend(self.db.read_catalog().iter().map(|c| c.family.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + + /// The available families as the font menus list them (see [`ScopedFonts::families`]), each + /// with its group and native name, unsorted ([`crate::sort_for_menu`] orders them). + pub fn family_infos(&self) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for f in self.own().iter().chain(self.db.read_faces().iter()) { + if seen.insert(f.family.to_lowercase()) { + let (group, native) = f.menu_group(); + out.push(FamilyInfo { family: f.family.clone(), group, native: native.map(str::to_string) }); + } + } + #[cfg(not(target_arch = "wasm32"))] + for c in self.db.read_catalog().iter() { + if seen.insert(c.family.to_lowercase()) { + out.push(FamilyInfo { family: c.family.clone(), group: c.group, native: c.native.clone() }); + } + } + out + } + + /// Style names available for `family` (Regular first, then by weight). + pub fn styles(&self, family: &str) -> Vec { + self.style_traits(family).into_iter().map(|t| t.2).collect() + } + + /// Weight and italic of a style of `family`: from the font when it is known, else from the + /// style's name. + pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { + let base = base_style(style); + self.style_traits(family) + .into_iter() + .find(|t| t.2.eq_ignore_ascii_case(base)) + .map_or((style_weight(base), style_italic(base)), |(italic, weight, _)| (weight, italic)) + } + + /// Style linking: the style of `family` that sets bold (`bold`) and italic (`italic`) as asked + /// and keeps the other from `current` (Bold from Italic is Bold Italic). `None` when the + /// family has no such style. + pub fn linked_style(&self, family: &str, current: &str, bold: Option, italic: Option) -> Option { + let (weight, cur_italic) = self.traits_of(family, current); + let want_italic = italic.unwrap_or(cur_italic); + let want_weight = match bold { + Some(true) => 700.0, + Some(false) => 400.0, + None => weight, + }; + // Width words (Condensed, Extended…) of the current style are kept when the family allows. + const WIDTHS: &[&str] = &["condensed", "compressed", "narrow", "extended", "expanded", "wide"]; + let widths = |s: &str| { + let n = norm(s); + WIDTHS.iter().filter(|w| n.contains(*w)).count() + }; + let cur_widths = widths(current); + self.style_traits(family) + .into_iter() + .filter(|(i, w, _)| *i == want_italic && bold.is_none_or(|b| b == (*w >= 600.0))) + .min_by(|a, b| { + let key = |t: &(bool, f32, String)| ((t.1 - want_weight).abs(), usize::from(widths(&t.2) != cur_widths), t.2.len()); + let (ka, kb) = (key(a), key(b)); + ka.0.total_cmp(&kb.0).then(ka.1.cmp(&kb.1)).then(ka.2.cmp(&kb.2)) + }) + .map(|t| t.2) + } + + /// `(italic, weight, name)` of each style of `family` (the document's, loaded, or installed), + /// Regular first, then by weight. + fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { + let v = self.exact_style_traits(family); + if !v.is_empty() { + return v; + } + alternate_names(family).map(|f| self.exact_style_traits(f)).find(|v| !v.is_empty()).unwrap_or_default() + } + + fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { + let canonical = self.canonical_family(family); + let family = canonical.as_deref().unwrap_or(family); + let v = self.named_style_traits(family, false); + // No face of that typographic family: the family may be a WWS name (see `find`). + if v.is_empty() { self.named_style_traits(family, true) } else { v } + } + + /// [`ScopedFonts::exact_style_traits`] by the faces' typographic names, or by their WWS names + /// when `by_wws`. + fn named_style_traits(&self, family: &str, by_wws: bool) -> Vec<(bool, f32, String)> { + let mut v: Vec<(bool, f32, String)> = self + .own() + .iter() + .chain(self.db.read_faces().iter()) + .filter(|f| !f.style.contains('{')) + .filter_map(|f| known_as(&f.family, &f.style, f.wws.as_ref(), by_wws).map(|n| (f, n))) + .filter(|(_, (fam, _))| fam.eq_ignore_ascii_case(family)) + .map(|(f, (_, style))| (f.italic, f.weight, style.to_string())) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + for c in self.db.read_catalog().iter() { + if let Some((fam, style)) = known_as(&c.family, &c.style, c.wws.as_ref(), by_wws) + && fam.eq_ignore_ascii_case(family) + && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) + { + v.push((style_italic(style), style_weight(style), style.to_string())); + } + } + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); + v.dedup_by(|a, b| a.2 == b.2); + v + } + + /// Resolve a family + style to a face, falling back to the closest style of the family, then to + /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A + /// family that isn't found as written is looked up without surrounding spaces, then without + /// InDesign's `$ID/` prefix or a format suffix (`Minion Pro (OTF)` → `Minion Pro`). + pub fn face(&self, family: &str, style: &str) -> Arc { + let canonical = self + .canonical_family(family) + .or_else(|| alternate_names(family).find_map(|f| self.canonical_family(f))) + .unwrap_or_else(|| self.substitute_family(alternate_names(family).last().unwrap_or(family))); + if let Some(f) = self.find_or_load(&canonical, style) { + return f; + } + self.find(FALLBACK_FAMILY, style) + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.db.read_faces().first().cloned()) + .unwrap_or_else(last_resort_face) + } + + fn find_or_load(&self, family: &str, style: &str) -> Option> { + if let Some(f) = self.find(family, style) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.load_cataloged(family) { + return self.find(family, style); + } + None + } + + /// The family and style of the face whose PostScript name is `name`, ignoring case (the + /// document's, loaded, or installed on the system, not loading it). SVGs exported from + /// Illustrator name fonts this way (`font-family="'MyriadPro-Regular'"`). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + let named = |ps: Option<&str>| ps.is_some_and(|ps| ps.eq_ignore_ascii_case(name)); + if let Some(f) = self.own().iter().chain(self.db.read_faces().iter()).find(|f| named(f.postscript_name())) { + return Some((f.family.clone(), f.style.clone())); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(c) = self.db.read_catalog().iter().find(|c| named(c.postscript.as_deref())) { + return Some((c.family.clone(), c.style.clone())); + } + None + } + + /// Is `family` available (the document's, loaded, or installed on the system), as named or + /// under an alternate name (see [`ScopedFonts::face`])? + pub fn has_family(&self, family: &str) -> bool { + self.canonical_family(family).is_some() || alternate_names(family).any(|f| self.canonical_family(f).is_some()) + } + + /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. + pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { + let face = self.face(family, base_style(style)); + let Some(f) = face.skrifa() else { return vec![] }; + f.axes() + .iter() + .map(|a| { + let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string(); + let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone()); + (tag, name, a.min_value(), a.default_value(), a.max_value()) + }) + .collect() + } + + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph); see + /// [`FontDb::outline`]. + pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { + self.db.outline(face, gid) + } + + /// See [`FontDb::missing_box`]. + pub fn missing_box(&self, face: &FontFace) -> Option { + self.db.missing_box(face) + } + + /// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use. + fn instance(&self, family: &str, style: &str) -> Option> { + let open = style.find('{')?; + let base = self.find(family, style[..open].trim())?; + if base.skrifa().is_none_or(|f| f.axes().is_empty()) { + return Some(base); + } + let key = (base.id, style.to_string()); + if let Some(f) = self.db.instances.read().unwrap_or_else(PoisonError::into_inner).get(&key) { + return Some(f.clone()); + } + let mut coords = base.coords.clone(); + for kv in style[open + 1..].trim_end_matches('}').split(',') { + let (k, v) = kv.split_once(':')?; + let k = k.trim().as_bytes(); + let v: f32 = v.trim().parse().ok()?; + if k.len() != 4 { + return None; + } + let tag = [k[0], k[1], k[2], k[3]]; + match coords.iter_mut().find(|(t, _)| *t == tag) { + Some(c) => c.1 = v, + None => coords.push((tag, v)), + } + } + let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?; + Some(self.db.instances.write().unwrap_or_else(PoisonError::into_inner).entry(key).or_insert_with(|| Arc::new(f)).clone()) + } + + pub(super) fn find(&self, family: &str, style: &str) -> Option> { + if style.contains('{') { + return self.instance(family, style); + } + let faces = self.db.read_faces(); + // The document's fonts first: they stand in for other fonts of the same name. + let named = |f: &&Arc| f.family.eq_ignore_ascii_case(family); + let own: Vec<&Arc> = self.own().iter().filter(named).collect(); + let mut cands: Vec<&Arc> = if own.is_empty() { faces.iter().filter(named).collect() } else { own }; + // No face of that typographic family: the family may be a WWS name, whose faces carry + // their WWS style names. + let by_wws = cands.is_empty(); + if by_wws { + let aliased = |f: &&Arc| has_wws_family(f, family); + let own: Vec<&Arc> = self.own().iter().filter(aliased).collect(); + cands = if own.is_empty() { faces.iter().filter(aliased).collect() } else { own }; + } + if cands.is_empty() { + return None; + } + let style_of = |f: &FontFace| -> String { norm(f.wws.as_ref().filter(|_| by_wws).map_or(&f.style, |w| &w.1)) }; + let ns = norm(style); + // A face's own style name first: another face's legacy subfamily ("Black" faces named + // "Regular" in their RIBBI family) or localized name must not shadow it. + if let Some(f) = cands + .iter() + .find(|f| style_of(f) == ns) + .or_else(|| cands.iter().find(|f| f.coords.is_empty() && f.style_aliases.iter().any(|alias| norm(alias) == ns))) + { + return Some((*f).clone()); + } + let (tw, ti) = (style_weight(style), style_italic(style)); + cands + .iter() + .min_by(|a, b| { + let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; + let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; + sa.total_cmp(&sb) + }) + .map(|f| (*f).clone()) + } + + /// 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`]), 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).or_else(|| script_chain(c)) + && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) + { + return Some(f); + } + if let Some(f) = self.loaded_fallback(c, exclude) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.system_fallback(c) { + return self.loaded_fallback(c, exclude); + } + None + } + + /// The face of `family` (the document's first, regular first) that covers `c`, loading the + /// installed family the first time a chain asks for it (native, while the system fallback is + /// on). + fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option> { + #[cfg(not(target_arch = "wasm32"))] + if !self.db.is_loaded(family) { + // Held while loading, so a parallel lookup waits for the font instead of passing it by. + let mut sys = self.db.sys.lock().unwrap_or_else(PoisonError::into_inner); + if sys.enabled && sys.chain_tried.insert(family) { + self.db.load_cataloged(family); + } + } + let faces = self.db.read_faces(); + let mut order: Vec<&Arc> = + self.own().iter().chain(faces.iter()).filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect(); + order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + let faces = self.db.read_faces(); + let mut order: Vec<&Arc> = faces.iter().chain(self.own().iter()).filter(|f| f.id != exclude).collect(); + order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod native_gui_document_fonts_tests { + use super::*; + + /// The production font scope contains the canonical bundled and Japanese faces in their existing order, including bundled Hebrew/Devanagari faces, without system or injected fonts. + #[test] + fn native_gui_canonical_document_faces_load_without_system_or_added_fonts() { + if crate::CRAFT_FONTS.is_empty() { + assert!(std::env::var_os("CRAFT_FONTS_REQUIRED").is_none(), "required canonical fonts were not embedded"); + return; // Optional-input builds retain their supported Latin-only database. + } + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let scope = db.scoped(0); + let japanese = crate::japanese_document_fonts(); + let previous: Vec<_> = BUNDLED + .iter() + .copied() + .chain(japanese.iter().map(|f| f.bytes)) + .flat_map(enumerate_faces) + .map(|(_, family, style, _)| (family, style)) + .collect(); + let loaded: Vec<_> = db.read_faces().iter().map(|f| (f.family.clone(), f.style.clone())).collect(); + assert!(loaded.len() >= previous.len(), "existing bundled/Japanese faces disappeared"); + assert_eq!(&loaded[..previous.len()], previous.as_slice(), "preserve all Latin and Japanese face ordering"); + for family in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { + let face = db.face(family, "Regular"); + assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled)); + } + assert!(!japanese.is_empty(), "the required central manifest includes the existing Japanese faces"); + assert!(japanese[0].family.contains("Mincho")); + assert_eq!(crate::japanese_ui_fonts(false)[0].family, "BIZ UDPGothic"); + assert_eq!(crate::japanese_ui_fonts(true)[0].style, "Bold"); + let latin = db.face(crate::DEFAULT_FAMILY, "Regular"); + for language in [Some("ja"), None] { + assert!(db.fallback_for('語', latin.id(), language).unwrap().family.contains("Mincho"), "Japanese fallback changed: {language:?}"); + } + let mut admitted = Vec::new(); + for (family, text) in [("Noto Sans Hebrew", "שלום עולם"), ("Noto Sans Devanagari", "नमस्ते दुनिया")] { + let embedded: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.family == family && f.style == "Regular").collect(); + assert_eq!(embedded.len(), 1, "exactly one canonical {family} Regular input"); + assert!(scope.has_family(family), "production database did not register {family}"); + assert!(scope.has_style(family, "Regular"), "{family} Regular"); + assert!(!scope.has_style(family, "Unavailable Native GUI Style")); + assert!(db.families().iter().any(|f| f == family), "family catalog omits {family}"); + assert!(scope.family_infos().iter().any(|f| f.family == family), "menu catalog omits {family}"); + let face = scope.face(family, "Regular"); + assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled)); + assert_eq!(face.data(), embedded[0].bytes, "neither OS discovery nor an injected face may satisfy this contract"); + for c in text.chars().filter(|c| !c.is_whitespace()) { + assert_ne!(face.glyph_for(c), 0, "{family} lacks U+{:04X} ({c})", u32::from(c)); + let fallback = db.fallback_for(c, latin.id(), None).unwrap(); + assert_eq!((fallback.family.as_str(), fallback.style.as_str(), &fallback.source), (family, "Regular", &FontSource::Bundled)); + assert_eq!(fallback.data(), embedded[0].bytes, "document fallback must use the canonical {family} face for {c}"); + assert_ne!(fallback.glyph_for(c), 0); + } + let replacement = scope.face(family, "Unavailable Native GUI Style"); + assert_eq!((replacement.family.as_str(), replacement.style.as_str()), (family, "Regular"), "nearest-style policy stays truthful"); + admitted.push((family.to_string(), "Regular".to_string())); + } + assert_eq!(&loaded[previous.len()..], admitted.as_slice(), "only the two deliberate Regular tuples follow the existing document faces"); + assert!(!scope.has_family("Unavailable Native GUI Family")); + assert_eq!(scope.face("Unavailable Native GUI Family", "Regular").family, FALLBACK_FAMILY); + } +} diff --git a/crates/fonts/src/fontdb/system.rs b/crates/fonts/src/fontdb/system.rs new file mode 100644 index 000000000..e7bb594f3 --- /dev/null +++ b/crates/fonts/src/fontdb/system.rs @@ -0,0 +1,630 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::db::NEXT_SCOPE; +#[cfg(not(target_arch = "wasm32"))] +use super::face::{ + DocumentFonts, FontBytes, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, MAX_DOCUMENT_FONTS_TOTAL, enumerate_faces, + make_face, +}; +#[cfg(not(target_arch = "wasm32"))] +use super::fallback::SYSTEM_FALLBACKS; +#[cfg(not(target_arch = "wasm32"))] +use super::names::{aliases, face_names, in_catalog_family, name, norm, wws_names}; +use super::*; + +impl FontFace { + /// When the font file was added (created, else last modified): the font menus' Show Recently + /// Added. `None` for fonts without a file. + pub fn added(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let meta = std::fs::metadata(self.source.path()?).ok()?; + meta.created().or_else(|_| meta.modified()).ok() + } + #[cfg(target_arch = "wasm32")] + { + None + } + } +} + +/// A font file's length and modification time. +#[cfg(not(target_arch = "wasm32"))] +pub(super) type FileStamp = (u64, Option); + +#[cfg(not(target_arch = "wasm32"))] +fn file_stamp(path: &std::path::Path) -> Option { + std::fs::metadata(path).ok().map(|m| (m.len(), m.modified().ok())) +} + +/// What [`FontDb::reload_fonts`] did: the faces cataloged by the rescan, and the installed font +/// files that changed or were removed since they were read (their faces are read again, from the +/// new file, the next time they are looked up). +#[derive(Debug, Default, PartialEq)] +pub struct ReloadedFonts { + pub cataloged: usize, + pub changed: Vec, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +pub(super) struct CatalogEntry { + pub(super) family: String, + pub(super) style: String, + pub(super) wws: Option<(String, String)>, + path: std::path::PathBuf, + /// The font menu group and native family name, read by the scan. + pub(super) group: FontGroup, + pub(super) native: Option, + pub(super) aliases: Vec, + pub(super) postscript: Option, +} + +/// What the scan reads of one face in a font file: its names and font menu group. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug, PartialEq)] +pub(super) struct ScannedFace { + pub(super) family: String, + pub(super) style: String, + wws: Option<(String, String)>, + group: FontGroup, + native: Option, + aliases: Vec, + postscript: Option, +} + +/// The tables glyph outlines are drawn from. +#[cfg(not(target_arch = "wasm32"))] +const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; + +/// The names and font menu group of every face in the font file at `path`, reading only its table +/// directories and the tables that name and classify it (`name`, `OS/2`, `meta`; `cmap` only for a +/// font that declares no language or code page): a scan opens hundreds of font files, many of them +/// megabytes long. A variable font is cataloged under its default style; its named instances +/// appear once it loads. Faces without glyph outlines (none of [`OUTLINE_TABLES`]) are left out. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn file_face_names(path: &std::path::Path) -> Vec { + use std::io::{Read, Seek, SeekFrom}; + /// Caps on what a (possibly damaged) file can make the scan read. + const MAX_FACES: u32 = 256; + const MAX_NAME_TABLE: u32 = 1 << 20; + const MAX_SMALL_TABLE: u32 = 64 << 10; + const MAX_CMAP: u32 = 4 << 20; + let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; + let mut read_at = |offset: u64, len: usize| -> Option> { + let mut buf = vec![0; len]; + file.seek(SeekFrom::Start(offset)).ok()?; + file.read_exact(&mut buf).ok()?; + Some(buf) + }; + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let Some(head) = read_at(0, 12) else { return vec![] }; + // A collection lists where each face's table directory starts. + let starts: Vec = if head.starts_with(b"ttcf") { + let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize; + read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default() + } else { + vec![0] + }; + starts + .into_iter() + .filter_map(|start| { + let dir = read_at(start.into(), 12)?; + let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; + let records = read_at(u64::from(start) + 12, tables * 16)?; + // Faces drawn from other tables (Apple's `hvgl`, bitmaps) would draw nothing. + if !records.as_chunks::<16>().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) { + return None; + } + // Table `tag`, unless missing or over `max` bytes. + let mut table = |tag: &[u8; 4], max: u32| -> Option<([u8; 4], Vec)> { + let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?; + let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); + if len > max { + return None; + } + Some((*tag, read_at(offset.into(), len as usize)?)) + }; + // A font holding just these tables, to read them as the font itself would. + let mut found = vec![table(b"name", MAX_NAME_TABLE)?]; + found.extend(table(b"OS/2", MAX_SMALL_TABLE)); + found.extend(table(b"meta", MAX_SMALL_TABLE)); + let font = crate::group::sfnt(found.clone())?; + let f = skrifa::FontRef::new(&font).ok()?; + let (family, style) = face_names(&f)?; + let (group, native) = match crate::group::declared_group(&f, &family) { + Some(g) => (g, crate::group::classify(&f, &family).1), + None => { + found.extend(table(b"cmap", MAX_CMAP)); + let font = crate::group::sfnt(found)?; + crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) + } + }; + let postscript = name(&f, &[StringId::POSTSCRIPT_NAME]); + let wws = wws_names(&f, &family, &style); + Some(ScannedFace { + family, + style, + wws, + group, + native, + aliases: aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]), + postscript, + }) + }) + .collect() +} + +/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. +/// Empty on wasm. +pub fn system_font_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if cfg!(target_arch = "wasm32") { + return dirs; + } + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font. + for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { + let Ok(entries) = std::fs::read_dir(parent) else { continue }; + for e in entries.flatten() { + if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { + dirs.push(e.path()); + } + } + } + // Microsoft Office bundles its fonts (Calibri, Cambria, …) inside each app; read them in + // place from the first installed one (they share the same set). + let office = ["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"] + .iter() + .map(|app| std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts"))) + .find(|p| p.is_dir()); + dirs.extend(office); + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from); + dirs.extend(unix_font_dirs(home.as_deref(), xdg_data_home.as_deref())); + } + dirs +} + +pub(super) fn unix_font_dirs(home: Option<&std::path::Path>, xdg_data_home: Option<&std::path::Path>) -> Vec { + let mut dirs: Vec = ["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into).into(); + if let Some(home) = home { + dirs.push(home.join(".fonts")); + } + let data_home = + xdg_data_home.filter(|path| path.is_absolute()).map(std::path::Path::to_path_buf).or_else(|| home.map(|path| path.join(".local/share"))); + dirs.extend(data_home.map(|path| path.join("fonts"))); + dirs +} + +#[cfg(not(target_os = "macos"))] +pub(super) fn platform_font_files() -> Vec { + Vec::new() +} + +/// The files in `listed` that aren't in `scanned` (canonical paths), each once and added to +/// `scanned`; paths that don't lead to a file (gone, unreadable folder) are left out. +#[cfg(not(target_arch = "wasm32"))] +fn unscanned_font_files(listed: Vec, scanned: &mut std::collections::HashSet) -> Vec { + listed + .into_iter() + .filter(|p| { + let Ok(canonical) = std::fs::canonicalize(p) else { return false }; + canonical.is_file() && scanned.insert(canonical) + }) + .collect() +} + +impl FontDb { + /// Catalog the installed fonts unless that has been done: the first caller scans the font + /// folders, any other waits for that scan to finish. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn ensure_catalog(&self) { + self.cataloged.get_or_init(|| { + self.scan_font_dirs(); + }); + } + + /// Whether the installed fonts are cataloged: a lookup by family name won't wait for the + /// scan (always true on wasm, which has none). See [`FontDb::scan_in_background`]. + pub fn system_fonts_cataloged(&self) -> bool { + #[cfg(target_arch = "wasm32")] + return true; + #[cfg(not(target_arch = "wasm32"))] + self.cataloged.get().is_some() + } + + /// Catalog the installed fonts on a background thread, so the first lookup by family name + /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are + /// cataloged, and on wasm. + pub fn scan_in_background(&'static self) { + #[cfg(not(target_arch = "wasm32"))] + if self.cataloged.get().is_none() { + // A failed spawn leaves the scan to the first lookup that needs it. + let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog()); + } + } + + /// Scan the font folders again (fonts installed or removed since), cataloging the faces + /// found (native only). Font data is loaded when a cataloged family is first resolved. + /// Returns the number of faces cataloged. + #[cfg(not(target_arch = "wasm32"))] + pub fn load_system_fonts(&self) -> usize { + // The first scan, or a rescan once it is done (never both at once). + let mut first = None; + self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); + let count = first.unwrap_or_else(|| self.scan_font_dirs()); + // Retry characters and CJK chain families that newly installed fonts can now cover. + // Reset outside scan_font_dirs: a chain lookup can hold sys during the initial scan. + let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); + sys.generation = sys.generation.wrapping_add(1); + sys.misses.clear(); + sys.chain_tried.clear(); + count + } + + /// Fonts installed, changed or removed since they were read (#328): scan the font folders + /// again (see [`FontDb::load_system_fonts`]), then let go of the shared faces whose installed + /// file changed (length or modification time) or is gone, so the next lookup reads the file as + /// it is now, or finds the font missing. What was composed with the old faces must be composed + /// again. Faces are never freed (see [`FaceRef`]): each reload of a changed file keeps the old + /// copy. Bundled, added and document fonts are left alone. Nothing to do on wasm. + pub fn reload_fonts(&self) -> ReloadedFonts { + #[cfg(not(target_arch = "wasm32"))] + { + // Rescan first: a lookup after the faces are dropped finds the new files. + let cataloged = self.load_system_fonts(); + let changed: Vec = { + let mut files = self.installed_files.lock().unwrap_or_else(PoisonError::into_inner); + let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect(); + changed.sort(); + for p in &changed { + files.remove(p); + } + changed + }; + if !changed.is_empty() { + let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p)); + let mut dropped = std::collections::HashSet::new(); + self.faces.write().unwrap_or_else(PoisonError::into_inner).retain(|f| { + let keep = !stale(f); + if !keep { + dropped.insert(f.id); + } + keep + }); + self.instances.write().unwrap_or_else(PoisonError::into_inner).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); + self.outlines.lock().unwrap_or_else(PoisonError::into_inner).retain(|(face, _), _| !dropped.contains(face)); + log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len()); + } + ReloadedFonts { cataloged, changed } + } + #[cfg(target_arch = "wasm32")] + ReloadedFonts::default() + } + + /// Read an installed font file, noting its length and modification time as they were before + /// the read (a change during it is then found by the next reload). + #[cfg(not(target_arch = "wasm32"))] + fn read_installed(&self, path: &std::path::Path) -> Option> { + let stamp = file_stamp(path)?; + let data = std::fs::read(path).ok()?; + self.installed_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), stamp); + Some(data) + } + + #[cfg(not(target_arch = "wasm32"))] + fn scan_font_dirs(&self) -> usize { + let mut found = Vec::new(); + let mut stack = self.font_dirs.clone(); + // Each folder once, however links lead back to it. + let mut visited = std::collections::HashSet::new(); + // The font files read, by canonical path, so the platform's list adds only the others. + let mut scanned = std::collections::HashSet::new(); + while let Some(d) = stack.pop() { + if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { + continue; + } + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + scanned.insert(std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone())); + for f in file_face_names(&p) { + found.push(CatalogEntry { + family: f.family, + style: f.style, + wws: f.wws, + path: p.clone(), + group: f.group, + native: f.native, + aliases: f.aliases, + postscript: f.postscript, + }); + } + } + } + // Font services name their files as they like (no extension, say): the face names read + // decide what is a font. + for p in unscanned_font_files((self.font_files)(), &mut scanned) { + for f in file_face_names(&p) { + found.push(CatalogEntry { + family: f.family, + style: f.style, + wws: f.wws, + path: p.clone(), + group: f.group, + native: f.native, + aliases: f.aliases, + postscript: f.postscript, + }); + } + } + let n = found.len(); + log::debug!("cataloged {n} system font faces"); + *self.catalog.write().unwrap_or_else(PoisonError::into_inner) = found; + n + } + + /// Load the files of the installed `family`. Returns whether any face was added. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn load_cataloged(&self, family: &str) -> bool { + let paths: Vec = { + let cat = self.read_catalog(); + let mut p: Vec<_> = cat.iter().filter(|c| in_catalog_family(c, family)).map(|c| c.path.clone()).collect(); + p.sort(); + p.dedup(); + p + }; + // The whole family arrives at once (see `insert_faces`). + let mut faces = Vec::new(); + for p in paths { + if let Some(data) = self.read_installed(&p) { + faces.extend(self.new_faces(data, FontSource::Installed(p))); + } + } + self.insert_faces(faces) > 0 + } + + /// Load the font files (.ttf, .otf, .ttc, .otc; not in subfolders) of a document's `Document + /// Fonts` folder under a new scope, for that document alone: looked up through + /// [`FontDb::scoped`], they come before the shared faces of the same family and style, until + /// [`FontDb::close_scope`]. A file loaded before and unchanged since (same length and + /// modification time) isn't read again: its faces are shared. The files are untrusted: past + /// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past + /// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A + /// missing folder loads nothing (scope 0). + /// + /// The folder is named by a document, so this crate does not trust it: `allow` (`Ok`, or why a + /// path may not be read) is asked about + /// the folder and about every font file in it before either is opened, and a path it refuses + /// is skipped with its message (the engine passes its automation-root check; a host with no + /// roots passes `&|_| Ok(())`). + #[cfg(not(target_arch = "wasm32"))] + pub fn load_document_fonts(&self, folder: &std::path::Path, allow: &dyn Fn(&std::path::Path) -> Result<(), String>) -> DocumentFonts { + let mut out = DocumentFonts::default(); + if let Err(why) = allow(folder) { + out.skipped.push(why); + return out; + } + let Ok(entries) = std::fs::read_dir(folder) else { return out }; + let mut files: Vec = Vec::new(); + for path in entries.flatten().map(|e| e.path()) { + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + // Each file is vetted before it is touched at all, a link out of the folder included. + match allow(&path) { + Ok(()) if path.is_file() => files.push(path), + Ok(()) => {} + Err(why) => out.skipped.push(why), + } + } + files.sort(); + let mut total = 0u64; + let mut own: Vec> = Vec::new(); + for (n, path) in files.iter().enumerate() { + if n == MAX_DOCUMENT_FONT_FILES { + out.skipped.push(format!("{} more font files not loaded (at most {MAX_DOCUMENT_FONT_FILES} are)", files.len() - n)); + break; + } + let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); + // The listed length skips oversized files unread. + let meta = std::fs::metadata(path).ok(); + let listed = meta.as_ref().map_or(0, std::fs::Metadata::len); + let faces = if listed > MAX_DOCUMENT_FONT_BYTES { + Err(too_large()) + } else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL { + Err(over_total()) + } else { + let modified = meta.and_then(|m| m.modified().ok()); + self.document_file(path, listed, modified, MAX_DOCUMENT_FONTS_TOTAL.saturating_sub(total)) + }; + match faces { + Err(r) => out.skipped.push(format!("{name}: {r}")), + Ok((faces, len)) => { + total = total.saturating_add(len); + // A family and style the folder already gave stays with its first file. + for f in faces { + if !own.iter().any(|o| o.family.eq_ignore_ascii_case(&f.family) && o.style.eq_ignore_ascii_case(&f.style)) { + own.push(f); + } + } + } + } + } + for s in &out.skipped { + log::warn!("{}: {s}", folder.display()); + } + if !own.is_empty() { + out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed); + out.faces = own.len(); + self.scopes.write().unwrap_or_else(PoisonError::into_inner).insert(out.scope, own.into()); + } + out + } + + /// The faces of the document font file at `path` (`listed` bytes long, modified at + /// `modified`): those read before if it hasn't changed since, else read now, within `room` + /// bytes of the folder's total → (faces, bytes), or why the file is skipped. + #[cfg(not(target_arch = "wasm32"))] + fn document_file( + &self, + path: &std::path::Path, + listed: u64, + modified: Option, + room: u64, + ) -> Result<(Vec>, u64), String> { + use std::io::Read; + if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(PoisonError::into_inner).get(path) + && (*len, *m) == (listed, modified) + { + return Ok((faces.clone(), listed)); + } + // One byte past the limit catches a file that grew since it was listed. + let mut data = Vec::new(); + std::fs::File::open(path) + .and_then(|f| f.take(MAX_DOCUMENT_FONT_BYTES + 1).read_to_end(&mut data)) + .map_err(|e| format!("can't be read ({e})"))?; + let len = data.len() as u64; + if len > MAX_DOCUMENT_FONT_BYTES { + return Err(too_large()); + } + if listed.max(len) > room { + return Err(over_total()); + } + let data = Arc::new(data); + let faces: Vec> = enumerate_faces(&data) + .into_iter() + .filter_map(|(i, family, style, coords)| { + make_face(FontBytes::Owned(data.clone()), FontSource::Document(path.to_path_buf()), i, family, style, coords) + }) + .map(Arc::new) + .collect(); + if faces.is_empty() { + return Err("not a font DesignCraft can read".into()); + } + self.document_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (listed, modified, faces.clone())); + Ok((faces, len)) + } + + /// Load a system font covering `c` (preferred fallback families first, then any cataloged + /// file under 40 MB). Returns true if one was loaded. Misses are remembered. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn system_fallback(&self, c: char) -> bool { + if c.is_control() || c.is_whitespace() { + return false; + } + let generation = { + let sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); + if !sys.enabled || sys.misses.contains(&c) { + return false; + } + sys.generation + }; + let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); + let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); + for fam in SYSTEM_FALLBACKS { + if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { + self.load_cataloged(fam); + if covered(self) { + return true; + } + } + } + let mut paths: Vec = self.catalog.read().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.path.clone()).collect(); + paths.sort(); + paths.dedup(); + #[cfg(all(test, target_os = "linux"))] + { + let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(PoisonError::into_inner).take(); + if let Some(hook) = hook { + let _ = hook.reached.send(()); + hook.resume.wait(); + } + } + for p in paths { + if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { + continue; + } + let Some(data) = self.read_installed(&p) else { continue }; + let hit = + enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); + if hit && self.add_faces(data, FontSource::Installed(p)) > 0 && covered(self) { + return true; + } + } + let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); + if sys.generation == generation { + sys.misses.insert(c); + } + false + } +} + +impl ScopedFonts<'_> { + /// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular + /// face (else its first), for the font menus' filters, which look at every family. A face + /// already loaded is asked; an installed family that isn't loaded is read from its file, which + /// is then let go rather than kept loaded. None for an unknown family or an unreadable file. + pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option)> { + let styles = self.styles(family); + let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone(); + if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) { + let f = self.find(family, &style)?; + return Some((f.kind(), f.added())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = { + let cat = self.db.read_catalog(); + let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family); + let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family)); + entry.map(|c| c.path.clone())? + }; + if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES { + return None; + } + let data = Arc::new(std::fs::read(&path).ok()?); + let found = enumerate_faces(&data); + let pick = found + .iter() + .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style)) + .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))? + .clone(); + let (index, fam, st, coords) = pick; + let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?; + Some((face.kind(), face.added())) + } + #[cfg(target_arch = "wasm32")] + None + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn too_large() -> String { + format!("larger than {} MB", MAX_DOCUMENT_FONT_BYTES >> 20) +} + +#[cfg(not(target_arch = "wasm32"))] +fn over_total() -> String { + format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) +}