fix(text): lay out a variable font's named instance from its PostScri… (#8909)

* fix(text): lay out a variable font's named instance from its PostScript name

A PSD type style set in a named instance of a variable font, such as
Bahnschrift Light Condensed, keeps the instance only as its PostScript
name ("Bahnschrift-LightCondensed"); its variations are empty. When
PhotoCraft re-laid the layer out, FontDb::select_named_face looked for a
face with that PostScript name, or one whose style and weight matched. A
variable family is a single face, so neither matched, and the text was
laid out at the font's default instance: about 35% wider than the
instance Photoshop drew with Bahnschrift.

select_named_face now falls back to the family's variable faces when the
PostScript name matches no face. It reads each face's fvar named
instances and compares the name with the instance's own PostScript name,
or, when the instance has none, with the name Adobe Technical Note #5902
derives (variations prefix or family name, a hyphen, then the subfamily
without spaces). Names compare without punctuation or case. On a match,
that face is selected and the instance's axis values are added to the
style's variations, keeping any axis the style already sets. Static
faces still match exactly as before, and PSD import is unchanged.

Tests:
- font_names_tests::postscript_name_of_a_named_instance_selects_its_axis_values
  rebuilds the bundled Inter at test time as a variable font (fvar with
  wght/wdth axes and two named instances, one with its own PostScript
  name and one derived) and checks the axis values each name selects, an
  unknown name, and a style's own axis value. It needs no installed font
  and no font file in the repository.
- font_names_tests::exact_static_face_gets_no_instance_axes checks that
  an exact static match gets no axis values.
- tests::postscript_name_of_a_variable_instance_lays_out_at_that_instance
  lays out "Hamburgefonstiv 0123" with Bahnschrift (Windows) or Ubuntu
  Sans (Ubuntu) when installed and checks that the instance's PostScript
  name gives the same width as its axis values. With Ubuntu Sans before
  the fix: UbuntuSans-CondensedLight 980.3 px (= the default instance),
  its axes 766.4 px; after the fix both are 766.4 px.

A style with only a weight (no PostScript name) is still not turned into
a wght axis value on a variable font; the issue reports that too, but its
fix and test are about the PostScript name, so that is left for a
separate change.

Fixes #8797

* fix(text): try a variable font's named instance before the style fallback

Review pointed out that select_named_face ran the style-and-weight
fallback before the named-instance lookup. A style that carries a named
instance's PostScript name and also a populated font_style matching the
variable face itself ("Regular", 400) selected that face at its default
instance, and the instance's axis values were never added.

Faces are now tried in order: exact PostScript name, named instance,
then style, weight and italic.

Review also noted that the layout-width test only runs when Bahnschrift
or Ubuntu Sans is installed. The synthesized variable font in
font_names_tests now carries an HVAR table that narrows every advance on
wdth, so layout width shows which instance was used. Two tests run
everywhere:

- named_instance_wins_over_a_matching_font_style
- postscript_name_of_a_named_instance_lays_out_at_that_instance

Both fail before this change. The installed-font test is kept as a check
against a real font.

---------

