mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
|
||||
//!
|
||||
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
|
||||
//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese
|
||||
//! text falls back to the installed system fonts. Release builds always set it. See `build.rs`
|
||||
//! and craftrules `standards/fonts.md`.
|
||||
|
||||
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
|
||||
pub struct CraftFont {
|
||||
pub family: &'static str,
|
||||
pub style: &'static str,
|
||||
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
|
||||
pub scripts: &'static [&'static str],
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
impl CraftFont {
|
||||
/// Is the font for Japanese text?
|
||||
pub fn is_japanese(&self) -> bool {
|
||||
self.scripts.contains(&"Jpan")
|
||||
}
|
||||
/// A serif (Mincho) family, for document text.
|
||||
pub fn is_mincho(&self) -> bool {
|
||||
self.family.contains("Mincho")
|
||||
}
|
||||
/// A sans (Gothic) family, for UI text.
|
||||
pub fn is_gothic(&self) -> bool {
|
||||
self.family.contains("Gothic")
|
||||
}
|
||||
/// A bold face.
|
||||
pub fn is_bold(&self) -> bool {
|
||||
crate::style_weight(self.style) >= 600.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Japanese text, best for document text first: Mincho (serif) families,
|
||||
/// then the others, regular weights before bold. Empty when built without craft-fonts.
|
||||
pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
|
||||
let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect();
|
||||
v.sort_by_key(|f| (!f.is_mincho(), f.is_bold()));
|
||||
v
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then
|
||||
/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts.
|
||||
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect();
|
||||
v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold));
|
||||
v
|
||||
}
|
||||
+352
-17
@@ -16,9 +16,6 @@ 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";
|
||||
|
||||
/// Bundled Japanese font bytes, shared with UI glyph fallback.
|
||||
pub static SHIPPORI_MINCHO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf");
|
||||
|
||||
static BUNDLED: &[&[u8]] = &[
|
||||
include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"),
|
||||
@@ -29,7 +26,6 @@ static BUNDLED: &[&[u8]] = &[
|
||||
include_bytes!("../../../assets/fonts/Inter-Medium.ttf"),
|
||||
include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"),
|
||||
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
|
||||
SHIPPORI_MINCHO_REGULAR,
|
||||
];
|
||||
|
||||
enum FontBytes {
|
||||
@@ -39,6 +35,8 @@ enum FontBytes {
|
||||
|
||||
/// 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,
|
||||
@@ -157,14 +155,21 @@ struct CatalogFamily {
|
||||
/// [`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"))]
|
||||
@@ -221,6 +226,116 @@ 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])?;
|
||||
@@ -243,10 +358,11 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// (family, style) 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.
|
||||
/// (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)> {
|
||||
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;
|
||||
@@ -288,7 +404,10 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
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()
|
||||
}
|
||||
@@ -305,6 +424,13 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
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"));
|
||||
@@ -330,7 +456,9 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path:
|
||||
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,
|
||||
@@ -387,7 +515,10 @@ 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();
|
||||
for data in BUNDLED {
|
||||
// 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));
|
||||
@@ -398,7 +529,9 @@ impl FontDb {
|
||||
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),
|
||||
@@ -471,6 +604,8 @@ impl FontDb {
|
||||
|
||||
/// 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()) {
|
||||
@@ -544,6 +679,8 @@ impl FontDb {
|
||||
#[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.
|
||||
@@ -563,7 +700,14 @@ impl FontDb {
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for (family, style) in file_face_names(&p) {
|
||||
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;
|
||||
@@ -575,10 +719,85 @@ impl FontDb {
|
||||
}
|
||||
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 {
|
||||
@@ -600,17 +819,21 @@ impl FontDb {
|
||||
/// 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.
|
||||
// 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)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
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))
|
||||
@@ -638,7 +861,8 @@ impl FontDb {
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
|
||||
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 {
|
||||
@@ -771,3 +995,114 @@ impl OutlinePen for FlipPen {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod composer;
|
||||
pub mod craft_fonts;
|
||||
pub mod edit;
|
||||
pub mod embed;
|
||||
mod features;
|
||||
@@ -19,8 +20,9 @@ mod layout;
|
||||
mod shape;
|
||||
pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, SHIPPORI_MINCHO_REGULAR, style_weight, system_font_dirs};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
@@ -422,8 +422,47 @@ fn tab_stops_position_text() {
|
||||
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
|
||||
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
|
||||
fn japanese_fonts() -> bool {
|
||||
let built_in = CRAFT_FONTS.iter().any(|f| f.is_japanese());
|
||||
if !built_in {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
}
|
||||
built_in
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_text_uses_the_craft_fonts_mincho_after_the_bundled_fonts() {
|
||||
if !japanese_fonts() {
|
||||
return;
|
||||
}
|
||||
let l = layout(db(), &point("日本語の文字", style(20.0)));
|
||||
assert_eq!(l.glyphs.len(), 6);
|
||||
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()), "real glyphs, no tofu");
|
||||
let face = db().face_covering('日').unwrap();
|
||||
assert!(face.family.contains("Mincho"), "document text falls back to a Mincho face: {}", face.family);
|
||||
assert!(CRAFT_FONTS.iter().any(|f| f.family == face.family));
|
||||
// Latin keeps the bundled fallback family.
|
||||
assert_eq!(db().face_covering('a').unwrap().family, FALLBACK_FAMILY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_text_lays_out_without_the_craft_fonts() {
|
||||
// Built either way, Japanese text lays out (as missing glyphs when no font has them).
|
||||
let l = layout(db(), &point("日本語の文字 abc", style(20.0)));
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
assert!(l.glyphs.iter().all(|g| g.advance.is_finite()));
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
assert!(db().families().iter().all(|f| !f.contains("Mincho") && !f.contains("Gothic")), "no Japanese font is bundled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() {
|
||||
if !japanese_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut t = point("日本語\n縦書き", style(30.0));
|
||||
t.vertical = true;
|
||||
let l = layout(db(), &t);
|
||||
|
||||
@@ -142,3 +142,15 @@ fn installed_styles_of_a_loaded_family_load_when_asked_for() {
|
||||
// A style nobody has still gets the closest one.
|
||||
assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold");
|
||||
}
|
||||
|
||||
/// Documents name fonts by PostScript name, and families can hold hyphens: the installed face of
|
||||
/// that exact name gives the family and style, however the name splits.
|
||||
#[test]
|
||||
fn installed_faces_are_found_by_postscript_name() {
|
||||
let dir = font_dir("postscript");
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
// The renamed Source Sans 3 files keep their PostScript names.
|
||||
assert_eq!(db.by_postscript_name("SourceSans3-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
|
||||
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
|
||||
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user