mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
475 lines
17 KiB
Rust
475 lines
17 KiB
Rust
//! The font database: bundled fonts (always available, also on the web) plus fonts discovered by
|
|
//! [`FontSource`]s (system font folders on native platforms).
|
|
//!
|
|
//! Faces are addressed by [`FaceId`] (an index that never changes for the life of the process).
|
|
//! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic
|
|
//! bold/italic needed when the family lacks that style. Unknown families fall back to Inter.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, OnceLock, RwLock};
|
|
|
|
use skrifa::instance::{LocationRef, Size};
|
|
use skrifa::{FontRef, MetadataProvider};
|
|
|
|
use crate::sfnt::{FaceNames, read_faces, read_faces_bytes};
|
|
|
|
/// Bundled fonts (SIL OFL 1.1; see `assets/fonts/*.attribution` and ATTRIBUTION.md).
|
|
pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
|
pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
|
|
pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
|
|
pub static INTER_BOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-Bold.ttf");
|
|
pub static INTER_ITALIC: &[u8] = include_bytes!("../../../assets/fonts/Inter-Italic.ttf");
|
|
pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
|
|
pub static NOTO_SERIF_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/NotoSerif-Regular.ttf");
|
|
|
|
/// The default family for new text.
|
|
pub const DEFAULT_FAMILY: &str = "Inter";
|
|
|
|
/// Index of a face in the database.
|
|
pub type FaceId = usize;
|
|
|
|
/// Where a face's bytes come from.
|
|
#[derive(Clone, Debug)]
|
|
pub enum FaceData {
|
|
Static(&'static [u8]),
|
|
Shared(Arc<Vec<u8>>),
|
|
/// A file read on first use.
|
|
File(PathBuf),
|
|
}
|
|
|
|
/// Description of a face offered by a [`FontSource`].
|
|
#[derive(Clone, Debug)]
|
|
pub struct FaceInfo {
|
|
pub family: String,
|
|
pub style: String,
|
|
pub weight: u16,
|
|
pub italic: bool,
|
|
pub index: u32,
|
|
pub data: FaceData,
|
|
/// "bundled", "system", …
|
|
pub origin: &'static str,
|
|
}
|
|
|
|
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
|
|
pub trait FontSource: Send + Sync {
|
|
fn faces(&self) -> Vec<FaceInfo>;
|
|
}
|
|
|
|
/// Scans font folders on disk (reading only name tables). Native only; on the web there are no
|
|
/// folders and the scan finds nothing.
|
|
pub struct DirectorySource {
|
|
pub dirs: Vec<PathBuf>,
|
|
}
|
|
|
|
impl DirectorySource {
|
|
/// The platform's standard font folders.
|
|
pub fn system() -> Self {
|
|
let mut dirs: Vec<PathBuf> = Vec::new();
|
|
let home = std::env::var_os("HOME").map(PathBuf::from);
|
|
if cfg!(target_os = "macos") {
|
|
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(PathBuf::from));
|
|
if let Some(h) = &home {
|
|
dirs.push(h.join("Library/Fonts"));
|
|
}
|
|
} else if cfg!(windows) {
|
|
let win = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows"));
|
|
dirs.push(win.join("Fonts"));
|
|
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
|
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
|
}
|
|
} else if cfg!(unix) && !cfg!(target_arch = "wasm32") {
|
|
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from));
|
|
if let Some(h) = &home {
|
|
dirs.push(h.join(".local/share/fonts"));
|
|
dirs.push(h.join(".fonts"));
|
|
}
|
|
}
|
|
Self { dirs }
|
|
}
|
|
}
|
|
|
|
fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
if depth < 4 {
|
|
walk(&p, depth + 1, out);
|
|
}
|
|
} else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
|
|
out.push(p);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FontSource for DirectorySource {
|
|
fn faces(&self) -> Vec<FaceInfo> {
|
|
let mut files = Vec::new();
|
|
for d in &self.dirs {
|
|
walk(d, 0, &mut files);
|
|
}
|
|
files.sort();
|
|
let mut out = Vec::new();
|
|
for f in files {
|
|
let Ok(mut file) = std::fs::File::open(&f) else { continue };
|
|
for n in read_faces(&mut file) {
|
|
out.push(info_from(n, FaceData::File(f.clone()), "system"));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
|
|
FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin }
|
|
}
|
|
|
|
/// A loaded (or loadable) face.
|
|
pub struct Face {
|
|
pub id: FaceId,
|
|
pub info: FaceInfo,
|
|
bytes: OnceLock<Option<FaceBytes>>,
|
|
shaper: OnceLock<harfrust::ShaperData>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum FaceBytes {
|
|
Static(&'static [u8]),
|
|
Shared(Arc<Vec<u8>>),
|
|
}
|
|
|
|
impl FaceBytes {
|
|
fn get(&self) -> &[u8] {
|
|
match self {
|
|
FaceBytes::Static(b) => b,
|
|
FaceBytes::Shared(b) => b,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Vertical metrics at a pixel size (all positive distances, y down from the baseline for the
|
|
/// underline position).
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
pub struct VMetrics {
|
|
pub ascent: f32,
|
|
pub descent: f32,
|
|
pub line_gap: f32,
|
|
pub cap_height: f32,
|
|
pub x_height: f32,
|
|
pub underline_pos: f32,
|
|
pub underline_thickness: f32,
|
|
}
|
|
|
|
impl Face {
|
|
fn bytes(&self) -> Option<&[u8]> {
|
|
self.bytes
|
|
.get_or_init(|| match &self.info.data {
|
|
FaceData::Static(b) => Some(FaceBytes::Static(b)),
|
|
FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())),
|
|
FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))),
|
|
})
|
|
.as_ref()
|
|
.map(FaceBytes::get)
|
|
}
|
|
/// The parsed font (None if the file is missing or unreadable).
|
|
pub fn font(&self) -> Option<FontRef<'_>> {
|
|
FontRef::from_index(self.bytes()?, self.info.index).ok()
|
|
}
|
|
pub(crate) fn shaper_data(&self) -> Option<&harfrust::ShaperData> {
|
|
let f = self.font()?;
|
|
Some(self.shaper.get_or_init(|| harfrust::ShaperData::new(&f)))
|
|
}
|
|
pub fn units_per_em(&self) -> f32 {
|
|
self.font().map(|f| f.metrics(Size::unscaled(), LocationRef::default()).units_per_em as f32).filter(|u| *u > 0.0).unwrap_or(1000.0)
|
|
}
|
|
/// Glyph for a character (None when the face does not cover it).
|
|
pub fn glyph(&self, c: char) -> Option<u32> {
|
|
self.font()?.charmap().map(c).map(|g| g.to_u32()).filter(|g| *g != 0)
|
|
}
|
|
pub fn has_char(&self, c: char) -> bool {
|
|
self.glyph(c).is_some()
|
|
}
|
|
pub fn metrics(&self, px: f32) -> VMetrics {
|
|
let Some(f) = self.font() else {
|
|
return VMetrics {
|
|
ascent: px * 0.8,
|
|
descent: px * 0.2,
|
|
cap_height: px * 0.7,
|
|
x_height: px * 0.5,
|
|
underline_pos: px * 0.1,
|
|
underline_thickness: px * 0.06,
|
|
..Default::default()
|
|
};
|
|
};
|
|
let m = f.metrics(Size::new(px), LocationRef::default());
|
|
VMetrics {
|
|
ascent: m.ascent,
|
|
descent: -m.descent,
|
|
line_gap: m.leading,
|
|
cap_height: m.cap_height.unwrap_or(m.ascent * 0.7),
|
|
x_height: m.x_height.unwrap_or(m.ascent * 0.5),
|
|
underline_pos: m.underline.map_or(px * 0.1, |u| -u.offset),
|
|
underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)),
|
|
}
|
|
}
|
|
/// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`).
|
|
pub fn has_feature(&self, tag: &[u8; 4]) -> bool {
|
|
use skrifa::raw::TableProvider;
|
|
let Some(f) = self.font() else { return false };
|
|
let Ok(gsub) = f.gsub() else { return false };
|
|
let Ok(list) = gsub.feature_list() else { return false };
|
|
list.feature_records().iter().any(|r| r.feature_tag().to_be_bytes() == *tag)
|
|
}
|
|
}
|
|
|
|
struct Db {
|
|
faces: Vec<Arc<Face>>,
|
|
scanned: bool,
|
|
}
|
|
|
|
fn db() -> &'static RwLock<Db> {
|
|
static DB: OnceLock<RwLock<Db>> = OnceLock::new();
|
|
DB.get_or_init(|| {
|
|
let mut db = Db { faces: Vec::new(), scanned: false };
|
|
for b in [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] {
|
|
for n in read_faces_bytes(b) {
|
|
push(&mut db, info_from(n, FaceData::Static(b), "bundled"));
|
|
}
|
|
}
|
|
RwLock::new(db)
|
|
})
|
|
}
|
|
|
|
fn push(db: &mut Db, info: FaceInfo) -> FaceId {
|
|
let id = db.faces.len();
|
|
db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), shaper: OnceLock::new() }));
|
|
id
|
|
}
|
|
|
|
/// Add faces from a source (skipping family+style pairs already present). Returns how many were
|
|
/// added.
|
|
pub fn add_source(src: &dyn FontSource) -> usize {
|
|
let faces = src.faces();
|
|
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
|
|
let mut n = 0;
|
|
for f in faces {
|
|
let dup = d.faces.iter().any(|g| g.info.family.eq_ignore_ascii_case(&f.family) && g.info.style.eq_ignore_ascii_case(&f.style));
|
|
if !dup {
|
|
push(&mut d, f);
|
|
n += 1;
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
/// Register a font from memory (e.g. a font file the user picked on the web). Returns its faces.
|
|
pub fn add_font_data(data: Vec<u8>) -> Vec<FaceId> {
|
|
let data = Arc::new(data);
|
|
let names = read_faces_bytes(&data);
|
|
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
|
|
names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect()
|
|
}
|
|
|
|
/// Scan the system font folders once (native only; a no-op on wasm). Returns the number of faces
|
|
/// added by this call.
|
|
pub fn scan_system() -> usize {
|
|
// Held for the whole scan: a concurrent caller waits for the complete list instead of
|
|
// returning early and resolving against a half-filled database.
|
|
static SCAN: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
|
|
if system_scanned() {
|
|
return 0;
|
|
}
|
|
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
|
|
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
|
|
n
|
|
}
|
|
|
|
pub fn system_scanned() -> bool {
|
|
db().read().unwrap_or_else(|e| e.into_inner()).scanned
|
|
}
|
|
|
|
/// A face by id (panics never: unknown ids give the default face).
|
|
pub fn face(id: FaceId) -> Arc<Face> {
|
|
let d = db().read().unwrap_or_else(|e| e.into_inner());
|
|
d.faces.get(id).or_else(|| d.faces.first()).cloned().expect("bundled fonts present")
|
|
}
|
|
|
|
/// All faces (bundled first).
|
|
pub fn all_faces() -> Vec<Arc<Face>> {
|
|
db().read().unwrap_or_else(|e| e.into_inner()).faces.clone()
|
|
}
|
|
|
|
/// Families with their style names, sorted by family; styles in weight order.
|
|
pub fn families() -> Vec<(String, Vec<String>)> {
|
|
let mut m: std::collections::BTreeMap<String, Vec<(bool, u16, String)>> = Default::default();
|
|
for f in all_faces() {
|
|
let v = m.entry(f.info.family.clone()).or_default();
|
|
if !v.iter().any(|(_, _, s)| s == &f.info.style) {
|
|
v.push((f.info.italic, f.info.weight, f.info.style.clone()));
|
|
}
|
|
}
|
|
m.into_iter()
|
|
.map(|(k, mut v)| {
|
|
v.sort();
|
|
(k, v.into_iter().map(|(_, _, s)| s).collect())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The face chosen for a family/style request, plus synthetic emboldening / slant when the family
|
|
/// lacks a real bold / italic.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub struct Resolved {
|
|
pub face: FaceId,
|
|
pub synth_bold: bool,
|
|
pub synth_italic: bool,
|
|
/// The requested family was not found (shown as missing in the UI).
|
|
pub missing: bool,
|
|
}
|
|
|
|
fn style_wants(style: &str) -> (u16, bool) {
|
|
let s = style.to_ascii_lowercase().replace([' ', '-'], "");
|
|
let italic = s.contains("italic") || s.contains("oblique");
|
|
let w = if s.contains("thin") || s.contains("hairline") {
|
|
100
|
|
} else if s.contains("extralight") || s.contains("ultralight") {
|
|
200
|
|
} else if s.contains("light") {
|
|
300
|
|
} else if s.contains("medium") {
|
|
500
|
|
} else if s.contains("semibold") || s.contains("demibold") {
|
|
600
|
|
} else if s.contains("extrabold") || s.contains("ultrabold") {
|
|
800
|
|
} else if s.contains("black") || s.contains("heavy") {
|
|
900
|
|
} else if s.contains("bold") {
|
|
700
|
|
} else {
|
|
400
|
|
};
|
|
(w, italic)
|
|
}
|
|
|
|
/// Resolve a family + style name. Family matching is case-insensitive; a style that the family
|
|
/// lacks picks the nearest weight and synthesises bold (≥ 600 requested, ≤ 500 found) or italic.
|
|
pub fn resolve(family: &str, style: &str) -> Resolved {
|
|
if let Some(r) = resolve_in(family, style) {
|
|
return r;
|
|
}
|
|
if !system_scanned() && !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY) {
|
|
scan_system();
|
|
if let Some(r) = resolve_in(family, style) {
|
|
return r;
|
|
}
|
|
}
|
|
let mut r = resolve_in(DEFAULT_FAMILY, style).expect("Inter is bundled");
|
|
r.missing = !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY);
|
|
r
|
|
}
|
|
|
|
fn resolve_in(family: &str, style: &str) -> Option<Resolved> {
|
|
let faces = all_faces();
|
|
let fam: Vec<&Arc<Face>> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect();
|
|
if fam.is_empty() {
|
|
return None;
|
|
}
|
|
if let Some(f) = fam.iter().find(|f| f.info.style.eq_ignore_ascii_case(style)) {
|
|
return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false });
|
|
}
|
|
let (w, it) = style_wants(style);
|
|
let best = fam
|
|
.iter()
|
|
.min_by_key(|f| {
|
|
let italic_pen = if f.info.italic == it { 0 } else { 1000 };
|
|
italic_pen + (f.info.weight as i32 - w as i32).unsigned_abs()
|
|
})
|
|
.expect("non-empty");
|
|
Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false })
|
|
}
|
|
|
|
/// A face that covers `c`, preferring `prefer`; bundled and already-registered faces are searched.
|
|
pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
|
|
let p = face(prefer);
|
|
if p.has_char(c) || c.is_control() {
|
|
return prefer;
|
|
}
|
|
let faces = all_faces();
|
|
// prefer a face of the same italic-ness that is bundled (cheap), then anything loaded already
|
|
for f in faces.iter().filter(|f| f.info.origin == "bundled") {
|
|
if f.has_char(c) {
|
|
return f.id;
|
|
}
|
|
}
|
|
for f in faces.iter().filter(|f| f.info.origin != "bundled" && f.bytes.get().is_some()) {
|
|
if f.has_char(c) {
|
|
return f.id;
|
|
}
|
|
}
|
|
prefer
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn bundled_families_and_resolution() {
|
|
let fams = families();
|
|
let inter = fams.iter().find(|(f, _)| f == "Inter").expect("Inter");
|
|
for s in ["Regular", "Medium", "SemiBold", "Bold", "Italic"] {
|
|
assert!(inter.1.iter().any(|x| x == s), "{s} in {:?}", inter.1);
|
|
}
|
|
assert!(fams.iter().any(|(f, _)| f == "Noto Serif"));
|
|
assert!(fams.iter().any(|(f, _)| f == "JetBrains Mono"));
|
|
let b = resolve("inter", "bold");
|
|
assert_eq!(face(b.face).info.style, "Bold");
|
|
assert!(!b.synth_bold);
|
|
// Noto Serif has only Regular: Bold Italic is synthesised
|
|
let s = resolve("Noto Serif", "Bold Italic");
|
|
assert_eq!(face(s.face).info.family, "Noto Serif");
|
|
assert!(s.synth_bold && s.synth_italic);
|
|
// Inter Bold Italic: nearest is Italic (400) → synth bold
|
|
let bi = resolve("Inter", "Bold Italic");
|
|
assert!(face(bi.face).info.italic && bi.synth_bold && !bi.synth_italic);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_family_falls_back_to_inter() {
|
|
let r = resolve("No Such Font Family 123", "Regular");
|
|
assert_eq!(face(r.face).info.family, "Inter");
|
|
assert!(r.missing);
|
|
}
|
|
|
|
/// Native: the system scan reads name tables only and resolves families installed there.
|
|
#[test]
|
|
fn system_scan_finds_fonts() {
|
|
let t = std::time::Instant::now();
|
|
scan_system(); // (another test may have triggered the scan already)
|
|
let n = all_faces().iter().filter(|f| f.info.origin == "system").count();
|
|
eprintln!("system fonts: {n} faces in {:?}", t.elapsed());
|
|
assert_eq!(scan_system(), 0, "scans once");
|
|
if cfg!(target_os = "macos") {
|
|
assert!(n > 20, "{n}");
|
|
let r = resolve("Helvetica", "Bold");
|
|
assert_eq!(face(r.face).info.family, "Helvetica");
|
|
let l = crate::layout_text("Helvetica", &crate::TextStyle { family: "Helvetica".into(), size: 30.0, ..Default::default() }, &Default::default());
|
|
assert!(!l.missing_font && l.glyphs.iter().all(|g| g.id != 0));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn metrics_and_coverage() {
|
|
let f = face(resolve("Inter", "Regular").face);
|
|
let m = f.metrics(100.0);
|
|
assert!(m.ascent > 80.0 && m.ascent < 110.0, "{m:?}");
|
|
assert!(m.descent > 10.0 && m.descent < 40.0);
|
|
assert!(m.cap_height > 60.0 && m.cap_height < 80.0);
|
|
assert!(f.has_char('A') && f.has_char('Ж'));
|
|
assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew");
|
|
assert!(f.has_feature(b"liga") || f.has_feature(b"calt"));
|
|
}
|
|
}
|