1508 lines
63 KiB
Rust
1508 lines
63 KiB
Rust
//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged the first
|
|
//! time a lookup needs them, native only), outline cache.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::{Arc, Mutex, RwLock};
|
|
|
|
use kurbo::BezPath;
|
|
use skrifa::instance::{Location, LocationRef, Size};
|
|
use skrifa::outline::{DrawSettings, OutlinePen};
|
|
use skrifa::raw::FileRef;
|
|
use skrifa::string::StringId;
|
|
use skrifa::{GlyphId, MetadataProvider};
|
|
|
|
/// 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<Vec<u8>>),
|
|
}
|
|
|
|
/// 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 family name Word lists and stores for the face (name ID 1 style, e.g. "Source Sans 3
|
|
/// Semibold"): the typographic family for Regular, Bold and Italic faces, the family plus the
|
|
/// other style words for the rest. See [`legacy_family`].
|
|
pub legacy_family: String,
|
|
/// The family's names in other languages (宋体 for SimSun), from the `name` table. Lookups by
|
|
/// family name accept them; the font list shows them in a matching interface language.
|
|
pub local_names: Vec<LocalName>,
|
|
/// usWeightClass-style weight (400 = regular).
|
|
pub weight: f32,
|
|
pub italic: bool,
|
|
bytes: FontBytes,
|
|
index: u32,
|
|
pub upem: f64,
|
|
/// Ascender in font units (positive = up).
|
|
pub ascent: f64,
|
|
/// Descender in font units (positive = down).
|
|
pub descent: f64,
|
|
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
|
|
/// values).
|
|
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<harfrust::ShaperInstance>,
|
|
/// Basic Multilingual Plane coverage bitset, built on first use.
|
|
bmp: std::sync::OnceLock<Box<[u64]>>,
|
|
}
|
|
|
|
/// 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<FontFace>) -> FaceRef {
|
|
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
|
let id = face.id as usize;
|
|
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
|
return FaceRef(f);
|
|
}
|
|
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
|
if w.len() <= id {
|
|
w.resize(id + 1, None);
|
|
}
|
|
let f: &'static FontFace = w[id].unwrap_or_else(|| {
|
|
let keep: &'static Arc<FontFace> = Box::leak(Box::new(face.clone()));
|
|
keep
|
|
});
|
|
w[id] = Some(f);
|
|
FaceRef(f)
|
|
}
|
|
pub fn get(self) -> &'static FontFace {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl std::ops::Deref for FaceRef {
|
|
type Target = FontFace;
|
|
fn deref(&self) -> &FontFace {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for FaceRef {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
self.0.fmt(f)
|
|
}
|
|
}
|
|
|
|
impl PartialEq for FaceRef {
|
|
fn eq(&self, o: &FaceRef) -> bool {
|
|
self.0.id == o.0.id
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for FontFace {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "FontFace({} {})", self.family, self.style)
|
|
}
|
|
}
|
|
|
|
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<'_>> {
|
|
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
|
}
|
|
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
|
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
|
|
}
|
|
/// Is `family` this face's typographic or legacy family name?
|
|
pub fn named(&self, family: &str) -> bool {
|
|
self.family.eq_ignore_ascii_case(family) || self.legacy_family.eq_ignore_ascii_case(family)
|
|
}
|
|
/// Does the face map `c` to a glyph?
|
|
pub fn covers(&self, c: char) -> bool {
|
|
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, _) in f.charmap().mappings() {
|
|
if cp < 0x1_0000 {
|
|
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())
|
|
}
|
|
/// 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)
|
|
}
|
|
/// 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)
|
|
}
|
|
/// Is this an East Asian (Chinese, Japanese or Korean) font? Its OS/2 code pages say so
|
|
/// (Shift-JIS, GB2312, Wansung, Big5, Johab), or, without them, it maps common ideographs.
|
|
/// Word applies such a font to East Asian text too (`w:eastAsia`).
|
|
pub fn is_east_asian(&self) -> bool {
|
|
self.skrifa().is_some_and(|f| east_asian_font(&f))
|
|
}
|
|
}
|
|
|
|
/// Is `f` an East Asian (Chinese, Japanese or Korean) font: do its OS/2 code pages say so
|
|
/// (Shift-JIS, GB2312, Wansung, Big5, Johab), or, without them, does it map common ideographs?
|
|
fn east_asian_font(f: &skrifa::FontRef<'_>) -> bool {
|
|
use skrifa::raw::TableProvider;
|
|
const CJK_CODE_PAGES: u32 = 0b1_1111 << 17;
|
|
match f.os2().ok().and_then(|t| t.ul_code_page_range_1()) {
|
|
Some(pages) if pages != 0 => pages & CJK_CODE_PAGES != 0,
|
|
_ => ['中', '国', '日'].iter().all(|c| f.charmap().map(*c).is_some()),
|
|
}
|
|
}
|
|
|
|
/// A family name in another language, from a font's `name` table.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct LocalName {
|
|
/// BCP 47 language tag, lowercase (`zh-hans`, `zh-tw`, `ja-jp`).
|
|
pub lang: String,
|
|
/// The name in that language (宋体).
|
|
pub name: String,
|
|
/// The English family name it stands for (SimSun): the face's typographic or legacy family.
|
|
pub english: String,
|
|
}
|
|
|
|
/// Does a `name` table language tag (`zh-Hans`, `zh-TW`, `ja-JP`) suit the interface language
|
|
/// `ui` (`zh-hans`, `zh-hant`, `ja`)? Chinese matches by script: Simplified for mainland China and
|
|
/// Singapore, Traditional for Taiwan, Hong Kong and Macao.
|
|
pub fn name_language_matches(ui: &str, tag: &str) -> bool {
|
|
let parts = |s: &str| s.split(['-', '_']).map(str::to_ascii_lowercase).collect::<Vec<_>>();
|
|
let (u, t) = (parts(ui), parts(tag));
|
|
let (Some(up), Some(tp)) = (u.first(), t.first()) else { return false };
|
|
if up.is_empty() || up != tp {
|
|
return false;
|
|
}
|
|
if up != "zh" {
|
|
return true;
|
|
}
|
|
let traditional = |p: &[String]| p.iter().any(|x| matches!(x.as_str(), "hant" | "tw" | "hk" | "mo"));
|
|
let simplified = |p: &[String]| p.iter().any(|x| matches!(x.as_str(), "hans" | "cn" | "sg"));
|
|
// Plain `zh` is Simplified Chinese, as in the interface languages.
|
|
if traditional(&u) { traditional(&t) } else { simplified(&t) }
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[derive(Clone, Debug)]
|
|
struct CatalogEntry {
|
|
family: String,
|
|
style: String,
|
|
legacy: String,
|
|
local: Vec<LocalName>,
|
|
/// A Chinese, Japanese or Korean font ([`FontFace::is_east_asian`]).
|
|
east_asian: bool,
|
|
path: std::path::PathBuf,
|
|
}
|
|
|
|
/// Process-wide font database.
|
|
pub struct FontDb {
|
|
faces: RwLock<Vec<Arc<FontFace>>>,
|
|
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
|
/// Fallback faces found per (character, excluded face), valid while the face count is the
|
|
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
|
|
/// search sorts every loaded face, so without this text in a script the requested font lacks
|
|
/// (Thai, #59) got slower with every font loaded.
|
|
fallbacks: RwLock<FallbackCache>,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
catalog: RwLock<Vec<CatalogEntry>>,
|
|
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
font_dirs: Vec<std::path::PathBuf>,
|
|
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
|
/// scan, so what they find doesn't depend on what ran before them.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
cataloged: std::sync::OnceLock<()>,
|
|
/// Held while an installed family's files load, so two lookups don't load it twice.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
loading: Mutex<()>,
|
|
/// System fallback state: enabled, and characters no system font covers.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
sys: Mutex<SysFallback>,
|
|
/// The characters each installed font file maps, read the first time a fallback search
|
|
/// needs that file. Held through a search, so two searches never read a file twice.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
coverage: Mutex<CoverageIndex>,
|
|
/// Counts the font folder scans, so the coverage index notices a rescan.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
catalog_gen: AtomicU32,
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[derive(Default)]
|
|
struct SysFallback {
|
|
enabled: bool,
|
|
misses: std::collections::HashSet<char>,
|
|
/// Preferred fallback families already loaded (or tried) by a fallback search.
|
|
tried: std::collections::HashSet<&'static str>,
|
|
}
|
|
|
|
/// Characters remembered as covered by no installed font (a cap on memory: once full the set
|
|
/// starts over, and the coverage index answers again quickly).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const SYS_MISSES_MAX: usize = 65_536;
|
|
|
|
/// Installed font files larger than this are not searched for fallback characters.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const SYS_FALLBACK_MAX_FILE: u64 = 40 << 20;
|
|
|
|
/// Which characters each cataloged font file maps (#605). Before it, every distinct character no
|
|
/// loaded font covered read and parsed every installed font file again: 500 distinct Tangut
|
|
/// characters froze the app for most of a minute. Now each file is read once, when a search
|
|
/// first gets to it, and later searches answer from its ranges, reading only a file that matches.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[derive(Default)]
|
|
struct CoverageIndex {
|
|
/// The catalog scan the file list was taken from (`None` until the first search).
|
|
generation: Option<u32>,
|
|
/// The cataloged font files, sorted.
|
|
files: Vec<IndexedFile>,
|
|
/// Font files read by fallback searches (each at most once to index it, once more to load it).
|
|
reads: usize,
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
struct IndexedFile {
|
|
path: std::path::PathBuf,
|
|
/// The code points the file's faces map, as sorted, disjoint inclusive ranges (`None` until
|
|
/// the file is read; empty when it can't be read or is too large).
|
|
ranges: Option<Box<[(u32, u32)]>>,
|
|
/// Loaded for a character it covers: its faces are searched as loaded fonts from then on.
|
|
used: bool,
|
|
}
|
|
|
|
/// Does `ranges` (sorted, disjoint, inclusive) hold `cp`?
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn ranges_hold(ranges: &[(u32, u32)], cp: u32) -> bool {
|
|
let i = ranges.partition_point(|r| r.1 < cp);
|
|
ranges.get(i).is_some_and(|r| r.0 <= cp)
|
|
}
|
|
|
|
/// The code points mapped by the faces of `data` (a font file or collection), as sorted,
|
|
/// disjoint, inclusive ranges. Bounded however damaged the file: at most 256 faces, a budget of
|
|
/// mappings read across them, and a cap on the ranges kept.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn coverage_ranges(data: &[u8]) -> Box<[(u32, u32)]> {
|
|
const MAX_FACES: u32 = 256;
|
|
const MAX_MAPPINGS: usize = 4 * 0x11_0000;
|
|
const MAX_RANGES: usize = 1 << 17;
|
|
fn compact(r: &mut Vec<(u32, u32)>) {
|
|
r.sort_unstable();
|
|
let mut out: Vec<(u32, u32)> = Vec::with_capacity(r.len());
|
|
for &(s, e) in r.iter() {
|
|
match out.last_mut() {
|
|
Some(last) if s <= last.1.saturating_add(1) => last.1 = last.1.max(e),
|
|
_ => out.push((s, e)),
|
|
}
|
|
}
|
|
*r = out;
|
|
}
|
|
let count = match FileRef::new(data) {
|
|
Ok(FileRef::Font(_)) => 1,
|
|
Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES),
|
|
Err(_) => 0,
|
|
};
|
|
let mut ranges: Vec<(u32, u32)> = Vec::new();
|
|
let mut budget = MAX_MAPPINGS;
|
|
'faces: for i in 0..count {
|
|
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
|
for (cp, _) in f.charmap().mappings() {
|
|
budget = match budget.checked_sub(1) {
|
|
Some(b) => b,
|
|
None => break 'faces,
|
|
};
|
|
match ranges.last_mut() {
|
|
Some(last) if last.1.checked_add(1) == Some(cp) => last.1 = cp,
|
|
Some(last) if last.0 <= cp && cp <= last.1 => {}
|
|
_ => {
|
|
if ranges.len() >= MAX_RANGES {
|
|
compact(&mut ranges);
|
|
if ranges.len() >= MAX_RANGES {
|
|
break 'faces;
|
|
}
|
|
}
|
|
ranges.push((cp, cp));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
compact(&mut ranges);
|
|
ranges.into_boxed_slice()
|
|
}
|
|
|
|
/// An installed font file's bytes, `None` when it can't be read or is larger than
|
|
/// [`SYS_FALLBACK_MAX_FILE`] (also when it grew past that since it was cataloged).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn read_fallback_file(path: &std::path::Path) -> Option<Vec<u8>> {
|
|
use std::io::Read;
|
|
let file = std::fs::File::open(path).ok()?;
|
|
if file.metadata().ok()?.len() > SYS_FALLBACK_MAX_FILE {
|
|
return None;
|
|
}
|
|
let mut data = Vec::new();
|
|
file.take(SYS_FALLBACK_MAX_FILE + 1).read_to_end(&mut data).ok()?;
|
|
(data.len() as u64 <= SYS_FALLBACK_MAX_FILE).then_some(data)
|
|
}
|
|
|
|
/// Families preferred for right-to-left scripts (Persian, Arabic, Urdu, Hebrew) when the
|
|
/// document's font lacks them: open fonts with full Persian coverage first (the Persian-only
|
|
/// letters پ چ ژ گ, the Persian digits), then the platforms' own.
|
|
pub const RTL_FALLBACKS: &[&str] = &[
|
|
"Vazirmatn",
|
|
"Noto Naskh Arabic",
|
|
"Noto Sans Arabic",
|
|
"Noto Sans Arabic UI",
|
|
"Sahel",
|
|
"Shabnam",
|
|
"Samim",
|
|
"Noto Sans Hebrew",
|
|
"Segoe UI",
|
|
"Tahoma",
|
|
"Geeza Pro",
|
|
"SF Arabic",
|
|
"Arial",
|
|
"DejaVu Sans",
|
|
];
|
|
|
|
/// 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",
|
|
];
|
|
|
|
/// Arabic-script marks and digits (bidi classes NSM / AN): they belong with the Arabic letters
|
|
/// around them when choosing a fallback font.
|
|
fn arabic_block(c: char) -> bool {
|
|
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF)
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FallbackCache {
|
|
faces: usize,
|
|
map: HashMap<(char, u32), Option<Arc<FontFace>>>,
|
|
}
|
|
|
|
const FALLBACK_CACHE_MAX: usize = 20_000;
|
|
|
|
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
|
const OUTLINE_CACHE_MAX: usize = 50_000;
|
|
|
|
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
|
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
|
}
|
|
|
|
/// The family's names in other languages: localized typographic family names (name ID 16) stand
|
|
/// for `family`, localized legacy family names (name ID 1) for `id1` (or for `family` when the
|
|
/// font has no typographic names). Bounded: a damaged font can list any number of records.
|
|
fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<LocalName> {
|
|
const MAX_NAMES: usize = 64;
|
|
const MAX_CHARS: usize = 128;
|
|
let typographic = f.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME).next().is_some();
|
|
let legacy = if typographic { id1 } else { Some(family) };
|
|
let mut out: Vec<LocalName> = Vec::new();
|
|
for (id, english) in [(StringId::TYPOGRAPHIC_FAMILY_NAME, Some(family)), (StringId::FAMILY_NAME, legacy)] {
|
|
let Some(english) = english else { continue };
|
|
for s in f.localized_strings(id) {
|
|
let Some(lang) = s.language().map(str::to_ascii_lowercase) else { continue };
|
|
if lang == "en" || lang.starts_with("en-") {
|
|
continue;
|
|
}
|
|
let name: String = s.chars().take(MAX_CHARS).collect::<String>().trim().to_string();
|
|
if name.is_empty() || name.eq_ignore_ascii_case(english) || out.iter().any(|n| n.lang == lang && n.name == name) {
|
|
continue;
|
|
}
|
|
if out.len() >= MAX_NAMES {
|
|
return out;
|
|
}
|
|
out.push(LocalName { lang, name, english: english.to_string() });
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A face's family and style names (the default instance's style for a variable font), and its
|
|
/// legacy family name (name ID 1) when it has one.
|
|
pub(crate) fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option<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, name(f, &[StringId::FAMILY_NAME])))
|
|
}
|
|
|
|
/// The family name Word uses for a face that has no legacy name (name ID 1) of its own, such as a
|
|
/// variable font's named instance: the family plus the style's words other than Regular, Bold and
|
|
/// Italic, which stay styles within it (OpenType `name` table, IDs 1/2 vs 16/17). "Roboto" +
|
|
/// "Light Italic" → "Roboto Light"; "Roboto" + "Bold Italic" → "Roboto".
|
|
pub(crate) fn legacy_family(family: &str, style: &str) -> String {
|
|
const RIBBI: &[&str] = &["regular", "normal", "bold", "italic", "oblique"];
|
|
let extra: Vec<&str> = base_style(style).split_whitespace().filter(|w| !RIBBI.iter().any(|r| w.eq_ignore_ascii_case(r))).collect();
|
|
if extra.is_empty() { family.to_string() } else { format!("{family} {}", extra.join(" ")) }
|
|
}
|
|
|
|
/// The names of every face in the font file at `path`, reading only its table directories and
|
|
/// `name` and `fvar` tables: a scan opens hundreds of font files, many of them megabytes long
|
|
/// (CJK collections run to 20 MB and more). A variable font yields one entry per named instance,
|
|
/// as it does loaded.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn file_face_names(path: &std::path::Path) -> Vec<Found> {
|
|
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_TABLE: u32 = 1 << 20;
|
|
let Ok(mut file) = std::fs::File::open(path) else { return vec![] };
|
|
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
|
|
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<u32> = 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)?;
|
|
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
|
|
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_TABLE {
|
|
return None;
|
|
}
|
|
read_at(offset.into(), len as usize)
|
|
};
|
|
let name = table(b"name")?;
|
|
// Variable fonts: the named instances; OS/2: the code pages (East Asian fonts).
|
|
let mut tables: Vec<([u8; 4], Vec<u8>)> = table(b"OS/2").map(|t| (*b"OS/2", t)).into_iter().collect();
|
|
tables.extend(table(b"fvar").map(|t| (*b"fvar", t)));
|
|
tables.push((*b"name", name));
|
|
// A font holding just those tables, to read them as the font itself would.
|
|
let font = mini_font(&tables);
|
|
Some(font_faces(&skrifa::FontRef::new(&font).ok()?, 0))
|
|
})
|
|
.flatten()
|
|
.collect()
|
|
}
|
|
|
|
/// A font file holding just `tables` (sorted by tag), each padded to four bytes.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn mini_font(tables: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
|
|
let mut font = Vec::new();
|
|
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
|
// numTables, then searchRange, entrySelector and rangeShift (which readers don't need).
|
|
font.extend_from_slice(&u16::try_from(tables.len()).unwrap_or(0).to_be_bytes());
|
|
font.extend_from_slice(&[0; 6]);
|
|
let mut offset = 12 + 16 * tables.len();
|
|
for (tag, data) in tables {
|
|
font.extend_from_slice(tag);
|
|
font.extend_from_slice(&[0; 4]);
|
|
for v in [offset, data.len()] {
|
|
font.extend_from_slice(&u32::try_from(v).unwrap_or(0).to_be_bytes());
|
|
}
|
|
offset += data.len().next_multiple_of(4);
|
|
}
|
|
for (_, data) in tables {
|
|
font.extend_from_slice(data);
|
|
font.resize(font.len().next_multiple_of(4), 0);
|
|
}
|
|
font
|
|
}
|
|
|
|
/// 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<std::path::PathBuf> {
|
|
let mut dirs: Vec<std::path::PathBuf> = 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<N>
|
|
// (`Assets` before macOS 11, `AssetsV2` since).
|
|
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs", "/System/Library/Assets"] {
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
} 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 {
|
|
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
|
if let Some(h) = &home {
|
|
dirs.push(h.join(".fonts"));
|
|
dirs.push(h.join(".local/share/fonts"));
|
|
}
|
|
}
|
|
dirs
|
|
}
|
|
|
|
/// One face found in a font file.
|
|
struct Found {
|
|
/// Face index within the file.
|
|
index: u32,
|
|
family: String,
|
|
style: String,
|
|
/// The legacy family name ([`FontFace::legacy_family`]).
|
|
legacy: String,
|
|
/// The family's names in other languages ([`FontFace::local_names`]).
|
|
local: Vec<LocalName>,
|
|
/// A Chinese, Japanese or Korean font ([`FontFace::is_east_asian`]); the scan's catalog keeps it.
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
east_asian: bool,
|
|
/// Variable fonts: the named instance's axis settings.
|
|
coords: Vec<([u8; 4], f32)>,
|
|
}
|
|
|
|
/// The faces of `f` (face `index` of its file): one per named instance of a variable font
|
|
/// (InDesign lists them as styles), otherwise the font itself.
|
|
fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec<Found> {
|
|
let Some((family, style, id1)) = face_names(f) else { return vec![] };
|
|
let local = local_names(f, &family, id1.as_deref());
|
|
let east_asian = east_asian_font(f);
|
|
let axes = f.axes();
|
|
let mut named: Vec<Found> = Vec::new();
|
|
for ni in f.named_instances().iter() {
|
|
let Some(style) = name(f, &[ni.subfamily_name_id()]) else { continue };
|
|
if named.iter().any(|n| n.style.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();
|
|
// Name ID 1 names the default instance only.
|
|
let legacy = legacy_family(&family, &style);
|
|
// Name ID 1 names the default instance only: keep the names of the whole family.
|
|
let local = local.iter().filter(|n| n.english.eq_ignore_ascii_case(&family)).cloned().collect();
|
|
named.push(Found { index, family: family.clone(), style, legacy, local, east_asian, coords });
|
|
}
|
|
if !named.is_empty() {
|
|
return named;
|
|
}
|
|
let legacy = id1.filter(|n| !n.eq_ignore_ascii_case(&family)).unwrap_or_else(|| legacy_family(&family, &style));
|
|
vec![Found { index, family, style, legacy, local, east_asian, coords: Vec::new() }]
|
|
}
|
|
|
|
/// Parse every face in `data` (a font file or collection); a variable font yields one face per
|
|
/// named instance.
|
|
fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
|
let count = match FileRef::new(data) {
|
|
Ok(FileRef::Font(_)) => 1,
|
|
Ok(FileRef::Collection(c)) => c.len(),
|
|
Err(_) => 0,
|
|
};
|
|
(0..count).filter_map(|i| skrifa::FontRef::from_index(data, i).ok().map(|f| font_faces(&f, i))).flatten().collect()
|
|
}
|
|
|
|
/// The compiled-in Source Sans 3 Regular, for a database that somehow has no faces at all.
|
|
pub(crate) fn last_resort_face() -> Arc<FontFace> {
|
|
static FACE: std::sync::OnceLock<Arc<FontFace>> = 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]), 0, FALLBACK_FAMILY.into(), "Regular".into(), FALLBACK_FAMILY.into(), Vec::new())
|
|
.expect("the compiled-in Source Sans 3 Regular parses");
|
|
Arc::new(face)
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy_family: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
|
|
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 a = f.attributes();
|
|
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));
|
|
Some(FontFace {
|
|
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
|
family,
|
|
style,
|
|
legacy_family,
|
|
local_names: Vec::new(),
|
|
weight,
|
|
italic,
|
|
upem: m.units_per_em.max(1) as f64,
|
|
ascent: m.ascent as f64,
|
|
descent: -(m.descent as f64),
|
|
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
|
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
|
shaper,
|
|
coords,
|
|
location,
|
|
instance,
|
|
bytes,
|
|
index,
|
|
bmp: std::sync::OnceLock::new(),
|
|
})
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
fn norm(s: &str) -> String {
|
|
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.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,
|
|
/// so they are the fallback for Japanese text after the requested and bundled fonts; empty
|
|
/// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
|
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
|
let mut faces = Vec::new();
|
|
let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
|
for data in BUNDLED.iter().copied().chain(craft) {
|
|
for f in enumerate_faces(data) {
|
|
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
|
face.local_names = f.local;
|
|
faces.push(Arc::new(face));
|
|
}
|
|
}
|
|
}
|
|
Self {
|
|
faces: RwLock::new(faces),
|
|
outlines: Mutex::new(HashMap::new()),
|
|
fallbacks: RwLock::new(FallbackCache::default()),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
catalog: RwLock::new(Vec::new()),
|
|
font_dirs,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
cataloged: std::sync::OnceLock::new(),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
loading: Mutex::new(()),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
coverage: Mutex::new(CoverageIndex::default()),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
catalog_gen: AtomicU32::new(0),
|
|
}
|
|
}
|
|
|
|
/// 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(|e| e.into_inner()).enabled = on;
|
|
}
|
|
// Misses remembered while it was off may be found now.
|
|
self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear();
|
|
#[cfg(target_arch = "wasm32")]
|
|
let _ = on;
|
|
}
|
|
|
|
/// Process-wide database: the bundled fonts, then the installed system fonts, 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<FontDb> = std::sync::OnceLock::new();
|
|
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
|
}
|
|
|
|
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
|
self.faces.read().unwrap_or_else(|e| e.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<CatalogEntry>> {
|
|
self.ensure_catalog();
|
|
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. Like
|
|
/// Word's font list, this holds each face's legacy family name too ("Roboto Light", "Inter
|
|
/// SemiBold"), which [`FontDb::face`] resolves to that face.
|
|
pub fn families(&self) -> Vec<String> {
|
|
let mut v: Vec<String> = self.read_faces().iter().flat_map(|f| [f.family.clone(), f.legacy_family.clone()]).collect();
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
v.extend(self.read_catalog().iter().flat_map(|c| [c.family.clone(), c.legacy.clone()]));
|
|
v.sort_by_key(|a| a.to_lowercase());
|
|
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
|
v
|
|
}
|
|
|
|
/// The installed Chinese, Japanese and Korean families (English names), for the Font dialog's
|
|
/// Asian text font list.
|
|
pub fn east_asian_families(&self) -> Vec<String> {
|
|
let mut v: Vec<String> = self.read_faces().iter().filter(|f| f.is_east_asian()).map(|f| f.family.clone()).collect();
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
v.extend(self.read_catalog().iter().filter(|c| c.east_asian).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
|
|
}
|
|
|
|
/// Every family's name in the interface language `lang` (`zh-hans`, `zh-hant`, `ja`…), as
|
|
/// (English name, localized name) pairs: SimSun is 宋体 in Simplified Chinese. Families without
|
|
/// a name in that language are left out.
|
|
pub fn localized_names(&self, lang: &str) -> Vec<(String, String)> {
|
|
let mut v: Vec<(String, String)> = Vec::new();
|
|
let mut add = |names: &[LocalName]| {
|
|
for n in names.iter().filter(|n| name_language_matches(lang, &n.lang)) {
|
|
if !v.iter().any(|(e, _)| e.eq_ignore_ascii_case(&n.english)) {
|
|
v.push((n.english.clone(), n.name.clone()));
|
|
}
|
|
}
|
|
};
|
|
for f in self.read_faces().iter() {
|
|
add(&f.local_names);
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
for c in self.read_catalog().iter() {
|
|
add(&c.local);
|
|
}
|
|
v
|
|
}
|
|
|
|
/// [`FontDb::families`] as the font list shows them in the interface language `lang`: each
|
|
/// family that has a name in that language by that name (宋体, 微软雅黑 in Simplified Chinese),
|
|
/// the others by their English names. Lookups accept either name.
|
|
pub fn families_in(&self, lang: &str) -> Vec<String> {
|
|
let local: HashMap<String, String> = self.localized_names(lang).into_iter().map(|(e, l)| (e.to_lowercase(), l)).collect();
|
|
let mut v: Vec<String> = self.families().into_iter().map(|f| local.get(&f.to_lowercase()).cloned().unwrap_or(f)).collect();
|
|
v.sort_by_key(|a| a.to_lowercase());
|
|
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
|
v
|
|
}
|
|
|
|
/// The English family a localized family name stands for (宋体 → SimSun), when no face has
|
|
/// `family` as its English name. `None` for names no font has.
|
|
pub fn english_family(&self, family: &str) -> Option<String> {
|
|
let hit = |names: &[LocalName]| names.iter().find(|n| n.name.eq_ignore_ascii_case(family)).map(|n| n.english.clone());
|
|
if let Some(e) = self.read_faces().iter().find_map(|f| hit(&f.local_names)) {
|
|
return Some(e);
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if let Some(e) = self.read_catalog().iter().find_map(|c| hit(&c.local)) {
|
|
return Some(e);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Style names available for `family` (Regular first, then by weight). For a legacy family
|
|
/// name ("Roboto Light"), the styles of the faces it names ("Light", "Light Italic"); for a
|
|
/// localized name (宋体), those of the family it names.
|
|
pub fn styles(&self, family: &str) -> Vec<String> {
|
|
let v = self.styles_named(family);
|
|
match self.english_family(family) {
|
|
Some(en) if v.is_empty() => self.styles_named(&en),
|
|
_ => v,
|
|
}
|
|
}
|
|
|
|
fn styles_named(&self, family: &str) -> Vec<String> {
|
|
let mut v: Vec<(bool, f32, String)> = Vec::new();
|
|
for legacy in [false, true] {
|
|
let named = |typographic: &str, legacy_name: &str| (if legacy { legacy_name } else { typographic }).eq_ignore_ascii_case(family);
|
|
v.extend(
|
|
self.read_faces()
|
|
.iter()
|
|
.filter(|f| named(&f.family, &f.legacy_family) && !f.style.contains('{'))
|
|
.map(|f| (f.italic, f.weight, f.style.clone())),
|
|
);
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
for c in self.read_catalog().iter() {
|
|
if named(&c.family, &c.legacy) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
|
|
v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone()));
|
|
}
|
|
}
|
|
if !v.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
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.into_iter().map(|t| t.2).collect()
|
|
}
|
|
|
|
/// 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<u8>) -> usize {
|
|
self.add_fonts(vec![bytes])
|
|
}
|
|
|
|
/// Add the faces of several font files at once: lookups see all of them or none, never a
|
|
/// family with only some of its styles (which would resolve, and be cached as, the wrong
|
|
/// face). Faces already present (same family and style) are skipped. Returns how many were
|
|
/// added.
|
|
fn add_fonts(&self, files: Vec<Vec<u8>>) -> usize {
|
|
let mut new = Vec::new();
|
|
for bytes in files {
|
|
let data = Arc::new(bytes);
|
|
for f in enumerate_faces(&data) {
|
|
if let Some(mut face) = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) {
|
|
face.local_names = f.local;
|
|
new.push(Arc::new(face));
|
|
}
|
|
}
|
|
}
|
|
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
|
let mut added = 0;
|
|
for f in new {
|
|
if !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style)) {
|
|
faces.push(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();
|
|
});
|
|
}
|
|
|
|
/// 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()));
|
|
first.unwrap_or_else(|| self.scan_font_dirs())
|
|
}
|
|
|
|
#[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();
|
|
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(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
|
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
|
continue;
|
|
}
|
|
for f in file_face_names(&p) {
|
|
found.push(CatalogEntry {
|
|
family: f.family,
|
|
style: f.style,
|
|
legacy: f.legacy,
|
|
local: f.local,
|
|
east_asian: f.east_asian,
|
|
path: p.clone(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
let n = found.len();
|
|
log::debug!("cataloged {n} system font faces");
|
|
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
|
self.catalog_gen.fetch_add(1, Ordering::AcqRel);
|
|
n
|
|
}
|
|
|
|
/// Load the files of the installed `family`: the whole typographic family, also when asked for
|
|
/// by a legacy name ("Roboto Light" loads Roboto), so a family never loads with only some of
|
|
/// its styles. Returns whether any face was added.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn load_cataloged(&self, family: &str) -> bool {
|
|
let _loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
|
// Another lookup may have loaded it while this one waited.
|
|
if self.is_loaded(family) {
|
|
return true;
|
|
}
|
|
let paths: Vec<std::path::PathBuf> = {
|
|
let cat = self.read_catalog();
|
|
let families: Vec<&str> = cat
|
|
.iter()
|
|
.filter(|c| c.family.eq_ignore_ascii_case(family) || c.legacy.eq_ignore_ascii_case(family))
|
|
.map(|c| c.family.as_str())
|
|
.collect();
|
|
let mut p: Vec<_> = cat.iter().filter(|c| families.iter().any(|f| c.family.eq_ignore_ascii_case(f))).map(|c| c.path.clone()).collect();
|
|
p.sort();
|
|
p.dedup();
|
|
p
|
|
};
|
|
let files = paths.iter().filter_map(|p| std::fs::read(p).ok()).collect();
|
|
self.add_fonts(files) > 0
|
|
}
|
|
|
|
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
|
|
/// Source Sans 3 Regular. `family` is a typographic family ("Roboto") or a face's legacy family
|
|
/// name ("Roboto Light", whose styles are Light and Light Italic). Installed system fonts are
|
|
/// found by name whatever ran before.
|
|
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
|
if let Some(f) = self.find(family, style) {
|
|
return f;
|
|
}
|
|
// Look again even when this load added nothing: another thread may have just loaded it.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
self.load_cataloged(family);
|
|
if let Some(f) = self.find(family, style) {
|
|
return f;
|
|
}
|
|
}
|
|
// A localized family name (宋体 → SimSun).
|
|
if let Some(en) = self.english_family(family).filter(|en| !en.eq_ignore_ascii_case(family)) {
|
|
if let Some(f) = self.find(&en, style) {
|
|
return f;
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
self.load_cataloged(&en);
|
|
if let Some(f) = self.find(&en, style) {
|
|
return f;
|
|
}
|
|
}
|
|
}
|
|
self.find(FALLBACK_FAMILY, style)
|
|
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
|
|
.or_else(|| self.read_faces().first().cloned())
|
|
.unwrap_or_else(last_resort_face)
|
|
}
|
|
|
|
/// Is `family` available (loaded, or installed on the system)? English and localized family
|
|
/// names both count.
|
|
pub fn has_family(&self, family: &str) -> bool {
|
|
if self.is_loaded(family) {
|
|
return true;
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family) || c.legacy.eq_ignore_ascii_case(family)) {
|
|
return true;
|
|
}
|
|
self.english_family(family).is_some()
|
|
}
|
|
|
|
/// Is a face named `family` (its typographic or legacy family) loaded?
|
|
fn is_loaded(&self, family: &str) -> bool {
|
|
self.read_faces().iter().any(|f| f.named(family))
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// `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<Arc<FontFace>> {
|
|
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 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 mut f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), base.legacy_family.clone(), coords)?;
|
|
f.local_names = base.local_names.clone();
|
|
let f = Arc::new(f);
|
|
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
|
|
Some(f)
|
|
}
|
|
|
|
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
|
if style.contains('{') {
|
|
{
|
|
let faces = self.read_faces();
|
|
if let Some(f) = faces.iter().find(|f| f.named(family) && f.style == style) {
|
|
return Some(f.clone());
|
|
}
|
|
}
|
|
return self.instance(family, style);
|
|
}
|
|
let faces = self.read_faces();
|
|
let mut cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
|
if cands.is_empty() {
|
|
// A legacy family name: choose among the faces it names (bold and italic as usual).
|
|
cands = faces.iter().filter(|f| f.legacy_family.eq_ignore_ascii_case(family)).collect();
|
|
}
|
|
if cands.is_empty() {
|
|
return None;
|
|
}
|
|
let ns = norm(style);
|
|
if let Some(f) = cands.iter().find(|f| norm(&f.style) == 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())
|
|
}
|
|
|
|
/// First face (fallback family first, then load order) 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) -> Option<Arc<FontFace>> {
|
|
self.fallback_styled(c, exclude, false, false)
|
|
}
|
|
|
|
/// [`Self::fallback_for`], preferring a bold and/or italic face of the chosen family (so
|
|
/// bold Persian text in a font without Persian letters stays bold).
|
|
pub fn fallback_styled(&self, c: char, exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
|
let base = self.cached_fallback(c, exclude)?;
|
|
if !bold && !italic {
|
|
return Some(base);
|
|
}
|
|
let style = match (bold, italic) {
|
|
(true, true) => "Bold Italic",
|
|
(true, false) => "Bold",
|
|
_ => "Italic",
|
|
};
|
|
let styled = self.face(&base.family, style);
|
|
Some(if styled.family.eq_ignore_ascii_case(&base.family) && styled.covers(c) && styled.id != exclude { styled } else { base })
|
|
}
|
|
|
|
/// The fallback search, cached per (character, excluded face) until more faces load.
|
|
fn cached_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
|
let n = self.read_faces().len();
|
|
{
|
|
let cache = self.fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
|
if cache.faces == n
|
|
&& let Some(hit) = cache.map.get(&(c, exclude))
|
|
{
|
|
return hit.clone();
|
|
}
|
|
}
|
|
let found = self.search_fallback(c, exclude);
|
|
// Keyed by the face count after the search (a system fallback may have loaded fonts).
|
|
let n = self.read_faces().len();
|
|
let mut cache = self.fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
|
if cache.faces != n || cache.map.len() >= FALLBACK_CACHE_MAX {
|
|
cache.map.clear();
|
|
cache.faces = n;
|
|
}
|
|
cache.map.insert((c, exclude), found.clone());
|
|
found
|
|
}
|
|
|
|
fn search_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
|
self.loaded_fallback(c, exclude).or_else(|| {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if self.system_fallback(c) {
|
|
return self.loaded_fallback(c, exclude);
|
|
}
|
|
None
|
|
})
|
|
}
|
|
|
|
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
|
let faces = self.read_faces();
|
|
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
|
// Right-to-left letters prefer a font made for the script, so a word isn't split across
|
|
// fonts (that breaks Arabic joining).
|
|
let rank = |f: &FontFace| {
|
|
if crate::is_rtl(c) || arabic_block(c) {
|
|
RTL_FALLBACKS.iter().position(|n| f.family.eq_ignore_ascii_case(n)).unwrap_or(RTL_FALLBACKS.len())
|
|
} else {
|
|
0
|
|
}
|
|
};
|
|
order.sort_by_key(|f| (rank(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()
|
|
}
|
|
|
|
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
|
/// file under 40 MB, found through the coverage index). 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 sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
|
if !sys.enabled || sys.misses.contains(&c) {
|
|
return false;
|
|
}
|
|
}
|
|
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
|
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
|
let rtl = crate::is_rtl(c) || arabic_block(c);
|
|
let preferred = RTL_FALLBACKS.iter().filter(|_| rtl);
|
|
for fam in preferred.chain(SYSTEM_FALLBACKS.iter()) {
|
|
// Each family is tried once: one that fails to load isn't read again per character.
|
|
if self.is_loaded(fam) || !cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
|
continue;
|
|
}
|
|
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).tried.insert(fam) {
|
|
continue;
|
|
}
|
|
self.load_cataloged(fam);
|
|
if covered(self) {
|
|
return true;
|
|
}
|
|
}
|
|
if self.indexed_fallback(c) {
|
|
return true;
|
|
}
|
|
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
|
if sys.misses.len() >= SYS_MISSES_MAX {
|
|
sys.misses.clear();
|
|
}
|
|
sys.misses.insert(c);
|
|
false
|
|
}
|
|
|
|
/// Load the first cataloged font file (in path order) that maps `c`, reading each file not
|
|
/// yet indexed as the search reaches it. Returns whether a loaded face now covers `c`.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn indexed_fallback(&self, c: char) -> bool {
|
|
let cp = u32::from(c);
|
|
let mut index = self.coverage.lock().unwrap_or_else(|e| e.into_inner());
|
|
let generation = self.catalog_gen.load(Ordering::Acquire);
|
|
if index.generation != Some(generation) {
|
|
// A new scan (or the first search): keep what is known of files still installed.
|
|
let mut known: HashMap<std::path::PathBuf, IndexedFile> =
|
|
std::mem::take(&mut index.files).into_iter().map(|f| (f.path.clone(), f)).collect();
|
|
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
|
paths.sort();
|
|
paths.dedup();
|
|
index.files = paths.into_iter().map(|path| known.remove(&path).unwrap_or(IndexedFile { path, ranges: None, used: false })).collect();
|
|
index.generation = Some(generation);
|
|
}
|
|
let CoverageIndex { files, reads, .. } = &mut *index;
|
|
for file in files.iter_mut().filter(|f| !f.used) {
|
|
let mut data = None;
|
|
match &file.ranges {
|
|
Some(r) if !ranges_hold(r, cp) => continue,
|
|
Some(_) => {}
|
|
None => {
|
|
*reads += 1;
|
|
data = read_fallback_file(&file.path);
|
|
let ranges = data.as_deref().map(coverage_ranges).unwrap_or_default();
|
|
let hit = ranges_hold(&ranges, cp);
|
|
file.ranges = Some(ranges);
|
|
if !hit {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
// The file maps `c`: load it (once; its faces are loaded fonts from now on).
|
|
file.used = true;
|
|
if data.is_none() {
|
|
*reads += 1;
|
|
data = read_fallback_file(&file.path);
|
|
}
|
|
if let Some(data) = data
|
|
&& self.add_font(data) > 0
|
|
&& self.read_faces().iter().any(|f| f.covers(c))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
|
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
|
let key = (face.id, gid);
|
|
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
|
return p.clone();
|
|
}
|
|
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);
|
|
}
|
|
let p = Arc::new(pen.0);
|
|
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
|
if cache.len() >= OUTLINE_CACHE_MAX {
|
|
cache.clear();
|
|
}
|
|
cache.insert(key, p.clone());
|
|
p
|
|
}
|
|
}
|
|
|
|
struct FlipPen(BezPath);
|
|
|
|
impl OutlinePen for FlipPen {
|
|
fn move_to(&mut self, x: f32, y: f32) {
|
|
self.0.move_to((x as f64, -y as f64));
|
|
}
|
|
fn line_to(&mut self, x: f32, y: f32) {
|
|
self.0.line_to((x as f64, -y as f64));
|
|
}
|
|
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
|
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
|
}
|
|
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
|
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
|
}
|
|
fn close(&mut self) {
|
|
self.0.close_path();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[path = "tests_sysfonts.rs"]
|
|
mod tests_sysfonts;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn legacy_family_keeps_the_style_words_that_are_not_regular_bold_or_italic() {
|
|
for (family, style, legacy) in [
|
|
("Roboto", "Regular", "Roboto"),
|
|
("Roboto", "Bold Italic", "Roboto"),
|
|
("Roboto", "italic", "Roboto"),
|
|
("Roboto", "Light", "Roboto Light"),
|
|
("Roboto", "Light Italic", "Roboto Light"),
|
|
("Roboto", "Condensed Bold", "Roboto Condensed"),
|
|
("Inter", "SemiBold", "Inter SemiBold"),
|
|
("Inter", "ExtraBold Italic", "Inter ExtraBold"),
|
|
("Inter", "Medium {wght:520}", "Inter Medium"),
|
|
("Inter", "", "Inter"),
|
|
] {
|
|
assert_eq!(legacy_family(family, style), legacy, "{family} {style}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn faces_are_found_by_their_legacy_family_name() {
|
|
// Bundled static fonts whose name ID 1 differs from the typographic family (#219).
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
let families = db.families();
|
|
for name in ["Inter", "Inter Medium", "Inter SemiBold", "Source Sans 3 Semibold", "Source Serif 4 Semibold"] {
|
|
assert!(families.iter().any(|f| f == name), "{name} in {families:?}");
|
|
assert!(db.has_family(name));
|
|
}
|
|
let semibold = db.face("Source Serif 4 Semibold", "Regular");
|
|
assert_eq!((semibold.family.as_str(), semibold.style.as_str()), ("Source Serif 4", "Semibold"));
|
|
assert_eq!(db.face("inter medium", "Regular").style, "Medium");
|
|
assert_eq!(db.face("Inter SemiBold", "Regular").style, "SemiBold");
|
|
// Bold and italic choose within the faces the legacy name covers; the family's own Bold
|
|
// isn't one of them.
|
|
assert_eq!(db.face("Source Serif 4 Semibold", "Bold").style, "Semibold");
|
|
assert_eq!(db.face("Source Serif 4 Semibold", "Italic").style, "Semibold");
|
|
assert_eq!(db.styles("Source Sans 3 Semibold"), ["Semibold"]);
|
|
// The typographic family is unchanged.
|
|
assert_eq!(db.face("Source Serif 4", "Bold").style, "Bold");
|
|
assert_eq!(db.face("Source Serif 4", "Regular").style, "Regular");
|
|
assert_eq!(db.face("Source Serif 4", "Regular").legacy_family, "Source Serif 4");
|
|
// Not a name any face has.
|
|
assert!(!db.has_family("Source Serif 4 Light"));
|
|
assert_eq!(db.face("Source Serif 4 Light", "Regular").family, FALLBACK_FAMILY);
|
|
}
|
|
|
|
#[test]
|
|
fn a_legacy_named_instance_with_axis_settings_is_made_once() {
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
let a = db.face("Inter Medium", "Medium {wght:520}");
|
|
let b = db.face("Inter Medium", "Medium {wght:520}");
|
|
assert_eq!(a.id(), b.id());
|
|
assert_eq!(a.legacy_family, "Inter Medium");
|
|
}
|
|
}
|