diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5a30675d..8edb9fb4 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -132,6 +132,8 @@ pub struct FontFace { style_aliases: Vec, /// Format, class and PANOSE numbers (the font menus), read on first use. kind: std::sync::OnceLock, + /// The PostScript name, read on first use. + postscript: std::sync::OnceLock>, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -327,6 +329,13 @@ impl FontFace { let (g, n) = self.group.get_or_init(|| self.skrifa().map(|f| crate::group::classify(&f, &self.family)).unwrap_or_default()); (*g, n.as_deref()) } + /// The PostScript name (`name` ID 6), read from the font once. None for a variable font's + /// named instance: the file's name belongs to its default instance. + pub fn postscript_name(&self) -> Option<&str> { + self.postscript + .get_or_init(|| if self.is_variable() { None } else { self.skrifa().and_then(|f| name(&f, &[StringId::POSTSCRIPT_NAME])) }) + .as_deref() + } /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) @@ -361,6 +370,7 @@ struct CatalogEntry { group: FontGroup, native: Option, aliases: Vec, + postscript: Option, } /// What the scan reads of one face in a font file: its names and font menu group. @@ -372,6 +382,7 @@ struct ScannedFace { group: FontGroup, native: Option, aliases: Vec, + postscript: Option, } /// Process-wide font database: the fonts every document shares (bundled, installed, added), and @@ -648,7 +659,15 @@ fn file_face_names(path: &std::path::Path) -> Vec { crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) } }; - Some(ScannedFace { family, style, group, native, aliases: aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]) }) + let postscript = name(&f, &[StringId::POSTSCRIPT_NAME]); + Some(ScannedFace { + family, + style, + group, + native, + aliases: aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]), + postscript, + }) }) .collect() } @@ -836,6 +855,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s icf: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), kind: std::sync::OnceLock::new(), + postscript: std::sync::OnceLock::new(), }) } @@ -1148,6 +1168,7 @@ impl FontDb { group: f.group, native: f.native, aliases: f.aliases, + postscript: f.postscript, }); } } @@ -1156,7 +1177,15 @@ impl FontDb { // decide what is a font. for p in unscanned_font_files((self.font_files)(), &mut scanned) { for f in file_face_names(&p) { - found.push(CatalogEntry { family: f.family, style: f.style, path: p.clone(), group: f.group, native: f.native, aliases: f.aliases }); + found.push(CatalogEntry { + family: f.family, + style: f.style, + path: p.clone(), + group: f.group, + native: f.native, + aliases: f.aliases, + postscript: f.postscript, + }); } } let n = found.len(); @@ -1304,6 +1333,11 @@ impl FontDb { self.scoped(0).face(family, style) } + /// The shared face with PostScript name `name` (see [`ScopedFonts::postscript_face`]). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + self.scoped(0).postscript_face(name) + } + /// Is `family` among the shared fonts (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { self.scoped(0).has_family(family) @@ -1692,6 +1726,21 @@ impl ScopedFonts<'_> { None } + /// The family and style of the face whose PostScript name is `name`, ignoring case (the + /// document's, loaded, or installed on the system, not loading it). SVGs exported from + /// Illustrator name fonts this way (`font-family="'MyriadPro-Regular'"`). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + let named = |ps: Option<&str>| ps.is_some_and(|ps| ps.eq_ignore_ascii_case(name)); + if let Some(f) = self.own().iter().chain(self.db.read_faces().iter()).find(|f| named(f.postscript_name())) { + return Some((f.family.clone(), f.style.clone())); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(c) = self.db.read_catalog().iter().find(|c| named(c.postscript.as_deref())) { + return Some((c.family.clone(), c.style.clone())); + } + None + } + /// Is `family` available (the document's, loaded, or installed on the system), as named or /// without an `$ID/` prefix or a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 8a872f4a..cf75772b 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -350,3 +350,23 @@ fn a_rescan_does_not_cache_an_older_in_flight_glyph_miss() { assert_eq!(db.fallback_for(c, 0, None).unwrap().family, family); std::fs::remove_dir_all(db.font_dirs.first().unwrap()).unwrap(); } + +#[test] +fn installed_and_loaded_fonts_are_found_by_postscript_name() { + let dir = std::env::temp_dir().join(format!("dc-sysfonts-postscript-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("ps.ttf"), crate::testing::font_with("DC Test PS Installed", &['a']).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + let found = Some(("DC Test PS Installed".to_string(), "Regular".to_string())); + // From the catalog, without loading the font; case doesn't matter. + assert_eq!(db.postscript_face("dctestpsinstalled-regular"), found); + assert!(!db.is_loaded("DC Test PS Installed")); + // Bundled and added (loaded) faces. + assert_eq!(db.postscript_face("SourceSerif4-Semibold"), Some(("Source Serif 4".to_string(), "Semibold".to_string()))); + db.add_font(crate::testing::font_with("DC Test PS Added", &['a']).unwrap()); + assert_eq!(db.postscript_face("DCTestPSAdded-Regular"), Some(("DC Test PS Added".to_string(), "Regular".to_string()))); + // A family name isn't a PostScript name. + assert_eq!(db.postscript_face("Source Serif 4"), None); + assert_eq!(db.postscript_face("NoSuchFont-Regular"), None); +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 1f5c064a..3bea7a3b 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -73,7 +73,11 @@ fn svg_options() -> &'static usvg::Options<'static> { } /// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too), -/// as it would in a text frame, and matches in any case, as it does there. +/// as it would in a text frame, and matches in any case, as it does there. A name that is no +/// family's but a face's PostScript name (as Illustrator's SVG export writes them: +/// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and +/// font-style say: the name already picks the weight and style, and a family's other faces would +/// contradict it. Families listed before it come first. fn select_font() -> usvg::FontSelectionFn<'static> { use usvg::fontdb::{Family, Query, Stretch, Style, Weight}; Box::new(move |font, db| { @@ -84,13 +88,29 @@ fn select_font() -> usvg::FontSelectionFn<'static> { for family in font.families() { if let usvg::FontFamily::Named(name) = family && spelled(db, name).is_none() - && fonts.has_family(name) { - for style in fonts.styles(name) { - add_face(db, fonts.face(name, &style)); + if fonts.has_family(name) { + for style in fonts.styles(name) { + add_face(db, fonts.face(name, &style)); + } + } else if postscript_face(db, name).is_none() + && let Some((family, style)) = fonts.postscript_face(name) + { + add_face(db, fonts.face(&family, &style)); } } } + for family in font.families() { + match family { + usvg::FontFamily::Named(name) if spelled(db, name).is_none() => { + if let Some(id) = postscript_face(db, name) { + return Some(id); + } + } + // A family (or generic family) that matches: usvg's own selection. + _ => break, + } + } // usvg's default query, but with each named family spelled as the database spells it: // fontdb compares names exactly, so "DeJaVu SaNs" would quietly fall to the serif. let names: Vec> = font @@ -135,6 +155,11 @@ fn select_font() -> usvg::FontSelectionFn<'static> { }) } +/// The face in the SVG's font database with PostScript name `name` (ignoring case). +fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option { + db.faces().find(|f| f.post_script_name.eq_ignore_ascii_case(name)).map(|f| f.id) +} + /// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them. fn select_fallback() -> usvg::FallbackSelectionFn<'static> { let loaded = usvg::FontResolver::default_fallback_selector(); @@ -406,6 +431,45 @@ mod tests { assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}"); } + /// The PostScript name of the face that sets the text of `svg` (one face for all of it). + fn text_face(svg_attrs: &str) -> String { + let svg = + format!(r#"ab"#); + let tree = svg_tree(svg.as_bytes()).unwrap(); + let mut faces = Vec::new(); + for n in tree.root().children() { + if let usvg::Node::Text(t) = n { + for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) { + faces.extend(tree.fontdb().face(g.font).map(|f| f.post_script_name.clone())); + } + } + } + faces.dedup(); + assert_eq!(faces.len(), 1, "{faces:?}"); + faces.remove(0) + } + + #[test] + fn svg_text_finds_fonts_by_postscript_name() { + // Bundled, as Illustrator's SVG export names it; any case. + assert_eq!(text_face(r#" font-family="'SourceSerif4-Semibold'""#), "SourceSerif4-Semibold"); + assert_eq!(text_face(r#" font-family="sourceserif4-semibold""#), "SourceSerif4-Semibold"); + // A shared font (as an installed one would be). + let font = designcraft_fonts::testing::font_with("DC Test SVG PostScript", &['a', 'b']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + assert_eq!(text_face(r#" font-family="DCTestSVGPostScript-Regular""#), "DCTestSVGPostScript-Regular"); + // The name picks that face: font-weight and font-style don't change it. + assert_eq!(text_face(r#" font-family="SourceSerif4-Semibold" font-weight="bold" font-style="italic""#), "SourceSerif4-Semibold"); + // A family listed before it still comes first; one after it is a fallback. + assert_eq!(text_face(r#" font-family="'Source Sans 3', SourceSerif4-Bold""#), "SourceSans3-Regular"); + assert_eq!(text_face(r#" font-family="NoSuch-Font, SourceSerif4-Bold, 'Source Sans 3'""#), "SourceSerif4-Bold"); + // Family names work as before. + assert_eq!(text_face(r#" font-family="'Source Serif 4'" font-weight="bold""#), "SourceSerif4-Bold"); + // An unknown name falls back as before (the serif default). + assert_eq!(text_face(r#" font-family="NoSuch-Font""#), text_face(r#" font-family="No Such Font""#)); + assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); + } + /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new();