1109 lines
47 KiB
Rust
1109 lines
47 KiB
Rust
//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged once, loaded
|
|
//! on demand), outline cache.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex, RwLock};
|
|
|
|
use kurbo::BezPath;
|
|
use skrifa::instance::{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 (Illustrator's Myriad Pro analogue).
|
|
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
|
|
|
|
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/Inter-Regular.ttf"),
|
|
include_bytes!("../../../assets/fonts/Inter-Medium.ttf"),
|
|
include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"),
|
|
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
|
|
];
|
|
|
|
enum FontBytes {
|
|
Static(&'static [u8]),
|
|
Owned(Arc<Vec<u8>>),
|
|
}
|
|
|
|
/// One loaded font face.
|
|
pub struct FontFace {
|
|
/// Every name the face answers to (normalized), in every language its name table has.
|
|
keys: FaceKeys,
|
|
id: u32,
|
|
/// Typographic family name (e.g. "Source Sans 3").
|
|
pub family: String,
|
|
/// Typographic style name (e.g. "Semibold", "Italic").
|
|
pub style: String,
|
|
/// usWeightClass-style weight (400 = regular).
|
|
pub weight: f32,
|
|
pub italic: bool,
|
|
bytes: FontBytes,
|
|
index: u32,
|
|
/// The file the face was read from (cataloged system fonts); `None` for the bundled fonts and
|
|
/// fonts added as bytes.
|
|
path: Option<std::path::PathBuf>,
|
|
pub(crate) upem: f64,
|
|
/// Ascender in font units (positive = up).
|
|
pub(crate) ascent: f64,
|
|
/// Descender in font units (positive = down).
|
|
pub(crate) descent: f64,
|
|
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
|
|
/// values).
|
|
pub(crate) cap_height: f64,
|
|
pub(crate) x_height: f64,
|
|
pub(crate) shaper: harfrust::ShaperData,
|
|
}
|
|
|
|
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(crate) fn data(&self) -> &[u8] {
|
|
match &self.bytes {
|
|
FontBytes::Static(b) => b,
|
|
FontBytes::Owned(v) => v.as_slice(),
|
|
}
|
|
}
|
|
pub(crate) fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
|
|
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
|
}
|
|
pub(crate) fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
|
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
|
}
|
|
/// The face's index in its font file (collections hold several).
|
|
pub(crate) fn index(&self) -> u32 {
|
|
self.index
|
|
}
|
|
/// Unique id of this face within the process.
|
|
pub fn id(&self) -> u32 {
|
|
self.id
|
|
}
|
|
/// Does the face map `c` to a glyph?
|
|
pub fn covers(&self, c: char) -> bool {
|
|
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(), LocationRef::default()).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)
|
|
}
|
|
/// The file the face was read from (cataloged system fonts).
|
|
pub fn path(&self) -> Option<&std::path::Path> {
|
|
self.path.as_deref()
|
|
}
|
|
/// The bytes of the face's font file (a collection holds several faces).
|
|
pub fn file_data(&self) -> &[u8] {
|
|
self.data()
|
|
}
|
|
/// The embedding permissions of the OS/2 table (`fsType`; 0, installable, when it has none).
|
|
pub fn fs_type(&self) -> u16 {
|
|
use skrifa::raw::TableProvider;
|
|
self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type())
|
|
}
|
|
/// May the font file be copied along with a document (its licence doesn't restrict embedding)?
|
|
pub fn embeddable(&self) -> bool {
|
|
self.fs_type() & 0x000f != 0x0002
|
|
}
|
|
/// The face's index in its font file ([`Self::file_data`]; collections hold several).
|
|
pub fn face_index(&self) -> u32 {
|
|
self.index
|
|
}
|
|
}
|
|
|
|
/// One installed family found by the system font scan: its name and the file of each style.
|
|
#[derive(Debug, Default)]
|
|
struct CatalogFamily {
|
|
name: String,
|
|
/// (style, file) of each face.
|
|
faces: Vec<(String, PathBuf)>,
|
|
}
|
|
|
|
/// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as
|
|
/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts.
|
|
type Catalog = HashMap<String, CatalogFamily>;
|
|
|
|
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
|
|
type PostScriptNames = HashMap<String, (String, String)>;
|
|
|
|
/// Process-wide font database.
|
|
pub struct FontDb {
|
|
faces: RwLock<Vec<Arc<FontFace>>>,
|
|
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
|
catalog: RwLock<Catalog>,
|
|
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
|
|
postscript: RwLock<PostScriptNames>,
|
|
/// 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<PathBuf>,
|
|
/// The installed fonts' other names (localized, legacy, PostScript) by normalized name.
|
|
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
|
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
|
/// scan, so what they find never depends on what ran before them.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
cataloged: std::sync::OnceLock<()>,
|
|
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
|
|
family_cache: Mutex<Option<Arc<[String]>>>,
|
|
generation: AtomicU64,
|
|
/// System fallback state: characters no system font covers.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
sys: Mutex<SysFallback>,
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[derive(Default)]
|
|
struct SysFallback {
|
|
enabled: bool,
|
|
misses: std::collections::HashSet<char>,
|
|
}
|
|
|
|
/// 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",
|
|
];
|
|
|
|
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()))
|
|
}
|
|
|
|
/// Every localized string of `id`, with its language.
|
|
fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, String)> {
|
|
font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect()
|
|
}
|
|
|
|
/// Every localized string of `id`.
|
|
fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<String> {
|
|
localized(font, id).into_iter().map(|(_, s)| s).collect()
|
|
}
|
|
|
|
/// The names a face answers to besides its (English) family and style, normalized ([`norm`]):
|
|
/// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise
|
|
/// (`ミディアム`), its legacy family names (`Hiragino Sans W3`) with their legacy styles, paired per
|
|
/// language record (the Mac and Windows records of a face differ), and its PostScript names
|
|
/// (`HiraginoSans-W3`, as PDF and `.ai` files name fonts).
|
|
#[derive(Clone, Debug, Default)]
|
|
struct FaceKeys {
|
|
families: Vec<String>,
|
|
styles: Vec<String>,
|
|
legacy: Vec<(String, String)>,
|
|
postscript: Vec<String>,
|
|
}
|
|
|
|
impl FaceKeys {
|
|
fn of(font: &skrifa::FontRef<'_>, family: &str, style: &str) -> Self {
|
|
let (typo_family, typo_style) = (all_names(font, StringId::TYPOGRAPHIC_FAMILY_NAME), all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME));
|
|
let (legacy_family, legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME));
|
|
// Without typographic names, the legacy ones are the family's.
|
|
let family_names: Vec<&str> = if typo_family.is_empty() {
|
|
legacy_family.iter().map(|(_, f)| f.as_str()).collect()
|
|
} else {
|
|
typo_family.iter().map(String::as_str).collect()
|
|
};
|
|
let mut families: Vec<String> = std::iter::once(family).chain(family_names).map(norm).collect();
|
|
// A legacy style only names the face beside its legacy family when the font has a
|
|
// typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family.
|
|
let style_names: Vec<&str> = if typo_style.is_empty() {
|
|
legacy_style.iter().map(|(_, s)| s.as_str()).collect()
|
|
} else {
|
|
typo_style.iter().map(String::as_str).collect()
|
|
};
|
|
let mut styles: Vec<String> = std::iter::once(style).chain(style_names).map(norm).collect();
|
|
let mut legacy: Vec<(String, String)> = if typo_family.is_empty() {
|
|
vec![]
|
|
} else {
|
|
legacy_family
|
|
.iter()
|
|
.flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s))))
|
|
.filter(|(f, s)| !f.is_empty() && !s.is_empty())
|
|
.collect()
|
|
};
|
|
let mut postscript: Vec<String> = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect();
|
|
for v in [&mut families, &mut styles, &mut postscript] {
|
|
v.retain(|k| !k.is_empty());
|
|
v.sort();
|
|
v.dedup();
|
|
}
|
|
legacy.sort();
|
|
legacy.dedup();
|
|
FaceKeys { families, styles, legacy, postscript }
|
|
}
|
|
}
|
|
|
|
/// What a name other than a family's own one stands for ([`FontDb::canonical`]).
|
|
#[derive(Clone, Debug)]
|
|
struct Alias {
|
|
/// The family's own name.
|
|
family: String,
|
|
/// The face the name names, for legacy family names and PostScript names.
|
|
style: Option<String>,
|
|
/// A legacy family name's legacy style (`Regular` beside `Hiragino Sans W3`).
|
|
paired: Option<String>,
|
|
}
|
|
|
|
impl Alias {
|
|
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
|
|
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
|
|
let mut v: Vec<(String, Alias)> =
|
|
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
|
|
v.extend(
|
|
keys.legacy
|
|
.iter()
|
|
.map(|(f, s)| (f.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: Some(s.clone()) })),
|
|
);
|
|
v.extend(keys.postscript.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: None })));
|
|
v
|
|
}
|
|
}
|
|
|
|
/// How a requested family and style resolved ([`FontDb::resolve`]).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum FontMatch {
|
|
/// The face of that family and style (by any of its names, in any language).
|
|
Exact,
|
|
/// The family is there but not the style: its closest style stands in.
|
|
Style,
|
|
/// The family is unknown: the fallback family stands in.
|
|
Missing,
|
|
}
|
|
|
|
impl FontMatch {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
FontMatch::Exact => "exact",
|
|
FontMatch::Style => "substitute",
|
|
FontMatch::Missing => "missing",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A face's (family, style) names.
|
|
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))
|
|
}
|
|
|
|
/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
|
|
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
|
let count = match FileRef::new(data) {
|
|
Ok(FileRef::Font(_)) => 1,
|
|
Ok(FileRef::Collection(c)) => c.len(),
|
|
Err(_) => 0,
|
|
};
|
|
(0..count)
|
|
.filter_map(|i| {
|
|
let (family, style) = face_names(&skrifa::FontRef::from_index(data, i).ok()?)?;
|
|
Some((i, family, style))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// (family, style, PostScript name) of every face in the font file at `path`, reading only its
|
|
/// table directories and `name` tables: a scan opens hundreds of font files, many of them
|
|
/// megabytes long.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> {
|
|
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;
|
|
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 rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?;
|
|
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
|
if len > MAX_NAME_TABLE {
|
|
return None;
|
|
}
|
|
let table = read_at(offset.into(), len as usize)?;
|
|
// A one-table font holding just the `name` table, to read it as the font itself would.
|
|
let mut font = Vec::with_capacity(28 + table.len());
|
|
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
|
font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]);
|
|
font.extend_from_slice(b"name");
|
|
for v in [0, 28, len] {
|
|
font.extend_from_slice(&u32::to_be_bytes(v));
|
|
}
|
|
font.extend_from_slice(&table);
|
|
let f = skrifa::FontRef::new(&font).ok()?;
|
|
let (family, style) = face_names(&f)?;
|
|
let keys = FaceKeys::of(&f, &family, &style);
|
|
Some((family, style, keys))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
|
pub fn system_font_dirs() -> Vec<PathBuf> {
|
|
let mut dirs: Vec<PathBuf> = Vec::new();
|
|
if cfg!(target_arch = "wasm32") {
|
|
return dirs;
|
|
}
|
|
let home = std::env::var_os("HOME").map(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 downloads on demand (Yu Gothic, Yu Mincho, Tsukushi Maru Gothic…): one
|
|
// asset folder per font, under a catalog folder whose number changes with macOS.
|
|
for e in std::fs::read_dir("/System/Library/AssetsV2").into_iter().flatten().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(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
|
dirs.push(root.join("Fonts"));
|
|
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
|
dirs.push(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
|
|
}
|
|
|
|
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option<std::path::PathBuf>) -> 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 m = f.metrics(Size::unscaled(), LocationRef::default());
|
|
let a = f.attributes();
|
|
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
|
|
let keys = FaceKeys::of(&f, &family, &style);
|
|
Some(FontFace {
|
|
keys,
|
|
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
|
family,
|
|
style,
|
|
weight: a.weight.value(),
|
|
italic: !matches!(a.style, skrifa::attribute::Style::Normal),
|
|
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,
|
|
bytes,
|
|
index,
|
|
path,
|
|
})
|
|
}
|
|
|
|
/// `s` lowercased, without anything but letters and digits.
|
|
fn norm_chars(s: &str) -> impl Iterator<Item = char> + '_ {
|
|
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase)
|
|
}
|
|
|
|
fn norm(s: &str) -> String {
|
|
norm_chars(s).collect()
|
|
}
|
|
|
|
/// Weight implied by a style name (400 = regular).
|
|
pub 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, whose system font scan reads `font_dirs`.
|
|
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
|
|
let mut faces = Vec::new();
|
|
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
|
|
// first: fallbacks for Japanese text after the requested and bundled fonts.
|
|
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
|
for data in BUNDLED.iter().copied().chain(craft) {
|
|
for (i, family, style) in enumerate_faces(data) {
|
|
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) {
|
|
faces.push(Arc::new(f));
|
|
}
|
|
}
|
|
}
|
|
Self {
|
|
faces: RwLock::new(faces),
|
|
outlines: Mutex::new(HashMap::new()),
|
|
catalog: RwLock::new(Catalog::new()),
|
|
postscript: RwLock::new(PostScriptNames::new()),
|
|
font_dirs,
|
|
aliases: RwLock::new(HashMap::new()),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
cataloged: std::sync::OnceLock::new(),
|
|
family_cache: Mutex::new(None),
|
|
generation: AtomicU64::new(0),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
#[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())
|
|
}
|
|
|
|
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Catalog> {
|
|
self.ensure_catalog();
|
|
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Fonts were added or the catalog changed: cached family lists are stale.
|
|
fn changed(&self) {
|
|
*self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
self.generation.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
/// A number that changes whenever the available fonts do (fonts added, system fonts
|
|
/// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't.
|
|
pub fn generation(&self) -> u64 {
|
|
self.generation.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Family names available (loaded plus installed system fonts), sorted and deduplicated.
|
|
pub fn families(&self) -> Vec<String> {
|
|
self.family_list().to_vec()
|
|
}
|
|
|
|
/// [`FontDb::families`], shared: cheap to call every frame.
|
|
pub fn family_list(&self) -> Arc<[String]> {
|
|
let catalog = self.read_catalog();
|
|
let mut cache = self.family_cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(list) = cache.as_ref() {
|
|
return list.clone();
|
|
}
|
|
let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();
|
|
v.extend(catalog.values().map(|c| c.name.clone()));
|
|
v.sort_by_key(|a| a.to_lowercase());
|
|
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
|
let list: Arc<[String]> = v.into();
|
|
*cache = Some(list.clone());
|
|
list
|
|
}
|
|
|
|
/// Style names available for `family`: upright styles by weight, then italics.
|
|
pub fn styles(&self, family: &str) -> Vec<String> {
|
|
let (family, _) = self.canonical(family, "");
|
|
let family = family.as_str();
|
|
let mut v: Vec<(bool, f32, String)> =
|
|
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
|
|
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
|
|
for (style, _) in &c.faces {
|
|
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) {
|
|
v.push((style_italic(style), style_weight(style), style.clone()));
|
|
}
|
|
}
|
|
}
|
|
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_font_from(bytes, None)
|
|
}
|
|
|
|
/// [`FontDb::add_font`] for the bytes of the file at `path` (when known).
|
|
fn add_font_from(&self, bytes: Vec<u8>, path: Option<&Path>) -> usize {
|
|
let data = Arc::new(bytes);
|
|
let mut added = 0;
|
|
for (i, family, style) in enumerate_faces(&data) {
|
|
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
|
|
continue;
|
|
}
|
|
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(Path::to_path_buf)) {
|
|
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
|
|
added += 1;
|
|
}
|
|
}
|
|
if added > 0 {
|
|
self.changed();
|
|
}
|
|
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. A no-op on wasm.
|
|
fn ensure_catalog(&self) {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
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 catalog = Catalog::new();
|
|
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
|
let mut postscript = PostScriptNames::new();
|
|
let mut n = 0;
|
|
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 (family, style, keys) in file_face_names(&p) {
|
|
for (k, a) in Alias::of(&family, &style, &keys) {
|
|
aliases.entry(k).or_default().push(a);
|
|
}
|
|
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
|
|
for ps in &keys.postscript {
|
|
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
|
}
|
|
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
|
if entry.name.is_empty() {
|
|
entry.name = family;
|
|
}
|
|
entry.faces.push((style, p.clone()));
|
|
n += 1;
|
|
}
|
|
}
|
|
}
|
|
log::debug!("cataloged {n} system font faces");
|
|
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
|
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
|
|
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
|
|
self.changed();
|
|
n
|
|
}
|
|
|
|
/// The family's own name and the style for a name given in a document: any of a family's
|
|
/// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its
|
|
/// legacy style names the W6 face) or a PostScript name (`HiraginoSans-W6`, as PDF and `.ai`
|
|
/// files name fonts). Case and anything but letters and digits are ignored. A name nothing
|
|
/// answers to comes back as it is.
|
|
pub fn canonical(&self, family: &str, style: &str) -> (String, String) {
|
|
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
|
|
return (family.to_string(), style.to_string());
|
|
}
|
|
let key = norm(family);
|
|
let mut found: Vec<Alias> =
|
|
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
|
|
if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
|
found.extend(v.iter().cloned());
|
|
}
|
|
let ns = norm(style);
|
|
// A family name keeps the style asked for; a legacy pair names its face; a PostScript name
|
|
// its face whatever style is asked for.
|
|
if let Some(a) = found.iter().find(|a| a.style.is_none()) {
|
|
return (a.family.clone(), style.to_string());
|
|
}
|
|
if let Some(a) = found.iter().find(|a| a.paired.as_ref().is_some_and(|p| *p == ns) || a.paired.is_none()) {
|
|
return (a.family.clone(), a.style.clone().unwrap_or_else(|| style.to_string()));
|
|
}
|
|
match found.first() {
|
|
Some(a) => (a.family.clone(), style.to_string()),
|
|
None => (family.to_string(), style.to_string()),
|
|
}
|
|
}
|
|
|
|
/// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how
|
|
/// it matched.
|
|
pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc<FontFace>, FontMatch)> {
|
|
let (family, style) = self.canonical(family, style);
|
|
let f = self.face(&family, &style)?;
|
|
let m = if !f.family.eq_ignore_ascii_case(&family) {
|
|
FontMatch::Missing
|
|
} else if norm(&f.style) == norm(&style) || f.keys.styles.contains(&norm(&style)) {
|
|
FontMatch::Exact
|
|
} else {
|
|
FontMatch::Style
|
|
};
|
|
Some((f, m))
|
|
}
|
|
|
|
/// The installed face whose PostScript name is `name` (`HiraginoSans-W3`; any case), loaded
|
|
/// from the system fonts if need be. `None` when no installed font has the name.
|
|
pub fn find_postscript(&self, name: &str) -> Option<Arc<FontFace>> {
|
|
let key = norm(name);
|
|
if key.is_empty() {
|
|
return None;
|
|
}
|
|
let loaded = |db: &FontDb| db.read_faces().iter().find(|f| f.keys.postscript.contains(&key)).cloned();
|
|
if let Some(f) = loaded(self) {
|
|
return Some(f);
|
|
}
|
|
self.ensure_catalog();
|
|
let face = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key).and_then(|v| {
|
|
v.iter().find(|a| a.paired.is_none() && a.style.is_some()).map(|a| (a.family.clone(), a.style.clone().unwrap_or_default()))
|
|
});
|
|
let (family, style) = face?;
|
|
self.face(&family, &style)?;
|
|
loaded(self)
|
|
}
|
|
|
|
/// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII
|
|
/// case). Documents name fonts this way, and a family can hold hyphens
|
|
/// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split.
|
|
pub fn by_postscript_name(&self, name: &str) -> Option<(String, String)> {
|
|
self.ensure_catalog();
|
|
self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&norm(name)).cloned()
|
|
}
|
|
|
|
/// 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 mut paths: Vec<PathBuf> =
|
|
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|(_, p)| p.clone()).collect()).unwrap_or_default();
|
|
paths.sort();
|
|
paths.dedup();
|
|
let mut any = false;
|
|
for p in paths {
|
|
if let Ok(data) = std::fs::read(&p) {
|
|
any |= self.add_font_from(data, Some(&p)) > 0;
|
|
}
|
|
}
|
|
any
|
|
}
|
|
|
|
/// 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. `None`
|
|
/// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has
|
|
/// no glyphs.
|
|
pub fn face(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
|
let (family, style) = self.canonical(family, style);
|
|
let (family, style) = (family.as_str(), style.as_str());
|
|
let found = self.find(family, style);
|
|
if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) {
|
|
return found;
|
|
}
|
|
// The family, or this style of it, is installed but not loaded yet. Looked for again
|
|
// whether or not this call loaded it: another thread may have just done so (then this
|
|
// load adds nothing, but the face is there).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if found.is_none() || self.is_cataloged(family, style) {
|
|
self.load_cataloged(family);
|
|
if let Some(f) = self.find(family, style) {
|
|
return Some(f);
|
|
}
|
|
}
|
|
found
|
|
.or_else(|| self.find(FALLBACK_FAMILY, style))
|
|
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
|
|
.or_else(|| self.read_faces().first().cloned())
|
|
}
|
|
|
|
/// Is `style` of `family` among the installed fonts?
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn is_cataloged(&self, family: &str, style: &str) -> bool {
|
|
let ns = norm(style);
|
|
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|(s, _)| norm(s) == ns))
|
|
}
|
|
|
|
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
|
|
pub fn face_by_id(&self, id: u32) -> Option<Arc<FontFace>> {
|
|
self.read_faces().iter().find(|f| f.id == id).cloned()
|
|
}
|
|
|
|
/// The available family `name` names, ignoring case and anything but letters and digits, as
|
|
/// PostScript names write families ("MicrosoftYaHei" is Microsoft YaHei).
|
|
pub fn find_family(&self, name: &str) -> Option<String> {
|
|
self.family_list().iter().find(|f| norm_chars(f).eq(norm_chars(name))).cloned()
|
|
}
|
|
|
|
/// Is `family` available (loaded, or installed on the system)?
|
|
pub fn has_family(&self, family: &str) -> bool {
|
|
let (family, _) = self.canonical(family, "");
|
|
self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
|
|
}
|
|
|
|
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
|
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
|
}
|
|
|
|
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
|
let faces = self.read_faces();
|
|
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.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 loaded lazily the first time no loaded face covers a character.
|
|
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
|
if let Some(f) = self.loaded_fallback(c, exclude) {
|
|
return Some(f);
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if self.system_fallback(c) {
|
|
return self.loaded_fallback(c, exclude);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// A face that covers `c`, for text drawn outside the canvas (the app's own UI): the loaded
|
|
/// fonts first, then (native) an installed one, loaded on demand. Characters no font covers
|
|
/// are remembered, so they are looked for once.
|
|
pub fn face_covering(&self, c: char) -> Option<Arc<FontFace>> {
|
|
self.fallback_for(c, 0)
|
|
}
|
|
|
|
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();
|
|
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()
|
|
}
|
|
|
|
/// 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 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));
|
|
for fam in SYSTEM_FALLBACKS {
|
|
if !self.is_loaded(fam) && self.load_cataloged(fam) && covered(self) {
|
|
return true;
|
|
}
|
|
}
|
|
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect();
|
|
paths.sort();
|
|
paths.dedup();
|
|
for p in paths {
|
|
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
|
|
continue;
|
|
}
|
|
let Ok(data) = std::fs::read(&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_font_from(data, Some(&p)) > 0 && covered(self) {
|
|
return true;
|
|
}
|
|
}
|
|
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
|
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(), LocationRef::default()), &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)]
|
|
mod alias_tests {
|
|
use super::*;
|
|
|
|
/// A one-table font holding a name table from (platform, language, name id, string) records:
|
|
/// Windows Unicode (UTF-16) or Mac Roman (ASCII here).
|
|
fn name_font(records: &[(u16, u16, u16, &str)]) -> Vec<u8> {
|
|
let (mut head, mut strings) = (vec![], vec![]);
|
|
let n = records.len() as u16;
|
|
for v in [0, n, 6 + 12 * n] {
|
|
head.extend_from_slice(&v.to_be_bytes());
|
|
}
|
|
for &(platform, lang, id, s) in records {
|
|
let bytes: Vec<u8> = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() };
|
|
let encoding = if platform == 3 { 1 } else { 0 };
|
|
for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] {
|
|
head.extend_from_slice(&v.to_be_bytes());
|
|
}
|
|
strings.extend(bytes);
|
|
}
|
|
head.extend(strings);
|
|
let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
|
font.extend_from_slice(b"name");
|
|
for v in [0u32, 28, head.len() as u32] {
|
|
font.extend_from_slice(&v.to_be_bytes());
|
|
}
|
|
font.extend(head);
|
|
font
|
|
}
|
|
|
|
fn keys(records: &[(u16, u16, u16, &str)]) -> (String, String, FaceKeys) {
|
|
let font = name_font(records);
|
|
let f = skrifa::FontRef::new(&font).unwrap();
|
|
let (family, style) = face_names(&f).unwrap();
|
|
let k = FaceKeys::of(&f, &family, &style);
|
|
(family, style, k)
|
|
}
|
|
|
|
const EN: u16 = 0x0409;
|
|
const JA: u16 = 0x0411;
|
|
|
|
/// Laid out like the name table of Hiragino Sans W3.
|
|
const HIRAGINO_W3: &[(u16, u16, u16, &str)] = &[
|
|
(1, 0, 1, "Hiragino Sans"),
|
|
(1, 0, 2, "W3"),
|
|
(3, EN, 1, "Hiragino Sans W3"),
|
|
(3, EN, 2, "Regular"),
|
|
(3, JA, 1, "ヒラギノ角ゴシック W3"),
|
|
(3, JA, 2, "Regular"),
|
|
(3, EN, 6, "HiraginoSans-W3"),
|
|
(3, EN, 16, "Hiragino Sans"),
|
|
(3, JA, 16, "ヒラギノ角ゴシック"),
|
|
(3, EN, 17, "W3"),
|
|
(3, JA, 17, "W3"),
|
|
];
|
|
|
|
#[test]
|
|
fn a_face_answers_to_its_names_in_every_language() {
|
|
let (family, style, k) = keys(HIRAGINO_W3);
|
|
assert_eq!((family.as_str(), style.as_str()), ("Hiragino Sans", "W3"));
|
|
let aliases = Alias::of(&family, &style, &k);
|
|
let find = |name: &str| aliases.iter().filter(|(n, _)| *n == norm(name)).map(|(_, a)| a.clone()).collect::<Vec<_>>();
|
|
assert!(find("ヒラギノ 角ゴシック").iter().any(|a| a.family == "Hiragino Sans" && a.style.is_none()), "a family name in Japanese");
|
|
let legacy = find("Hiragino Sans W3");
|
|
assert!(legacy.iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.as_deref() == Some("regular")), "{legacy:?}");
|
|
assert!(!legacy.iter().any(|a| a.paired.as_deref() == Some("w3")), "Mac family + Windows style is no pair");
|
|
assert!(find("HiraginoSans-W3").iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.is_none()), "the PostScript name");
|
|
}
|
|
|
|
#[test]
|
|
fn without_typographic_names_the_legacy_ones_are_the_familys() {
|
|
let (family, style, k) = keys(&[(3, EN, 1, "Example Serif"), (3, EN, 2, "Bold Italic"), (3, JA, 1, "例セリフ"), (3, JA, 2, "太字斜体")]);
|
|
assert_eq!((family.as_str(), style.as_str()), ("Example Serif", "Bold Italic"));
|
|
assert!(k.families.contains(&norm("例セリフ")) && k.styles.contains(&norm("太字斜体")) && k.legacy.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_says_how_a_font_matched() {
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.family.clone(), face.style.clone(), m)).unwrap();
|
|
assert_eq!(m("Source Sans 3", "Semibold"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
|
|
assert_eq!(m("Source Sans 3", "Black").2, FontMatch::Style);
|
|
assert_eq!(m("No Such Family", "Regular"), (FALLBACK_FAMILY.into(), "Regular".into(), FontMatch::Missing));
|
|
// A PostScript name names its face, whatever style is asked for (text opened from a PDF).
|
|
assert_eq!(m("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
|
|
let semi = db.find_postscript("sourcesans3-semibold").unwrap();
|
|
assert_eq!(semi.style, "Semibold");
|
|
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
|
|
assert!(db.has_family("SourceSans3-Regular"));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod race_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_family_loaded_by_another_thread_meanwhile_is_still_found() {
|
|
// Two threads ask for the same installed family at once: each must get it, not the
|
|
// fallback (the second one's load finds every face already added).
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
|
|
let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir])));
|
|
// Only the catalog knows Inter (the bundled faces are dropped).
|
|
db.faces.write().unwrap().retain(|f| f.family != "Inter");
|
|
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(move || db.face("Inter", "Regular").map(|f| f.family.clone()))).collect();
|
|
for t in threads {
|
|
assert_eq!(t.join().unwrap().as_deref(), Some("Inter"));
|
|
}
|
|
}
|
|
}
|