mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #209 from imachus/pr/ja-fonts
Type: find fonts by any of their names; say how each one resolved
This commit is contained in:
6 files changed
+425
-44
No files matched your search
@@ -127,6 +127,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name",
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Font",
|
||||
[],
|
||||
None,
|
||||
"{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -96,12 +96,12 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let families = db.families();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>)> = BTreeMap::new();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
if !e.1.contains(&id) {
|
||||
e.1.push(id);
|
||||
}
|
||||
@@ -109,10 +109,20 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let list: Vec<Value> = found
|
||||
.into_iter()
|
||||
.map(|((family, style), (runs, objects))| {
|
||||
let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family));
|
||||
let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style));
|
||||
json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing })
|
||||
.map(|((family, style), (runs, objects, chars))| {
|
||||
let resolved = db.resolve(&family, &style);
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
"status": status.as_str(),
|
||||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||||
"missingGlyphs": lacking.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(list))
|
||||
@@ -130,9 +140,11 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ff, fs) = font_param(p, "from", C)?;
|
||||
let (tf, ts) = font_param(p, "to", C)?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) {
|
||||
return Err(bad(C, format!("font family `{tf}` is not available")));
|
||||
}
|
||||
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).
|
||||
let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
|
||||
};
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let matches = |st: &vectorcraft_doc::CharStyle| {
|
||||
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
||||
@@ -261,6 +273,39 @@ mod tests {
|
||||
mod open_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_says_how_each_font_resolved() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
for (text, font, style) in
|
||||
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black"), ("Gone", "No Such Font Family", "Regular")]
|
||||
{
|
||||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap();
|
||||
}
|
||||
let list = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let rows: Vec<(String, String, String, String)> = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
(
|
||||
f["style"].as_str().unwrap().into(),
|
||||
f["status"].as_str().unwrap().into(),
|
||||
f["resolved"]["family"].as_str().unwrap().into(),
|
||||
f["resolved"]["style"].as_str().unwrap().into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap();
|
||||
assert_eq!(row("Semibold").1, "exact");
|
||||
assert_eq!(row("Black").1, "substitute", "{rows:?}");
|
||||
assert_eq!(row("Black").2, "Source Sans 3");
|
||||
assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY));
|
||||
s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap();
|
||||
let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap();
|
||||
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_documents_have_exact_text_bounds() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1488,8 +1488,14 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
let mut runs = vec![BezPath::new(); t.runs.len()];
|
||||
let mut all = BezPath::new();
|
||||
// Per run: is its family missing, and the face its glyphs should come from.
|
||||
let faces: Vec<(bool, Option<u32>)> =
|
||||
t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect();
|
||||
let faces: Vec<(bool, Option<u32>)> = t
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
|
||||
Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())),
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
|
||||
for g in &layout.glyphs {
|
||||
if let Some(r) = runs.get_mut(g.run) {
|
||||
|
||||
+330
-21
@@ -35,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,
|
||||
@@ -153,7 +155,7 @@ 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 ASCII-lowercased PostScript name.
|
||||
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
|
||||
type PostScriptNames = HashMap<String, (String, String)>;
|
||||
|
||||
/// Process-wide font database.
|
||||
@@ -166,6 +168,8 @@ pub struct FontDb {
|
||||
/// 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"))]
|
||||
@@ -222,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])?;
|
||||
@@ -229,13 +343,6 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
Some((family, style))
|
||||
}
|
||||
|
||||
/// A face's (family, style, PostScript name).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn face_names_ps(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option<String>)> {
|
||||
let (family, style) = face_names(f)?;
|
||||
Some((family, style, name(f, &[StringId::POSTSCRIPT_NAME])))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -255,7 +362,7 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
||||
/// 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, Option<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;
|
||||
@@ -297,7 +404,10 @@ fn file_face_names(path: &Path) -> Vec<(String, String, Option<String>)> {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
face_names_ps(&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()
|
||||
}
|
||||
@@ -314,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"));
|
||||
@@ -339,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,
|
||||
@@ -412,6 +531,7 @@ impl FontDb {
|
||||
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),
|
||||
@@ -484,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()) {
|
||||
@@ -557,6 +679,7 @@ 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();
|
||||
@@ -577,9 +700,13 @@ impl FontDb {
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for (family, style, ps) in file_face_names(&p) {
|
||||
if let Some(ps) = ps {
|
||||
postscript.insert(ps.to_ascii_lowercase(), (family.clone(), style.clone()));
|
||||
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() {
|
||||
@@ -592,17 +719,83 @@ 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(&name.to_ascii_lowercase()).cloned()
|
||||
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.
|
||||
@@ -626,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))
|
||||
@@ -664,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 {
|
||||
@@ -797,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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, 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;
|
||||
|
||||
@@ -58,15 +58,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
for (i, f) in list.iter().enumerate() {
|
||||
let missing = f["missing"] == true;
|
||||
let label = format!(
|
||||
"{} {}{} ({})",
|
||||
f["family"].as_str().unwrap_or(""),
|
||||
f["style"].as_str().unwrap_or(""),
|
||||
if missing { " — missing" } else { "" },
|
||||
f["runs"]
|
||||
);
|
||||
let text = egui::RichText::new(label).color(if missing { egui::Color32::from_rgb(230, 90, 90) } else { t.text });
|
||||
let (note, color) = font_note(f);
|
||||
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
|
||||
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
|
||||
if ui.selectable_label(i == sel, text).clicked() {
|
||||
d.fields.insert("selected".into(), json!(i));
|
||||
}
|
||||
@@ -126,6 +120,22 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Find Font row says after the font's name (`text.fonts` row): a missing family, a style
|
||||
/// standing in for another, characters the font lacks; and the colour to flag it with.
|
||||
fn font_note(f: &Value) -> (String, Option<egui::Color32>) {
|
||||
let lacking = f["missingGlyphs"].as_u64().unwrap_or(0);
|
||||
let glyphs = if lacking > 0 { format!(" — {lacking} characters from another font") } else { String::new() };
|
||||
match f["status"].as_str() {
|
||||
Some("missing") => (format!(" — missing{glyphs}"), Some(egui::Color32::from_rgb(230, 90, 90))),
|
||||
Some("substitute") => {
|
||||
let used = f["resolved"]["style"].as_str().unwrap_or("");
|
||||
(format!(" — substituted by {used}{glyphs}"), Some(egui::Color32::from_rgb(220, 160, 60)))
|
||||
}
|
||||
_ if lacking > 0 => (glyphs, Some(egui::Color32::from_rgb(220, 160, 60))),
|
||||
_ => (String::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -146,4 +156,14 @@ mod tests {
|
||||
change(&mut app, &d, &from, true).unwrap();
|
||||
assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_flag_missing_fonts_substituted_styles_and_lacking_characters() {
|
||||
let row = |status: &str, glyphs: u64| json!({"status": status, "resolved": {"style": "W4"}, "missingGlyphs": glyphs});
|
||||
assert_eq!(font_note(&row("exact", 0)), (String::new(), None));
|
||||
assert!(font_note(&row("missing", 0)).0.contains("missing"));
|
||||
assert!(font_note(&row("substitute", 0)).0.contains("substituted by W4"));
|
||||
let (note, color) = font_note(&row("exact", 2));
|
||||
assert!(note.contains("2 characters") && color.is_some());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user