1"""Module to build FeatureVariation tables: 2https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#featurevariations-table 3 4NOTE: The API is experimental and subject to change. 5""" 6from fontTools.misc.dictTools import hashdict 7from fontTools.misc.intTools import popCount 8from fontTools.ttLib import newTable 9from fontTools.ttLib.tables import otTables as ot 10from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable 11from collections import OrderedDict 12 13from .errors import VarLibError, VarLibValidationError 14 15 16def addFeatureVariations(font, conditionalSubstitutions, featureTag='rvrn'): 17 """Add conditional substitutions to a Variable Font. 18 19 The `conditionalSubstitutions` argument is a list of (Region, Substitutions) 20 tuples. 21 22 A Region is a list of Boxes. A Box is a dict mapping axisTags to 23 (minValue, maxValue) tuples. Irrelevant axes may be omitted and they are 24 interpretted as extending to end of axis in each direction. A Box represents 25 an orthogonal 'rectangular' subset of an N-dimensional design space. 26 A Region represents a more complex subset of an N-dimensional design space, 27 ie. the union of all the Boxes in the Region. 28 For efficiency, Boxes within a Region should ideally not overlap, but 29 functionality is not compromised if they do. 30 31 The minimum and maximum values are expressed in normalized coordinates. 32 33 A Substitution is a dict mapping source glyph names to substitute glyph names. 34 35 Example: 36 37 # >>> f = TTFont(srcPath) 38 # >>> condSubst = [ 39 # ... # A list of (Region, Substitution) tuples. 40 # ... ([{"wdth": (0.5, 1.0)}], {"cent": "cent.rvrn"}), 41 # ... ([{"wght": (0.5, 1.0)}], {"dollar": "dollar.rvrn"}), 42 # ... ] 43 # >>> addFeatureVariations(f, condSubst) 44 # >>> f.save(dstPath) 45 """ 46 47 _checkSubstitutionGlyphsExist( 48 glyphNames=set(font.getGlyphOrder()), 49 substitutions=conditionalSubstitutions, 50 ) 51 52 substitutions = overlayFeatureVariations(conditionalSubstitutions) 53 54 # turn substitution dicts into tuples of tuples, so they are hashable 55 conditionalSubstitutions, allSubstitutions = makeSubstitutionsHashable(substitutions) 56 if "GSUB" not in font: 57 font["GSUB"] = buildGSUB() 58 59 # setup lookups 60 lookupMap = buildSubstitutionLookups(font["GSUB"].table, allSubstitutions) 61 62 # addFeatureVariationsRaw takes a list of 63 # ( {condition}, [ lookup indices ] ) 64 # so rearrange our lookups to match 65 conditionsAndLookups = [] 66 for conditionSet, substitutions in conditionalSubstitutions: 67 conditionsAndLookups.append((conditionSet, [lookupMap[s] for s in substitutions])) 68 69 addFeatureVariationsRaw(font, font["GSUB"].table, 70 conditionsAndLookups, 71 featureTag) 72 73def _checkSubstitutionGlyphsExist(glyphNames, substitutions): 74 referencedGlyphNames = set() 75 for _, substitution in substitutions: 76 referencedGlyphNames |= substitution.keys() 77 referencedGlyphNames |= set(substitution.values()) 78 missing = referencedGlyphNames - glyphNames 79 if missing: 80 raise VarLibValidationError( 81 "Missing glyphs are referenced in conditional substitution rules:" 82 f" {', '.join(missing)}" 83 ) 84 85def overlayFeatureVariations(conditionalSubstitutions): 86 """Compute overlaps between all conditional substitutions. 87 88 The `conditionalSubstitutions` argument is a list of (Region, Substitutions) 89 tuples. 90 91 A Region is a list of Boxes. A Box is a dict mapping axisTags to 92 (minValue, maxValue) tuples. Irrelevant axes may be omitted and they are 93 interpretted as extending to end of axis in each direction. A Box represents 94 an orthogonal 'rectangular' subset of an N-dimensional design space. 95 A Region represents a more complex subset of an N-dimensional design space, 96 ie. the union of all the Boxes in the Region. 97 For efficiency, Boxes within a Region should ideally not overlap, but 98 functionality is not compromised if they do. 99 100 The minimum and maximum values are expressed in normalized coordinates. 101 102 A Substitution is a dict mapping source glyph names to substitute glyph names. 103 104 Returns data is in similar but different format. Overlaps of distinct 105 substitution Boxes (*not* Regions) are explicitly listed as distinct rules, 106 and rules with the same Box merged. The more specific rules appear earlier 107 in the resulting list. Moreover, instead of just a dictionary of substitutions, 108 a list of dictionaries is returned for substitutions corresponding to each 109 unique space, with each dictionary being identical to one of the input 110 substitution dictionaries. These dictionaries are not merged to allow data 111 sharing when they are converted into font tables. 112 113 Example:: 114 115 >>> condSubst = [ 116 ... # A list of (Region, Substitution) tuples. 117 ... ([{"wght": (0.5, 1.0)}], {"dollar": "dollar.rvrn"}), 118 ... ([{"wght": (0.5, 1.0)}], {"dollar": "dollar.rvrn"}), 119 ... ([{"wdth": (0.5, 1.0)}], {"cent": "cent.rvrn"}), 120 ... ([{"wght": (0.5, 1.0), "wdth": (-1, 1.0)}], {"dollar": "dollar.rvrn"}), 121 ... ] 122 >>> from pprint import pprint 123 >>> pprint(overlayFeatureVariations(condSubst)) 124 [({'wdth': (0.5, 1.0), 'wght': (0.5, 1.0)}, 125 [{'dollar': 'dollar.rvrn'}, {'cent': 'cent.rvrn'}]), 126 ({'wdth': (0.5, 1.0)}, [{'cent': 'cent.rvrn'}]), 127 ({'wght': (0.5, 1.0)}, [{'dollar': 'dollar.rvrn'}])] 128 129 """ 130 131 # Merge same-substitutions rules, as this creates fewer number oflookups. 132 merged = OrderedDict() 133 for value,key in conditionalSubstitutions: 134 key = hashdict(key) 135 if key in merged: 136 merged[key].extend(value) 137 else: 138 merged[key] = value 139 conditionalSubstitutions = [(v,dict(k)) for k,v in merged.items()] 140 del merged 141 142 # Merge same-region rules, as this is cheaper. 143 # Also convert boxes to hashdict() 144 # 145 # Reversing is such that earlier entries win in case of conflicting substitution 146 # rules for the same region. 147 merged = OrderedDict() 148 for key,value in reversed(conditionalSubstitutions): 149 key = tuple(sorted((hashdict(cleanupBox(k)) for k in key), 150 key=lambda d: tuple(sorted(d.items())))) 151 if key in merged: 152 merged[key].update(value) 153 else: 154 merged[key] = dict(value) 155 conditionalSubstitutions = list(reversed(merged.items())) 156 del merged 157 158 # Overlay 159 # 160 # Rank is the bit-set of the index of all contributing layers. 161 initMapInit = ((hashdict(),0),) # Initializer representing the entire space 162 boxMap = OrderedDict(initMapInit) # Map from Box to Rank 163 for i,(currRegion,_) in enumerate(conditionalSubstitutions): 164 newMap = OrderedDict(initMapInit) 165 currRank = 1<<i 166 for box,rank in boxMap.items(): 167 for currBox in currRegion: 168 intersection, remainder = overlayBox(currBox, box) 169 if intersection is not None: 170 intersection = hashdict(intersection) 171 newMap[intersection] = newMap.get(intersection, 0) | rank|currRank 172 if remainder is not None: 173 remainder = hashdict(remainder) 174 newMap[remainder] = newMap.get(remainder, 0) | rank 175 boxMap = newMap 176 177 # Generate output 178 items = [] 179 for box,rank in sorted(boxMap.items(), 180 key=(lambda BoxAndRank: -popCount(BoxAndRank[1]))): 181 # Skip any box that doesn't have any substitution. 182 if rank == 0: 183 continue 184 substsList = [] 185 i = 0 186 while rank: 187 if rank & 1: 188 substsList.append(conditionalSubstitutions[i][1]) 189 rank >>= 1 190 i += 1 191 items.append((dict(box),substsList)) 192 return items 193 194 195# 196# Terminology: 197# 198# A 'Box' is a dict representing an orthogonal "rectangular" bit of N-dimensional space. 199# The keys in the dict are axis tags, the values are (minValue, maxValue) tuples. 200# Missing dimensions (keys) are substituted by the default min and max values 201# from the corresponding axes. 202# 203 204def overlayBox(top, bot): 205 """Overlays ``top`` box on top of ``bot`` box. 206 207 Returns two items: 208 209 * Box for intersection of ``top`` and ``bot``, or None if they don't intersect. 210 * Box for remainder of ``bot``. Remainder box might not be exact (since the 211 remainder might not be a simple box), but is inclusive of the exact 212 remainder. 213 """ 214 215 # Intersection 216 intersection = {} 217 intersection.update(top) 218 intersection.update(bot) 219 for axisTag in set(top) & set(bot): 220 min1, max1 = top[axisTag] 221 min2, max2 = bot[axisTag] 222 minimum = max(min1, min2) 223 maximum = min(max1, max2) 224 if not minimum < maximum: 225 return None, bot # Do not intersect 226 intersection[axisTag] = minimum,maximum 227 228 # Remainder 229 # 230 # Remainder is empty if bot's each axis range lies within that of intersection. 231 # 232 # Remainder is shrank if bot's each, except for exactly one, axis range lies 233 # within that of intersection, and that one axis, it spills out of the 234 # intersection only on one side. 235 # 236 # Bot is returned in full as remainder otherwise, as true remainder is not 237 # representable as a single box. 238 239 remainder = dict(bot) 240 exactlyOne = False 241 fullyInside = False 242 for axisTag in bot: 243 if axisTag not in intersection: 244 fullyInside = False 245 continue # Axis range lies fully within 246 min1, max1 = intersection[axisTag] 247 min2, max2 = bot[axisTag] 248 if min1 <= min2 and max2 <= max1: 249 continue # Axis range lies fully within 250 251 # Bot's range doesn't fully lie within that of top's for this axis. 252 # We know they intersect, so it cannot lie fully without either; so they 253 # overlap. 254 255 # If we have had an overlapping axis before, remainder is not 256 # representable as a box, so return full bottom and go home. 257 if exactlyOne: 258 return intersection, bot 259 exactlyOne = True 260 fullyInside = False 261 262 # Otherwise, cut remainder on this axis and continue. 263 if min1 <= min2: 264 # Right side survives. 265 minimum = max(max1, min2) 266 maximum = max2 267 elif max2 <= max1: 268 # Left side survives. 269 minimum = min2 270 maximum = min(min1, max2) 271 else: 272 # Remainder leaks out from both sides. Can't cut either. 273 return intersection, bot 274 275 remainder[axisTag] = minimum,maximum 276 277 if fullyInside: 278 # bot is fully within intersection. Remainder is empty. 279 return intersection, None 280 281 return intersection, remainder 282 283def cleanupBox(box): 284 """Return a sparse copy of `box`, without redundant (default) values. 285 286 >>> cleanupBox({}) 287 {} 288 >>> cleanupBox({'wdth': (0.0, 1.0)}) 289 {'wdth': (0.0, 1.0)} 290 >>> cleanupBox({'wdth': (-1.0, 1.0)}) 291 {} 292 293 """ 294 return {tag: limit for tag, limit in box.items() if limit != (-1.0, 1.0)} 295 296 297# 298# Low level implementation 299# 300 301def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag='rvrn'): 302 """Low level implementation of addFeatureVariations that directly 303 models the possibilities of the FeatureVariations table.""" 304 305 # 306 # if there is no <featureTag> feature: 307 # make empty <featureTag> feature 308 # sort features, get <featureTag> feature index 309 # add <featureTag> feature to all scripts 310 # make lookups 311 # add feature variations 312 # 313 if table.Version < 0x00010001: 314 table.Version = 0x00010001 # allow table.FeatureVariations 315 316 table.FeatureVariations = None # delete any existing FeatureVariations 317 318 varFeatureIndices = [] 319 for index, feature in enumerate(table.FeatureList.FeatureRecord): 320 if feature.FeatureTag == featureTag: 321 varFeatureIndices.append(index) 322 323 if not varFeatureIndices: 324 varFeature = buildFeatureRecord(featureTag, []) 325 table.FeatureList.FeatureRecord.append(varFeature) 326 table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord) 327 328 sortFeatureList(table) 329 varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature) 330 331 for scriptRecord in table.ScriptList.ScriptRecord: 332 if scriptRecord.Script.DefaultLangSys is None: 333 raise VarLibError( 334 "Feature variations require that the script " 335 f"'{scriptRecord.ScriptTag}' defines a default language system." 336 ) 337 langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord] 338 for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems: 339 langSys.FeatureIndex.append(varFeatureIndex) 340 341 varFeatureIndices = [varFeatureIndex] 342 343 axisIndices = {axis.axisTag: axisIndex for axisIndex, axis in enumerate(font["fvar"].axes)} 344 345 featureVariationRecords = [] 346 for conditionSet, lookupIndices in conditionalSubstitutions: 347 conditionTable = [] 348 for axisTag, (minValue, maxValue) in sorted(conditionSet.items()): 349 if minValue > maxValue: 350 raise VarLibValidationError( 351 "A condition set has a minimum value above the maximum value." 352 ) 353 ct = buildConditionTable(axisIndices[axisTag], minValue, maxValue) 354 conditionTable.append(ct) 355 records = [] 356 for varFeatureIndex in varFeatureIndices: 357 existingLookupIndices = table.FeatureList.FeatureRecord[varFeatureIndex].Feature.LookupListIndex 358 records.append(buildFeatureTableSubstitutionRecord(varFeatureIndex, existingLookupIndices + lookupIndices)) 359 featureVariationRecords.append(buildFeatureVariationRecord(conditionTable, records)) 360 361 table.FeatureVariations = buildFeatureVariations(featureVariationRecords) 362 363 364# 365# Building GSUB/FeatureVariations internals 366# 367 368def buildGSUB(): 369 """Build a GSUB table from scratch.""" 370 fontTable = newTable("GSUB") 371 gsub = fontTable.table = ot.GSUB() 372 gsub.Version = 0x00010001 # allow gsub.FeatureVariations 373 374 gsub.ScriptList = ot.ScriptList() 375 gsub.ScriptList.ScriptRecord = [] 376 gsub.FeatureList = ot.FeatureList() 377 gsub.FeatureList.FeatureRecord = [] 378 gsub.LookupList = ot.LookupList() 379 gsub.LookupList.Lookup = [] 380 381 srec = ot.ScriptRecord() 382 srec.ScriptTag = 'DFLT' 383 srec.Script = ot.Script() 384 srec.Script.DefaultLangSys = None 385 srec.Script.LangSysRecord = [] 386 387 langrec = ot.LangSysRecord() 388 langrec.LangSys = ot.LangSys() 389 langrec.LangSys.ReqFeatureIndex = 0xFFFF 390 langrec.LangSys.FeatureIndex = [] 391 srec.Script.DefaultLangSys = langrec.LangSys 392 393 gsub.ScriptList.ScriptRecord.append(srec) 394 gsub.ScriptList.ScriptCount = 1 395 gsub.FeatureVariations = None 396 397 return fontTable 398 399 400def makeSubstitutionsHashable(conditionalSubstitutions): 401 """Turn all the substitution dictionaries in sorted tuples of tuples so 402 they are hashable, to detect duplicates so we don't write out redundant 403 data.""" 404 allSubstitutions = set() 405 condSubst = [] 406 for conditionSet, substitutionMaps in conditionalSubstitutions: 407 substitutions = [] 408 for substitutionMap in substitutionMaps: 409 subst = tuple(sorted(substitutionMap.items())) 410 substitutions.append(subst) 411 allSubstitutions.add(subst) 412 condSubst.append((conditionSet, substitutions)) 413 return condSubst, sorted(allSubstitutions) 414 415 416def buildSubstitutionLookups(gsub, allSubstitutions): 417 """Build the lookups for the glyph substitutions, return a dict mapping 418 the substitution to lookup indices.""" 419 firstIndex = len(gsub.LookupList.Lookup) 420 lookupMap = {} 421 for i, substitutionMap in enumerate(allSubstitutions): 422 lookupMap[substitutionMap] = i + firstIndex 423 424 for subst in allSubstitutions: 425 substMap = dict(subst) 426 lookup = buildLookup([buildSingleSubstSubtable(substMap)]) 427 gsub.LookupList.Lookup.append(lookup) 428 assert gsub.LookupList.Lookup[lookupMap[subst]] is lookup 429 gsub.LookupList.LookupCount = len(gsub.LookupList.Lookup) 430 return lookupMap 431 432 433def buildFeatureVariations(featureVariationRecords): 434 """Build the FeatureVariations subtable.""" 435 fv = ot.FeatureVariations() 436 fv.Version = 0x00010000 437 fv.FeatureVariationRecord = featureVariationRecords 438 fv.FeatureVariationCount = len(featureVariationRecords) 439 return fv 440 441 442def buildFeatureRecord(featureTag, lookupListIndices): 443 """Build a FeatureRecord.""" 444 fr = ot.FeatureRecord() 445 fr.FeatureTag = featureTag 446 fr.Feature = ot.Feature() 447 fr.Feature.LookupListIndex = lookupListIndices 448 fr.Feature.populateDefaults() 449 return fr 450 451 452def buildFeatureVariationRecord(conditionTable, substitutionRecords): 453 """Build a FeatureVariationRecord.""" 454 fvr = ot.FeatureVariationRecord() 455 fvr.ConditionSet = ot.ConditionSet() 456 fvr.ConditionSet.ConditionTable = conditionTable 457 fvr.ConditionSet.ConditionCount = len(conditionTable) 458 fvr.FeatureTableSubstitution = ot.FeatureTableSubstitution() 459 fvr.FeatureTableSubstitution.Version = 0x00010000 460 fvr.FeatureTableSubstitution.SubstitutionRecord = substitutionRecords 461 fvr.FeatureTableSubstitution.SubstitutionCount = len(substitutionRecords) 462 return fvr 463 464 465def buildFeatureTableSubstitutionRecord(featureIndex, lookupListIndices): 466 """Build a FeatureTableSubstitutionRecord.""" 467 ftsr = ot.FeatureTableSubstitutionRecord() 468 ftsr.FeatureIndex = featureIndex 469 ftsr.Feature = ot.Feature() 470 ftsr.Feature.LookupListIndex = lookupListIndices 471 ftsr.Feature.LookupCount = len(lookupListIndices) 472 return ftsr 473 474 475def buildConditionTable(axisIndex, filterRangeMinValue, filterRangeMaxValue): 476 """Build a ConditionTable.""" 477 ct = ot.ConditionTable() 478 ct.Format = 1 479 ct.AxisIndex = axisIndex 480 ct.FilterRangeMinValue = filterRangeMinValue 481 ct.FilterRangeMaxValue = filterRangeMaxValue 482 return ct 483 484 485def sortFeatureList(table): 486 """Sort the feature list by feature tag, and remap the feature indices 487 elsewhere. This is needed after the feature list has been modified. 488 """ 489 # decorate, sort, undecorate, because we need to make an index remapping table 490 tagIndexFea = [(fea.FeatureTag, index, fea) for index, fea in enumerate(table.FeatureList.FeatureRecord)] 491 tagIndexFea.sort() 492 table.FeatureList.FeatureRecord = [fea for tag, index, fea in tagIndexFea] 493 featureRemap = dict(zip([index for tag, index, fea in tagIndexFea], range(len(tagIndexFea)))) 494 495 # Remap the feature indices 496 remapFeatures(table, featureRemap) 497 498 499def remapFeatures(table, featureRemap): 500 """Go through the scripts list, and remap feature indices.""" 501 for scriptIndex, script in enumerate(table.ScriptList.ScriptRecord): 502 defaultLangSys = script.Script.DefaultLangSys 503 if defaultLangSys is not None: 504 _remapLangSys(defaultLangSys, featureRemap) 505 for langSysRecordIndex, langSysRec in enumerate(script.Script.LangSysRecord): 506 langSys = langSysRec.LangSys 507 _remapLangSys(langSys, featureRemap) 508 509 if hasattr(table, "FeatureVariations") and table.FeatureVariations is not None: 510 for fvr in table.FeatureVariations.FeatureVariationRecord: 511 for ftsr in fvr.FeatureTableSubstitution.SubstitutionRecord: 512 ftsr.FeatureIndex = featureRemap[ftsr.FeatureIndex] 513 514 515def _remapLangSys(langSys, featureRemap): 516 if langSys.ReqFeatureIndex != 0xffff: 517 langSys.ReqFeatureIndex = featureRemap[langSys.ReqFeatureIndex] 518 langSys.FeatureIndex = [featureRemap[index] for index in langSys.FeatureIndex] 519 520 521if __name__ == "__main__": 522 import doctest, sys 523 sys.exit(doctest.testmod().failed) 524