From 2be13d50acf3f7c89dc06bbe26f79b7da4f61106 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 15 Feb 2021 12:22:48 +0000 Subject: [PATCH] make instancer into a package dir and move all names-related funcs to submodule move instancer tests to Tests/varLib/instancer directory create instancer/__main__.py to make package executable --- .../{instancer.py => instancer/__init__.py} | 443 +----------------- Lib/fontTools/varLib/instancer/__main__.py | 5 + Lib/fontTools/varLib/instancer/names.py | 441 +++++++++++++++++ Tests/varLib/instancer/conftest.py | 13 + .../data/PartialInstancerTest-VF.ttx | 0 .../data/PartialInstancerTest2-VF.ttx | 0 .../data/PartialInstancerTest3-VF.ttx | 0 ...tialInstancerTest2-VF-instance-100,100.ttx | 0 ...ialInstancerTest2-VF-instance-100,62.5.ttx | 0 ...tialInstancerTest2-VF-instance-400,100.ttx | 0 ...ialInstancerTest2-VF-instance-400,62.5.ttx | 0 ...tialInstancerTest2-VF-instance-900,100.ttx | 0 ...ialInstancerTest2-VF-instance-900,62.5.ttx | 0 ...Test3-VF-instance-400-no-overlap-flags.ttx | 0 ...ancerTest3-VF-instance-400-no-overlaps.ttx | 0 ...ancerTest3-VF-instance-700-no-overlaps.ttx | 0 .../varLib/{ => instancer}/instancer_test.py | 312 +----------- Tests/varLib/instancer/names_test.py | 307 ++++++++++++ 18 files changed, 771 insertions(+), 750 deletions(-) rename Lib/fontTools/varLib/{instancer.py => instancer/__init__.py} (76%) create mode 100644 Lib/fontTools/varLib/instancer/__main__.py create mode 100644 Lib/fontTools/varLib/instancer/names.py create mode 100644 Tests/varLib/instancer/conftest.py rename Tests/varLib/{ => instancer}/data/PartialInstancerTest-VF.ttx (100%) rename Tests/varLib/{ => instancer}/data/PartialInstancerTest2-VF.ttx (100%) rename Tests/varLib/{ => instancer}/data/PartialInstancerTest3-VF.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx (100%) rename Tests/varLib/{ => instancer}/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx (100%) rename Tests/varLib/{ => instancer}/instancer_test.py (86%) create mode 100644 Tests/varLib/instancer/names_test.py diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer/__init__.py similarity index 76% rename from Lib/fontTools/varLib/instancer.py rename to Lib/fontTools/varLib/instancer/__init__.py index 581f52ee6..aa64cc798 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -84,6 +84,7 @@ from fontTools import subset # noqa: F401 from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger +from fontTools.varLib.instancer import names from contextlib import contextmanager import collections from copy import deepcopy @@ -128,21 +129,6 @@ class OverlapMode(IntEnum): REMOVE = 2 -class NameID(IntEnum): - FAMILY_NAME = 1 - SUBFAMILY_NAME = 2 - UNIQUE_FONT_IDENTIFIER = 3 - FULL_FONT_NAME = 4 - VERSION_STRING = 5 - POSTSCRIPT_NAME = 6 - TYPOGRAPHIC_FAMILY_NAME = 16 - TYPOGRAPHIC_SUBFAMILY_NAME = 17 - VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25 - - -ELIDABLE_AXIS_VALUE_NAME = 2 - - def instantiateTupleVariationStore( variations, axisLimits, origCoords=None, endPts=None ): @@ -1073,423 +1059,6 @@ def axisValuesFromAxisLimits(stat, axisLimits): return newAxisValueTables -def getVariationNameIDs(varfont): - used = [] - if "fvar" in varfont: - fvar = varfont["fvar"] - for axis in fvar.axes: - used.append(axis.axisNameID) - for instance in fvar.instances: - used.append(instance.subfamilyNameID) - if instance.postscriptNameID != 0xFFFF: - used.append(instance.postscriptNameID) - if "STAT" in varfont: - stat = varfont["STAT"].table - for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): - used.append(axis.AxisNameID) - for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): - used.append(value.ValueNameID) - # nameIDs <= 255 are reserved by OT spec so we don't touch them - return {nameID for nameID in used if nameID > 255} - - -@contextmanager -def pruningUnusedNames(varfont): - origNameIDs = getVariationNameIDs(varfont) - - yield - - log.info("Pruning name table") - exclude = origNameIDs - getVariationNameIDs(varfont) - varfont["name"].names[:] = [ - record for record in varfont["name"].names if record.nameID not in exclude - ] - if "ltag" in varfont: - # Drop the whole 'ltag' table if all the language-dependent Unicode name - # records that reference it have been dropped. - # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. - # Note ltag can also be used by feat or morx tables, so check those too. - if not any( - record - for record in varfont["name"].names - if record.platformID == 0 and record.langID != 0xFFFF - ): - del varfont["ltag"] - - -def updateNameTable(varfont, axisLimits): - """Update an instatiated variable font's name table using the Axis - Values from the STAT table. - - The updated name table will conform to the R/I/B/BI naming model. - """ - # This task can be split into two parts: - - # Task 1: Collecting and sorting the relevant AxisValues: - # 1. First check the variable font has a STAT table and it contains - # AxisValues. - # 2. Create a dictionary which contains the pinned axes from the - # axisLimits dict and for the unpinned axes, we'll use the fvar - # default coordinates e.g - # axisLimits = {"wght": 500, "wdth": AxisRange(75, 100), our dict will - # be {"wght": 500, "wdth": 100} if the width axis has a default of 100. - # 3. Create a new list of AxisValues whose Values match the dict we just - # created. - # 4. Remove any AxisValues from the list which have the - # Elidable_AXIS_VALUE_NAME flag set. - # 5. Remove and sort AxisValues in the list so format 4 AxisValues take - # precedence. According to the MS Spec "if a format 1, format 2 or - # format 3 table has a (nominal) value used in a format 4 table that - # also has values for other axes, the format 4 table, being the more - # specific match, is used", - # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4 - - # Task 2: Updating a name table's style and family names from a list of - # AxisValues: - # 1. Sort AxisValues into two groups. For the first group, the names must be - # any of the following ["Regular", "Italic", "Bold", "Bold Italic"]. - # This group of names is often referred to as "RIBBI" names. For the - # other group, names must be non-RIBBI e.g "Medium Italic", "Condensed" - # etc. - # 2. Repeat the next steps for each name table record platform: - # a. Create new subFamily name and Typographic subFamily name from the - # above groups. - # b. Update nameIDs 1, 2, 3, 4, 6, 16, 17 using the new name created - # in the last step. - # - # Step by step example: - # A variable font which has a width and weight axes. - # AxisValues in font (represented as simplified dicts): - # axisValues = [ - # {"name": "Light", "axis": "wght", "value": 300}, - # {"name": "Regular", "axis": "wght", "value": 400}, - # {"name": "Medium", "axis": "wght", "value": 500}, - # {"name": "Bold", "axis": "wght", "value": 600}, - # {"name": "Condensed", "axis": "wdth", "value": 75}, - # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2}, - # ] - # # Let's instantiate a partial font which has a pinned wght axis and an - # unpinned width axis. - # >>> axisLimits = {"wght": 500, "width": AxisRange(75, 100)} - # >>> updateNameTable(varfont, axisLimits) - # - # AxisValues remaining after task 1.3: - # axisValues = [ - # {"name": "Medium", "axis": "wght", "value": 500}, - # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2} - # ] - # - # AxisValues remaining after completing all 1.x tasks: - # axisValues = [{"name": "Medium", "axis": "wght", "value": 500}] - # The Normal AxisValue is removed because it has the - # Elidable_AXIS_VALUE_NAME flag set. - # - # # AxisValues after separating into two groups in task 2.1: - # ribbiAxisValues = [] - # nonRibbiAxisValues = [{"name": "Medium", "axis": "wght", "value": 500}] - # - # # Names created from AxisValues in task 2.2a for Win US English platform: - # subFamilyName = "" - # typoSubFamilyName = "Medium" - # - # NameRecords updated in task 2.2b for Win US English platform: - # NameID 1 familyName: "Open Sans" --> "Open Sans Medium" - # NameID 2 subFamilyName: "Regular" --> "Regular" - # NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ - # "3.000;GOOG;OpenSans-Medium" - # NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Medium" - # NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Medium" - # NameID 16 Typographic Family name: None --> "Open Sans" - # NameID 17 Typographic Subfamily name: None --> "Medium" - # - # Notes on name table record updates: - # - Typographic names have been added since Medium is a non-Ribbi name. - # - Neither the before or after name records include the Width AxisValue - # names because the "Normal" AxisValue has the - # Elidable_AXIS_VALUE_NAME flag set. - # If we instantiate the same font but pin the wdth axis to 75, - # the "Condensed" AxisValue will be included. - # - For info regarding how RIBBI and non-RIBBI can be constructed see: - # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids - - if "STAT" not in varfont: - raise ValueError("Cannot update name table since there is no STAT table.") - stat = varfont["STAT"].table - if not stat.AxisValueArray: - raise ValueError("Cannot update name table since there are no STAT Axis Values") - fvar = varfont["fvar"] - - # The updated name table must reflect the new 'zero origin' of the font. - # If we're instantiating a partial font, we will populate the unpinned - # axes with their default axis values. - fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} - defaultAxisCoords = deepcopy(axisLimits) - for axisTag, val in fvarDefaults.items(): - if axisTag not in defaultAxisCoords or isinstance( - defaultAxisCoords[axisTag], AxisRange - ): - defaultAxisCoords[axisTag] = val - - axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) - checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) - - # Remove axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. - # Axis Values which have this flag enabled won't be visible in - # application font menus. - axisValueTables = [ - v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME - ] - axisValueTables = _sortAxisValues(axisValueTables) - _updateNameRecords(varfont, axisValueTables) - - -def checkAxisValuesExist(stat, axisValues, axisCoords): - seen = set() - designAxes = stat.DesignAxisRecord.Axis - for axisValueTable in axisValues: - axisValueFormat = axisValueTable.Format - if axisValueTable.Format in (1, 2, 3): - axisTag = designAxes[axisValueTable.AxisIndex].AxisTag - if axisValueFormat == 2: - axisValue = axisValueTable.NominalValue - else: - axisValue = axisValueTable.Value - if axisTag in axisCoords and axisValue == axisCoords[axisTag]: - seen.add(axisTag) - elif axisValueTable.Format == 4: - for rec in axisValueTable.AxisValueRecord: - axisTag = designAxes[rec.AxisIndex].AxisTag - if axisTag in axisCoords and rec.Value == axisCoords[axisTag]: - seen.add(axisTag) - - missingAxes = set(axisCoords) - seen - if missingAxes: - missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes) - raise ValueError(f"Cannot find Axis Values [{missing}]") - - -def _sortAxisValues(axisValues): - # Sort and remove duplicates ensuring that format 4 Axis Values - # are dominant - results = [] - seenAxes = set() - # Sort format 4 axes so the tables with the most AxisValueRecords - # are first - format4 = sorted( - [v for v in axisValues if v.Format == 4], - key=lambda v: len(v.AxisValueRecord), - reverse=True, - ) - nonFormat4 = [v for v in axisValues if v not in format4] - - for val in format4: - axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) - minIndex = min(axisIndexes) - if not seenAxes & axisIndexes: - seenAxes |= axisIndexes - results.append((minIndex, val)) - - for val in nonFormat4: - axisIndex = val.AxisIndex - if axisIndex not in seenAxes: - seenAxes.add(axisIndex) - results.append((axisIndex, val)) - - return [axisValue for _, axisValue in sorted(results)] - - -def _updateNameRecords(varfont, axisValues): - # Update nametable based on the axisValues using the R/I/B/BI model. - nametable = varfont["name"] - stat = varfont["STAT"].table - - axisValueNameIDs = [a.ValueNameID for a in axisValues] - ribbiNameIDs = [n for n in axisValueNameIDs if nameIDIsRibbi(nametable, n)] - nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] - elidedNameID = stat.ElidedFallbackNameID - elidedNameIsRibbi = nameIDIsRibbi(nametable, elidedNameID) - - getName = nametable.getName - platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) - for platform in platforms: - if not all(getName(i, *platform) for i in (1, 2, elidedNameID)): - # Since no family name and subfamily name records were found, - # we cannot update this set of name Records. - continue - - subFamilyName = " ".join( - getName(n, *platform).toUnicode() for n in ribbiNameIDs - ) - typoSubFamilyName = " ".join( - getName(n, *platform).toUnicode() - for n in axisValueNameIDs - if nonRibbiNameIDs - ) - - # If neither subFamilyName and typographic SubFamilyName exist, - # we will use the STAT's elidedFallbackName - if not typoSubFamilyName and not subFamilyName: - if elidedNameIsRibbi: - subFamilyName = getName(elidedNameID, *platform).toUnicode() - else: - typoSubFamilyName = getName(elidedNameID, *platform).toUnicode() - - familyNameSuffix = " ".join( - getName(n, *platform).toUnicode() for n in nonRibbiNameIDs - ) - - _updateNameTableStyleRecords( - varfont, - familyNameSuffix, - subFamilyName, - typoSubFamilyName, - *platform, - ) - - -def nameIDIsRibbi(nametable, nameID): - engNameRecords = any( - r - for r in nametable.names - if (r.platformID, r.platEncID, r.langID) == (3, 1, 0x409) - ) - if not engNameRecords: - raise ValueError( - f"Canot determine if there are RIBBI Axis Value Tables " - "since there are no name table Records which have " - "platformID=3, platEncID=1, langID=0x409" - ) - return ( - True - if nametable.getName(nameID, 3, 1, 0x409).toUnicode() - in ("Regular", "Italic", "Bold", "Bold Italic") - else False - ) - - -def _updateNameTableStyleRecords( - varfont, - familyNameSuffix, - subFamilyName, - typoSubFamilyName, - platformID=3, - platEncID=1, - langID=0x409, -): - # TODO (Marc F) It may be nice to make this part a standalone - # font renamer in the future. - nametable = varfont["name"] - platform = (platformID, platEncID, langID) - - currentFamilyName = nametable.getName( - NameID.TYPOGRAPHIC_FAMILY_NAME, *platform - ) or nametable.getName(NameID.FAMILY_NAME, *platform) - - currentStyleName = nametable.getName( - NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform - ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) - - currentFamilyName = currentFamilyName.toUnicode() - currentStyleName = currentStyleName.toUnicode() - - nameIDs = { - NameID.FAMILY_NAME: currentFamilyName, - NameID.SUBFAMILY_NAME: subFamilyName, - } - if typoSubFamilyName: - nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() - nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName - nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{typoSubFamilyName}" - # Remove previous Typographic Family and SubFamily names since they're - # no longer required - else: - for nameID in ( - NameID.TYPOGRAPHIC_FAMILY_NAME, - NameID.TYPOGRAPHIC_SUBFAMILY_NAME, - ): - nametable.removeNames(nameID=nameID) - - newFamilyName = ( - nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME] - ) - newStyleName = ( - nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME] - ) - - nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" - nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord( - varfont, newFamilyName, newStyleName, platform - ) - nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( - varfont, nameIDs, platform - ) - - for nameID, string in nameIDs.items(): - if not string: - continue - nametable.setName(string, nameID, *platform) - - -def _updatePSNameRecord(varfont, familyName, styleName, platform): - # Implementation based on Adobe Technical Note #5902 : - # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf - nametable = varfont["name"] - - family_prefix = nametable.getName( - NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform - ) - if family_prefix: - family_prefix = familyPrefix.toUnicode() - else: - family_prefix = familyName - - psName = f"{family_prefix}-{styleName}" - # Remove any characters other than uppercase Latin letters, lowercase - # Latin letters, digits and hyphens. - psName = re.sub(r"[^A-Za-z0-9-]", r"", psName) - - if len(psName) > 127: - # Abbreviating the stylename so it fits within 127 characters whilst - # conforming to every vendor's specification is too complex. Instead - # we simply truncate the psname and add the required "..." - return f"{psName[:124]}..." - return psName - - -def _updateUniqueIdNameRecord(varfont, nameIDs, platform): - nametable = varfont["name"] - currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform) - if not currentRecord: - return None - - # Check if full name and postscript name are a substring of currentRecord - for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME): - nameRecord = nametable.getName(nameID, *platform) - if not nameRecord: - continue - if currentRecord.toUnicode() in nameRecord.toUnicode(): - return currentRecord.toUnicode().replace( - nameRecord.toUnicode(), nameIDs[nameRecord.nameID] - ) - # Create a new string since we couldn't find any substrings. - fontVersion = _fontVersion(varfont, platform) - achVendID = varfont["OS/2"].achVendID - # Remove non-ASCII characers and trailing spaces - vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip() - psName = nameIDs[NameID.POSTSCRIPT_NAME] - return f"{fontVersion};{vendor};{psName}" - - -def _fontVersion(font, platform=(3, 1, 0x409)): - nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform) - if nameRecord is None: - return f'{font["head"].fontRevision:.3f}' - # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101" - # Also works fine with inputs "Version 1.101" or "1.101" etc - versionNumber = nameRecord.toUnicode().split(";")[0] - return versionNumber.lstrip("Version ").strip() - - def setMacOverlapFlags(glyfTable): flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND flagOverlapSimple = _g_l_y_f.flagOverlapSimple @@ -1633,7 +1202,7 @@ def instantiateVariableFont( if updateFontNames: log.info("Updating name table") - updateNameTable(varfont, axisLimits) + names.updateNameTable(varfont, axisLimits) if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) @@ -1657,7 +1226,7 @@ def instantiateVariableFont( if "avar" in varfont: instantiateAvar(varfont, axisLimits) - with pruningUnusedNames(varfont): + with names.pruningUnusedNames(varfont): if "STAT" in varfont: instantiateSTAT(varfont, axisLimits) @@ -1851,9 +1420,3 @@ def main(args=None): outfile, ) varfont.save(outfile) - - -if __name__ == "__main__": - import sys - - sys.exit(main()) diff --git a/Lib/fontTools/varLib/instancer/__main__.py b/Lib/fontTools/varLib/instancer/__main__.py new file mode 100644 index 000000000..64ffff2b9 --- /dev/null +++ b/Lib/fontTools/varLib/instancer/__main__.py @@ -0,0 +1,5 @@ +import sys +from fontTools.varLib.instancer import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py new file mode 100644 index 000000000..7cd7515c5 --- /dev/null +++ b/Lib/fontTools/varLib/instancer/names.py @@ -0,0 +1,441 @@ +"""Helpers for instantiating name table records.""" + +from contextlib import contextmanager +from copy import deepcopy +from enum import IntEnum +import re + + +class NameID(IntEnum): + FAMILY_NAME = 1 + SUBFAMILY_NAME = 2 + UNIQUE_FONT_IDENTIFIER = 3 + FULL_FONT_NAME = 4 + VERSION_STRING = 5 + POSTSCRIPT_NAME = 6 + TYPOGRAPHIC_FAMILY_NAME = 16 + TYPOGRAPHIC_SUBFAMILY_NAME = 17 + VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25 + + +ELIDABLE_AXIS_VALUE_NAME = 2 + + +def getVariationNameIDs(varfont): + used = [] + if "fvar" in varfont: + fvar = varfont["fvar"] + for axis in fvar.axes: + used.append(axis.axisNameID) + for instance in fvar.instances: + used.append(instance.subfamilyNameID) + if instance.postscriptNameID != 0xFFFF: + used.append(instance.postscriptNameID) + if "STAT" in varfont: + stat = varfont["STAT"].table + for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): + used.append(axis.AxisNameID) + for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): + used.append(value.ValueNameID) + # nameIDs <= 255 are reserved by OT spec so we don't touch them + return {nameID for nameID in used if nameID > 255} + + +@contextmanager +def pruningUnusedNames(varfont): + from . import log + + origNameIDs = getVariationNameIDs(varfont) + + yield + + log.info("Pruning name table") + exclude = origNameIDs - getVariationNameIDs(varfont) + varfont["name"].names[:] = [ + record for record in varfont["name"].names if record.nameID not in exclude + ] + if "ltag" in varfont: + # Drop the whole 'ltag' table if all the language-dependent Unicode name + # records that reference it have been dropped. + # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. + # Note ltag can also be used by feat or morx tables, so check those too. + if not any( + record + for record in varfont["name"].names + if record.platformID == 0 and record.langID != 0xFFFF + ): + del varfont["ltag"] + + +def updateNameTable(varfont, axisLimits): + """Update an instatiated variable font's name table using the Axis + Values from the STAT table. + + The updated name table will conform to the R/I/B/BI naming model. + """ + # This task can be split into two parts: + + # Task 1: Collecting and sorting the relevant AxisValues: + # 1. First check the variable font has a STAT table and it contains + # AxisValues. + # 2. Create a dictionary which contains the pinned axes from the + # axisLimits dict and for the unpinned axes, we'll use the fvar + # default coordinates e.g + # axisLimits = {"wght": 500, "wdth": AxisRange(75, 100), our dict will + # be {"wght": 500, "wdth": 100} if the width axis has a default of 100. + # 3. Create a new list of AxisValues whose Values match the dict we just + # created. + # 4. Remove any AxisValues from the list which have the + # Elidable_AXIS_VALUE_NAME flag set. + # 5. Remove and sort AxisValues in the list so format 4 AxisValues take + # precedence. According to the MS Spec "if a format 1, format 2 or + # format 3 table has a (nominal) value used in a format 4 table that + # also has values for other axes, the format 4 table, being the more + # specific match, is used", + # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4 + + # Task 2: Updating a name table's style and family names from a list of + # AxisValues: + # 1. Sort AxisValues into two groups. For the first group, the names must be + # any of the following ["Regular", "Italic", "Bold", "Bold Italic"]. + # This group of names is often referred to as "RIBBI" names. For the + # other group, names must be non-RIBBI e.g "Medium Italic", "Condensed" + # etc. + # 2. Repeat the next steps for each name table record platform: + # a. Create new subFamily name and Typographic subFamily name from the + # above groups. + # b. Update nameIDs 1, 2, 3, 4, 6, 16, 17 using the new name created + # in the last step. + # + # Step by step example: + # A variable font which has a width and weight axes. + # AxisValues in font (represented as simplified dicts): + # axisValues = [ + # {"name": "Light", "axis": "wght", "value": 300}, + # {"name": "Regular", "axis": "wght", "value": 400}, + # {"name": "Medium", "axis": "wght", "value": 500}, + # {"name": "Bold", "axis": "wght", "value": 600}, + # {"name": "Condensed", "axis": "wdth", "value": 75}, + # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2}, + # ] + # # Let's instantiate a partial font which has a pinned wght axis and an + # unpinned width axis. + # >>> axisLimits = {"wght": 500, "width": AxisRange(75, 100)} + # >>> updateNameTable(varfont, axisLimits) + # + # AxisValues remaining after task 1.3: + # axisValues = [ + # {"name": "Medium", "axis": "wght", "value": 500}, + # {"name": "Normal", "axis": "wdth", "value": 100, "flags": 0x2} + # ] + # + # AxisValues remaining after completing all 1.x tasks: + # axisValues = [{"name": "Medium", "axis": "wght", "value": 500}] + # The Normal AxisValue is removed because it has the + # Elidable_AXIS_VALUE_NAME flag set. + # + # # AxisValues after separating into two groups in task 2.1: + # ribbiAxisValues = [] + # nonRibbiAxisValues = [{"name": "Medium", "axis": "wght", "value": 500}] + # + # # Names created from AxisValues in task 2.2a for Win US English platform: + # subFamilyName = "" + # typoSubFamilyName = "Medium" + # + # NameRecords updated in task 2.2b for Win US English platform: + # NameID 1 familyName: "Open Sans" --> "Open Sans Medium" + # NameID 2 subFamilyName: "Regular" --> "Regular" + # NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ + # "3.000;GOOG;OpenSans-Medium" + # NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Medium" + # NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Medium" + # NameID 16 Typographic Family name: None --> "Open Sans" + # NameID 17 Typographic Subfamily name: None --> "Medium" + # + # Notes on name table record updates: + # - Typographic names have been added since Medium is a non-Ribbi name. + # - Neither the before or after name records include the Width AxisValue + # names because the "Normal" AxisValue has the + # Elidable_AXIS_VALUE_NAME flag set. + # If we instantiate the same font but pin the wdth axis to 75, + # the "Condensed" AxisValue will be included. + # - For info regarding how RIBBI and non-RIBBI can be constructed see: + # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + from . import AxisRange, axisValuesFromAxisLimits + + if "STAT" not in varfont: + raise ValueError("Cannot update name table since there is no STAT table.") + stat = varfont["STAT"].table + if not stat.AxisValueArray: + raise ValueError("Cannot update name table since there are no STAT Axis Values") + fvar = varfont["fvar"] + + # The updated name table must reflect the new 'zero origin' of the font. + # If we're instantiating a partial font, we will populate the unpinned + # axes with their default axis values. + fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} + defaultAxisCoords = deepcopy(axisLimits) + for axisTag, val in fvarDefaults.items(): + if axisTag not in defaultAxisCoords or isinstance( + defaultAxisCoords[axisTag], AxisRange + ): + defaultAxisCoords[axisTag] = val + + axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) + checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) + + # Remove axis Values which have ELIDABLE_AXIS_VALUE_NAME flag set. + # Axis Values which have this flag enabled won't be visible in + # application font menus. + axisValueTables = [ + v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME + ] + axisValueTables = _sortAxisValues(axisValueTables) + _updateNameRecords(varfont, axisValueTables) + + +def checkAxisValuesExist(stat, axisValues, axisCoords): + seen = set() + designAxes = stat.DesignAxisRecord.Axis + for axisValueTable in axisValues: + axisValueFormat = axisValueTable.Format + if axisValueTable.Format in (1, 2, 3): + axisTag = designAxes[axisValueTable.AxisIndex].AxisTag + if axisValueFormat == 2: + axisValue = axisValueTable.NominalValue + else: + axisValue = axisValueTable.Value + if axisTag in axisCoords and axisValue == axisCoords[axisTag]: + seen.add(axisTag) + elif axisValueTable.Format == 4: + for rec in axisValueTable.AxisValueRecord: + axisTag = designAxes[rec.AxisIndex].AxisTag + if axisTag in axisCoords and rec.Value == axisCoords[axisTag]: + seen.add(axisTag) + + missingAxes = set(axisCoords) - seen + if missingAxes: + missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes) + raise ValueError(f"Cannot find Axis Values [{missing}]") + + +def _sortAxisValues(axisValues): + # Sort and remove duplicates ensuring that format 4 Axis Values + # are dominant + results = [] + seenAxes = set() + # Sort format 4 axes so the tables with the most AxisValueRecords + # are first + format4 = sorted( + [v for v in axisValues if v.Format == 4], + key=lambda v: len(v.AxisValueRecord), + reverse=True, + ) + nonFormat4 = [v for v in axisValues if v not in format4] + + for val in format4: + axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) + minIndex = min(axisIndexes) + if not seenAxes & axisIndexes: + seenAxes |= axisIndexes + results.append((minIndex, val)) + + for val in nonFormat4: + axisIndex = val.AxisIndex + if axisIndex not in seenAxes: + seenAxes.add(axisIndex) + results.append((axisIndex, val)) + + return [axisValue for _, axisValue in sorted(results)] + + +def _updateNameRecords(varfont, axisValues): + # Update nametable based on the axisValues using the R/I/B/BI model. + nametable = varfont["name"] + stat = varfont["STAT"].table + + axisValueNameIDs = [a.ValueNameID for a in axisValues] + ribbiNameIDs = [n for n in axisValueNameIDs if nameIDIsRibbi(nametable, n)] + nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] + elidedNameID = stat.ElidedFallbackNameID + elidedNameIsRibbi = nameIDIsRibbi(nametable, elidedNameID) + + getName = nametable.getName + platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) + for platform in platforms: + if not all(getName(i, *platform) for i in (1, 2, elidedNameID)): + # Since no family name and subfamily name records were found, + # we cannot update this set of name Records. + continue + + subFamilyName = " ".join( + getName(n, *platform).toUnicode() for n in ribbiNameIDs + ) + typoSubFamilyName = " ".join( + getName(n, *platform).toUnicode() + for n in axisValueNameIDs + if nonRibbiNameIDs + ) + + # If neither subFamilyName and typographic SubFamilyName exist, + # we will use the STAT's elidedFallbackName + if not typoSubFamilyName and not subFamilyName: + if elidedNameIsRibbi: + subFamilyName = getName(elidedNameID, *platform).toUnicode() + else: + typoSubFamilyName = getName(elidedNameID, *platform).toUnicode() + + familyNameSuffix = " ".join( + getName(n, *platform).toUnicode() for n in nonRibbiNameIDs + ) + + _updateNameTableStyleRecords( + varfont, + familyNameSuffix, + subFamilyName, + typoSubFamilyName, + *platform, + ) + + +def nameIDIsRibbi(nametable, nameID): + engNameRecords = any( + r + for r in nametable.names + if (r.platformID, r.platEncID, r.langID) == (3, 1, 0x409) + ) + if not engNameRecords: + raise ValueError( + f"Canot determine if there are RIBBI Axis Value Tables " + "since there are no name table Records which have " + "platformID=3, platEncID=1, langID=0x409" + ) + return ( + True + if nametable.getName(nameID, 3, 1, 0x409).toUnicode() + in ("Regular", "Italic", "Bold", "Bold Italic") + else False + ) + + +def _updateNameTableStyleRecords( + varfont, + familyNameSuffix, + subFamilyName, + typoSubFamilyName, + platformID=3, + platEncID=1, + langID=0x409, +): + # TODO (Marc F) It may be nice to make this part a standalone + # font renamer in the future. + nametable = varfont["name"] + platform = (platformID, platEncID, langID) + + currentFamilyName = nametable.getName( + NameID.TYPOGRAPHIC_FAMILY_NAME, *platform + ) or nametable.getName(NameID.FAMILY_NAME, *platform) + + currentStyleName = nametable.getName( + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform + ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) + + currentFamilyName = currentFamilyName.toUnicode() + currentStyleName = currentStyleName.toUnicode() + + nameIDs = { + NameID.FAMILY_NAME: currentFamilyName, + NameID.SUBFAMILY_NAME: subFamilyName, + } + if typoSubFamilyName: + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() + nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName + nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = f"{typoSubFamilyName}" + # Remove previous Typographic Family and SubFamily names since they're + # no longer required + else: + for nameID in ( + NameID.TYPOGRAPHIC_FAMILY_NAME, + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, + ): + nametable.removeNames(nameID=nameID) + + newFamilyName = ( + nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME] + ) + newStyleName = ( + nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME] + ) + + nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" + nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord( + varfont, newFamilyName, newStyleName, platform + ) + nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = _updateUniqueIdNameRecord( + varfont, nameIDs, platform + ) + + for nameID, string in nameIDs.items(): + if not string: + continue + nametable.setName(string, nameID, *platform) + + +def _updatePSNameRecord(varfont, familyName, styleName, platform): + # Implementation based on Adobe Technical Note #5902 : + # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf + nametable = varfont["name"] + + family_prefix = nametable.getName( + NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform + ) + if family_prefix: + family_prefix = family_prefix.toUnicode() + else: + family_prefix = familyName + + psName = f"{family_prefix}-{styleName}" + # Remove any characters other than uppercase Latin letters, lowercase + # Latin letters, digits and hyphens. + psName = re.sub(r"[^A-Za-z0-9-]", r"", psName) + + if len(psName) > 127: + # Abbreviating the stylename so it fits within 127 characters whilst + # conforming to every vendor's specification is too complex. Instead + # we simply truncate the psname and add the required "..." + return f"{psName[:124]}..." + return psName + + +def _updateUniqueIdNameRecord(varfont, nameIDs, platform): + nametable = varfont["name"] + currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform) + if not currentRecord: + return None + + # Check if full name and postscript name are a substring of currentRecord + for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME): + nameRecord = nametable.getName(nameID, *platform) + if not nameRecord: + continue + if currentRecord.toUnicode() in nameRecord.toUnicode(): + return currentRecord.toUnicode().replace( + nameRecord.toUnicode(), nameIDs[nameRecord.nameID] + ) + # Create a new string since we couldn't find any substrings. + fontVersion = _fontVersion(varfont, platform) + achVendID = varfont["OS/2"].achVendID + # Remove non-ASCII characers and trailing spaces + vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip() + psName = nameIDs[NameID.POSTSCRIPT_NAME] + return f"{fontVersion};{vendor};{psName}" + + +def _fontVersion(font, platform=(3, 1, 0x409)): + nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform) + if nameRecord is None: + return f'{font["head"].fontRevision:.3f}' + # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101" + # Also works fine with inputs "Version 1.101" or "1.101" etc + versionNumber = nameRecord.toUnicode().split(";")[0] + return versionNumber.lstrip("Version ").strip() diff --git a/Tests/varLib/instancer/conftest.py b/Tests/varLib/instancer/conftest.py new file mode 100644 index 000000000..0ac8091df --- /dev/null +++ b/Tests/varLib/instancer/conftest.py @@ -0,0 +1,13 @@ +import os +from fontTools import ttLib +import pytest + + +TESTDATA = os.path.join(os.path.dirname(__file__), "data") + + +@pytest.fixture +def varfont(): + f = ttLib.TTFont() + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) + return f diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx diff --git a/Tests/varLib/data/PartialInstancerTest2-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest2-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest2-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest2-VF.ttx diff --git a/Tests/varLib/data/PartialInstancerTest3-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest3-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest3-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest3-VF.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer/instancer_test.py similarity index 86% rename from Tests/varLib/instancer_test.py rename to Tests/varLib/instancer/instancer_test.py index e4fc81bc9..c3e0729be 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -7,7 +7,6 @@ from fontTools.ttLib.tables import _f_v_a_r, _g_l_y_f from fontTools.ttLib.tables import otTables from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools import varLib -from fontTools.otlLib.builder import buildStatTable from fontTools.varLib import instancer from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib import builder @@ -21,16 +20,11 @@ import re import pytest +# see Tests/varLib/instancer/conftest.py for "varfont" fixture definition + TESTDATA = os.path.join(os.path.dirname(__file__), "data") -@pytest.fixture -def varfont(): - f = ttLib.TTFont() - f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) - return f - - @pytest.fixture(params=[True, False], ids=["optimize", "no-optimize"]) def optimize(request): return request.param @@ -1342,30 +1336,6 @@ class InstantiateSTATTest(object): assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue -def test_pruningUnusedNames(varfont): - varNameIDs = instancer.getVariationNameIDs(varfont) - - assert varNameIDs == set(range(256, 297 + 1)) - - fvar = varfont["fvar"] - stat = varfont["STAT"].table - - with instancer.pruningUnusedNames(varfont): - del fvar.axes[0] # Weight (nameID=256) - del fvar.instances[0] # Thin (nameID=258) - del stat.DesignAxisRecord.Axis[0] # Weight (nameID=256) - del stat.AxisValueArray.AxisValue[0] # Thin (nameID=258) - - assert not any(n for n in varfont["name"].names if n.nameID in {256, 258}) - - with instancer.pruningUnusedNames(varfont): - del varfont["fvar"] - del varfont["STAT"] - - assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) - assert "ltag" not in varfont - - def test_setMacOverlapFlags(): flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND flagOverlapSimple = _g_l_y_f.flagOverlapSimple @@ -1917,284 +1887,6 @@ def test_normalizeAxisLimits_missing_from_fvar(varfont): instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000}) -def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): - nametable = varfont["name"] - font_names = { - (r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() - for r in nametable.names - } - for k in expected: - if k[-1] not in platforms: - continue - assert font_names[k] == expected[k] - if isNonRIBBI: - font_nameids = set(i[0] for i in font_names) - assert 16 in font_nameids - assert 17 in font_nameids - - -@pytest.mark.parametrize( - "limits, expected, isNonRIBBI", - [ - # Regular - ( - {"wght": 400}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", - (6, 3, 1, 0x409): "TestVariableFont-Regular", - }, - False, - ), - # Regular Normal (width axis Normal isn't included since it is elided) - ( - {"wght": 400, "wdth": 100}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", - (6, 3, 1, 0x409): "TestVariableFont-Regular", - }, - False, - ), - # Black - ( - {"wght": 900}, - { - (1, 3, 1, 0x409): "Test Variable Font Black", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Black", - (6, 3, 1, 0x409): "TestVariableFont-Black", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Black", - }, - True, - ), - # Thin - ( - {"wght": 100}, - { - (1, 3, 1, 0x409): "Test Variable Font Thin", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Thin", - (6, 3, 1, 0x409): "TestVariableFont-Thin", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Thin", - }, - True, - ), - # Thin Condensed - ( - {"wght": 100, "wdth": 79}, - { - (1, 3, 1, 0x409): "Test Variable Font Thin Condensed", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-ThinCondensed", - (6, 3, 1, 0x409): "TestVariableFont-ThinCondensed", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Thin Condensed", - }, - True, - ), - # Condensed with unpinned weights - ( - {"wdth": 79, "wght": instancer.AxisRange(400, 900)}, - { - (1, 3, 1, 0x409): "Test Variable Font Condensed", - (2, 3, 1, 0x409): "Regular", - (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Condensed", - (6, 3, 1, 0x409): "TestVariableFont-Condensed", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Condensed", - }, - True, - ), - ], -) -def test_updateNameTable_with_registered_axes_ribbi( - varfont, limits, expected, isNonRIBBI -): - instancer.updateNameTable(varfont, limits) - _test_name_records(varfont, expected, isNonRIBBI) - - -def test_updatetNameTable_axis_order(varfont): - axes = [ - dict( - tag="wght", - name="Weight", - values=[ - dict(value=400, name="Regular"), - ], - ), - dict( - tag="wdth", - name="Width", - values=[ - dict(value=75, name="Condensed"), - ], - ), - ] - nametable = varfont["name"] - buildStatTable(varfont, axes) - instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) - assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Regular Condensed" - - # Swap the axes so the names get swapped - axes[0], axes[1] = axes[1], axes[0] - - buildStatTable(varfont, axes) - instancer.updateNameTable(varfont, {"wdth": 75, "wght": 400}) - assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Condensed Regular" - - -@pytest.mark.parametrize( - "limits, expected, isNonRIBBI", - [ - # Regular | Normal - ( - {"wght": 400}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Normal", - }, - False, - ), - # Black | Negreta - ( - {"wght": 900}, - { - (1, 3, 1, 0x409): "Test Variable Font Negreta", - (2, 3, 1, 0x409): "Normal", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Negreta", - }, - True, - ), - # Black Condensed | Negreta Zhuštěné - ( - {"wght": 900, "wdth": 79}, - { - (1, 3, 1, 0x409): "Test Variable Font Negreta Zhuštěné", - (2, 3, 1, 0x409): "Normal", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Negreta Zhuštěné", - }, - True, - ), - ], -) -def test_updateNameTable_with_multilingual_names(varfont, limits, expected, isNonRIBBI): - name = varfont["name"] - # langID 0x405 is the Czech Windows langID - name.setName("Test Variable Font", 1, 3, 1, 0x405) - name.setName("Normal", 2, 3, 1, 0x405) - name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry - name.setName("Negreta", 266, 3, 1, 0x405) # nameID 266=Black STAT entry - name.setName("Zhuštěné", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry - - instancer.updateNameTable(varfont, limits) - names = _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x405]) - - -def test_updateNameTable_missing_axisValues(varfont): - with pytest.raises(ValueError, match="Cannot find Axis Values \['wght=200'\]"): - instancer.updateNameTable(varfont, {"wght": 200}) - - -def test_updateNameTable_missing_stat(varfont): - del varfont["STAT"] - with pytest.raises( - ValueError, match="Cannot update name table since there is no STAT table." - ): - instancer.updateNameTable(varfont, {"wght": 400}) - - -@pytest.mark.parametrize( - "limits, expected, isNonRIBBI", - [ - # Regular | Normal - ( - {"wght": 400}, - { - (1, 3, 1, 0x409): "Test Variable Font", - (2, 3, 1, 0x409): "Italic", - (6, 3, 1, 0x409): "TestVariableFont-Italic", - }, - False, - ), - # Black Condensed Italic - ( - {"wght": 900, "wdth": 79}, - { - (1, 3, 1, 0x409): "Test Variable Font Black Condensed", - (2, 3, 1, 0x409): "Italic", - (6, 3, 1, 0x409): "TestVariableFont-BlackCondensedItalic", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Black Condensed Italic", - }, - True, - ), - ], -) -def test_updateNameTable_vf_with_italic_attribute( - varfont, limits, expected, isNonRIBBI -): - font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] - # Unset ELIDABLE_AXIS_VALUE_NAME flag - font_link_axisValue.Flags &= ~instancer.ELIDABLE_AXIS_VALUE_NAME - font_link_axisValue.ValueNameID = 294 # Roman --> Italic - - instancer.updateNameTable(varfont, limits) - names = _test_name_records(varfont, expected, isNonRIBBI) - - -def test_updateNameTable_format4_axisValues(varfont): - # format 4 axisValues should dominate the other axisValues - stat = varfont["STAT"].table - - axisValue = otTables.AxisValue() - axisValue.Format = 4 - axisValue.Flags = 0 - varfont["name"].setName("Dominant Value", 297, 3, 1, 0x409) - axisValue.ValueNameID = 297 - axisValue.AxisValueRecord = [] - for tag, value in (("wght", 900), ("wdth", 79)): - rec = otTables.AxisValueRecord() - rec.AxisIndex = next( - i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag - ) - rec.Value = value - axisValue.AxisValueRecord.append(rec) - stat.AxisValueArray.AxisValue.append(axisValue) - - instancer.updateNameTable(varfont, {"wdth": 79, "wght": 900}) - expected = { - (1, 3, 1, 0x409): "Test Variable Font Dominant Value", - (2, 3, 1, 0x409): "Regular", - (16, 3, 1, 0x409): "Test Variable Font", - (17, 3, 1, 0x409): "Dominant Value", - } - _test_name_records(varfont, expected, isNonRIBBI=True) - - -def test_updateNameTable_elided_axisValues(varfont): - stat = varfont["STAT"].table - # set ELIDABLE_AXIS_VALUE_NAME flag for all axisValues - for axisValue in stat.AxisValueArray.AxisValue: - axisValue.Flags |= instancer.ELIDABLE_AXIS_VALUE_NAME - - stat.ElidedFallbackNameID = 266 # Regular --> Black - instancer.updateNameTable(varfont, {"wght": 400}) - # Since all axis values are elided, the elided fallback name - # must be used to construct the style names. Since we - # changed it to Black, we need both a typoSubFamilyName and - # the subFamilyName set so it conforms to the RIBBI model. - expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Black"} - _test_name_records(varfont, expected, isNonRIBBI=True) - - def test_sanityCheckVariableTables(varfont): font = ttLib.TTFont() with pytest.raises(ValueError, match="Missing required table fvar"): diff --git a/Tests/varLib/instancer/names_test.py b/Tests/varLib/instancer/names_test.py new file mode 100644 index 000000000..27446e373 --- /dev/null +++ b/Tests/varLib/instancer/names_test.py @@ -0,0 +1,307 @@ +from fontTools.ttLib.tables import otTables +from fontTools.otlLib.builder import buildStatTable +from fontTools.varLib import instancer + +import pytest + + +def test_pruningUnusedNames(varfont): + varNameIDs = instancer.names.getVariationNameIDs(varfont) + + assert varNameIDs == set(range(256, 297 + 1)) + + fvar = varfont["fvar"] + stat = varfont["STAT"].table + + with instancer.names.pruningUnusedNames(varfont): + del fvar.axes[0] # Weight (nameID=256) + del fvar.instances[0] # Thin (nameID=258) + del stat.DesignAxisRecord.Axis[0] # Weight (nameID=256) + del stat.AxisValueArray.AxisValue[0] # Thin (nameID=258) + + assert not any(n for n in varfont["name"].names if n.nameID in {256, 258}) + + with instancer.names.pruningUnusedNames(varfont): + del varfont["fvar"] + del varfont["STAT"] + + assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) + assert "ltag" not in varfont + + +def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): + nametable = varfont["name"] + font_names = { + (r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() + for r in nametable.names + } + for k in expected: + if k[-1] not in platforms: + continue + assert font_names[k] == expected[k] + if isNonRIBBI: + font_nameids = set(i[0] for i in font_names) + assert 16 in font_nameids + assert 17 in font_nameids + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Regular Normal (width axis Normal isn't included since it is elided) + ( + {"wght": 400, "wdth": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Black + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Black", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Black", + (6, 3, 1, 0x409): "TestVariableFont-Black", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black", + }, + True, + ), + # Thin + ( + {"wght": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Thin", + (6, 3, 1, 0x409): "TestVariableFont-Thin", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin", + }, + True, + ), + # Thin Condensed + ( + {"wght": 100, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-ThinCondensed", + (6, 3, 1, 0x409): "TestVariableFont-ThinCondensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin Condensed", + }, + True, + ), + # Condensed with unpinned weights + ( + {"wdth": 79, "wght": instancer.AxisRange(400, 900)}, + { + (1, 3, 1, 0x409): "Test Variable Font Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Condensed", + (6, 3, 1, 0x409): "TestVariableFont-Condensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Condensed", + }, + True, + ), + ], +) +def test_updateNameTable_with_registered_axes_ribbi( + varfont, limits, expected, isNonRIBBI +): + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI) + + +def test_updatetNameTable_axis_order(varfont): + axes = [ + dict( + tag="wght", + name="Weight", + values=[ + dict(value=400, name="Regular"), + ], + ), + dict( + tag="wdth", + name="Width", + values=[ + dict(value=75, name="Condensed"), + ], + ), + ] + nametable = varfont["name"] + buildStatTable(varfont, axes) + instancer.names.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Regular Condensed" + + # Swap the axes so the names get swapped + axes[0], axes[1] = axes[1], axes[0] + + buildStatTable(varfont, axes) + instancer.names.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Condensed Regular" + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Normal", + }, + False, + ), + # Black | Negreta + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta", + }, + True, + ), + # Black Condensed | Negreta Zhuštěné + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta Zhuštěné", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta Zhuštěné", + }, + True, + ), + ], +) +def test_updateNameTable_with_multilingual_names(varfont, limits, expected, isNonRIBBI): + name = varfont["name"] + # langID 0x405 is the Czech Windows langID + name.setName("Test Variable Font", 1, 3, 1, 0x405) + name.setName("Normal", 2, 3, 1, 0x405) + name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry + name.setName("Negreta", 266, 3, 1, 0x405) # nameID 266=Black STAT entry + name.setName("Zhuštěné", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry + + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x405]) + + +def test_updateNameTable_missing_axisValues(varfont): + with pytest.raises(ValueError, match="Cannot find Axis Values \['wght=200'\]"): + instancer.names.updateNameTable(varfont, {"wght": 200}) + + +def test_updateNameTable_missing_stat(varfont): + del varfont["STAT"] + with pytest.raises( + ValueError, match="Cannot update name table since there is no STAT table." + ): + instancer.names.updateNameTable(varfont, {"wght": 400}) + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-Italic", + }, + False, + ), + # Black Condensed Italic + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Black Condensed", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-BlackCondensedItalic", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black Condensed Italic", + }, + True, + ), + ], +) +def test_updateNameTable_vf_with_italic_attribute( + varfont, limits, expected, isNonRIBBI +): + font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] + # Unset ELIDABLE_AXIS_VALUE_NAME flag + font_link_axisValue.Flags &= ~instancer.names.ELIDABLE_AXIS_VALUE_NAME + font_link_axisValue.ValueNameID = 294 # Roman --> Italic + + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI) + + +def test_updateNameTable_format4_axisValues(varfont): + # format 4 axisValues should dominate the other axisValues + stat = varfont["STAT"].table + + axisValue = otTables.AxisValue() + axisValue.Format = 4 + axisValue.Flags = 0 + varfont["name"].setName("Dominant Value", 297, 3, 1, 0x409) + axisValue.ValueNameID = 297 + axisValue.AxisValueRecord = [] + for tag, value in (("wght", 900), ("wdth", 79)): + rec = otTables.AxisValueRecord() + rec.AxisIndex = next( + i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag + ) + rec.Value = value + axisValue.AxisValueRecord.append(rec) + stat.AxisValueArray.AxisValue.append(axisValue) + + instancer.names.updateNameTable(varfont, {"wdth": 79, "wght": 900}) + expected = { + (1, 3, 1, 0x409): "Test Variable Font Dominant Value", + (2, 3, 1, 0x409): "Regular", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Dominant Value", + } + _test_name_records(varfont, expected, isNonRIBBI=True) + + +def test_updateNameTable_elided_axisValues(varfont): + stat = varfont["STAT"].table + # set ELIDABLE_AXIS_VALUE_NAME flag for all axisValues + for axisValue in stat.AxisValueArray.AxisValue: + axisValue.Flags |= instancer.names.ELIDABLE_AXIS_VALUE_NAME + + stat.ElidedFallbackNameID = 266 # Regular --> Black + instancer.names.updateNameTable(varfont, {"wght": 400}) + # Since all axis values are elided, the elided fallback name + # must be used to construct the style names. Since we + # changed it to Black, we need both a typoSubFamilyName and + # the subFamilyName set so it conforms to the RIBBI model. + expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Black"} + _test_name_records(varfont, expected, isNonRIBBI=True)