|
"""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) |
|
elidedFallbackNameID = getattr(stat, "ElidedFallbackNameID", None) |
|
if elidedFallbackNameID is not None: |
|
used.append(elidedFallbackNameID) |
|
|
|
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: |
|
|
|
|
|
|
|
|
|
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": (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 AxisLimits, 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"] |
|
|
|
|
|
|
|
|
|
axisLimits = AxisLimits(axisLimits).limitAxesAndPopulateDefaults(varfont) |
|
partialDefaults = axisLimits.defaultLocation() |
|
fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} |
|
defaultAxisCoords = AxisLimits({**fvarDefaults, **partialDefaults}) |
|
assert all(v.minimum == v.maximum for v in defaultAxisCoords.values()) |
|
|
|
axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) |
|
checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords.pinnedLocation()) |
|
|
|
|
|
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): |
|
|
|
|
|
|
|
|
|
|
|
|
|
results = [] |
|
seenAxes = set() |
|
|
|
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): |
|
|
|
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)): |
|
|
|
|
|
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 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, |
|
): |
|
|
|
|
|
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: |
|
|
|
|
|
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): |
|
|
|
|
|
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}" |
|
|
|
|
|
psName = re.sub(r"[^A-Za-z0-9-]", r"", psName) |
|
|
|
if len(psName) > 127: |
|
|
|
|
|
|
|
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 |
|
|
|
|
|
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] |
|
) |
|
|
|
|
|
fontVersion = _fontVersion(varfont, platform) |
|
achVendID = varfont["OS/2"].achVendID |
|
|
|
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}' |
|
|
|
|
|
versionNumber = nameRecord.toUnicode().split(";")[0] |
|
return versionNumber.lstrip("Version ").strip() |
|
|