Skip to content

Commit 01d3fae

Browse files
authored
Merge pull request #943 from googlefonts/copy-shared-panose
instantiator: preserve instance openTypeOS2Panose when values shared by all sources
2 parents a7f7ae0 + 2b2f7d1 commit 01d3fae

File tree

2 files changed

+257
-4
lines changed

2 files changed

+257
-4
lines changed

Lib/ufo2ft/instantiator.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@
121121
# - openTypeNameUniqueID
122122
# - openTypeNameWWSFamilyName
123123
# - openTypeNameWWSSubfamilyName
124-
# - openTypeOS2Panose
125124
# - postscriptFullName
126125
# - postscriptUniqueID
127126
# - woffMetadataUniqueID
@@ -152,6 +151,7 @@
152151
"openTypeNameVersion",
153152
"openTypeOS2CodePageRanges",
154153
"openTypeOS2FamilyClass",
154+
"openTypeOS2Panose",
155155
"openTypeOS2Selection",
156156
"openTypeOS2Type",
157157
"openTypeOS2UnicodeRanges",
@@ -467,6 +467,29 @@ def from_designspace(
467467
else {}
468468
)
469469
copy_info: Optional[Info] = default_font.info if do_info else None
470+
if copy_info is not None and copy_info.openTypeOS2Panose is not None:
471+
# only copy panose values that are shared by all the designspace.sources,
472+
# and set to 0 those that differ
473+
panose_sources = [
474+
source.font.info.openTypeOS2Panose
475+
for source in designspace.sources
476+
if source.font.info.openTypeOS2Panose is not None
477+
]
478+
shared_panose = [
479+
values[0] if len(set(values)) == 1 else 0
480+
for values in zip(*panose_sources)
481+
]
482+
# defcon already validates openTypeOS2Panose length, while ufoLib2 doesn't;
483+
# to be safe, ensure we have exactly 10 values (pad with zeros or truncate)
484+
if len(shared_panose) != 10:
485+
logger.warning(
486+
"openTypeOS2Panose values in designspace sources have invalid "
487+
"length, they will be padded/truncated to 10 values"
488+
)
489+
shared_panose = (shared_panose + [0] * 10)[:10]
490+
copy_info = copy.copy(copy_info)
491+
copy_info.openTypeOS2Panose = shared_panose if any(shared_panose) else None
492+
470493
copy_lib: Mapping[str, Any] = default_font.lib if do_info else {}
471494

472495
# The list of glyphs-not-to-export-and-decompose-where-used-as-a-component is

tests/instantiator_test.py

Lines changed: 233 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,8 +1011,8 @@ def test_data_independence(ufo_module, data_dir):
10111011

10121012
assert generator.copy_info.openTypeOS2Panose == [2, 11, 5, 4, 2, 2, 2, 2, 2, 4]
10131013
generator.copy_info.openTypeOS2Panose.append(1000)
1014-
assert instance_font1.info.openTypeOS2Panose is None
1015-
assert instance_font2.info.openTypeOS2Panose is None
1014+
assert instance_font1.info.openTypeOS2Panose == [2, 11, 5, 4, 2, 2, 2, 2, 2, 4]
1015+
assert instance_font2.info.openTypeOS2Panose == [2, 11, 5, 4, 2, 2, 2, 2, 2, 4]
10161016

10171017
# copy_feature_text not tested because it is a(n immutable) string
10181018

@@ -1039,7 +1039,6 @@ def test_skipped_fontinfo_attributes():
10391039
"openTypeNameUniqueID",
10401040
"openTypeNameWWSFamilyName",
10411041
"openTypeNameWWSSubfamilyName",
1042-
"openTypeOS2Panose",
10431042
"postscriptFontName",
10441043
"postscriptFullName",
10451044
"postscriptUniqueID",
@@ -1070,6 +1069,237 @@ def test_designspace_v5_discrete_axis_raises_error(data_dir):
10701069
ufo2ft.instantiator.Instantiator.from_designspace(designspace)
10711070

10721071