Co-authored-by: John Turnbach <jturnbach@users.noreply.github.com>
This commit is contained in:
jturnbach
2026-10-23 17:39:30 -04:00
committed by GitHub
co-authored by John Turnbach
parent 2d9640bb0b
commit 316a07c8c9
3 changed files with 307 additions and 1 deletions
+217
View File
@@ -251,3 +251,220 @@ fn craft_font_collections_keep_their_face_families() {
assert!(db.has_family("Craft Test A") && db.has_family("Craft Test B"), "{:?}", db.families());
assert!(!db.has_family("Craft Test Collection"));
}
/// A 16.16 fixed-point number as `fvar` stores it.
fn fixed(v: f32) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
/// `source` rebuilt as a variable font with `wght` 100–900 and `wdth` 75–100 axes and two named
/// instances, without redistributing a variable font (#8797): "Light Condensed" (300, 75) has no
/// PostScript name of its own, so its name is derived as `InstanceTest-LightCondensed`;
/// "Heavy" (900, 100) names itself `InstanceTest-Black`. Outlines do not vary, but an `HVAR` table
/// (see [`width_hvar`]) narrows every advance on `wdth`, so layout width shows which instance drew.
fn instance_font(source: &[u8]) -> Vec<u8> {
let names = [
(1u16, "Instance Test"),
(2, "Regular"),
(6, "InstanceTest-Regular"),
(16, "Instance Test"),
(256, "Weight"),
(257, "Width"),
(258, "Light Condensed"),
(259, "InstanceTest-Black"),
(260, "Heavy"),
];
let mut name = Vec::new();
let mut strings = Vec::new();
for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] {
name.extend(value.to_be_bytes());
}
for (id, value) in names {
let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect();
for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] {
name.extend(n.to_be_bytes());
}
strings.extend(encoded);
}
name.extend(strings);
// Two axes of 20 bytes, then two instances of 4 + 2 * 4 + 2 bytes (with PostScript name IDs).
let mut fvar = Vec::new();
for n in [1u16, 0, 16, 2, 2, 20, 2, 14] {
fvar.extend(n.to_be_bytes());
}
for (tag, min, default, max, name_id) in [(b"wght", 100.0, 400.0, 900.0, 256u16), (b"wdth", 75.0, 100.0, 100.0, 257)] {
fvar.extend(tag);
for v in [min, default, max] {
fvar.extend(fixed(v));
}
fvar.extend([0, 0]);
fvar.extend(name_id.to_be_bytes());
}
for (subfamily, coords, ps) in [(258u16, [300.0, 75.0], 0xFFFFu16), (260, [900.0, 100.0], 259)] {
fvar.extend(subfamily.to_be_bytes());
fvar.extend([0, 0]);
for v in coords {
fvar.extend(fixed(v));
}
fvar.extend(ps.to_be_bytes());
}
// Rebuild the table directory: every table but `name` as it was, plus the new `name`, `fvar`
// and `HVAR`.
let count = u16::from_be_bytes(source[4..6].try_into().unwrap()) as usize;
let mut tables: Vec<([u8; 4], Vec<u8>)> = (0..count)
.map(|i| {
let r = 12 + i * 16;
let at = u32::from_be_bytes(source[r + 8..r + 12].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(source[r + 12..r + 16].try_into().unwrap()) as usize;
(source[r..r + 4].try_into().unwrap(), source[at..at + len].to_vec())
})
.filter(|(tag, _)| tag != b"name")
.collect();
let table = |tag: &[u8; 4]| tables.iter().find(|(t, _)| t == tag).map(|(_, data)| data.as_slice()).unwrap();
let hvar = width_hvar(table(b"hhea"), table(b"hmtx"), table(b"maxp"));
tables.push((*b"name", name));
tables.push((*b"fvar", fvar));
tables.push((*b"HVAR", hvar));
tables.sort_by_key(|(tag, _)| *tag);
let mut out = source[0..4].to_vec();
for n in [tables.len() as u16, 0, 0, 0] {
out.extend(n.to_be_bytes());
}
let mut at = 12 + 16 * tables.len();
for (tag, data) in &tables {
out.extend(tag);
out.extend(0u32.to_be_bytes());
out.extend((at as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
at += data.len().next_multiple_of(4);
}
for (_, data) in &tables {
out.extend(data);
out.resize(out.len().next_multiple_of(4), 0);
}
out
}
/// An `HVAR` table for [`instance_font`]'s axes (`wght`, then `wdth`) that takes 30% off every
/// glyph's advance at `wdth` 75 and nothing at the default 100, scaled linearly between. Advances
/// come from `hmtx`; glyph IDs map to deltas directly, as no advance mapping is given.
fn width_hvar(hhea: &[u8], hmtx: &[u8], maxp: &[u8]) -> Vec<u8> {
let glyphs = u16::from_be_bytes(maxp[4..6].try_into().unwrap());
let metrics = u16::from_be_bytes(hhea[34..36].try_into().unwrap()) as usize;
let advance = |gid: usize| {
let at = 4 * gid.min(metrics - 1);
i32::from(u16::from_be_bytes(hmtx[at..at + 2].try_into().unwrap()))
};
let mut out = Vec::new();
// Header: version 1.0, item variation store at 20, no advance, LSB or RSB mappings.
for n in [1u16, 0] {
out.extend(n.to_be_bytes());
}
for n in [20u32, 0, 0, 0] {
out.extend(n.to_be_bytes());
}
// Item variation store: format 1, region list at 12, one item variation data at 28.
out.extend(1u16.to_be_bytes());
out.extend(12u32.to_be_bytes());
out.extend(1u16.to_be_bytes());
out.extend(28u32.to_be_bytes());
// One region over two axes as (start, peak, end) in F2Dot14: `wght` unused, `wdth` -1..0
// peaking at -1 (75).
for n in [2u16, 1, 0, 0, 0, 0xC000, 0xC000, 0] {
out.extend(n.to_be_bytes());
}
// Item variation data: one 16-bit delta per glyph for region 0.
for n in [glyphs, 1, 1, 0] {
out.extend(n.to_be_bytes());
}
for gid in 0..glyphs as usize {
out.extend((-(advance(gid) * 3 / 10) as i16).to_be_bytes());
}
out
}
/// The axis values `select_named_face` gives a style of the synthesized variable family that
/// carries the PostScript name `ps`, sorted by axis.
fn instance_axes(db: &mut FontDb, ps: &str, variations: Vec<photocraft_doc::text::FontVariation>) -> Vec<(String, f32)> {
let mut style = CharStyle { font_family: "Instance Test".into(), postscript_name: Some(ps.into()), weight: 300, variations, ..Default::default() };
db.select_named_face(&mut style);
let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect();
axes.sort_by(|a, b| a.0.cmp(&b.0));
axes
}
/// #8797: a PostScript name that names a named instance of a variable font (as a PSD stores
/// "Bahnschrift-LightCondensed") selects that instance's axis values, whether the instance names
/// itself or its name is derived from the family and subfamily names. A name no instance has
/// leaves the style at the default instance, and axes the style sets itself are kept.
#[test]
fn postscript_name_of_a_named_instance_selects_its_axis_values() {
use photocraft_doc::text::FontVariation;
let mut db = FontDb::new();
db.register_font_data(instance_font(&INTER_REGULAR));
assert_eq!(db.faces("Instance Test").len(), 1, "a variable family is one face");
let axes = |pairs: &[(&str, f32)]| pairs.iter().map(|(a, v)| (a.to_string(), *v)).collect::<Vec<_>>();
assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", vec![]), axes(&[("wdth", 75.0), ("wght", 300.0)]));
assert_eq!(instance_axes(&mut db, "InstanceTest-Black", vec![]), axes(&[("wdth", 100.0), ("wght", 900.0)]));
assert_eq!(instance_axes(&mut db, "InstanceTest-Missing", vec![]), axes(&[]));
let own = vec![FontVariation { axis: "wght".into(), value: 350.0 }];
assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", own), axes(&[("wdth", 75.0), ("wght", 350.0)]));
}
/// A static face whose PostScript name matches exactly is selected as before (#8797 only adds
/// a fallback for names no face has), and gets no axis values.
#[test]
fn exact_static_face_gets_no_instance_axes() {
let mut db = FontDb::new();
db.register_font_data(numeric_font(&INTER_SEMIBOLD, "30", "StaticPS30"));
let mut style = CharStyle { font_family: "Numeric Test".into(), postscript_name: Some("StaticPS30".into()), ..Default::default() };
db.select_named_face(&mut style);
assert_ne!(style.font_family, "Numeric Test", "the exact face is selected through its alias");
assert!(style.variations.is_empty(), "{:?}", style.variations);
}
/// A style whose `font_style` and weight also match the variable face itself ("Regular", 400) still
/// gets the named instance its PostScript name gives: the instance is tried before the
/// style-and-weight fallback, which would select the face at its default instance (#8797).
#[test]
fn named_instance_wins_over_a_matching_font_style() {
let mut db = FontDb::new();
db.register_font_data(instance_font(&INTER_REGULAR));
let mut style = CharStyle {
font_family: "Instance Test".into(),
font_style: "Regular".into(),
weight: 400,
postscript_name: Some("InstanceTest-LightCondensed".into()),
..Default::default()
};
db.select_named_face(&mut style);
assert_ne!(style.font_family, "Instance Test", "the variable face is selected through its alias");
let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect();
axes.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(axes, vec![("wdth".to_string(), 75.0), ("wght".to_string(), 300.0)]);
}
/// #8797 end to end without an installed font: text whose style carries a named instance's
/// PostScript name lays out as wide as that instance's axis values, which the synthesized `HVAR`
/// makes narrower than the default instance. The style's `font_style` and weight match the
/// variable face itself, so the default instance would be chosen if the instance were skipped.
#[test]
fn postscript_name_of_a_named_instance_lays_out_at_that_instance() {
use photocraft_doc::TextLayer;
use photocraft_doc::text::{FontVariation, TextRun};
let mut engine = crate::TextEngine::new();
engine.fonts.register_font_data(instance_font(&INTER_REGULAR));
let text = "Hamburgefonstiv 0123";
let mut width = |style: CharStyle| {
let layer = TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() };
let l = engine.layout(&layer, 96.0);
l.lines[0].x1 - l.lines[0].x0
};
let base = CharStyle { font_family: "Instance Test".into(), font_style: "Regular".into(), size_pt: 72.0, weight: 400, ..Default::default() };
let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }];
let family = width(base.clone());
let instance = width(CharStyle { postscript_name: Some("InstanceTest-LightCondensed".into()), ..base.clone() });
let explicit = width(CharStyle { variations: axes, ..base });
assert!(explicit < family * 0.8, "wdth 75 is narrower: {explicit} vs {family}");
assert!((instance - explicit).abs() < 0.5, "instance: {instance} px, its axes: {explicit} px, default instance: {family} px");
}
+58 -1
View File
@@ -483,10 +483,29 @@ impl FontDb {
/// Select an exact face for shaping. Private aliases are runtime-only: the document keeps
/// its public family and PostScript name. CSS matching alone cannot distinguish e.g. the
/// numeric subfamilies of Yoon fonts, all of which declare weight 400 / normal.
///
/// A PostScript name that no face has may name an instance of a variable face
/// ("Bahnschrift-LightCondensed"): that face is selected and the instance's axis values are
/// added to the style's variations, keeping any axis the style sets itself (#8797). Faces are
/// tried in this order: exact PostScript name, named instance, then style, weight and italic.
pub(crate) fn select_named_face(&mut self, style: &mut photocraft_doc::text::CharStyle) {
let family = style.font_family.clone();
let faces = self.faces(&family);
let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps))).or_else(|| {
let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps)));
// The named instance is tried before the style-and-weight fallback: a variable face's own
// style ("Regular") can match a populated `font_style` and would drop the instance's axes.
let instance = match (index, &style.postscript_name) {
(None, Some(ps)) => self.named_instance(&family, &faces, ps),
_ => None,
};
if let Some((_, axes)) = &instance {
for (axis, value) in axes {
if !style.variations.iter().any(|v| &v.axis == axis) {
style.variations.push(photocraft_doc::text::FontVariation { axis: axis.clone(), value: *value });
}
}
}
let index = index.or(instance.map(|(i, _)| i)).or_else(|| {
faces.iter().position(|f| {
!style.font_style.is_empty()
&& f.style.eq_ignore_ascii_case(&style.font_style)
@@ -514,6 +533,44 @@ impl FontDb {
style.italic = face.italic;
}
/// The variable face of `family` (index into `faces`) with a named instance whose PostScript
/// name is `ps`, and that instance's axis values. An instance without a PostScript name of its
/// own is named as Adobe Technical Note #5902 derives it: the variations prefix (name 25, else
/// the family name) and the subfamily name without spaces, joined by a hyphen. Names compare
/// without punctuation or case, and the subfamily alone matches the part of `ps` after the
/// first hyphen, since the prefix a document stored can differ from the font's.
fn named_instance(&mut self, family: &str, faces: &[FaceInfo], ps: &str) -> Option<(usize, Vec<(String, f32)>)> {
let key = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect::<String>();
let wanted = key(ps);
let wanted_style = ps.split_once('-').map(|(_, s)| key(s));
let info = self.fcx.collection.family_by_name(family)?;
for (index, face) in info.fonts().iter().enumerate() {
if faces.get(index).is_none_or(|f| f.axes.is_empty()) {
continue;
}
let Some(blob) = face.load(Some(&mut self.fcx.source_cache)) else { continue };
let Ok(font) = skrifa::FontRef::from_index(blob.as_ref(), face.index()) else { continue };
let name = |id: StringId| font.localized_strings(id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty());
let prefix = name(StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX)
.or_else(|| name(StringId::TYPOGRAPHIC_FAMILY_NAME))
.or_else(|| name(StringId::FAMILY_NAME))
.unwrap_or_default();
for instance in font.named_instances().iter() {
let subfamily = name(instance.subfamily_name_id()).unwrap_or_default();
let own = instance.postscript_name_id().and_then(name);
let matches = match own {
Some(own) => key(&own) == wanted,
None => key(&prefix) + &key(&subfamily) == wanted || (!subfamily.is_empty() && wanted_style.as_deref() == Some(key(&subfamily).as_str())),
};
if matches {
let axes = font.axes().iter().map(|a| a.tag().to_string()).zip(instance.user_coords()).collect();
return Some((index, axes));
}
}
}
None
}
/// Finds a face by PostScript name (as stored in PSD files): exact match by reading the
/// `name` table of candidate faces, else a heuristic split (`Arial-BoldMT` → Arial, 700).
pub fn resolve_postscript(&mut self, ps: &str) -> ResolvedFont {
+32
View File
@@ -847,6 +847,38 @@ fn variable_font_axes() {
assert!(heavy > light * 1.5, "{light} → {heavy}");
}
/// #8797: a style that carries the PostScript name of a variable font's named condensed light
/// instance lays out as wide as that instance's axis values, not at the default instance. Uses
/// Bahnschrift (Windows) or Ubuntu Sans (Ubuntu) when installed, and skips otherwise; the
/// synthesized-font tests in `font_names_tests` cover the instance lookup and its layout width
/// everywhere.
#[test]
fn postscript_name_of_a_variable_instance_lays_out_at_that_instance() {
use photocraft_doc::text::FontVariation;
let candidates = [
("C:\\Windows\\Fonts\\bahnschrift.ttf", "Bahnschrift-LightCondensed"),
("/usr/share/fonts/truetype/ubuntu/UbuntuSans[wdth,wght].ttf", "UbuntuSans-CondensedLight"),
];
let Some((bytes, ps)) = candidates.iter().find_map(|(p, ps)| std::fs::read(p).ok().map(|b| (b, *ps))) else {
return;
};
let mut e = TextEngine::new();
let Some(fam) = e.fonts.register_font_data(bytes).first().cloned() else {
return;
};
let width = |e: &mut TextEngine, style: CharStyle| {
let l = e.layout(&styled("Hamburgefonstiv 0123", style), 96.0);
l.lines[0].x1 - l.lines[0].x0
};
let base = CharStyle { font_family: fam.clone(), size_pt: 72.0, weight: 300, ..Default::default() };
let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }];
let family = width(&mut e, base.clone());
let instance = width(&mut e, CharStyle { postscript_name: Some(ps.into()), ..base.clone() });
let explicit = width(&mut e, CharStyle { variations: axes, ..base });
assert!(explicit < family * 0.9, "the condensed light axes are narrower: {explicit} vs {family}");
assert!((instance - explicit).abs() < 0.5, "{ps}: {instance} px, its axes: {explicit} px, default instance: {family} px");
}
#[test]
fn warp_bends_rendered_text_and_outlines() {
use photocraft_doc::text::TextWarp;