xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/cff.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from collections import namedtuple
2from fontTools.cffLib import (
3    maxStackLimit,
4    TopDictIndex,
5    buildOrder,
6    topDictOperators,
7    topDictOperators2,
8    privateDictOperators,
9    privateDictOperators2,
10    FDArrayIndex,
11    FontDict,
12    VarStoreData,
13)
14from io import BytesIO
15from fontTools.cffLib.specializer import specializeCommands, commandsToProgram
16from fontTools.ttLib import newTable
17from fontTools import varLib
18from fontTools.varLib.models import allEqual
19from fontTools.misc.roundTools import roundFunc
20from fontTools.misc.psCharStrings import T2CharString, T2OutlineExtractor
21from fontTools.pens.t2CharStringPen import T2CharStringPen
22from functools import partial
23
24from .errors import (
25    VarLibCFFDictMergeError,
26    VarLibCFFPointTypeMergeError,
27    VarLibCFFHintTypeMergeError,
28    VarLibMergeError,
29)
30
31
32# Backwards compatibility
33MergeDictError = VarLibCFFDictMergeError
34MergeTypeError = VarLibCFFPointTypeMergeError
35
36
37def addCFFVarStore(varFont, varModel, varDataList, masterSupports):
38    fvarTable = varFont["fvar"]
39    axisKeys = [axis.axisTag for axis in fvarTable.axes]
40    varTupleList = varLib.builder.buildVarRegionList(masterSupports, axisKeys)
41    varStoreCFFV = varLib.builder.buildVarStore(varTupleList, varDataList)
42
43    topDict = varFont["CFF2"].cff.topDictIndex[0]
44    topDict.VarStore = VarStoreData(otVarStore=varStoreCFFV)
45    if topDict.FDArray[0].vstore is None:
46        fdArray = topDict.FDArray
47        for fontDict in fdArray:
48            if hasattr(fontDict, "Private"):
49                fontDict.Private.vstore = topDict.VarStore
50
51
52def lib_convertCFFToCFF2(cff, otFont):
53    # This assumes a decompiled CFF table.
54    cff2GetGlyphOrder = cff.otFont.getGlyphOrder
55    topDictData = TopDictIndex(None, cff2GetGlyphOrder, None)
56    topDictData.items = cff.topDictIndex.items
57    cff.topDictIndex = topDictData
58    topDict = topDictData[0]
59    if hasattr(topDict, "Private"):
60        privateDict = topDict.Private
61    else:
62        privateDict = None
63    opOrder = buildOrder(topDictOperators2)
64    topDict.order = opOrder
65    topDict.cff2GetGlyphOrder = cff2GetGlyphOrder
66    if not hasattr(topDict, "FDArray"):
67        fdArray = topDict.FDArray = FDArrayIndex()
68        fdArray.strings = None
69        fdArray.GlobalSubrs = topDict.GlobalSubrs
70        topDict.GlobalSubrs.fdArray = fdArray
71        charStrings = topDict.CharStrings
72        if charStrings.charStringsAreIndexed:
73            charStrings.charStringsIndex.fdArray = fdArray
74        else:
75            charStrings.fdArray = fdArray
76        fontDict = FontDict()
77        fontDict.setCFF2(True)
78        fdArray.append(fontDict)
79        fontDict.Private = privateDict
80        privateOpOrder = buildOrder(privateDictOperators2)
81        if privateDict is not None:
82            for entry in privateDictOperators:
83                key = entry[1]
84                if key not in privateOpOrder:
85                    if key in privateDict.rawDict:
86                        # print "Removing private dict", key
87                        del privateDict.rawDict[key]
88                    if hasattr(privateDict, key):
89                        delattr(privateDict, key)
90                        # print "Removing privateDict attr", key
91    else:
92        # clean up the PrivateDicts in the fdArray
93        fdArray = topDict.FDArray
94        privateOpOrder = buildOrder(privateDictOperators2)
95        for fontDict in fdArray:
96            fontDict.setCFF2(True)
97            for key in list(fontDict.rawDict.keys()):
98                if key not in fontDict.order:
99                    del fontDict.rawDict[key]
100                    if hasattr(fontDict, key):
101                        delattr(fontDict, key)
102
103            privateDict = fontDict.Private
104            for entry in privateDictOperators:
105                key = entry[1]
106                if key not in privateOpOrder:
107                    if key in privateDict.rawDict:
108                        # print "Removing private dict", key
109                        del privateDict.rawDict[key]
110                    if hasattr(privateDict, key):
111                        delattr(privateDict, key)
112                        # print "Removing privateDict attr", key
113    # Now delete up the deprecated topDict operators from CFF 1.0
114    for entry in topDictOperators:
115        key = entry[1]
116        if key not in opOrder:
117            if key in topDict.rawDict:
118                del topDict.rawDict[key]
119            if hasattr(topDict, key):
120                delattr(topDict, key)
121
122    # At this point, the Subrs and Charstrings are all still T2Charstring class
123    # easiest to fix this by compiling, then decompiling again
124    cff.major = 2
125    file = BytesIO()
126    cff.compile(file, otFont, isCFF2=True)
127    file.seek(0)
128    cff.decompile(file, otFont, isCFF2=True)
129
130
131def convertCFFtoCFF2(varFont):
132    # Convert base font to a single master CFF2 font.
133    cffTable = varFont["CFF "]
134    lib_convertCFFToCFF2(cffTable.cff, varFont)
135    newCFF2 = newTable("CFF2")
136    newCFF2.cff = cffTable.cff
137    varFont["CFF2"] = newCFF2
138    del varFont["CFF "]
139
140
141def conv_to_int(num):
142    if isinstance(num, float) and num.is_integer():
143        return int(num)
144    return num
145
146
147pd_blend_fields = (
148    "BlueValues",
149    "OtherBlues",
150    "FamilyBlues",
151    "FamilyOtherBlues",
152    "BlueScale",
153    "BlueShift",
154    "BlueFuzz",
155    "StdHW",
156    "StdVW",
157    "StemSnapH",
158    "StemSnapV",
159)
160
161
162def get_private(regionFDArrays, fd_index, ri, fd_map):
163    region_fdArray = regionFDArrays[ri]
164    region_fd_map = fd_map[fd_index]
165    if ri in region_fd_map:
166        region_fdIndex = region_fd_map[ri]
167        private = region_fdArray[region_fdIndex].Private
168    else:
169        private = None
170    return private
171
172
173def merge_PrivateDicts(top_dicts, vsindex_dict, var_model, fd_map):
174    """
175    I step through the FontDicts in the FDArray of the varfont TopDict.
176    For each varfont FontDict:
177
178    * step through each key in FontDict.Private.
179    * For each key, step through each relevant source font Private dict, and
180            build a list of values to blend.
181
182    The 'relevant' source fonts are selected by first getting the right
183    submodel using ``vsindex_dict[vsindex]``. The indices of the
184    ``subModel.locations`` are mapped to source font list indices by
185    assuming the latter order is the same as the order of the
186    ``var_model.locations``. I can then get the index of each subModel
187    location in the list of ``var_model.locations``.
188    """
189
190    topDict = top_dicts[0]
191    region_top_dicts = top_dicts[1:]
192    if hasattr(region_top_dicts[0], "FDArray"):
193        regionFDArrays = [fdTopDict.FDArray for fdTopDict in region_top_dicts]
194    else:
195        regionFDArrays = [[fdTopDict] for fdTopDict in region_top_dicts]
196    for fd_index, font_dict in enumerate(topDict.FDArray):
197        private_dict = font_dict.Private
198        vsindex = getattr(private_dict, "vsindex", 0)
199        # At the moment, no PrivateDict has a vsindex key, but let's support
200        # how it should work. See comment at end of
201        # merge_charstrings() - still need to optimize use of vsindex.
202        sub_model, _ = vsindex_dict[vsindex]
203        master_indices = []
204        for loc in sub_model.locations[1:]:
205            i = var_model.locations.index(loc) - 1
206            master_indices.append(i)
207        pds = [private_dict]
208        last_pd = private_dict
209        for ri in master_indices:
210            pd = get_private(regionFDArrays, fd_index, ri, fd_map)
211            # If the region font doesn't have this FontDict, just reference
212            # the last one used.
213            if pd is None:
214                pd = last_pd
215            else:
216                last_pd = pd
217            pds.append(pd)
218        num_masters = len(pds)
219        for key, value in private_dict.rawDict.items():
220            dataList = []
221            if key not in pd_blend_fields:
222                continue
223            if isinstance(value, list):
224                try:
225                    values = [pd.rawDict[key] for pd in pds]
226                except KeyError:
227                    print(
228                        "Warning: {key} in default font Private dict is "
229                        "missing from another font, and was "
230                        "discarded.".format(key=key)
231                    )
232                    continue
233                try:
234                    values = zip(*values)
235                except IndexError:
236                    raise VarLibCFFDictMergeError(key, value, values)
237                """
238				Row 0 contains the first  value from each master.
239				Convert each row from absolute values to relative
240				values from the previous row.
241				e.g for three masters,	a list of values was:
242				master 0 OtherBlues = [-217,-205]
243				master 1 OtherBlues = [-234,-222]
244				master 1 OtherBlues = [-188,-176]
245				The call to zip() converts this to:
246				[(-217, -234, -188), (-205, -222, -176)]
247				and is converted finally to:
248				OtherBlues = [[-217, 17.0, 46.0], [-205, 0.0, 0.0]]
249				"""
250                prev_val_list = [0] * num_masters
251                any_points_differ = False
252                for val_list in values:
253                    rel_list = [
254                        (val - prev_val_list[i]) for (i, val) in enumerate(val_list)
255                    ]
256                    if (not any_points_differ) and not allEqual(rel_list):
257                        any_points_differ = True
258                    prev_val_list = val_list
259                    deltas = sub_model.getDeltas(rel_list)
260                    # For PrivateDict BlueValues, the default font
261                    # values are absolute, not relative to the prior value.
262                    deltas[0] = val_list[0]
263                    dataList.append(deltas)
264                # If there are no blend values,then
265                # we can collapse the blend lists.
266                if not any_points_differ:
267                    dataList = [data[0] for data in dataList]
268            else:
269                values = [pd.rawDict[key] for pd in pds]
270                if not allEqual(values):
271                    dataList = sub_model.getDeltas(values)
272                else:
273                    dataList = values[0]
274
275            # Convert numbers with no decimal part to an int
276            if isinstance(dataList, list):
277                for i, item in enumerate(dataList):
278                    if isinstance(item, list):
279                        for j, jtem in enumerate(item):
280                            dataList[i][j] = conv_to_int(jtem)
281                    else:
282                        dataList[i] = conv_to_int(item)
283            else:
284                dataList = conv_to_int(dataList)
285
286            private_dict.rawDict[key] = dataList
287
288
289def _cff_or_cff2(font):
290    if "CFF " in font:
291        return font["CFF "]
292    return font["CFF2"]
293
294
295def getfd_map(varFont, fonts_list):
296    """Since a subset source font may have fewer FontDicts in their
297    FDArray than the default font, we have to match up the FontDicts in
298    the different fonts . We do this with the FDSelect array, and by
299    assuming that the same glyph will reference  matching FontDicts in
300    each source font. We return a mapping from fdIndex in the default
301    font to a dictionary which maps each master list index of each
302    region font to the equivalent fdIndex in the region font."""
303    fd_map = {}
304    default_font = fonts_list[0]
305    region_fonts = fonts_list[1:]
306    num_regions = len(region_fonts)
307    topDict = _cff_or_cff2(default_font).cff.topDictIndex[0]
308    if not hasattr(topDict, "FDSelect"):
309        # All glyphs reference only one FontDict.
310        # Map the FD index for regions to index 0.
311        fd_map[0] = {ri: 0 for ri in range(num_regions)}
312        return fd_map
313
314    gname_mapping = {}
315    default_fdSelect = topDict.FDSelect
316    glyphOrder = default_font.getGlyphOrder()
317    for gid, fdIndex in enumerate(default_fdSelect):
318        gname_mapping[glyphOrder[gid]] = fdIndex
319        if fdIndex not in fd_map:
320            fd_map[fdIndex] = {}
321    for ri, region_font in enumerate(region_fonts):
322        region_glyphOrder = region_font.getGlyphOrder()
323        region_topDict = _cff_or_cff2(region_font).cff.topDictIndex[0]
324        if not hasattr(region_topDict, "FDSelect"):
325            # All the glyphs share the same FontDict. Pick any glyph.
326            default_fdIndex = gname_mapping[region_glyphOrder[0]]
327            fd_map[default_fdIndex][ri] = 0
328        else:
329            region_fdSelect = region_topDict.FDSelect
330            for gid, fdIndex in enumerate(region_fdSelect):
331                default_fdIndex = gname_mapping[region_glyphOrder[gid]]
332                region_map = fd_map[default_fdIndex]
333                if ri not in region_map:
334                    region_map[ri] = fdIndex
335    return fd_map
336
337
338CVarData = namedtuple("CVarData", "varDataList masterSupports vsindex_dict")
339
340
341def merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder):
342    topDict = varFont["CFF2"].cff.topDictIndex[0]
343    top_dicts = [topDict] + [
344        _cff_or_cff2(ttFont).cff.topDictIndex[0] for ttFont in ordered_fonts_list[1:]
345    ]
346    num_masters = len(model.mapping)
347    cvData = merge_charstrings(glyphOrder, num_masters, top_dicts, model)
348    fd_map = getfd_map(varFont, ordered_fonts_list)
349    merge_PrivateDicts(top_dicts, cvData.vsindex_dict, model, fd_map)
350    addCFFVarStore(varFont, model, cvData.varDataList, cvData.masterSupports)
351
352
353def _get_cs(charstrings, glyphName, filterEmpty=False):
354    if glyphName not in charstrings:
355        return None
356    cs = charstrings[glyphName]
357
358    if filterEmpty:
359        cs.decompile()
360        if cs.program == []:  # CFF2 empty charstring
361            return None
362        elif (
363            len(cs.program) <= 2
364            and cs.program[-1] == "endchar"
365            and (len(cs.program) == 1 or type(cs.program[0]) in (int, float))
366        ):  # CFF1 empty charstring
367            return None
368
369    return cs
370
371
372def _add_new_vsindex(
373    model, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList
374):
375    varTupleIndexes = []
376    for support in model.supports[1:]:
377        if support not in masterSupports:
378            masterSupports.append(support)
379        varTupleIndexes.append(masterSupports.index(support))
380    var_data = varLib.builder.buildVarData(varTupleIndexes, None, False)
381    vsindex = len(vsindex_dict)
382    vsindex_by_key[key] = vsindex
383    vsindex_dict[vsindex] = (model, [key])
384    varDataList.append(var_data)
385    return vsindex
386
387
388def merge_charstrings(glyphOrder, num_masters, top_dicts, masterModel):
389    vsindex_dict = {}
390    vsindex_by_key = {}
391    varDataList = []
392    masterSupports = []
393    default_charstrings = top_dicts[0].CharStrings
394    for gid, gname in enumerate(glyphOrder):
395        # interpret empty non-default masters as missing glyphs from a sparse master
396        all_cs = [
397            _get_cs(td.CharStrings, gname, i != 0) for i, td in enumerate(top_dicts)
398        ]
399        model, model_cs = masterModel.getSubModel(all_cs)
400        # create the first pass CFF2 charstring, from
401        # the default charstring.
402        default_charstring = model_cs[0]
403        var_pen = CFF2CharStringMergePen([], gname, num_masters, 0)
404        # We need to override outlineExtractor because these
405        # charstrings do have widths in the 'program'; we need to drop these
406        # values rather than post assertion error for them.
407        default_charstring.outlineExtractor = MergeOutlineExtractor
408        default_charstring.draw(var_pen)
409
410        # Add the coordinates from all the other regions to the
411        # blend lists in the CFF2 charstring.
412        region_cs = model_cs[1:]
413        for region_idx, region_charstring in enumerate(region_cs, start=1):
414            var_pen.restart(region_idx)
415            region_charstring.outlineExtractor = MergeOutlineExtractor
416            region_charstring.draw(var_pen)
417
418        # Collapse each coordinate list to a blend operator and its args.
419        new_cs = var_pen.getCharString(
420            private=default_charstring.private,
421            globalSubrs=default_charstring.globalSubrs,
422            var_model=model,
423            optimize=True,
424        )
425        default_charstrings[gname] = new_cs
426
427        if not region_cs:
428            continue
429
430        if (not var_pen.seen_moveto) or ("blend" not in new_cs.program):
431            # If this is not a marking glyph, or if there are no blend
432            # arguments, then we can use vsindex 0. No need to
433            # check if we need a new vsindex.
434            continue
435
436        # If the charstring required a new model, create
437        # a VarData table to go with, and set vsindex.
438        key = tuple(v is not None for v in all_cs)
439        try:
440            vsindex = vsindex_by_key[key]
441        except KeyError:
442            vsindex = _add_new_vsindex(
443                model, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList
444            )
445        # We do not need to check for an existing new_cs.private.vsindex,
446        # as we know it doesn't exist yet.
447        if vsindex != 0:
448            new_cs.program[:0] = [vsindex, "vsindex"]
449
450    # If there is no variation in any of the charstrings, then vsindex_dict
451    # never gets built. This could still be needed if there is variation
452    # in the PrivatDict, so we will build the default data for vsindex = 0.
453    if not vsindex_dict:
454        key = (True,) * num_masters
455        _add_new_vsindex(
456            masterModel, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList
457        )
458    cvData = CVarData(
459        varDataList=varDataList,
460        masterSupports=masterSupports,
461        vsindex_dict=vsindex_dict,
462    )
463    # XXX To do: optimize use of vsindex between the PrivateDicts and
464    # charstrings
465    return cvData
466
467
468class CFFToCFF2OutlineExtractor(T2OutlineExtractor):
469    """This class is used to remove the initial width from the CFF
470    charstring without trying to add the width to self.nominalWidthX,
471    which is None."""
472
473    def popallWidth(self, evenOdd=0):
474        args = self.popall()
475        if not self.gotWidth:
476            if evenOdd ^ (len(args) % 2):
477                args = args[1:]
478            self.width = self.defaultWidthX
479            self.gotWidth = 1
480        return args
481
482
483class MergeOutlineExtractor(CFFToCFF2OutlineExtractor):
484    """Used to extract the charstring commands - including hints - from a
485    CFF charstring in order to merge it as another set of region data
486    into a CFF2 variable font charstring."""
487
488    def __init__(
489        self,
490        pen,
491        localSubrs,
492        globalSubrs,
493        nominalWidthX,
494        defaultWidthX,
495        private=None,
496        blender=None,
497    ):
498        super().__init__(
499            pen, localSubrs, globalSubrs, nominalWidthX, defaultWidthX, private, blender
500        )
501
502    def countHints(self):
503        args = self.popallWidth()
504        self.hintCount = self.hintCount + len(args) // 2
505        return args
506
507    def _hint_op(self, type, args):
508        self.pen.add_hint(type, args)
509
510    def op_hstem(self, index):
511        args = self.countHints()
512        self._hint_op("hstem", args)
513
514    def op_vstem(self, index):
515        args = self.countHints()
516        self._hint_op("vstem", args)
517
518    def op_hstemhm(self, index):
519        args = self.countHints()
520        self._hint_op("hstemhm", args)
521
522    def op_vstemhm(self, index):
523        args = self.countHints()
524        self._hint_op("vstemhm", args)
525
526    def _get_hintmask(self, index):
527        if not self.hintMaskBytes:
528            args = self.countHints()
529            if args:
530                self._hint_op("vstemhm", args)
531            self.hintMaskBytes = (self.hintCount + 7) // 8
532        hintMaskBytes, index = self.callingStack[-1].getBytes(index, self.hintMaskBytes)
533        return index, hintMaskBytes
534
535    def op_hintmask(self, index):
536        index, hintMaskBytes = self._get_hintmask(index)
537        self.pen.add_hintmask("hintmask", [hintMaskBytes])
538        return hintMaskBytes, index
539
540    def op_cntrmask(self, index):
541        index, hintMaskBytes = self._get_hintmask(index)
542        self.pen.add_hintmask("cntrmask", [hintMaskBytes])
543        return hintMaskBytes, index
544
545
546class CFF2CharStringMergePen(T2CharStringPen):
547    """Pen to merge Type 2 CharStrings."""
548
549    def __init__(
550        self, default_commands, glyphName, num_masters, master_idx, roundTolerance=0.01
551    ):
552        # For roundTolerance see https://github.com/fonttools/fonttools/issues/2838
553        super().__init__(
554            width=None, glyphSet=None, CFF2=True, roundTolerance=roundTolerance
555        )
556        self.pt_index = 0
557        self._commands = default_commands
558        self.m_index = master_idx
559        self.num_masters = num_masters
560        self.prev_move_idx = 0
561        self.seen_moveto = False
562        self.glyphName = glyphName
563        self.round = roundFunc(roundTolerance, round=round)
564
565    def add_point(self, point_type, pt_coords):
566        if self.m_index == 0:
567            self._commands.append([point_type, [pt_coords]])
568        else:
569            cmd = self._commands[self.pt_index]
570            if cmd[0] != point_type:
571                raise VarLibCFFPointTypeMergeError(
572                    point_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName
573                )
574            cmd[1].append(pt_coords)
575        self.pt_index += 1
576
577    def add_hint(self, hint_type, args):
578        if self.m_index == 0:
579            self._commands.append([hint_type, [args]])
580        else:
581            cmd = self._commands[self.pt_index]
582            if cmd[0] != hint_type:
583                raise VarLibCFFHintTypeMergeError(
584                    hint_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName
585                )
586            cmd[1].append(args)
587        self.pt_index += 1
588
589    def add_hintmask(self, hint_type, abs_args):
590        # For hintmask, fonttools.cffLib.specializer.py expects
591        # each of these to be represented by two sequential commands:
592        # first holding only the operator name, with an empty arg list,
593        # second with an empty string as the op name, and the mask arg list.
594        if self.m_index == 0:
595            self._commands.append([hint_type, []])
596            self._commands.append(["", [abs_args]])
597        else:
598            cmd = self._commands[self.pt_index]
599            if cmd[0] != hint_type:
600                raise VarLibCFFHintTypeMergeError(
601                    hint_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName
602                )
603            self.pt_index += 1
604            cmd = self._commands[self.pt_index]
605            cmd[1].append(abs_args)
606        self.pt_index += 1
607
608    def _moveTo(self, pt):
609        if not self.seen_moveto:
610            self.seen_moveto = True
611        pt_coords = self._p(pt)
612        self.add_point("rmoveto", pt_coords)
613        # I set prev_move_idx here because add_point()
614        # can change self.pt_index.
615        self.prev_move_idx = self.pt_index - 1
616
617    def _lineTo(self, pt):
618        pt_coords = self._p(pt)
619        self.add_point("rlineto", pt_coords)
620
621    def _curveToOne(self, pt1, pt2, pt3):
622        _p = self._p
623        pt_coords = _p(pt1) + _p(pt2) + _p(pt3)
624        self.add_point("rrcurveto", pt_coords)
625
626    def _closePath(self):
627        pass
628
629    def _endPath(self):
630        pass
631
632    def restart(self, region_idx):
633        self.pt_index = 0
634        self.m_index = region_idx
635        self._p0 = (0, 0)
636
637    def getCommands(self):
638        return self._commands
639
640    def reorder_blend_args(self, commands, get_delta_func):
641        """
642        We first re-order the master coordinate values.
643        For a moveto to lineto, the args are now arranged as::
644
645                [ [master_0 x,y], [master_1 x,y], [master_2 x,y] ]
646
647        We re-arrange this to::
648
649                [	[master_0 x, master_1 x, master_2 x],
650                        [master_0 y, master_1 y, master_2 y]
651                ]
652
653        If the master values are all the same, we collapse the list to
654        as single value instead of a list.
655
656        We then convert this to::
657
658                [ [master_0 x] + [x delta tuple] + [numBlends=1]
659                  [master_0 y] + [y delta tuple] + [numBlends=1]
660                ]
661        """
662        for cmd in commands:
663            # arg[i] is the set of arguments for this operator from master i.
664            args = cmd[1]
665            m_args = zip(*args)
666            # m_args[n] is now all num_master args for the i'th argument
667            # for this operation.
668            cmd[1] = list(m_args)
669        lastOp = None
670        for cmd in commands:
671            op = cmd[0]
672            # masks are represented by two cmd's: first has only op names,
673            # second has only args.
674            if lastOp in ["hintmask", "cntrmask"]:
675                coord = list(cmd[1])
676                if not allEqual(coord):
677                    raise VarLibMergeError(
678                        "Hintmask values cannot differ between source fonts."
679                    )
680                cmd[1] = [coord[0][0]]
681            else:
682                coords = cmd[1]
683                new_coords = []
684                for coord in coords:
685                    if allEqual(coord):
686                        new_coords.append(coord[0])
687                    else:
688                        # convert to deltas
689                        deltas = get_delta_func(coord)[1:]
690                        coord = [coord[0]] + deltas
691                        coord.append(1)
692                        new_coords.append(coord)
693                cmd[1] = new_coords
694            lastOp = op
695        return commands
696
697    def getCharString(
698        self, private=None, globalSubrs=None, var_model=None, optimize=True
699    ):
700        commands = self._commands
701        commands = self.reorder_blend_args(
702            commands, partial(var_model.getDeltas, round=self.round)
703        )
704        if optimize:
705            commands = specializeCommands(
706                commands, generalizeFirst=False, maxstack=maxStackLimit
707            )
708        program = commandsToProgram(commands)
709        charString = T2CharString(
710            program=program, private=private, globalSubrs=globalSubrs
711        )
712        return charString
713