389 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			389 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """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()
 |