Merge pull request #2189 from fonttools/instancer-name
Instancer: update name table (take 2)
This commit is contained in:
commit
52b742fdc9
@ -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
|
||||
@ -1008,6 +1009,13 @@ def instantiateSTAT(varfont, axisLimits):
|
||||
):
|
||||
return # STAT table empty, nothing to do
|
||||
|
||||
log.info("Instantiating STAT table")
|
||||
newAxisValueTables = axisValuesFromAxisLimits(stat, axisLimits)
|
||||
stat.AxisValueArray.AxisValue = newAxisValueTables
|
||||
stat.AxisValueCount = len(stat.AxisValueArray.AxisValue)
|
||||
|
||||
|
||||
def axisValuesFromAxisLimits(stat, axisLimits):
|
||||
location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange)
|
||||
|
||||
def isAxisValueOutsideLimits(axisTag, axisValue):
|
||||
@ -1019,8 +1027,6 @@ def instantiateSTAT(varfont, axisLimits):
|
||||
return True
|
||||
return False
|
||||
|
||||
log.info("Instantiating STAT table")
|
||||
|
||||
# only keep AxisValues whose axis is not pinned nor restricted, or is pinned at the
|
||||
# exact (nominal) value, or is restricted but the value is within the new range
|
||||
designAxes = stat.DesignAxisRecord.Axis
|
||||
@ -1050,53 +1056,7 @@ def instantiateSTAT(varfont, axisLimits):
|
||||
else:
|
||||
log.warn("Unknown AxisValue table format (%s); ignored", axisValueFormat)
|
||||
newAxisValueTables.append(axisValueTable)
|
||||
|
||||
stat.AxisValueArray.AxisValue = newAxisValueTables
|
||||
stat.AxisValueCount = len(stat.AxisValueArray.AxisValue)
|
||||
|
||||
|
||||
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"]
|
||||
return newAxisValueTables
|
||||
|
||||
|
||||
def setMacOverlapFlags(glyfTable):
|
||||
@ -1187,6 +1147,7 @@ def instantiateVariableFont(
|
||||
inplace=False,
|
||||
optimize=True,
|
||||
overlap=OverlapMode.KEEP_AND_SET_FLAGS,
|
||||
updateFontNames=False,
|
||||
):
|
||||
"""Instantiate variable font, either fully or partially.
|
||||
|
||||
@ -1219,6 +1180,11 @@ def instantiateVariableFont(
|
||||
contours and components, you can pass OverlapMode.REMOVE. Note that this
|
||||
requires the skia-pathops package (available to pip install).
|
||||
The overlap parameter only has effect when generating full static instances.
|
||||
updateFontNames (bool): if True, update the instantiated font's name table using
|
||||
the Axis Value Tables from the STAT table. The name table will be updated so
|
||||
it conforms to the R/I/B/BI model. If the STAT table is missing or
|
||||
an Axis Value table is missing for a given axis coordinate, a ValueError will
|
||||
be raised.
|
||||
"""
|
||||
# 'overlap' used to be bool and is now enum; for backward compat keep accepting bool
|
||||
overlap = OverlapMode(int(overlap))
|
||||
@ -1234,6 +1200,10 @@ def instantiateVariableFont(
|
||||
if not inplace:
|
||||
varfont = deepcopy(varfont)
|
||||
|
||||
if updateFontNames:
|
||||
log.info("Updating name table")
|
||||
names.updateNameTable(varfont, axisLimits)
|
||||
|
||||
if "gvar" in varfont:
|
||||
instantiateGvar(varfont, normalizedLimits, optimize=optimize)
|
||||
|
||||
@ -1256,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)
|
||||
|
||||
@ -1377,6 +1347,12 @@ def parseArgs(args):
|
||||
help="Merge overlapping contours and components (only applicable "
|
||||
"when generating a full instance). Requires skia-pathops",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update-name-table",
|
||||
action="store_true",
|
||||
help="Update the instantiated font's `name` table. Input font must have "
|
||||
"a STAT table with Axis Value Tables",
|
||||
)
|
||||
loggingGroup = parser.add_mutually_exclusive_group(required=False)
|
||||
loggingGroup.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Run more verbosely."
|
||||
@ -1428,6 +1404,7 @@ def main(args=None):
|
||||
inplace=True,
|
||||
optimize=options.optimize,
|
||||
overlap=options.overlap,
|
||||
updateFontNames=options.update_name_table,
|
||||
)
|
||||
|
||||
outfile = (
|
||||
@ -1443,9 +1420,3 @@ def main(args=None):
|
||||
outfile,
|
||||
)
|
||||
varfont.save(outfile)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
5
Lib/fontTools/varLib/instancer/__main__.py
Normal file
5
Lib/fontTools/varLib/instancer/__main__.py
Normal file
@ -0,0 +1,5 @@
|
||||
import sys
|
||||
from fontTools.varLib.instancer import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
379
Lib/fontTools/varLib/instancer/names.py
Normal file
379
Lib/fontTools/varLib/instancer/names.py
Normal file
@ -0,0 +1,379 @@
|
||||
"""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 instatiated variable font's name table using STAT AxisValues.
|
||||
|
||||
Raises ValueError if the STAT table is missing or an Axis Value table is
|
||||
missing for requested axis locations.
|
||||
|
||||
First, collect all STAT AxisValues that match the new default axis locations
|
||||
(excluding "elided" ones); concatenate the strings in design axis order,
|
||||
while giving priority to "synthetic" values (Format 4), to form the
|
||||
typographic subfamily name associated with the new default instance.
|
||||
Finally, update all related records in the name table, making sure that
|
||||
legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic,
|
||||
Bold, Bold Italic) naming model.
|
||||
|
||||
Example: Updating a partial variable font:
|
||||
| >>> ttFont = TTFont("OpenSans[wdth,wght].ttf")
|
||||
| >>> updateNameTable(ttFont, {"wght": AxisRange(400, 900), "wdth": 75})
|
||||
|
||||
The name table records will be updated in the following manner:
|
||||
NameID 1 familyName: "Open Sans" --> "Open Sans Condensed"
|
||||
NameID 2 subFamilyName: "Regular" --> "Regular"
|
||||
NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \
|
||||
"3.000;GOOG;OpenSans-Condensed"
|
||||
NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed"
|
||||
NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed"
|
||||
NameID 16 Typographic Family name: None --> "Open Sans"
|
||||
NameID 17 Typographic Subfamily name: None --> "Condensed"
|
||||
|
||||
References:
|
||||
https://docs.microsoft.com/en-us/typography/opentype/spec/stat
|
||||
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 will 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)
|
||||
|
||||
# ignore "elidable" axis values, should be omitted 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 by axis index, remove duplicates and ensure that format 4 AxisValues
|
||||
# are dominant.
|
||||
# The MS Spec states: "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
|
||||
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,
|
||||
)
|
||||
|
||||
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 axisValues:
|
||||
if val in format4:
|
||||
continue
|
||||
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 _isRibbi(nametable, n)]
|
||||
nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs]
|
||||
elidedNameID = stat.ElidedFallbackNameID
|
||||
elidedNameIsRibbi = _isRibbi(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
|
||||
)
|
||||
if nonRibbiNameIDs:
|
||||
typoSubFamilyName = " ".join(
|
||||
getName(n, *platform).toUnicode() for n in axisValueNameIDs
|
||||
)
|
||||
else:
|
||||
typoSubFamilyName = None
|
||||
|
||||
# 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 _isRibbi(nametable, nameID):
|
||||
englishRecord = nametable.getName(nameID, 3, 1, 0x409)
|
||||
return (
|
||||
True
|
||||
if englishRecord is not None
|
||||
and englishRecord.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)
|
||||
|
||||
if not all([currentFamilyName, currentStyleName]):
|
||||
raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}")
|
||||
|
||||
currentFamilyName = currentFamilyName.toUnicode()
|
||||
currentStyleName = currentStyleName.toUnicode()
|
||||
|
||||
nameIDs = {
|
||||
NameID.FAMILY_NAME: currentFamilyName,
|
||||
NameID.SUBFAMILY_NAME: subFamilyName or "Regular",
|
||||
}
|
||||
if typoSubFamilyName:
|
||||
nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip()
|
||||
nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName
|
||||
nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName
|
||||
else:
|
||||
# Remove previous Typographic Family and SubFamily names since they're
|
||||
# no longer required
|
||||
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
|
||||
)
|
||||
|
||||
uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform)
|
||||
if uniqueID:
|
||||
nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID
|
||||
|
||||
for nameID, string in nameIDs.items():
|
||||
assert string, nameID
|
||||
nametable.setName(string, nameID, *platform)
|
||||
|
||||
if "fvar" not in varfont:
|
||||
nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX)
|
||||
|
||||
|
||||
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 nameRecord.toUnicode() in currentRecord.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()
|
13
Tests/varLib/instancer/conftest.py
Normal file
13
Tests/varLib/instancer/conftest.py
Normal file
@ -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
|
@ -479,6 +479,9 @@
|
||||
<namerecord nameID="296" platformID="3" platEncID="1" langID="0x409">
|
||||
TestVariableFont-XCdBd
|
||||
</namerecord>
|
||||
<namerecord nameID="297" platformID="3" platEncID="1" langID="0x409">
|
||||
Normal
|
||||
</namerecord>
|
||||
</name>
|
||||
|
||||
<post>
|
||||
@ -764,6 +767,15 @@
|
||||
<Value value="0.0"/>
|
||||
<LinkedValue value="1.0"/>
|
||||
</AxisValue>
|
||||
<AxisValue index="3" Format="4">
|
||||
<!-- AxisCount=1 -->
|
||||
<Flags value="2"/>
|
||||
<ValueNameID value="297"/> <!-- Normal -->
|
||||
<AxisValueRecord index="0">
|
||||
<AxisIndex value="1"/>
|
||||
<Value value="100.0"/>
|
||||
</AxisValueRecord>
|
||||
</AxisValue>
|
||||
</AxisValueArray>
|
||||
<ElidedFallbackNameID value="2"/> <!-- Regular -->
|
||||
</STAT>
|
@ -20,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
|
||||
@ -144,7 +139,7 @@ class InstantiateGvarTest(object):
|
||||
assert "gvar" not in varfont
|
||||
|
||||
def test_composite_glyph_not_in_gvar(self, varfont):
|
||||
""" The 'minus' glyph is a composite glyph, which references 'hyphen' as a
|
||||
"""The 'minus' glyph is a composite glyph, which references 'hyphen' as a
|
||||
component, but has no tuple variations in gvar table, so the component offset
|
||||
and the phantom points do not change; however the sidebearings and bounding box
|
||||
do change as a result of the parent glyph 'hyphen' changing.
|
||||
@ -1209,8 +1204,8 @@ class InstantiateSTATTest(object):
|
||||
@pytest.mark.parametrize(
|
||||
"location, expected",
|
||||
[
|
||||
({"wght": 400}, ["Regular", "Condensed", "Upright"]),
|
||||
({"wdth": 100}, ["Thin", "Regular", "Black", "Upright"]),
|
||||
({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]),
|
||||
({"wdth": 100}, ["Thin", "Regular", "Black", "Upright", "Normal"]),
|
||||
],
|
||||
)
|
||||
def test_pin_and_drop_axis(self, varfont, location, expected):
|
||||
@ -1341,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, 296 + 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
|
322
Tests/varLib/instancer/names_test.py
Normal file
322
Tests/varLib/instancer/names_test.py
Normal file
@ -0,0 +1,322 @@
|
||||
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]
|
||||
|
||||
font_nameids = set(i[0] for i in font_names)
|
||||
if isNonRIBBI:
|
||||
assert 16 in font_nameids
|
||||
assert 17 in font_nameids
|
||||
|
||||
if "fvar" not in varfont:
|
||||
assert 25 not 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)
|
||||
|
||||
|
||||
def test_updateNameTable_existing_subfamily_name_is_not_regular(varfont):
|
||||
# Check the subFamily name will be set to Regular when we update a name
|
||||
# table to a non-RIBBI style and the current subFamily name is a RIBBI
|
||||
# style which isn't Regular.
|
||||
varfont["name"].setName("Bold", 2, 3, 1, 0x409) # subFamily Regular --> Bold
|
||||
|
||||
instancer.names.updateNameTable(varfont, {"wght": 100})
|
||||
expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Thin"}
|
||||
_test_name_records(varfont, expected, isNonRIBBI=True)
|
Loading…
x
Reference in New Issue
Block a user