This commit is contained in:
Brandon Thomas
2026-10-14 22:05:10 -04:00
3 changed files with 135 additions and 7 deletions
+42 -2
View File
@@ -125,6 +125,8 @@ pub struct FontFace {
em: std::sync::OnceLock<((f64, f64), bool)>,
/// The font menu group and native family name, read on first use.
group: std::sync::OnceLock<(FontGroup, Option<String>)>,
/// The PostScript name, read on first use.
postscript: std::sync::OnceLock<Option<String>>,
/// Format, class and PANOSE numbers (the font menus), read on first use.
kind: std::sync::OnceLock<crate::kind::FaceKind>,
}
@@ -309,6 +311,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)
@@ -342,6 +351,7 @@ struct CatalogEntry {
/// The font menu group and native family name, read by the scan.
group: FontGroup,
native: Option<String>,
postscript: Option<String>,
}
/// What the scan reads of one face in a font file: its names and font menu group.
@@ -352,6 +362,7 @@ struct ScannedFace {
style: String,
group: FontGroup,
native: Option<String>,
postscript: Option<String>,
}
/// Process-wide font database: the fonts every document shares (bundled, installed, added), and
@@ -628,7 +639,8 @@ fn file_face_names(path: &std::path::Path) -> Vec<ScannedFace> {
crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family)
}
};
Some(ScannedFace { family, style, group, native })
let postscript = name(&f, &[StringId::POSTSCRIPT_NAME]);
Some(ScannedFace { family, style, group, native, postscript })
})
.collect()
}
@@ -779,6 +791,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
bmp: std::sync::OnceLock::new(),
em: std::sync::OnceLock::new(),
group: std::sync::OnceLock::new(),
postscript: std::sync::OnceLock::new(),
kind: std::sync::OnceLock::new(),
})
}
@@ -1063,7 +1076,14 @@ impl FontDb {
continue;
}
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 });
found.push(CatalogEntry {
family: f.family,
style: f.style,
path: p.clone(),
group: f.group,
native: f.native,
postscript: f.postscript,
});
}
}
}
@@ -1212,6 +1232,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)
@@ -1503,6 +1528,21 @@ impl ScopedFonts<'_> {
self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base))
}
/// 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
}
fn has_exact_family(&self, family: &str) -> bool {
if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) {
return true;
+20
View File
@@ -181,3 +181,23 @@ fn font_menus_group_installed_and_loaded_fonts_without_loading_them() {
assert_eq!(infos.iter().filter(|i| i.family == "Groups Mincho").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[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);
}
+73 -5
View File
@@ -73,25 +73,54 @@ 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.
/// as it would in a text frame. 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> {
let bundled = usvg::FontResolver::default_font_selector();
Box::new(move |font, db| {
let fonts = designcraft_fonts::FontDb::global();
for family in font.families() {
if let usvg::FontFamily::Named(name) = family
&& !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)))
&& fonts.has_family(name)
&& !has_family(db, 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 !has_family(db, name) => {
if let Some(id) = postscript_face(db, name) {
return Some(id);
}
}
// A family (or generic family) that matches: usvg's own selection.
_ => break,
}
}
bundled(font, db)
})
}
/// Does a face in the SVG's font database have family `name` (ignoring case)?
fn has_family(db: &usvg::fontdb::Database, name: &str) -> bool {
db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)))
}
/// The face in the SVG's font database with PostScript name `name` (ignoring case).
fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option<usvg::fontdb::ID> {
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();
@@ -351,6 +380,45 @@ mod tests {
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{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#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20"{svg_attrs}>ab</text></svg>"#);
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<u8> {
let mut b = Vec::new();