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