1072+
def test_opentype_os2_panose_merging(ufo_module):
1073+
"""Test that openTypeOS2Panose values are properly merged across sources.
1074+
1075+
Only panose values that are identical across all sources should be copied
1076+
to instances. Different values should be set to 0.
1077+
"""
1078+
d = designspaceLib.DesignSpaceDocument()
1079+
d.addAxisDescriptor(
1080+
name="Weight", tag="wght", minimum=300, default=300, maximum=900
1081+
)
1082+
1083+
font1 = ufo_module.Font()
1084+
font1.info.openTypeOS2Panose = [2, 11, 5, 2, 4, 5, 4, 2, 2, 4] # default source
1085+
1086+
font2 = ufo_module.Font()
1087+
font2.info.openTypeOS2Panose = [
1088+
2,
1089+
11,
1090+
8, # different at index 2
1091+
2,
1092+
4,
1093+
5,
1094+
4,
1095+
2,
1096+
2,
1097+
4,
1098+
]
1099+
1100+
font3 = ufo_module.Font()
1101+
font3.info.openTypeOS2Panose = [
1102+
2,
1103+
11,
1104+
5,
1105+
3, # different at index 3
1106+
4,
1107+
5,
1108+
4,
1109+
2,
1110+
2,
1111+
4,
1112+
]
1113+
1114+
d.addSourceDescriptor(location={"Weight": 300}, font=font1)
1115+
d.addSourceDescriptor(location={"Weight": 600}, font=font2)
1116+
d.addSourceDescriptor(location={"Weight": 900}, font=font3)
1117+
d.addInstanceDescriptor(styleName="Regular", location={"Weight": 400})
1118+
d.addInstanceDescriptor(styleName="Bold", location={"Weight": 700})
1119+
1120+
generator = ufo2ft.instantiator.Instantiator.from_designspace(d)
1121+
1122+
instance1 = generator.generate_instance(d.instances[0])
1123+
instance2 = generator.generate_instance(d.instances[1])
1124+
1125+
# Indices 2 and 3 differ across sources, so they should be 0
1126+
# All other indices are the same across sources
1127+
expected_panose = [2, 11, 0, 0, 4, 5, 4, 2, 2, 4]
1128+
1129+
assert instance1.info.openTypeOS2Panose == expected_panose
1130+
assert instance2.info.openTypeOS2Panose == expected_panose
1131+
1132+
1133+
def test_opentype_os2_panose_all_different(ufo_module):
1134+
"""Test that when all panose values differ, the attribute is deleted."""
1135+
d = designspaceLib.DesignSpaceDocument()
1136+
d.addAxisDescriptor(
1137+
name="Weight", tag="wght", minimum=300, default=300, maximum=900
1138+
)
1139+
1140+
# Create fonts with completely different panose values
1141+
font1 = ufo_module.Font()
1142+
font1.info.openTypeOS2Panose = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
1143+
1144+
font2 = ufo_module.Font()
1145+
font2.info.openTypeOS2Panose = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
1146+
1147+
d.addSourceDescriptor(location={"Weight": 300}, font=font1)
1148+
d.addSourceDescriptor(location={"Weight": 900}, font=font2)
1149+
d.addInstanceDescriptor(styleName="Regular", location={"Weight": 600})
1150+
1151+
generator = ufo2ft.instantiator.Instantiator.from_designspace(d)
1152+
instance = generator.generate_instance(d.instances[0])
1153+
1154+
# All values differ, so openTypeOS2Panose should be unset
1155+
assert instance.info.openTypeOS2Panose is None
1156+
1157+
1158+
def test_opentype_os2_panose_missing_in_some_sources(ufo_module):
1159+
"""Test handling when some sources don't have panose values."""
1160+
d = designspaceLib.DesignSpaceDocument()
1161+
d.addAxisDescriptor(
1162+
name="Weight", tag="wght", minimum=300, default=300, maximum=900
1163+
)
1164+
1165+
font1 = ufo_module.Font()
1166+
font1.info.openTypeOS2Panose = [2, 11, 5, 2, 4, 5, 4, 2, 2, 4]
1167+
1168+
# Second font doesn't have panose, doesn't contribute
1169+
font2 = ufo_module.Font()
1170+
assert font2.info.openTypeOS2Panose is None
1171+
1172+
font3 = ufo_module.Font()
1173+
font3.info.openTypeOS2Panose = [2, 11, 5, 2, 4, 5, 4, 2, 2, 4]
1174+
1175+
d.addSourceDescriptor(location={"Weight": 300}, font=font1)
1176+
d.addSourceDescriptor(location={"Weight": 600}, font=font2)
1177+
d.addSourceDescriptor(location={"Weight": 900}, font=font3)
1178+
d.addInstanceDescriptor(styleName="Regular", location={"Weight": 400})
1179+
1180+
generator = ufo2ft.instantiator.Instantiator.from_designspace(d)
1181+
instance = generator.generate_instance(d.instances[0])
1182+
1183+
# The default source's panose should be copied since font2 has None and
1184+
# font3 has same panose as font1
1185+
assert instance.info.openTypeOS2Panose == [2, 11, 5, 2, 4, 5, 4, 2, 2, 4]
1186+
1187+
1188+
def test_opentype_os2_panose_single_source(ufo_module):
1189+
"""Test that panose values are preserved as-is when there's only one source.
1190+
1191+
Cf. https://github.com/googlefonts/fontc/issues/1609
1192+
"""
1193+
d = designspaceLib.DesignSpaceDocument()
1194+
d.addAxisDescriptor(
1195+
name="Weight", tag="wght", minimum=300, default=400, maximum=900
1196+
)
1197+
1198+
font1 = ufo_module.Font()
1199+
font1.info.openTypeOS2Panose = [2, 11, 5, 2, 4, 5, 4, 2, 2, 4]
1200+
1201+
d.addSourceDescriptor(location={"Weight": 400}, font=font1)
1202+
d.addInstanceDescriptor(styleName="Regular", location={"Weight": 400})
1203+
1204+
generator = ufo2ft.instantiator.Instantiator.from_designspace(d)
1205+
1206+
instance = generator.generate_instance(d.instances[0])
1207+
1208+
# With only one source, panose should be preserved as-is
1209+
assert instance.info.openTypeOS2Panose == [2, 11, 5, 2, 4, 5, 4, 2, 2, 4]
1210+
1211+
1212+
def test_opentype_os2_panose_no_mutation_of_default(ufo_module):
1213+
"""Test that processing panose values doesn't mutate the default source."""
1214+
d = designspaceLib.DesignSpaceDocument()
1215+
d.addAxisDescriptor(
1216+
name="Weight", tag="wght", minimum=300, default=300, maximum=900
1217+
)
1218+
1219+
font1 = ufo_module.Font()
1220+
original_panose = [2, 11, 5, 2, 4, 5, 4, 2, 2, 4]
1221+
font1.info.openTypeOS2Panose = original_panose.copy()
1222+
1223+
font2 = ufo_module.Font()
1224+
font2.info.openTypeOS2Panose = [
1225+
2,
1226+
11,
1227+
8, # different at index 2
1228+
2,
1229+
4,
1230+
5,
1231+
4,
1232+
2,
1233+
2,
1234+
4,
1235+
]
1236+
1237+
d.addSourceDescriptor(location={"Weight": 300}, font=font1)
1238+
d.addSourceDescriptor(location={"Weight": 900}, font=font2)
1239+
d.addInstanceDescriptor(styleName="Regular", location={"Weight": 600})
1240+
1241+
generator = ufo2ft.instantiator.Instantiator.from_designspace(d)
1242+
instance = generator.generate_instance(d.instances[0])
1243+
1244+
# The instance should have merged panose with 0 at index 2
1245+
assert instance.info.openTypeOS2Panose == [2, 11, 0, 2, 4, 5, 4, 2, 2, 4]
1246+
1247+
# The default source should remain unchanged
1248+
assert font1.info.openTypeOS2Panose == original_panose
1249+
assert d.default.font.info.openTypeOS2Panose == original_panose
1250+
1251+
1252+
@pytest.mark.parametrize(
1253+
"panose_data,expected",
1254+
[
1255+
pytest.param(
1256+
[[2, 11, 5], [2, 11, 8, 2, 4, 5, 4, 2, 2, 4, 99, 88, 77, 66, 55]],
1257+
[2, 11, 0, 0, 0, 0, 0, 0, 0, 0],
1258+
id="zero-padded", # At least one source has length < 10 (will be padded)
1259+
),
1260+
pytest.param(
1261+
[
1262+
[2, 11, 5, 2, 4, 5, 4, 2, 2, 4, 99, 88],
1263+
[2, 11, 8, 2, 4, 5, 4, 2, 2, 4, 77, 66, 55],
1264+
],
1265+
[2, 11, 0, 2, 4, 5, 4, 2, 2, 4],
1266+
id="truncated", # All sources have length > 10 (will be truncated)
1267+
),
1268+
],
1269+
)
1270+
def test_opentype_os2_panose_malformed_lengths(panose_data, expected, caplog):
1271+
"""Test handling of panose values with incorrect lengths (ufoLib2 only)."""
1272+
ufo_module = pytest.importorskip("ufoLib2")
1273+
1274+
d = designspaceLib.DesignSpaceDocument()
1275+
d.addAxisDescriptor(
1276+
name="Weight", tag="wght", minimum=300, default=300, maximum=900
1277+
)
1278+
1279+
fonts = []
1280+
for i, panose_values in enumerate(panose_data):
1281+
font = ufo_module.Font()
1282+
font.info.openTypeOS2Panose = panose_values
1283+
fonts.append(font)
1284+
d.addSourceDescriptor(location={"Weight": 300 + i * 600}, font=font)
1285+
1286+
d.addInstanceDescriptor(styleName="Regular", location={"Weight": 600})
1287+
1288+
with caplog.at_level(logging.WARNING):
1289+
generator = ufo2ft.instantiator.Instantiator.from_designspace(d)
1290+
instance = generator.generate_instance(d.instances[0])
1291+
1292+
# Should log a warning about invalid length
1293+
assert (
1294+
"openTypeOS2Panose values in designspace sources have invalid length"
1295+
in caplog.text
1296+
)
1297+
1298+
# Should produce a valid result with exactly 10 values
1299+
assert instance.info.openTypeOS2Panose == expected
1300+
assert len(instance.info.openTypeOS2Panose) == 10
1301+
1302+
10731303
def test_strict_math_glyph(ufo_module, data_dir):
10741304
designspace = designspaceLib.DesignSpaceDocument.fromfile(
10751305
data_dir / "InstantiatorStrictMathGlyph" / "StrictMathGlyph.designspace"

0 commit comments

Comments
 (0)