From caba979b01e8f2eb51bf258f3b07a4fe20133210 Mon Sep 17 00:00:00 2001 From: Hiroshi Inukai Date: Tue, 6 Oct 2026 18:22:46 +0900 Subject: [PATCH] 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 --- crates/pdf/src/import_text.rs | 4 ++++ crates/text/src/fontdb.rs | 37 ++++++++++++++++++++++++++----- crates/text/src/tests_sysfonts.rs | 12 ++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 5230d5c3..dc7df0fe 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -206,6 +206,10 @@ impl Families { Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest, _ => 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 found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()); let style = match spaced(strip_ps(style)).as_str() { diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8a48f7e9..906f6d2a 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -153,11 +153,16 @@ struct CatalogFamily { /// [`FontDb::face`] does). Always empty on wasm, which has no system fonts. type Catalog = HashMap; +/// The installed faces' (family, style) by ASCII-lowercased PostScript name. +type PostScriptNames = HashMap; + /// Process-wide font database. pub struct FontDb { faces: RwLock>>, outlines: Mutex>>, catalog: RwLock, + /// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai). + postscript: RwLock, /// 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, @@ -224,6 +229,13 @@ 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)> { + 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) { @@ -239,10 +251,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, Option)> { use std::io::{Read, Seek, SeekFrom}; /// Caps on what a (possibly damaged) file can make the scan read. 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(&table); - face_names(&skrifa::FontRef::new(&font).ok()?) + face_names_ps(&skrifa::FontRef::new(&font).ok()?) }) .collect() } @@ -394,6 +407,7 @@ impl FontDb { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), catalog: RwLock::new(Catalog::new()), + postscript: RwLock::new(PostScriptNames::new()), font_dirs, #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), @@ -540,6 +554,7 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut catalog = Catalog::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. @@ -559,7 +574,10 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { 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(); if entry.name.is_empty() { entry.name = family; @@ -571,10 +589,19 @@ impl FontDb { } log::debug!("cataloged {n} system font faces"); *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog; + *self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript; self.changed(); 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. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index 5b9c96e1..530ebc93 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -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); +}