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