PDF/.ai import: fonts are found by their PostScript name, whatever hyphens the family holds

A document names its fonts by PostScript name, which the importer split at the
first hyphen into family and style. A family with hyphens in its name was never
found: "Rounded-X-Mplus-1c-black" became family "rounded", style
"x-mplus-1c-black", and the text fell back to another font. Common Japanese
families have hyphens in their names.

The system font scan now also indexes each installed face's PostScript name
(read from the same name table it already reads). The importer asks for the face
with that exact name first (`FontDb::by_postscript_name`) and splits the name only
when no installed face has it.

Test: installed faces are found by PostScript name, in any case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hiroshi Inukai 2026-10-06 18:22:46 +09:00
parent 025315f4bf
commit caba979b01
3 changed files with 48 additions and 5 deletions

View File

@ -206,6 +206,10 @@ impl Families {
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest, Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
_ => name, _ => name,
}; };
// An installed face of that exact PostScript name: its own family and style.
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
return (family, style, true);
}
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, "")); let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()); let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
let style = match spaced(strip_ps(style)).as_str() { let style = match spaced(strip_ps(style)).as_str() {

View File

@ -153,11 +153,16 @@ struct CatalogFamily {
/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts. /// [`FontDb::face`] does). Always empty on wasm, which has no system fonts.
type Catalog = HashMap<String, CatalogFamily>; type Catalog = HashMap<String, CatalogFamily>;
/// The installed faces' (family, style) by ASCII-lowercased PostScript name.
type PostScriptNames = HashMap<String, (String, String)>;
/// Process-wide font database. /// Process-wide font database.
pub struct FontDb { pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>, faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>, outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
catalog: RwLock<Catalog>, 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`]). /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
font_dirs: Vec<PathBuf>, font_dirs: Vec<PathBuf>,
@ -224,6 +229,13 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
Some((family, style)) 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)`. /// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
let count = match FileRef::new(data) { let count = match FileRef::new(data) {
@ -239,10 +251,11 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
.collect() .collect()
} }
/// (family, style) of every face in the font file at `path`, reading only its table directories /// (family, style, PostScript name) of every face in the font file at `path`, reading only its
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. /// table directories and `name` tables: a scan opens hundreds of font files, many of them
/// megabytes long.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn file_face_names(path: &Path) -> Vec<(String, String)> { fn file_face_names(path: &Path) -> Vec<(String, String, Option<String>)> {
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
/// Caps on what a (possibly damaged) file can make the scan read. /// Caps on what a (possibly damaged) file can make the scan read.
const MAX_FACES: u32 = 256; const MAX_FACES: u32 = 256;
@ -284,7 +297,7 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> {
font.extend_from_slice(&u32::to_be_bytes(v)); font.extend_from_slice(&u32::to_be_bytes(v));
} }
font.extend_from_slice(&table); font.extend_from_slice(&table);
face_names(&skrifa::FontRef::new(&font).ok()?) face_names_ps(&skrifa::FontRef::new(&font).ok()?)
}) })
.collect() .collect()
} }
@ -394,6 +407,7 @@ impl FontDb {
faces: RwLock::new(faces), faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()), outlines: Mutex::new(HashMap::new()),
catalog: RwLock::new(Catalog::new()), catalog: RwLock::new(Catalog::new()),
postscript: RwLock::new(PostScriptNames::new()),
font_dirs, font_dirs,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(), cataloged: std::sync::OnceLock::new(),
@ -540,6 +554,7 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn scan_font_dirs(&self) -> usize { fn scan_font_dirs(&self) -> usize {
let mut catalog = Catalog::new(); let mut catalog = Catalog::new();
let mut postscript = PostScriptNames::new();
let mut n = 0; let mut n = 0;
let mut stack = self.font_dirs.clone(); let mut stack = self.font_dirs.clone();
// Each folder once, however links lead back to it. // Each folder once, however links lead back to it.
@ -559,7 +574,10 @@ impl FontDb {
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue; continue;
} }
for (family, style) in file_face_names(&p) { for (family, style, ps) in file_face_names(&p) {
if let Some(ps) = ps {
postscript.insert(ps.to_ascii_lowercase(), (family.clone(), style.clone()));
}
let entry = catalog.entry(family.to_ascii_lowercase()).or_default(); let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
if entry.name.is_empty() { if entry.name.is_empty() {
entry.name = family; entry.name = family;
@ -571,10 +589,19 @@ impl FontDb {
} }
log::debug!("cataloged {n} system font faces"); log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog; *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
self.changed(); self.changed();
n n
} }
/// 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()
}
/// Load the files of the installed `family`. Returns whether any face was added. /// Load the files of the installed `family`. Returns whether any face was added.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool { fn load_cataloged(&self, family: &str) -> bool {

View File

@ -142,3 +142,15 @@ fn installed_styles_of_a_loaded_family_load_when_asked_for() {
// A style nobody has still gets the closest one. // A style nobody has still gets the closest one.
assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold"); 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);
}