• 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    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