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