• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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