collective

Browse / github.com/storytold/designcraft / patch

Fonts: the PostScript name of a variable font's named instance (#1690)

Fonts: the PostScript name of a variable font's named instance (#1690) Part of #1690. Thank you for the analysis in the issue — the cause (`name` ID 6 names only the default instance) and the TN #5902 rule are exactly right, and this follows them. ## What it computes `psname::instance_postscript_name(font, coords)`, per Adobe TN #5902: 1. the named instance's own `postScriptNameID`, when it has one; 2. otherwise the family prefix — `name` ID 25, else the typographic family, else the family — joined to the instance's subfamily with a hyphen; 3. ASCII letters and digits only, cut to 63 characters. `FontFace::instance_postscript_name()` exposes it. `postscript_n

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #1864, open)
Base
fec0897a5d42a9c8d6d162504b431efde225c547
Size
+191 −0 in 4 files
Review
pending
Shared
2026-10-28
Id
67acf56211e962772246e61a

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts/src/fontdb.rs crates/fonts/src/lib.rs crates/fonts/src/psname.rs crates/fonts/src/testing.rs

diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs--- a/crates/fonts/src/fontdb.rs+++ b/crates/fonts/src/fontdb.rs@@ -339,6 +339,19 @@ impl FontFace {             .get_or_init(|| if self.is_variable() { None } else { self.skrifa().and_then(|f| name(&f, &[StringId::POSTSCRIPT_NAME])) })             .as_deref()     }+    /// The PostScript name of this face as an instance of its variable font (Adobe TN #5902), or+    /// `None` for a static face or a point no named instance covers.+    ///+    /// [`Self::postscript_name`] is the file's own `name` ID 6, which names the font's default+    /// instance: embedding by it put every weight of a variable font into a PDF under one name+    /// (#1690).+    pub fn instance_postscript_name(&self) -> Option<String> {+        if !self.is_variable() {+            return None;+        }+        crate::psname::instance_postscript_name(&self.skrifa()?, &self.coords)+    }+     /// 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)diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs--- a/crates/fonts/src/lib.rs+++ b/crates/fonts/src/lib.rs@@ -13,6 +13,7 @@ mod group; mod kind; #[cfg(target_os = "macos")] mod mac_fonts;+pub mod psname; #[cfg(any(test, feature = "testing"))] pub mod testing; mod vertical;diff --git a/crates/fonts/src/psname.rs b/crates/fonts/src/psname.rsnew file mode 100644--- /dev/null+++ b/crates/fonts/src/psname.rs@@ -0,0 +1,143 @@+//! The PostScript name of a variable font's named instance (Adobe TN #5902).+//!+//! `name` ID 6 names the font's **default** instance, so every instance of a variable font embeds+//! under the same name: all the weights of Noto Sans KR went into a PDF as `NotoSansKR-Thin`, and+//! Acrobat's font list, preflight and a print shop's checks then report Thin for every weight+//! (#1690). The outlines were right; only the name was wrong.++use skrifa::raw::TableProvider;+use skrifa::{FontRef, MetadataProvider, raw::types::NameId};++/// Longest PostScript name TN #5902 allows.+const MAX_LEN: usize = 63;++/// Axis values count as the same instance within this much (user units).+const AXIS_EPSILON: f32 = 1e-4;++/// The PostScript name for the instance of `font` at `coords`, or `None` when the font names no+/// such instance (a point in between, or a static font).+///+/// TN #5902: a named instance's own `postScriptNameID` wins; otherwise the name is the family+/// prefix (`name` ID 25, else the typographic family, else the family) joined to the instance's+/// subfamily with a hyphen, keeping only ASCII letters and digits and no more than 63 characters.+pub fn instance_postscript_name(font: &FontRef<'_>, coords: &[([u8; 4], f32)]) -> Option<String> {+    let axes = font.axes();+    let instance = font.named_instances().iter().find(|ni| {+        let mut values = axes.iter().zip(ni.user_coords());+        coords.len() == axes.len()+            && values.all(|(axis, value)| coords.iter().any(|(tag, want)| *tag == axis.tag().to_be_bytes() && (want - value).abs() <= AXIS_EPSILON))+    })?;+    if let Some(id) = instance.postscript_name_id()+        && let Some(name) = string(font, id)+    {+        let kept = keep(&name);+        if !kept.is_empty() {+            return Some(truncate(kept));+        }+    }+    let prefix = [NameId::VARIATIONS_POSTSCRIPT_NAME_PREFIX, NameId::TYPOGRAPHIC_FAMILY_NAME, NameId::FAMILY_NAME]+        .into_iter()+        .find_map(|id| string(font, id))+        .map(|s| keep(&s))+        .filter(|s| !s.is_empty())?;+    let subfamily = string(font, instance.subfamily_name_id()).map(|s| keep(&s)).filter(|s| !s.is_empty())?;+    Some(truncate(format!("{prefix}-{subfamily}")))+}++/// A `name` table string, preferring the Windows record a PDF reader looks at.+fn string(font: &FontRef<'_>, id: NameId) -> Option<String> {+    let name = font.name().ok()?;+    let records = name.name_record();+    let pick = |want: u16| records.iter().find(|r| r.name_id() == id && r.platform_id() == want);+    let record = pick(3).or_else(|| pick(1)).or_else(|| records.iter().find(|r| r.name_id() == id))?;+    let text = record.string(name.string_data()).ok()?.chars().collect::<String>();+    (!text.trim().is_empty()).then_some(text)+}++/// ASCII letters and digits of `s`, which is what a PostScript name may hold.+fn keep(s: &str) -> String {+    s.chars().filter(char::is_ascii_alphanumeric).collect()+}++/// `s` at most [`MAX_LEN`] characters, cut on a character boundary (the name is ASCII here, so+/// this is a byte cut, but the font's own string need not have been).+fn truncate(s: String) -> String {+    match s.char_indices().nth(MAX_LEN) {+        Some((at, _)) => s[..at].to_string(),+        None => s,+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A variable font named like Noto Sans KR: a `wght` axis and four named instances, with the+    /// family prefix in name ID 25.+    fn variable(entries: &[(u16, &str)], instances: &[(u16, Option<u16>, Vec<f32>)]) -> Vec<u8> {+        let base = crate::testing::font_with_names(entries, &['A']).expect("a font with these names");+        crate::testing::with_fvar(&base, &[(*b"wght", 100.0, 100.0, 900.0, 256)], instances).expect("an fvar")+    }++    fn at(font: &[u8], weight: f32) -> Option<String> {+        let f = skrifa::FontRef::new(font).expect("the fixture parses");+        instance_postscript_name(&f, &[(*b"wght", weight)])+    }++    /// `name` ID 6 names the default instance, so every weight embedded under it: the four+    /// weights of Noto Sans KR all went into the PDF as `NotoSansKR-Thin` (#1690).+    #[test]+    fn each_named_instance_has_its_own_postscript_name() {+        // 1 family, 6 the default instance's PostScript name, 25 the variations prefix,+        // 257.. the instances' subfamily names.+        let font = variable(+            &[(1, "Noto Sans KR"), (6, "NotoSansKR-Thin"), (25, "NotoSansKR"), (257, "Thin"), (258, "Regular"), (259, "Bold"), (260, "Black")],+            &[(257, None, vec![100.0]), (258, None, vec![400.0]), (259, None, vec![700.0]), (260, None, vec![900.0])],+        );+        assert_eq!(at(&font, 100.0).as_deref(), Some("NotoSansKR-Thin"));+        assert_eq!(at(&font, 400.0).as_deref(), Some("NotoSansKR-Regular"));+        assert_eq!(at(&font, 700.0).as_deref(), Some("NotoSansKR-Bold"));+        assert_eq!(at(&font, 900.0).as_deref(), Some("NotoSansKR-Black"));+        // A point between the named instances is not a named instance.+        assert_eq!(at(&font, 550.0), None);+    }++    /// An instance that names its own PostScript name uses it, whatever the prefix says.+    #[test]+    fn an_instances_own_postscript_name_wins() {+        let font = variable(+            &[(1, "Pretendard Variable"), (6, "PretendardVariable-Regular"), (25, "PretendardVariable"), (257, "Bold"), (300, "Pretendard-Heavy")],+            &[(257, Some(300), vec![700.0])],+        );+        assert_eq!(at(&font, 700.0).as_deref(), Some("PretendardHeavy"));+    }++    /// Without ID 25 the typographic family is the prefix, then the family; and a name a+    /// PostScript name cannot hold keeps only its letters and digits.+    #[test]+    fn the_prefix_falls_back_and_the_name_is_sanitised() {+        let typographic = variable(&[(1, "Fixture"), (16, "Fixture Display"), (257, "Semi Bold")], &[(257, None, vec![600.0])]);+        assert_eq!(at(&typographic, 600.0).as_deref(), Some("FixtureDisplay-SemiBold"));++        let family_only = variable(&[(1, "Fixture (Beta)"), (257, "Extra Light")], &[(257, None, vec![200.0])]);+        assert_eq!(at(&family_only, 200.0).as_deref(), Some("FixtureBeta-ExtraLight"));+    }++    /// TN #5902 allows 63 characters.+    #[test]+    fn a_long_name_is_cut_to_the_limit() {+        let long = "L".repeat(80);+        let font = variable(&[(1, "Fixture"), (25, &long), (257, "Regular")], &[(257, None, vec![400.0])]);+        let name = at(&font, 400.0).expect("a name");+        assert_eq!(name.len(), MAX_LEN, "cut to the limit: {name}");+        assert!(name.starts_with("LLL"));+    }++    /// A static font has no named instances, so it keeps its own `name` ID 6.+    #[test]+    fn a_static_font_has_no_instance_name() {+        let base = crate::testing::font_with_names(&[(1, "Fixture"), (6, "Fixture-Regular")], &['A']).unwrap();+        let f = skrifa::FontRef::new(&base).unwrap();+        assert_eq!(instance_postscript_name(&f, &[]), None);+    }+}diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs--- a/crates/fonts/src/testing.rs+++ b/crates/fonts/src/testing.rs@@ -95,6 +95,40 @@ pub fn font_with_names(entries: &[(u16, &str)], chars: &[char]) -> Option<Vec<u8     with_tables(&base, vec![(*b"name", name)]) } +/// `font` with an `fvar` table: `axes` are (tag, min, default, max, axis name id) and `instances`+/// are (subfamily name id, PostScript name id, one user coordinate per axis). Version 1.0, with+/// the optional `postScriptNameID` present only when some instance names one.+pub fn with_fvar(font: &[u8], axes: &[([u8; 4], f32, f32, f32, u16)], instances: &[(u16, Option<u16>, Vec<f32>)]) -> Option<Vec<u8>> {+    let fixed = |v: f32| ((v * 65536.0).round() as i32).to_be_bytes();+    let any_ps = instances.iter().any(|(_, ps, _)| ps.is_some());+    let axis_count = u16::try_from(axes.len()).ok()?;+    let instance_size = axis_count.checked_mul(4)?.checked_add(if any_ps { 6 } else { 4 })?;+    let mut t = Vec::new();+    for v in [1u16, 0, 16, 2, axis_count, 20, u16::try_from(instances.len()).ok()?, instance_size] {+        t.extend_from_slice(&v.to_be_bytes());+    }+    for (tag, min, def, max, name_id) in axes {+        t.extend_from_slice(tag);+        t.extend_from_slice(&fixed(*min));+        t.extend_from_slice(&fixed(*def));+        t.extend_from_slice(&fixed(*max));+        t.extend_from_slice(&0u16.to_be_bytes());+        t.extend_from_slice(&name_id.to_be_bytes());+    }+    for (subfamily, ps, coords) in instances {+        if coords.len() != axes.len() {+            return None;+        }+        t.extend_from_slice(&subfamily.to_be_bytes());+        t.extend_from_slice(&0u16.to_be_bytes());+        coords.iter().for_each(|v| t.extend_from_slice(&fixed(*v)));+        if any_ps {+            t.extend_from_slice(&ps.unwrap_or(0).to_be_bytes());+        }+    }+    with_tables(font, vec![(*b"fvar", t)])+}+ /// Every table of the font file `font`: (tag, bytes). fn tables_of(font: &[u8]) -> Option<Vec<([u8; 4], Vec<u8>)>> {     let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes);