123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- """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)
- # 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": (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"]
- # 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 from 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())
- # 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
- hasValues = set()
- for value in stat.AxisValueArray.AxisValue:
- if value.Format in (1, 2, 3):
- hasValues.add(designAxes[value.AxisIndex].AxisTag)
- elif value.Format == 4:
- for rec in value.AxisValueRecord:
- hasValues.add(designAxes[rec.AxisIndex].AxisTag)
- 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) & hasValues
- 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()
|