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