1"""Helpers for instantiating name table records.""" 2 3from contextlib import contextmanager 4from copy import deepcopy 5from enum import IntEnum 6import re 7 8 9class NameID(IntEnum): 10 FAMILY_NAME = 1 11 SUBFAMILY_NAME = 2 12 UNIQUE_FONT_IDENTIFIER = 3 13 FULL_FONT_NAME = 4 14 VERSION_STRING = 5 15 POSTSCRIPT_NAME = 6 16 TYPOGRAPHIC_FAMILY_NAME = 16 17 TYPOGRAPHIC_SUBFAMILY_NAME = 17 18 VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25 19 20 21ELIDABLE_AXIS_VALUE_NAME = 2 22 23 24def getVariationNameIDs(varfont): 25 used = [] 26 if "fvar" in varfont: 27 fvar = varfont["fvar"] 28 for axis in fvar.axes: 29 used.append(axis.axisNameID) 30 for instance in fvar.instances: 31 used.append(instance.subfamilyNameID) 32 if instance.postscriptNameID != 0xFFFF: 33 used.append(instance.postscriptNameID) 34 if "STAT" in varfont: 35 stat = varfont["STAT"].table 36 for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): 37 used.append(axis.AxisNameID) 38 for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): 39 used.append(value.ValueNameID) 40 elidedFallbackNameID = getattr(stat, "ElidedFallbackNameID", None) 41 if elidedFallbackNameID is not None: 42 used.append(elidedFallbackNameID) 43 # nameIDs <= 255 are reserved by OT spec so we don't touch them 44 return {nameID for nameID in used if nameID > 255} 45 46 47@contextmanager 48def pruningUnusedNames(varfont): 49 from . import log 50 51 origNameIDs = getVariationNameIDs(varfont) 52 53 yield 54 55 log.info("Pruning name table") 56 exclude = origNameIDs - getVariationNameIDs(varfont) 57 varfont["name"].names[:] = [ 58 record for record in varfont["name"].names if record.nameID not in exclude 59 ] 60 if "ltag" in varfont: 61 # Drop the whole 'ltag' table if all the language-dependent Unicode name 62 # records that reference it have been dropped. 63 # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. 64 # Note ltag can also be used by feat or morx tables, so check those too. 65 if not any( 66 record 67 for record in varfont["name"].names 68 if record.platformID == 0 and record.langID != 0xFFFF 69 ): 70 del varfont["ltag"] 71 72 73def updateNameTable(varfont, axisLimits): 74 """Update instatiated variable font's name table using STAT AxisValues. 75 76 Raises ValueError if the STAT table is missing or an Axis Value table is 77 missing for requested axis locations. 78 79 First, collect all STAT AxisValues that match the new default axis locations 80 (excluding "elided" ones); concatenate the strings in design axis order, 81 while giving priority to "synthetic" values (Format 4), to form the 82 typographic subfamily name associated with the new default instance. 83 Finally, update all related records in the name table, making sure that 84 legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic, 85 Bold, Bold Italic) naming model. 86 87 Example: Updating a partial variable font: 88 | >>> ttFont = TTFont("OpenSans[wdth,wght].ttf") 89 | >>> updateNameTable(ttFont, {"wght": (400, 900), "wdth": 75}) 90 91 The name table records will be updated in the following manner: 92 NameID 1 familyName: "Open Sans" --> "Open Sans Condensed" 93 NameID 2 subFamilyName: "Regular" --> "Regular" 94 NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ 95 "3.000;GOOG;OpenSans-Condensed" 96 NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed" 97 NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed" 98 NameID 16 Typographic Family name: None --> "Open Sans" 99 NameID 17 Typographic Subfamily name: None --> "Condensed" 100 101 References: 102 https://docs.microsoft.com/en-us/typography/opentype/spec/stat 103 https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids 104 """ 105 from . import AxisLimits, axisValuesFromAxisLimits 106 107 if "STAT" not in varfont: 108 raise ValueError("Cannot update name table since there is no STAT table.") 109 stat = varfont["STAT"].table 110 if not stat.AxisValueArray: 111 raise ValueError("Cannot update name table since there are no STAT Axis Values") 112 fvar = varfont["fvar"] 113 114 # The updated name table will reflect the new 'zero origin' of the font. 115 # If we're instantiating a partial font, we will populate the unpinned 116 # axes with their default axis values from fvar. 117 axisLimits = AxisLimits(axisLimits).limitAxesAndPopulateDefaults(varfont) 118 partialDefaults = axisLimits.defaultLocation() 119 fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} 120 defaultAxisCoords = AxisLimits({**fvarDefaults, **partialDefaults}) 121 assert all(v.minimum == v.maximum for v in defaultAxisCoords.values()) 122 123 axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) 124 checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords.pinnedLocation()) 125 126 # ignore "elidable" axis values, should be omitted in application font menus. 127 axisValueTables = [ 128 v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME 129 ] 130 axisValueTables = _sortAxisValues(axisValueTables) 131 _updateNameRecords(varfont, axisValueTables) 132 133 134def checkAxisValuesExist(stat, axisValues, axisCoords): 135 seen = set() 136 designAxes = stat.DesignAxisRecord.Axis 137 hasValues = set() 138 for value in stat.AxisValueArray.AxisValue: 139 if value.Format in (1, 2, 3): 140 hasValues.add(designAxes[value.AxisIndex].AxisTag) 141 elif value.Format == 4: 142 for rec in value.AxisValueRecord: 143 hasValues.add(designAxes[rec.AxisIndex].AxisTag) 144 145 for axisValueTable in axisValues: 146 axisValueFormat = axisValueTable.Format 147 if axisValueTable.Format in (1, 2, 3): 148 axisTag = designAxes[axisValueTable.AxisIndex].AxisTag 149 if axisValueFormat == 2: 150 axisValue = axisValueTable.NominalValue 151 else: 152 axisValue = axisValueTable.Value 153 if axisTag in axisCoords and axisValue == axisCoords[axisTag]: 154 seen.add(axisTag) 155 elif axisValueTable.Format == 4: 156 for rec in axisValueTable.AxisValueRecord: 157 axisTag = designAxes[rec.AxisIndex].AxisTag 158 if axisTag in axisCoords and rec.Value == axisCoords[axisTag]: 159 seen.add(axisTag) 160 161 missingAxes = (set(axisCoords) - seen) & hasValues 162 if missingAxes: 163 missing = ", ".join(f"'{i}': {axisCoords[i]}" for i in missingAxes) 164 raise ValueError(f"Cannot find Axis Values {{{missing}}}") 165 166 167def _sortAxisValues(axisValues): 168 # Sort by axis index, remove duplicates and ensure that format 4 AxisValues 169 # are dominant. 170 # The MS Spec states: "if a format 1, format 2 or format 3 table has a 171 # (nominal) value used in a format 4 table that also has values for 172 # other axes, the format 4 table, being the more specific match, is used", 173 # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4 174 results = [] 175 seenAxes = set() 176 # Sort format 4 axes so the tables with the most AxisValueRecords are first 177 format4 = sorted( 178 [v for v in axisValues if v.Format == 4], 179 key=lambda v: len(v.AxisValueRecord), 180 reverse=True, 181 ) 182 183 for val in format4: 184 axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) 185 minIndex = min(axisIndexes) 186 if not seenAxes & axisIndexes: 187 seenAxes |= axisIndexes 188 results.append((minIndex, val)) 189 190 for val in axisValues: 191 if val in format4: 192 continue 193 axisIndex = val.AxisIndex 194 if axisIndex not in seenAxes: 195 seenAxes.add(axisIndex) 196 results.append((axisIndex, val)) 197 198 return [axisValue for _, axisValue in sorted(results)] 199 200 201def _updateNameRecords(varfont, axisValues): 202 # Update nametable based on the axisValues using the R/I/B/BI model. 203 nametable = varfont["name"] 204 stat = varfont["STAT"].table 205 206 axisValueNameIDs = [a.ValueNameID for a in axisValues] 207 ribbiNameIDs = [n for n in axisValueNameIDs if _isRibbi(nametable, n)] 208 nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] 209 elidedNameID = stat.ElidedFallbackNameID 210 elidedNameIsRibbi = _isRibbi(nametable, elidedNameID) 211 212 getName = nametable.getName 213 platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) 214 for platform in platforms: 215 if not all(getName(i, *platform) for i in (1, 2, elidedNameID)): 216 # Since no family name and subfamily name records were found, 217 # we cannot update this set of name Records. 218 continue 219 220 subFamilyName = " ".join( 221 getName(n, *platform).toUnicode() for n in ribbiNameIDs 222 ) 223 if nonRibbiNameIDs: 224 typoSubFamilyName = " ".join( 225 getName(n, *platform).toUnicode() for n in axisValueNameIDs 226 ) 227 else: 228 typoSubFamilyName = None 229 230 # If neither subFamilyName and typographic SubFamilyName exist, 231 # we will use the STAT's elidedFallbackName 232 if not typoSubFamilyName and not subFamilyName: 233 if elidedNameIsRibbi: 234 subFamilyName = getName(elidedNameID, *platform).toUnicode() 235 else: 236 typoSubFamilyName = getName(elidedNameID, *platform).toUnicode() 237 238 familyNameSuffix = " ".join( 239 getName(n, *platform).toUnicode() for n in nonRibbiNameIDs 240 ) 241 242 _updateNameTableStyleRecords( 243 varfont, 244 familyNameSuffix, 245 subFamilyName, 246 typoSubFamilyName, 247 *platform, 248 ) 249 250 251def _isRibbi(nametable, nameID): 252 englishRecord = nametable.getName(nameID, 3, 1, 0x409) 253 return ( 254 True 255 if englishRecord is not None 256 and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic") 257 else False 258 ) 259 260 261def _updateNameTableStyleRecords( 262 varfont, 263 familyNameSuffix, 264 subFamilyName, 265 typoSubFamilyName, 266 platformID=3, 267 platEncID=1, 268 langID=0x409, 269): 270 # TODO (Marc F) It may be nice to make this part a standalone 271 # font renamer in the future. 272 nametable = varfont["name"] 273 platform = (platformID, platEncID, langID) 274 275 currentFamilyName = nametable.getName( 276 NameID.TYPOGRAPHIC_FAMILY_NAME, *platform 277 ) or nametable.getName(NameID.FAMILY_NAME, *platform) 278 279 currentStyleName = nametable.getName( 280 NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform 281 ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) 282 283 if not all([currentFamilyName, currentStyleName]): 284 raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}") 285 286 currentFamilyName = currentFamilyName.toUnicode() 287 currentStyleName = currentStyleName.toUnicode() 288 289 nameIDs = { 290 NameID.FAMILY_NAME: currentFamilyName, 291 NameID.SUBFAMILY_NAME: subFamilyName or "Regular", 292 } 293 if typoSubFamilyName: 294 nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() 295 nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName 296 nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName 297 else: 298 # Remove previous Typographic Family and SubFamily names since they're 299 # no longer required 300 for nameID in ( 301 NameID.TYPOGRAPHIC_FAMILY_NAME, 302 NameID.TYPOGRAPHIC_SUBFAMILY_NAME, 303 ): 304 nametable.removeNames(nameID=nameID) 305 306 newFamilyName = ( 307 nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME] 308 ) 309 newStyleName = ( 310 nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME] 311 ) 312 313 nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" 314 nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord( 315 varfont, newFamilyName, newStyleName, platform 316 ) 317 318 uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform) 319 if uniqueID: 320 nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID 321 322 for nameID, string in nameIDs.items(): 323 assert string, nameID 324 nametable.setName(string, nameID, *platform) 325 326 if "fvar" not in varfont: 327 nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX) 328 329 330def _updatePSNameRecord(varfont, familyName, styleName, platform): 331 # Implementation based on Adobe Technical Note #5902 : 332 # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf 333 nametable = varfont["name"] 334 335 family_prefix = nametable.getName( 336 NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform 337 ) 338 if family_prefix: 339 family_prefix = family_prefix.toUnicode() 340 else: 341 family_prefix = familyName 342 343 psName = f"{family_prefix}-{styleName}" 344 # Remove any characters other than uppercase Latin letters, lowercase 345 # Latin letters, digits and hyphens. 346 psName = re.sub(r"[^A-Za-z0-9-]", r"", psName) 347 348 if len(psName) > 127: 349 # Abbreviating the stylename so it fits within 127 characters whilst 350 # conforming to every vendor's specification is too complex. Instead 351 # we simply truncate the psname and add the required "..." 352 return f"{psName[:124]}..." 353 return psName 354 355 356def _updateUniqueIdNameRecord(varfont, nameIDs, platform): 357 nametable = varfont["name"] 358 currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform) 359 if not currentRecord: 360 return None 361 362 # Check if full name and postscript name are a substring of currentRecord 363 for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME): 364 nameRecord = nametable.getName(nameID, *platform) 365 if not nameRecord: 366 continue 367 if nameRecord.toUnicode() in currentRecord.toUnicode(): 368 return currentRecord.toUnicode().replace( 369 nameRecord.toUnicode(), nameIDs[nameRecord.nameID] 370 ) 371 372 # Create a new string since we couldn't find any substrings. 373 fontVersion = _fontVersion(varfont, platform) 374 achVendID = varfont["OS/2"].achVendID 375 # Remove non-ASCII characers and trailing spaces 376 vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip() 377 psName = nameIDs[NameID.POSTSCRIPT_NAME] 378 return f"{fontVersion};{vendor};{psName}" 379 380 381def _fontVersion(font, platform=(3, 1, 0x409)): 382 nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform) 383 if nameRecord is None: 384 return f'{font["head"].fontRevision:.3f}' 385 # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101" 386 # Also works fine with inputs "Version 1.101" or "1.101" etc 387 versionNumber = nameRecord.toUnicode().split(";")[0] 388 return versionNumber.lstrip("Version ").strip() 389