xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/mutator.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""
2Instantiate a variation font.  Run, eg:
3
4$ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
5"""
6
7from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed
8from fontTools.misc.roundTools import otRound
9from fontTools.pens.boundsPen import BoundsPen
10from fontTools.ttLib import TTFont, newTable
11from fontTools.ttLib.tables import ttProgram
12from fontTools.ttLib.tables._g_l_y_f import (
13    GlyphCoordinates,
14    flagOverlapSimple,
15    OVERLAP_COMPOUND,
16)
17from fontTools.varLib.models import (
18    supportScalar,
19    normalizeLocation,
20    piecewiseLinearMap,
21)
22from fontTools.varLib.merger import MutatorMerger
23from fontTools.varLib.varStore import VarStoreInstancer
24from fontTools.varLib.mvar import MVAR_ENTRIES
25from fontTools.varLib.iup import iup_delta
26import fontTools.subset.cff
27import os.path
28import logging
29from io import BytesIO
30
31
32log = logging.getLogger("fontTools.varlib.mutator")
33
34# map 'wdth' axis (1..200) to OS/2.usWidthClass (1..9), rounding to closest
35OS2_WIDTH_CLASS_VALUES = {}
36percents = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0]
37for i, (prev, curr) in enumerate(zip(percents[:-1], percents[1:]), start=1):
38    half = (prev + curr) / 2
39    OS2_WIDTH_CLASS_VALUES[half] = i
40
41
42def interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas):
43    pd_blend_lists = (
44        "BlueValues",
45        "OtherBlues",
46        "FamilyBlues",
47        "FamilyOtherBlues",
48        "StemSnapH",
49        "StemSnapV",
50    )
51    pd_blend_values = ("BlueScale", "BlueShift", "BlueFuzz", "StdHW", "StdVW")
52    for fontDict in topDict.FDArray:
53        pd = fontDict.Private
54        vsindex = pd.vsindex if (hasattr(pd, "vsindex")) else 0
55        for key, value in pd.rawDict.items():
56            if (key in pd_blend_values) and isinstance(value, list):
57                delta = interpolateFromDeltas(vsindex, value[1:])
58                pd.rawDict[key] = otRound(value[0] + delta)
59            elif (key in pd_blend_lists) and isinstance(value[0], list):
60                """If any argument in a BlueValues list is a blend list,
61                then they all are. The first value of each list is an
62                absolute value. The delta tuples are calculated from
63                relative master values, hence we need to append all the
64                deltas to date to each successive absolute value."""
65                delta = 0
66                for i, val_list in enumerate(value):
67                    delta += otRound(interpolateFromDeltas(vsindex, val_list[1:]))
68                    value[i] = val_list[0] + delta
69
70
71def interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder):
72    charstrings = topDict.CharStrings
73    for gname in glyphOrder:
74        # Interpolate charstring
75        # e.g replace blend op args with regular args,
76        # and use and discard vsindex op.
77        charstring = charstrings[gname]
78        new_program = []
79        vsindex = 0
80        last_i = 0
81        for i, token in enumerate(charstring.program):
82            if token == "vsindex":
83                vsindex = charstring.program[i - 1]
84                if last_i != 0:
85                    new_program.extend(charstring.program[last_i : i - 1])
86                last_i = i + 1
87            elif token == "blend":
88                num_regions = charstring.getNumRegions(vsindex)
89                numMasters = 1 + num_regions
90                num_args = charstring.program[i - 1]
91                # The program list starting at program[i] is now:
92                # ..args for following operations
93                # num_args values  from the default font
94                # num_args tuples, each with numMasters-1 delta values
95                # num_blend_args
96                # 'blend'
97                argi = i - (num_args * numMasters + 1)
98                end_args = tuplei = argi + num_args
99                while argi < end_args:
100                    next_ti = tuplei + num_regions
101                    deltas = charstring.program[tuplei:next_ti]
102                    delta = interpolateFromDeltas(vsindex, deltas)
103                    charstring.program[argi] += otRound(delta)
104                    tuplei = next_ti
105                    argi += 1
106                new_program.extend(charstring.program[last_i:end_args])
107                last_i = i + 1
108        if last_i != 0:
109            new_program.extend(charstring.program[last_i:])
110            charstring.program = new_program
111
112
113def interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc):
114    """Unlike TrueType glyphs, neither advance width nor bounding box
115    info is stored in a CFF2 charstring. The width data exists only in
116    the hmtx and HVAR tables. Since LSB data cannot be interpolated
117    reliably from the master LSB values in the hmtx table, we traverse
118    the charstring to determine the actual bound box."""
119
120    charstrings = topDict.CharStrings
121    boundsPen = BoundsPen(glyphOrder)
122    hmtx = varfont["hmtx"]
123    hvar_table = None
124    if "HVAR" in varfont:
125        hvar_table = varfont["HVAR"].table
126        fvar = varfont["fvar"]
127        varStoreInstancer = VarStoreInstancer(hvar_table.VarStore, fvar.axes, loc)
128
129    for gid, gname in enumerate(glyphOrder):
130        entry = list(hmtx[gname])
131        # get width delta.
132        if hvar_table:
133            if hvar_table.AdvWidthMap:
134                width_idx = hvar_table.AdvWidthMap.mapping[gname]
135            else:
136                width_idx = gid
137            width_delta = otRound(varStoreInstancer[width_idx])
138        else:
139            width_delta = 0
140
141        # get LSB.
142        boundsPen.init()
143        charstring = charstrings[gname]
144        charstring.draw(boundsPen)
145        if boundsPen.bounds is None:
146            # Happens with non-marking glyphs
147            lsb_delta = 0
148        else:
149            lsb = otRound(boundsPen.bounds[0])
150            lsb_delta = entry[1] - lsb
151
152        if lsb_delta or width_delta:
153            if width_delta:
154                entry[0] = max(0, entry[0] + width_delta)
155            if lsb_delta:
156                entry[1] = lsb
157            hmtx[gname] = tuple(entry)
158
159
160def instantiateVariableFont(varfont, location, inplace=False, overlap=True):
161    """Generate a static instance from a variable TTFont and a dictionary
162    defining the desired location along the variable font's axes.
163    The location values must be specified as user-space coordinates, e.g.:
164
165            {'wght': 400, 'wdth': 100}
166
167    By default, a new TTFont object is returned. If ``inplace`` is True, the
168    input varfont is modified and reduced to a static font.
169
170    When the overlap parameter is defined as True,
171    OVERLAP_SIMPLE and OVERLAP_COMPOUND bits are set to 1.  See
172    https://docs.microsoft.com/en-us/typography/opentype/spec/glyf
173    """
174    if not inplace:
175        # make a copy to leave input varfont unmodified
176        stream = BytesIO()
177        varfont.save(stream)
178        stream.seek(0)
179        varfont = TTFont(stream)
180
181    fvar = varfont["fvar"]
182    axes = {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes}
183    loc = normalizeLocation(location, axes)
184    if "avar" in varfont:
185        maps = varfont["avar"].segments
186        loc = {k: piecewiseLinearMap(v, maps[k]) for k, v in loc.items()}
187    # Quantize to F2Dot14, to avoid surprise interpolations.
188    loc = {k: floatToFixedToFloat(v, 14) for k, v in loc.items()}
189    # Location is normalized now
190    log.info("Normalized location: %s", loc)
191
192    if "gvar" in varfont:
193        log.info("Mutating glyf/gvar tables")
194        gvar = varfont["gvar"]
195        glyf = varfont["glyf"]
196        hMetrics = varfont["hmtx"].metrics
197        vMetrics = getattr(varfont.get("vmtx"), "metrics", None)
198        # get list of glyph names in gvar sorted by component depth
199        glyphnames = sorted(
200            gvar.variations.keys(),
201            key=lambda name: (
202                (
203                    glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
204                    if glyf[name].isComposite() or glyf[name].isVarComposite()
205                    else 0
206                ),
207                name,
208            ),
209        )
210        for glyphname in glyphnames:
211            variations = gvar.variations[glyphname]
212            coordinates, _ = glyf._getCoordinatesAndControls(
213                glyphname, hMetrics, vMetrics
214            )
215            origCoords, endPts = None, None
216            for var in variations:
217                scalar = supportScalar(loc, var.axes)
218                if not scalar:
219                    continue
220                delta = var.coordinates
221                if None in delta:
222                    if origCoords is None:
223                        origCoords, g = glyf._getCoordinatesAndControls(
224                            glyphname, hMetrics, vMetrics
225                        )
226                    delta = iup_delta(delta, origCoords, g.endPts)
227                coordinates += GlyphCoordinates(delta) * scalar
228            glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics)
229    else:
230        glyf = None
231
232    if "DSIG" in varfont:
233        del varfont["DSIG"]
234
235    if "cvar" in varfont:
236        log.info("Mutating cvt/cvar tables")
237        cvar = varfont["cvar"]
238        cvt = varfont["cvt "]
239        deltas = {}
240        for var in cvar.variations:
241            scalar = supportScalar(loc, var.axes)
242            if not scalar:
243                continue
244            for i, c in enumerate(var.coordinates):
245                if c is not None:
246                    deltas[i] = deltas.get(i, 0) + scalar * c
247        for i, delta in deltas.items():
248            cvt[i] += otRound(delta)
249
250    if "CFF2" in varfont:
251        log.info("Mutating CFF2 table")
252        glyphOrder = varfont.getGlyphOrder()
253        CFF2 = varfont["CFF2"]
254        topDict = CFF2.cff.topDictIndex[0]
255        vsInstancer = VarStoreInstancer(topDict.VarStore.otVarStore, fvar.axes, loc)
256        interpolateFromDeltas = vsInstancer.interpolateFromDeltas
257        interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas)
258        CFF2.desubroutinize()
259        interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder)
260        interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc)
261        del topDict.rawDict["VarStore"]
262        del topDict.VarStore
263
264    if "MVAR" in varfont:
265        log.info("Mutating MVAR table")
266        mvar = varfont["MVAR"].table
267        varStoreInstancer = VarStoreInstancer(mvar.VarStore, fvar.axes, loc)
268        records = mvar.ValueRecord
269        for rec in records:
270            mvarTag = rec.ValueTag
271            if mvarTag not in MVAR_ENTRIES:
272                continue
273            tableTag, itemName = MVAR_ENTRIES[mvarTag]
274            delta = otRound(varStoreInstancer[rec.VarIdx])
275            if not delta:
276                continue
277            setattr(
278                varfont[tableTag],
279                itemName,
280                getattr(varfont[tableTag], itemName) + delta,
281            )
282
283    log.info("Mutating FeatureVariations")
284    for tableTag in "GSUB", "GPOS":
285        if not tableTag in varfont:
286            continue
287        table = varfont[tableTag].table
288        if not getattr(table, "FeatureVariations", None):
289            continue
290        variations = table.FeatureVariations
291        for record in variations.FeatureVariationRecord:
292            applies = True
293            for condition in record.ConditionSet.ConditionTable:
294                if condition.Format == 1:
295                    axisIdx = condition.AxisIndex
296                    axisTag = fvar.axes[axisIdx].axisTag
297                    Min = condition.FilterRangeMinValue
298                    Max = condition.FilterRangeMaxValue
299                    v = loc[axisTag]
300                    if not (Min <= v <= Max):
301                        applies = False
302                else:
303                    applies = False
304                if not applies:
305                    break
306
307            if applies:
308                assert record.FeatureTableSubstitution.Version == 0x00010000
309                for rec in record.FeatureTableSubstitution.SubstitutionRecord:
310                    table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = (
311                        rec.Feature
312                    )
313                break
314        del table.FeatureVariations
315
316    if "GDEF" in varfont and varfont["GDEF"].table.Version >= 0x00010003:
317        log.info("Mutating GDEF/GPOS/GSUB tables")
318        gdef = varfont["GDEF"].table
319        instancer = VarStoreInstancer(gdef.VarStore, fvar.axes, loc)
320
321        merger = MutatorMerger(varfont, instancer)
322        merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"])
323
324        # Downgrade GDEF.
325        del gdef.VarStore
326        gdef.Version = 0x00010002
327        if gdef.MarkGlyphSetsDef is None:
328            del gdef.MarkGlyphSetsDef
329            gdef.Version = 0x00010000
330
331        if not (
332            gdef.LigCaretList
333            or gdef.MarkAttachClassDef
334            or gdef.GlyphClassDef
335            or gdef.AttachList
336            or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef)
337        ):
338            del varfont["GDEF"]
339
340    addidef = False
341    if glyf:
342        for glyph in glyf.glyphs.values():
343            if hasattr(glyph, "program"):
344                instructions = glyph.program.getAssembly()
345                # If GETVARIATION opcode is used in bytecode of any glyph add IDEF
346                addidef = any(op.startswith("GETVARIATION") for op in instructions)
347                if addidef:
348                    break
349        if overlap:
350            for glyph_name in glyf.keys():
351                glyph = glyf[glyph_name]
352                # Set OVERLAP_COMPOUND bit for compound glyphs
353                if glyph.isComposite():
354                    glyph.components[0].flags |= OVERLAP_COMPOUND
355                # Set OVERLAP_SIMPLE bit for simple glyphs
356                elif glyph.numberOfContours > 0:
357                    glyph.flags[0] |= flagOverlapSimple
358    if addidef:
359        log.info("Adding IDEF to fpgm table for GETVARIATION opcode")
360        asm = []
361        if "fpgm" in varfont:
362            fpgm = varfont["fpgm"]
363            asm = fpgm.program.getAssembly()
364        else:
365            fpgm = newTable("fpgm")
366            fpgm.program = ttProgram.Program()
367            varfont["fpgm"] = fpgm
368        asm.append("PUSHB[000] 145")
369        asm.append("IDEF[ ]")
370        args = [str(len(loc))]
371        for a in fvar.axes:
372            args.append(str(floatToFixed(loc[a.axisTag], 14)))
373        asm.append("NPUSHW[ ] " + " ".join(args))
374        asm.append("ENDF[ ]")
375        fpgm.program.fromAssembly(asm)
376
377        # Change maxp attributes as IDEF is added
378        if "maxp" in varfont:
379            maxp = varfont["maxp"]
380            setattr(
381                maxp, "maxInstructionDefs", 1 + getattr(maxp, "maxInstructionDefs", 0)
382            )
383            setattr(
384                maxp,
385                "maxStackElements",
386                max(len(loc), getattr(maxp, "maxStackElements", 0)),
387            )
388
389    if "name" in varfont:
390        log.info("Pruning name table")
391        exclude = {a.axisNameID for a in fvar.axes}
392        for i in fvar.instances:
393            exclude.add(i.subfamilyNameID)
394            exclude.add(i.postscriptNameID)
395        if "ltag" in varfont:
396            # Drop the whole 'ltag' table if all its language tags are referenced by
397            # name records to be pruned.
398            # TODO: prune unused ltag tags and re-enumerate langIDs accordingly
399            excludedUnicodeLangIDs = [
400                n.langID
401                for n in varfont["name"].names
402                if n.nameID in exclude and n.platformID == 0 and n.langID != 0xFFFF
403            ]
404            if set(excludedUnicodeLangIDs) == set(range(len((varfont["ltag"].tags)))):
405                del varfont["ltag"]
406        varfont["name"].names[:] = [
407            n for n in varfont["name"].names if n.nameID not in exclude
408        ]
409
410    if "wght" in location and "OS/2" in varfont:
411        varfont["OS/2"].usWeightClass = otRound(max(1, min(location["wght"], 1000)))
412    if "wdth" in location:
413        wdth = location["wdth"]
414        for percent, widthClass in sorted(OS2_WIDTH_CLASS_VALUES.items()):
415            if wdth < percent:
416                varfont["OS/2"].usWidthClass = widthClass
417                break
418        else:
419            varfont["OS/2"].usWidthClass = 9
420    if "slnt" in location and "post" in varfont:
421        varfont["post"].italicAngle = max(-90, min(location["slnt"], 90))
422
423    log.info("Removing variable tables")
424    for tag in ("avar", "cvar", "fvar", "gvar", "HVAR", "MVAR", "VVAR", "STAT"):
425        if tag in varfont:
426            del varfont[tag]
427
428    return varfont
429
430
431def main(args=None):
432    """Instantiate a variation font"""
433    from fontTools import configLogger
434    import argparse
435
436    parser = argparse.ArgumentParser(
437        "fonttools varLib.mutator", description="Instantiate a variable font"
438    )
439    parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.")
440    parser.add_argument(
441        "locargs",
442        metavar="AXIS=LOC",
443        nargs="*",
444        help="List of space separated locations. A location consist in "
445        "the name of a variation axis, followed by '=' and a number. E.g.: "
446        " wght=700 wdth=80. The default is the location of the base master.",
447    )
448    parser.add_argument(
449        "-o",
450        "--output",
451        metavar="OUTPUT.ttf",
452        default=None,
453        help="Output instance TTF file (default: INPUT-instance.ttf).",
454    )
455    parser.add_argument(
456        "--no-recalc-timestamp",
457        dest="recalc_timestamp",
458        action="store_false",
459        help="Don't set the output font's timestamp to the current time.",
460    )
461    logging_group = parser.add_mutually_exclusive_group(required=False)
462    logging_group.add_argument(
463        "-v", "--verbose", action="store_true", help="Run more verbosely."
464    )
465    logging_group.add_argument(
466        "-q", "--quiet", action="store_true", help="Turn verbosity off."
467    )
468    parser.add_argument(
469        "--no-overlap",
470        dest="overlap",
471        action="store_false",
472        help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags.",
473    )
474    options = parser.parse_args(args)
475
476    varfilename = options.input
477    outfile = (
478        os.path.splitext(varfilename)[0] + "-instance.ttf"
479        if not options.output
480        else options.output
481    )
482    configLogger(
483        level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
484    )
485
486    loc = {}
487    for arg in options.locargs:
488        try:
489            tag, val = arg.split("=")
490            assert len(tag) <= 4
491            loc[tag.ljust(4)] = float(val)
492        except (ValueError, AssertionError):
493            parser.error("invalid location argument format: %r" % arg)
494    log.info("Location: %s", loc)
495
496    log.info("Loading variable font")
497    varfont = TTFont(varfilename, recalcTimestamp=options.recalc_timestamp)
498
499    instantiateVariableFont(varfont, loc, inplace=True, overlap=options.overlap)
500
501    log.info("Saving instance font %s", outfile)
502    varfont.save(outfile)
503
504
505if __name__ == "__main__":
506    import sys
507
508    if len(sys.argv) > 1:
509        sys.exit(main())
510    import doctest
511
512    sys.exit(doctest.testmod().failed)
513