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