Skip to content

Commit 8d4e766

Browse files
committed
Add instances_name_length_req check
Similar to name_length_req, but checks the family and subfamily names of the instances, computed from STAT table. Add the new check to Microsoft profile. Also add failing test case for the new check as well as name_length_req.
1 parent 758c9a5 commit 8d4e766

4 files changed

Lines changed: 152 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ A more detailed list of changes is available in the corresponding milestones for
99
### On the opentype profile
1010
- **[check_monospace]:** Check CFF fonts as well: remove conditions "is_ttf" + remove "glyf" from required tables (issue #5030)
1111

12+
### New checks
13+
### On micorosoft profile
14+
- **[instances_name_length_req]:** Check family and subfamily name length, similar to **name_length_req** but for "fvar" instances, using names computed from "STAT" table. (PR #5035)
15+
1216

1317
## 1.0.0 (2025-May-07)
1418
- See also: https://github.com/fonttools/fontspector
Lines changed: 128 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,28 @@
1-
from fontbakery.prelude import check, FAIL
1+
from fontbakery.constants import NameID
2+
from fontbakery.prelude import check, FAIL, Message
23
from fontbakery.utils import get_family_name, get_subfamily_name
34

45

6+
def _check_name_length_req(family_name, subfamily_name):
7+
if family_name is None:
8+
yield FAIL, Message("missing-name-id", "Name ID 1 (family) missing")
9+
if subfamily_name is None:
10+
yield FAIL, Message("missing-name-id", "Name ID 2 (sub family) missing")
11+
12+
logfont = (
13+
family_name
14+
if subfamily_name in ("Regular", "Bold", "Italic", "Bold Italic")
15+
else " ".join([family_name, subfamily_name])
16+
)
17+
18+
if len(logfont) > 31:
19+
yield FAIL, Message(
20+
"long-name",
21+
f"Family + subfamily name, '{logfont}', is too long: "
22+
f"{len(logfont)} characters; must be 31 or less",
23+
)
24+
25+
526
@check(
627
id="name_length_req",
728
rationale="""
@@ -14,19 +35,112 @@ def check_name_length_req(ttFont):
1435
"""Maximum allowed length for family and subfamily names."""
1536
family_name = get_family_name(ttFont)
1637
subfamily_name = get_subfamily_name(ttFont)
17-
if family_name is None:
18-
yield FAIL, "Name ID 1 (family) missing"
19-
if subfamily_name is None:
20-
yield FAIL, "Name ID 2 (sub family) missing"
38+
yield from _check_name_length_req(family_name, subfamily_name)
2139

22-
logfont = (
23-
family_name
24-
if subfamily_name in ("Regular", "Bold", "Italic", "Bold Italic")
25-
else " ".join([family_name, subfamily_name])
40+
41+
def resolve_stat_names(vf, coordinates):
42+
"""
43+
Returns a dictionary of names & axis order, indexed by axis tag, for an
44+
instance in the given font at the given coordinates.
45+
46+
e.g. result['wght']: ('Regular', 0)
47+
"""
48+
stat_table = vf["STAT"].table
49+
result = {}
50+
51+
for avr in stat_table.AxisValueArray.AxisValue:
52+
axis_tag = stat_table.DesignAxisRecord.Axis[avr.AxisIndex].AxisTag
53+
if (
54+
axis_tag in coordinates
55+
and (
56+
(
57+
avr.Format == 2
58+
and avr.RangeMinValue <= coordinates[axis_tag] <= avr.RangeMaxValue
59+
)
60+
or (avr.Format in (1, 3) and avr.Value == coordinates[axis_tag])
61+
)
62+
) or (axis_tag not in coordinates):
63+
if avr.Flags & 0x2 == 0:
64+
name = vf["name"].getDebugName(avr.ValueNameID)
65+
axis_order = stat_table.DesignAxisRecord.Axis[
66+
avr.AxisIndex
67+
].AxisOrdering
68+
result[axis_tag] = (name, axis_order)
69+
70+
return result
71+
72+
73+
RBIZ_AXES = ["wght", "ital"]
74+
75+
76+
def compute_rbiz_names(family, stat_names, rbiz_axes):
77+
# Name IDs 1 & 2 in a RBIZ model
78+
rbiz_family_names = [
79+
n
80+
for axis, (n, _) in sorted(stat_names.items(), key=lambda x: x[1][1])
81+
if axis not in rbiz_axes
82+
]
83+
rbiz_family = " ".join([f"{family}", *rbiz_family_names])
84+
rbiz_subfamily_names = [
85+
stat_names[axis] for axis in rbiz_axes if axis in stat_names
86+
]
87+
rbiz_subfamily = " ".join(
88+
[n for n, _ in sorted(rbiz_subfamily_names, key=lambda x: x[1])]
2689
)
90+
if rbiz_subfamily == "":
91+
rbiz_subfamily = "Regular"
92+
return (rbiz_family, rbiz_subfamily)
2793

28-
if len(logfont) > 31:
29-
yield FAIL, (
30-
f"Family + subfamily name, '{logfont}', is too long: "
31-
f"{len(logfont)} characters; must be 31 or less"
32-
)
94+
95+
def names_from_stat(vf, coordinates):
96+
"""
97+
Returns family and subfamily names generated from the STAT and name tables, for an instance
98+
at the given coordinates.
99+
"""
100+
stat_names = resolve_stat_names(vf, coordinates)
101+
102+
# Find any implicit style names in ID 1 by removing ID 16 and any of the
103+
# default instance style names. E.g. if ID 1 is "Bahnschrift Rounded Light",
104+
# and ID 16 is "Bahnschrift", find "Rounded".
105+
id1 = vf["name"].getDebugName(NameID.FONT_FAMILY_NAME)
106+
id16 = vf["name"].getDebugName(NameID.TYPOGRAPHIC_FAMILY_NAME)
107+
if id1 and id16 and id1.startswith(id16) and id1 != id16:
108+
id1_names = id1[len(id16) + 1 :].split(" ")
109+
default_inst_coords = {
110+
axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes
111+
}
112+
default_stat_names = resolve_stat_names(vf, default_inst_coords)
113+
default_stat_names = [n for n, _ in sorted(default_stat_names.values())]
114+
remaining_names = [n for n in id1_names if n not in default_stat_names]
115+
# Update axis ordering in stat_names
116+
for axis_tag, (name, ordering) in stat_names.items():
117+
stat_names[axis_tag] = (name, ordering + len(remaining_names))
118+
# Add the names from ID 1
119+
stat_names.update({f"X{i:<3}": (n, i) for i, n in enumerate(remaining_names)})
120+
121+
family = vf["name"].getDebugName(NameID.TYPOGRAPHIC_FAMILY_NAME)
122+
if family is None:
123+
family = vf["name"].getDebugName(NameID.FONT_FAMILY_NAME)
124+
125+
rbiz_axes = list(RBIZ_AXES)
126+
if "wght" in coordinates and coordinates["wght"] not in (400, 700):
127+
rbiz_axes = [x for x in rbiz_axes if x != "wght"]
128+
129+
rbiz_family, rbiz_subfamily = compute_rbiz_names(family, stat_names, rbiz_axes)
130+
return rbiz_family, rbiz_subfamily
131+
132+
133+
@check(
134+
id="instances_name_length_req",
135+
conditions=["is_variable_font"],
136+
rationale="""
137+
For Office, instance family and subfamily names must be 31 characters or less total
138+
to fit in a LOGFONT.
139+
""",
140+
)
141+
def instances_name_length_req(ttFont):
142+
"""Maximum allowed length for instance family and subfamily names."""
143+
fvar = ttFont["fvar"]
144+
for instance in fvar.instances:
145+
family_name, subfamily_name = names_from_stat(ttFont, instance.coordinates)
146+
yield from _check_name_length_req(family_name, subfamily_name)

Lib/fontbakery/profiles/microsoft.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"name_id_1", # TODO: These name id 1 & 2 checks are too simple. Maybe they could be merged.
7171
"name_id_2", # TODO: Also, they could be included in some other name table check on the universal profile.
7272
"name_length_req", # TODO: Maybe the same applies to this one.
73+
"instances_name_length_req",
7374
],
7475
"Metrics Checks": [
7576
"microsoft/vertical_metrics",

tests/test_checks_name.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import pytest
23
from fontTools.ttLib import TTFont
34

@@ -82,7 +83,24 @@ def test_check_name_length_req(check, test_ttFont):
8283

8384
assert_PASS(check(test_ttFont), "with a good font...")
8485

85-
# TODO: test a FAIL case
86+
test_ttFont_bad = copy.deepcopy(test_ttFont)
87+
for rec in test_ttFont_bad["name"].names:
88+
if rec.nameID in (NameID.FONT_FAMILY_NAME, NameID.TYPOGRAPHIC_FAMILY_NAME):
89+
rec.string = "A" * 32
90+
assert_results_contain(check(test_ttFont_bad), FAIL, "long-name")
91+
92+
93+
@check_id("instances_name_length_req")
94+
def test_check_instances_name_length_req(check, test_ttFont):
95+
"""Maximum allowed length for family and subfamily names."""
96+
97+
assert_PASS(check(test_ttFont), "with a good font...")
98+
99+
test_ttFont_bad = copy.deepcopy(test_ttFont)
100+
for rec in test_ttFont_bad["name"].names:
101+
if rec.nameID in (NameID.FONT_FAMILY_NAME, NameID.TYPOGRAPHIC_FAMILY_NAME):
102+
rec.string = "A" * 32
103+
assert_results_contain(check(test_ttFont_bad), FAIL, "long-name")
86104

87105

88106
@check_id("typographic_family_name")

0 commit comments

Comments
 (0)