1# Copyright 2016 Adobe. All rights reserved.
2
3# Methods:
4
5# Parse args. If glyphlist is from file, read in entire file as single string,
6# and remove all white space, then parse out glyph-names and GID's.
7
8# For each font name:
9#   Use fontTools library to open font and extract CFF table.
10#   If error, skip font and report error.
11#   Filter specified glyph list, if any, with list of glyphs in the font.
12#   Open font plist file, if any. If not, create empty font plist.
13#   Build alignment zone string
14#   For identifier in glyph-list:
15#     Get T2 charstring for glyph from parent font CFF table. If not present,
16#       report and skip.
17#     Get new alignment zone string if FDarray index (which font dict is used)
18#       has changed.
19#     Convert to bez
20#     Build autohint point list string; this is used to tell if glyph has been
21#       changed since the last time it was hinted.
22#     If requested, check against plist dict, and skip if glyph is already
23#       hinted or is manually hinted.
24#     Call autohint library on bez string.
25#     If change to the point list is permitted and happened, rebuild.
26#     Autohint point list string.
27#     Convert bez string to T2 charstring, and update parent font CFF.
28#     Add glyph hint entry to plist file
29#  Save font plist file.
30
31import ast
32import logging
33import os
34import re
35import time
36from collections import defaultdict, namedtuple
37
38from .otfFont import CFFFontData
39from .ufoFont import UFOFontData
40from ._psautohint import error as PsAutoHintCError
41
42from . import (get_font_format, hint_bez_glyph, hint_compatible_bez_glyphs,
43               FontParseError)
44
45log = logging.getLogger(__name__)
46
47
48class ACOptions(object):
49    def __init__(self):
50        self.inputPaths = []
51        self.outputPaths = []
52        self.reference_font = None
53        self.glyphList = []
54        self.nameAliases = {}
55        self.excludeGlyphList = False
56        self.hintAll = False
57        self.read_hints = False
58        self.allowChanges = False
59        self.noFlex = False
60        self.noHintSub = False
61        self.allow_no_blues = False
62        self.hCounterGlyphs = []
63        self.vCounterGlyphs = []
64        self.logOnly = False
65        self.printDefaultFDDict = False
66        self.printFDDictList = False
67        self.round_coords = True
68        self.writeToDefaultLayer = False
69        self.baseMaster = {}
70        self.font_format = None
71        self.report_zones = False
72        self.report_stems = False
73        self.report_all_stems = False
74
75    def __str__(self):
76        # used only when debugging.
77        import inspect
78        data = []
79        methodList = inspect.getmembers(self)
80        for fname, fvalue in methodList:
81            if fname[0] == "_":
82                continue
83            data.append(str((fname, fvalue)))
84        data.append("")
85        return os.linesep.join(data)
86
87
88class ACHintError(Exception):
89    pass
90
91
92class GlyphReports:
93    def __init__(self):
94        self.glyphs = {}
95
96    def addGlyphReport(self, glyphName, reportString):
97        hstems = {}
98        vstems = {}
99        hstems_pos = {}
100        vstems_pos = {}
101        char_zones = {}
102        stem_zone_stems = {}
103        self.glyphs[glyphName] = [hstems, vstems, char_zones, stem_zone_stems]
104
105        lines = reportString.splitlines()
106        for line in lines:
107            tokens = line.split()
108            key = tokens[0]
109            x = ast.literal_eval(tokens[3])
110            y = ast.literal_eval(tokens[5])
111            hintpos = "%s %s" % (x, y)
112            if key == "charZone":
113                char_zones[hintpos] = (x, y)
114            elif key == "stemZone":
115                stem_zone_stems[hintpos] = (x, y)
116            elif key == "HStem":
117                width = x - y
118                # avoid counting duplicates
119                if hintpos not in hstems_pos:
120                    count = hstems.get(width, 0)
121                    hstems[width] = count + 1
122                    hstems_pos[hintpos] = width
123            elif key == "VStem":
124                width = x - y
125                # avoid counting duplicates
126                if hintpos not in vstems_pos:
127                    count = vstems.get(width, 0)
128                    vstems[width] = count + 1
129                    vstems_pos[hintpos] = width
130            else:
131                raise FontParseError("Found unknown keyword %s in report file "
132                                     "for glyph %s." % (key, glyphName))
133
134    @staticmethod
135    def round_value(val):
136        if val >= 0:
137            return int(val + 0.5)
138        else:
139            return int(val - 0.5)
140
141    def parse_stem_dict(self, stem_dict):
142        """
143        stem_dict: {45.5: 1, 47.0: 2}
144        """
145        # key: stem width
146        # value: stem count
147        width_dict = defaultdict(int)
148        for width, count in stem_dict.items():
149            width = self.round_value(width)
150            width_dict[width] += count
151        return width_dict
152
153    def parse_zone_dicts(self, char_dict, stem_dict):
154        all_zones_dict = char_dict.copy()
155        all_zones_dict.update(stem_dict)
156        # key: zone height
157        # value: zone count
158        top_dict = defaultdict(int)
159        bot_dict = defaultdict(int)
160        for top, bot in all_zones_dict.values():
161            top = self.round_value(top)
162            top_dict[top] += 1
163            bot = self.round_value(bot)
164            bot_dict[bot] += 1
165        return top_dict, bot_dict
166
167    def assemble_rep_list(self, items_dict, count_dict):
168        # item 0: stem/zone count
169        # item 1: stem width/zone height
170        # item 2: list of glyph names
171        gorder = list(self.glyphs.keys())
172        rep_list = []
173        for item in items_dict:
174            gnames = list(items_dict[item])
175            # sort the names by the font's glyph order
176            if len(gnames) > 1:
177                gindexes = [gorder.index(gname) for gname in gnames]
178                gnames = [x for _, x in sorted(zip(gindexes, gnames))]
179            rep_list.append((count_dict[item], item, gnames))
180        return rep_list
181
182    def _get_lists(self):
183        """
184        self.glyphs is a dictionary:
185            key: glyph name
186            value: list of 4 dictionaries
187                   hstems
188                   vstems
189                   char_zones
190                   stem_zone_stems
191        {
192         'A': [{45.5: 1, 47.0: 2}, {229.0: 1}, {}, {}],
193         'B': [{46.0: 2, 46.5: 2, 47.0: 1}, {94.0: 1, 100.0: 1}, {}, {}],
194         'C': [{50.0: 2}, {109.0: 1}, {}, {}],
195         'D': [{46.0: 1, 46.5: 2, 47.0: 1}, {95.0: 1, 109.0: 1}, {}, {}],
196         'E': [{46.5: 2, 47.0: 1, 50.0: 2, 177.0: 1, 178.0: 1},
197               {46.0: 1, 75.5: 2, 95.0: 1}, {}, {}],
198         'F': [{46.5: 2, 47.0: 1, 50.0: 1, 177.0: 1},
199               {46.0: 1, 60.0: 1, 75.5: 1, 95.0: 1}, {}, {}],
200         'G': [{43.0: 1, 44.5: 1, 50.0: 1}, {94.0: 1, 109.0: 1}, {}, {}]
201        }
202        """
203        h_stem_items_dict = defaultdict(set)
204        h_stem_count_dict = defaultdict(int)
205        v_stem_items_dict = defaultdict(set)
206        v_stem_count_dict = defaultdict(int)
207
208        top_zone_items_dict = defaultdict(set)
209        top_zone_count_dict = defaultdict(int)
210        bot_zone_items_dict = defaultdict(set)
211        bot_zone_count_dict = defaultdict(int)
212
213        for gName, dicts in self.glyphs.items():
214            hStemDict, vStemDict, charZoneDict, stemZoneStemDict = dicts
215
216            glyph_h_stem_dict = self.parse_stem_dict(hStemDict)
217            glyph_v_stem_dict = self.parse_stem_dict(vStemDict)
218
219            for stem_width, stem_count in glyph_h_stem_dict.items():
220                h_stem_items_dict[stem_width].add(gName)
221                h_stem_count_dict[stem_width] += stem_count
222
223            for stem_width, stem_count in glyph_v_stem_dict.items():
224                v_stem_items_dict[stem_width].add(gName)
225                v_stem_count_dict[stem_width] += stem_count
226
227            glyph_top_zone_dict, glyph_bot_zone_dict = self.parse_zone_dicts(
228                charZoneDict, stemZoneStemDict)
229
230            for zone_height, zone_count in glyph_top_zone_dict.items():
231                top_zone_items_dict[zone_height].add(gName)
232                top_zone_count_dict[zone_height] += zone_count
233
234            for zone_height, zone_count in glyph_bot_zone_dict.items():
235                bot_zone_items_dict[zone_height].add(gName)
236                bot_zone_count_dict[zone_height] += zone_count
237
238        # item 0: stem count
239        # item 1: stem width
240        # item 2: list of glyph names
241        h_stem_list = self.assemble_rep_list(
242            h_stem_items_dict, h_stem_count_dict)
243
244        v_stem_list = self.assemble_rep_list(
245            v_stem_items_dict, v_stem_count_dict)
246
247        # item 0: zone count
248        # item 1: zone height
249        # item 2: list of glyph names
250        top_zone_list = self.assemble_rep_list(
251            top_zone_items_dict, top_zone_count_dict)
252
253        bot_zone_list = self.assemble_rep_list(
254            bot_zone_items_dict, bot_zone_count_dict)
255
256        return h_stem_list, v_stem_list, top_zone_list, bot_zone_list
257
258    @staticmethod
259    def _sort_count(t):
260        """
261        sort by: count (1st item), value (2nd item), list of glyph names (3rd
262        item)
263        """
264        return (-t[0], -t[1], t[2])
265
266    @staticmethod
267    def _sort_val(t):
268        """
269        sort by: value (2nd item), count (1st item), list of glyph names (3rd
270        item)
271        """
272        return (t[1], -t[0], t[2])
273
274    @staticmethod
275    def _sort_val_reversed(t):
276        """
277        sort by: value (2nd item), count (1st item), list of glyph names (3rd
278        item)
279        """
280        return (-t[1], -t[0], t[2])
281
282    def save(self, path):
283        h_stems, v_stems, top_zones, bot_zones = self._get_lists()
284        items = ([h_stems, self._sort_count],
285                 [v_stems, self._sort_count],
286                 [top_zones, self._sort_val_reversed],
287                 [bot_zones, self._sort_val])
288        atime = time.asctime()
289        suffixes = (".hstm.txt", ".vstm.txt", ".top.txt", ".bot.txt")
290        titles = ("Horizontal Stem List for %s on %s\n" % (path, atime),
291                  "Vertical Stem List for %s on %s\n" % (path, atime),
292                  "Top Zone List for %s on %s\n" % (path, atime),
293                  "Bottom Zone List for %s on %s\n" % (path, atime),
294                  )
295        headers = (["count    width    glyphs\n"] * 2 +
296                   ["count   height    glyphs\n"] * 2)
297
298        for i, item in enumerate(items):
299            reps, sortFunc = item
300            if not reps:
301                continue
302            fName = f'{path}{suffixes[i]}'
303            title = titles[i]
304            header = headers[i]
305            with open(fName, "w") as fp:
306                fp.write(title)
307                fp.write(header)
308                reps.sort(key=sortFunc)
309                for rep in reps:
310                    gnames = ' '.join(rep[2])
311                    fp.write(f"{rep[0]:5}    {rep[1]:5}    [{gnames}]\n")
312                log.info("Wrote %s" % fName)
313
314
315def getGlyphID(glyphTag, fontGlyphList):
316    if glyphTag in fontGlyphList:
317        return fontGlyphList.index(glyphTag)
318
319    return None
320
321
322def getGlyphNames(glyphTag, fontGlyphList, fontFileName):
323    glyphNameList = []
324    rangeList = glyphTag.split("-")
325    prevGID = getGlyphID(rangeList[0], fontGlyphList)
326    if prevGID is None:
327        if len(rangeList) > 1:
328            log.warning("glyph ID <%s> in range %s from glyph selection "
329                        "list option is not in font. <%s>.",
330                        rangeList[0], glyphTag, fontFileName)
331        else:
332            log.warning("glyph ID <%s> from glyph selection list option "
333                        "is not in font. <%s>.", rangeList[0], fontFileName)
334        return None
335    glyphNameList.append(fontGlyphList[prevGID])
336
337    for glyphTag2 in rangeList[1:]:
338        gid = getGlyphID(glyphTag2, fontGlyphList)
339        if gid is None:
340            log.warning("glyph ID <%s> in range %s from glyph selection "
341                        "list option is not in font. <%s>.",
342                        glyphTag2, glyphTag, fontFileName)
343            return None
344        for i in range(prevGID + 1, gid + 1):
345            glyphNameList.append(fontGlyphList[i])
346        prevGID = gid
347
348    return glyphNameList
349
350
351def filterGlyphList(options, fontGlyphList, fontFileName):
352    # Return the list of glyphs which are in the intersection of the argument
353    # list and the glyphs in the font.
354    # Complain about glyphs in the argument list which are not in the font.
355    if not options.glyphList:
356        glyphList = fontGlyphList
357    else:
358        # expand ranges:
359        glyphList = []
360        for glyphTag in options.glyphList:
361            glyphNames = getGlyphNames(glyphTag, fontGlyphList, fontFileName)
362            if glyphNames is not None:
363                glyphList.extend(glyphNames)
364        if options.excludeGlyphList:
365            newList = filter(lambda name: name not in glyphList, fontGlyphList)
366            glyphList = newList
367    return glyphList
368
369
370fontInfoKeywordList = [
371    'FontName',  # string
372    'OrigEmSqUnits',
373    'LanguageGroup',
374    'DominantV',  # array
375    'DominantH',  # array
376    'FlexOK',  # string
377    'BlueFuzz',
378    'VCounterChars',  # counter
379    'HCounterChars',  # counter
380    'BaselineYCoord',
381    'BaselineOvershoot',
382    'CapHeight',
383    'CapOvershoot',
384    'LcHeight',
385    'LcOvershoot',
386    'AscenderHeight',
387    'AscenderOvershoot',
388    'FigHeight',
389    'FigOvershoot',
390    'Height5',
391    'Height5Overshoot',
392    'Height6',
393    'Height6Overshoot',
394    'DescenderOvershoot',
395    'DescenderHeight',
396    'SuperiorOvershoot',
397    'SuperiorBaseline',
398    'OrdinalOvershoot',
399    'OrdinalBaseline',
400    'Baseline5Overshoot',
401    'Baseline5',
402    'Baseline6Overshoot',
403    'Baseline6',
404]
405
406integerPattern = r""" -?\d+"""
407arrayPattern = r""" \[[ ,0-9]+\]"""
408stringPattern = r""" \S+"""
409counterPattern = r""" \([\S ]+\)"""
410
411
412def printFontInfo(fontInfoString):
413    for item in fontInfoKeywordList:
414        if item in ['FontName', 'FlexOK']:
415            matchingExp = item + stringPattern
416        elif item in ['VCounterChars', 'HCounterChars']:
417            matchingExp = item + counterPattern
418        elif item in ['DominantV', 'DominantH']:
419            matchingExp = item + arrayPattern
420        else:
421            matchingExp = item + integerPattern
422
423        try:
424            print('\t%s' % re.search(matchingExp, fontInfoString).group())
425        except Exception:
426            pass
427
428
429def openFile(path, options):
430    font_format = get_font_format(path)
431    if font_format is None:
432        raise FontParseError(f"{path} is not a supported font format")
433
434    if font_format == "UFO":
435        font = UFOFontData(path, options.logOnly, options.writeToDefaultLayer)
436    else:
437        font = CFFFontData(path, font_format)
438
439    return font
440
441
442def get_glyph_list(options, font, path):
443    filename = os.path.basename(path)
444
445    # filter specified list, if any, with font list.
446    glyph_list = filterGlyphList(options, font.getGlyphList(), filename)
447    if not glyph_list:
448        raise FontParseError("Selected glyph list is empty for font <%s>." %
449                             filename)
450
451    return glyph_list
452
453
454def get_bez_glyphs(options, font, glyph_list):
455    glyphs = {}
456
457    for name in glyph_list:
458        # Convert to bez format
459        try:
460            bez_glyph = font.convertToBez(name, options.read_hints,
461                                          options.round_coords,
462                                          options.hintAll)
463
464            if bez_glyph is None or "mt" not in bez_glyph:
465                # skip empty glyphs.
466                continue
467        except KeyError:
468            # Source fonts may be sparse, e.g. be a subset of the
469            # reference font.
470            bez_glyph = None
471        glyphs[name] = GlyphEntry(bez_glyph, font)
472
473    total = len(glyph_list)
474    processed = len(glyphs)
475    if processed != total:
476        log.info("Skipped %s of %s glyphs.", total - processed, total)
477
478    return glyphs
479
480
481def get_fontinfo_list(options, font, path, glyph_list, is_var):
482
483    # Check counter glyphs, if any.
484    counter_glyphs = options.hCounterGlyphs + options.vCounterGlyphs
485    if counter_glyphs:
486        missing = [n for n in counter_glyphs if n not in font.getGlyphList()]
487        if missing:
488            log.error("H/VCounterChars glyph named in fontinfo is "
489                      "not in font: %s", missing)
490
491    # For Type1 name keyed fonts, psautohint supports defining
492    # different alignment zones for different glyphs by FontDict
493    # entries in the fontinfo file. This is NOT supported for CID
494    # or CFF2 fonts, as these have FDArrays, can can truly support
495    # different Font.Dict.Private Dicts for different groups of glyphs.
496    if font.hasFDArray():
497        return get_fontinfo_list_withFDArray(options, font, path,
498                                             glyph_list, is_var)
499    else:
500        return get_fontinfo_list_withFontInfo(options, font, path, glyph_list)
501
502
503def get_fontinfo_list_withFDArray(options, font, path, glyph_list, is_var):
504    lastFDIndex = None
505    fontinfo_list = {}
506    for name in glyph_list:
507        # get new fontinfo string if FDarray index has changed,
508        # as each FontDict has different alignment zones.
509        fdIndex = font.getfdIndex(name)
510        if not fdIndex == lastFDIndex:
511            lastFDIndex = fdIndex
512            fddict = font.getFontInfo(options.allow_no_blues,
513                                      options.noFlex,
514                                      options.vCounterGlyphs,
515                                      options.hCounterGlyphs,
516                                      fdIndex)
517            fontinfo = fddict.getFontInfo()
518        fontinfo_list[name] = (fontinfo, None, None)
519
520    return fontinfo_list
521
522
523def get_fontinfo_list_withFontInfo(options, font, path, glyph_list):
524    # Build alignment zone string
525    if options.printDefaultFDDict:
526        print("Showing default FDDict Values:")
527        fddict = font.getFontInfo(options.allow_no_blues,
528                                  options.noFlex,
529                                  options.vCounterGlyphs,
530                                  options.hCounterGlyphs)
531        printFontInfo(str(fddict))
532        return
533
534    fdglyphdict, fontDictList = font.getfdInfo(options.allow_no_blues,
535                                               options.noFlex,
536                                               options.vCounterGlyphs,
537                                               options.hCounterGlyphs,
538                                               glyph_list)
539
540    if options.printFDDictList:
541        # Print the user defined FontDicts, and exit.
542        if fdglyphdict:
543            print("Showing user-defined FontDict Values:\n")
544            for fi, fontDict in enumerate(fontDictList):
545                print(fontDict.DictName)
546                printFontInfo(str(fontDict))
547                gnameList = []
548                # item = [glyphName, [fdIndex, glyphListIndex]]
549                itemList = sorted(fdglyphdict.items(), key=lambda x: x[1][1])
550                for gName, entry in itemList:
551                    if entry[0] == fi:
552                        gnameList.append(gName)
553                print("%d glyphs:" % len(gnameList))
554                if len(gnameList) > 0:
555                    gTxt = " ".join(gnameList)
556                else:
557                    gTxt = "None"
558                print(gTxt + "\n")
559        else:
560            print("There are no user-defined FontDict Values.")
561        return
562
563    if fdglyphdict is None:
564        fddict = fontDictList[0]
565        fontinfo = fddict.getFontInfo()
566    else:
567        log.info("Using alternate FDDict global values from fontinfo "
568                 "file for some glyphs.")
569
570    lastFDIndex = None
571    fontinfo_list = {}
572    for name in glyph_list:
573        if fdglyphdict is not None:
574            fdIndex = fdglyphdict[name][0]
575            if lastFDIndex != fdIndex:
576                lastFDIndex = fdIndex
577                fddict = fontDictList[fdIndex]
578                fontinfo = fddict.getFontInfo()
579
580        fontinfo_list[name] = (fontinfo, fddict, fdglyphdict)
581
582    return fontinfo_list
583
584
585class MMHintInfo:
586    def __init__(self, glyph_name=None):
587        self.defined = False
588        self.h_order = None
589        self.v_order = None
590        self.hint_masks = []
591        self.glyph_name = glyph_name
592        # bad_hint_idxs contains the hint pair indices for all the bad
593        # hint pairs in any of the fonts for the current glyph.
594        self.bad_hint_idxs = set()
595        self.cntr_masks = []
596        self.new_cntr_masks = []
597        self.glyph_programs = None
598
599    @property
600    def needs_fix(self):
601        return len(self.bad_hint_idxs) > 0
602
603
604def hint_glyph(options, name, bez_glyph, fontinfo):
605    try:
606        hinted = hint_bez_glyph(fontinfo, bez_glyph, options.allowChanges,
607                                not options.noHintSub, options.round_coords,
608                                options.report_zones, options.report_stems,
609                                options.report_all_stems)
610    except PsAutoHintCError:
611        raise ACHintError("%s: Failure in processing outline data." %
612                          options.nameAliases.get(name, name))
613
614    return hinted
615
616
617def hint_compatible_glyphs(options, name, bez_glyphs, masters, fontinfo):
618    # This function is used by both
619    #   hint_with_reference_font->hint_compatible_fonts
620    # and hint_vf_font.
621    try:
622        ref_master = masters[0]
623        # *************************************************************
624        # *********** DO NOT DELETE THIS COMMENTED-OUT CODE ***********
625        # If you're tempted to "clean up", work on solving
626        # https://github.com/adobe-type-tools/psautohint/issues/202
627        # first, then you can uncomment the "hint_compatible_bez_glyphs"
628        # line and remove this and other related comments, as well as
629        # the workaround block following "# else:", below. Thanks.
630        # *************************************************************
631        #
632        # if False:
633        #     # This is disabled because it causes crashes on the CI servers
634        #     # which are not reproducible locally. The branch below is a hack
635        #     # to avoid the crash and should be dropped once the crash is
636        #     # fixed, https://github.com/adobe-type-tools/psautohint/pull/131
637        #     hinted = hint_compatible_bez_glyphs(
638        #         fontinfo, bez_glyphs, masters)
639        # *** see https://github.com/adobe-type-tools/psautohint/issues/202 ***
640        # else:
641        hinted = []
642        hinted_ref_bez = hint_glyph(options, name, bez_glyphs[0], fontinfo)
643        for i, bez in enumerate(bez_glyphs[1:]):
644            if bez is None:
645                out = [hinted_ref_bez, None]
646            else:
647                in_bez = [hinted_ref_bez, bez]
648                in_masters = [ref_master, masters[i + 1]]
649                out = hint_compatible_bez_glyphs(fontinfo,
650                                                 in_bez,
651                                                 in_masters)
652                # out is [hinted_ref_bez, new_hinted_region_bez]
653            if i == 0:
654                hinted = out
655            else:
656                hinted.append(out[1])
657    except PsAutoHintCError:
658        raise ACHintError("%s: Failure in processing outline data." %
659                          options.nameAliases.get(name, name))
660
661    return hinted
662
663
664def get_glyph_reports(options, font, glyph_list, fontinfo_list):
665    reports = GlyphReports()
666
667    glyphs = get_bez_glyphs(options, font, glyph_list)
668    for name in glyphs:
669        if name == ".notdef":
670            continue
671
672        bez_glyph = glyphs[name][0]
673        fontinfo = fontinfo_list[name][0]
674
675        report = hint_glyph(options, name, bez_glyph, fontinfo)
676        reports.addGlyphReport(name, report.strip())
677
678    return reports
679
680
681GlyphEntry = namedtuple("GlyphEntry", "bez_data,font")
682
683
684def hint_font(options, font, glyph_list, fontinfo_list):
685    aliases = options.nameAliases
686
687    hinted = {}
688    glyphs = get_bez_glyphs(options, font, glyph_list)
689    for name in glyphs:
690        g_entry = glyphs[name]
691        fontinfo, fddict, fdglyphdict = fontinfo_list[name]
692
693        if fdglyphdict:
694            log.info("%s: Begin hinting (using fdDict %s).",
695                     aliases.get(name, name), fddict.DictName)
696        else:
697            log.info("%s: Begin hinting.", aliases.get(name, name))
698
699        # Call auto-hint library on bez string.
700        new_bez_glyph = hint_glyph(options, name, g_entry.bez_data, fontinfo)
701        options.baseMaster[name] = new_bez_glyph
702
703        if not ("ry" in new_bez_glyph or "rb" in new_bez_glyph or
704                "rm" in new_bez_glyph or "rv" in new_bez_glyph):
705            log.info("%s: No hints added!", aliases.get(name, name))
706            continue
707
708        if options.logOnly:
709            continue
710
711        hinted[name] = GlyphEntry(new_bez_glyph, font)
712
713    return hinted
714
715
716def hint_compatible_fonts(options, paths, glyphs,
717                          fontinfo_list):
718    # glyphs is a list of dicts, one per font. Each dict is keyed by glyph name
719    # and references a tuple of (src bez file, font)
720    aliases = options.nameAliases
721
722    hinted_glyphs = set()
723    reference_font = None
724
725    for name in glyphs[0]:
726        fontinfo, _, _ = fontinfo_list[name]
727
728        log.info("%s: Begin hinting.", aliases.get(name, name))
729
730        masters = [os.path.basename(path) for path in paths]
731        bez_glyphs = [g[name].bez_data for g in glyphs]
732        new_bez_glyphs = hint_compatible_glyphs(options, name, bez_glyphs,
733                                                masters, fontinfo)
734        if options.logOnly:
735            continue
736
737        if reference_font is None:
738            fonts = [g[name].font for g in glyphs]
739            reference_font = fonts[0]
740        mm_hint_info = MMHintInfo()
741
742        for i, new_bez_glyph in enumerate(new_bez_glyphs):
743            if new_bez_glyph is not None:
744                g_entry = glyphs[i][name]
745                g_entry.font.updateFromBez(new_bez_glyph, name, mm_hint_info)
746
747        hinted_glyphs.add(name)
748        # Now check if we need to fix any hint lists.
749        if mm_hint_info.needs_fix:
750            reference_font.fix_glyph_hints(name, mm_hint_info,
751                                           is_reference_font=True)
752            for font in fonts[1:]:
753                font.fix_glyph_hints(name,
754                                     mm_hint_info,
755                                     is_reference_font=False)
756
757    return len(hinted_glyphs) > 0
758
759
760def hint_vf_font(options, font_path, out_path):
761    font = openFile(font_path, options)
762    options.noFlex = True  # work around for incompatibel flex args.
763    aliases = options.nameAliases
764    glyph_names = get_glyph_list(options, font, font_path)
765    log.info("Hinting font %s. Start time: %s.", font_path, time.asctime())
766    fontinfo_list = get_fontinfo_list(options, font, font_path,
767                                      glyph_names, True)
768    hinted_glyphs = set()
769
770    for name in glyph_names:
771        fontinfo, _, _ = fontinfo_list[name]
772        log.info("%s: Begin hinting.", aliases.get(name, name))
773
774        bez_glyphs = font.get_vf_bez_glyphs(name)
775        num_masters = len(bez_glyphs)
776        masters = [f"Master-{i}" for i in range(num_masters)]
777        new_bez_glyphs = hint_compatible_glyphs(options, name, bez_glyphs,
778                                                masters, fontinfo)
779        if None in new_bez_glyphs:
780            log.info(f"Error while hinting glyph {name}.")
781            continue
782        if options.logOnly:
783            continue
784        hinted_glyphs.add(name)
785
786        # First, convert bez to fontTools T2 programs,
787        # and check if any hints conflict.
788        mm_hint_info = MMHintInfo()
789        for i, new_bez_glyph in enumerate(new_bez_glyphs):
790            if new_bez_glyph is not None:
791                font.updateFromBez(new_bez_glyph, name, mm_hint_info)
792
793        # Now check if we need to fix any hint lists.
794        if mm_hint_info.needs_fix:
795            font.fix_glyph_hints(name, mm_hint_info)
796
797        # Now merge the programs into a singel CFF2 charstring program
798        font.merge_hinted_glyphs(name)
799
800    if hinted_glyphs:
801        log.info(f"Saving font file {out_path} with new hints...")
802        font.save(out_path)
803    else:
804        log.info("No glyphs were hinted.")
805        font.close()
806
807    log.info("Done with font %s. End time: %s.", font_path, time.asctime())
808
809
810def hint_with_reference_font(options, fonts, paths, outpaths):
811    # We are doing compatible, AKA multiple master, hinting.
812    log.info("Start time: %s.", time.asctime())
813    options.noFlex = True  # work-around for mm-hinting
814
815    # Get the glyphs and font info of the reference font. We assume the
816    # fonts have the same glyph set, glyph dict and in general are
817    # compatible. If not bad things will happen.
818    glyph_names = get_glyph_list(options, fonts[0], paths[0])
819    fontinfo_list = get_fontinfo_list(options, fonts[0], paths[0],
820                                      glyph_names, False)
821
822    glyphs = []
823    for i, font in enumerate(fonts):
824        glyphs.append(get_bez_glyphs(options, font, glyph_names))
825
826    have_hinted_glyphs = hint_compatible_fonts(options, paths,
827                                               glyphs, fontinfo_list)
828    if have_hinted_glyphs:
829        log.info("Saving font files with new hints...")
830
831        for i, font in enumerate(fonts):
832            font.save(outpaths[i])
833    else:
834        log.info("No glyphs were hinted.")
835        font.close()
836
837    log.info("End time: %s.", time.asctime())
838
839
840def hint_regular_fonts(options, fonts, paths, outpaths):
841    # Regular fonts, just iterate over the list and hint each one.
842    for i, font in enumerate(fonts):
843        path = paths[i]
844        outpath = outpaths[i]
845
846        glyph_names = get_glyph_list(options, font, path)
847        fontinfo_list = get_fontinfo_list(options, font, path, glyph_names,
848                                          False)
849
850        log.info("Hinting font %s. Start time: %s.", path, time.asctime())
851
852        if options.report_zones or options.report_stems:
853            reports = get_glyph_reports(options, font, glyph_names,
854                                        fontinfo_list)
855            reports.save(outpath)
856        else:
857            hinted = hint_font(options, font, glyph_names, fontinfo_list)
858            if hinted:
859                log.info("Saving font file with new hints...")
860                for name in hinted:
861                    g_entry = hinted[name]
862                    font.updateFromBez(g_entry.bez_data, name)
863                font.save(outpath)
864            else:
865                log.info("No glyphs were hinted.")
866                font.close()
867
868        log.info("Done with font %s. End time: %s.", path, time.asctime())
869
870
871def get_outpath(options, font_path, i):
872    if options.outputPaths is not None and i < len(options.outputPaths):
873        outpath = options.outputPaths[i]
874    else:
875        outpath = font_path
876    return outpath
877
878
879def hintFiles(options):
880    fonts = []
881    paths = []
882    outpaths = []
883    # If there is a reference font, prepend it to font paths.
884    # It must be the first font in the list, code below assumes that.
885    if options.reference_font:
886        font = openFile(options.reference_font, options)
887        fonts.append(font)
888        paths.append(options.reference_font)
889        outpaths.append(options.reference_font)
890        if hasattr(font, 'ttFont'):
891            assert 'fvar' not in font.ttFont, ("Can't use a CFF2 VF font as a "
892                                               "default font in a set of MM "
893                                               "fonts.")
894
895    # Open the rest of the fonts and handle output paths.
896    for i, path in enumerate(options.inputPaths):
897        font = openFile(path, options)
898        out_path = get_outpath(options, path, i)
899        if hasattr(font, 'ttFont') and 'fvar' in font.ttFont:
900            assert not options.report_zones or options.report_stems
901            # Certainly not supported now, also I think it only makes sense
902            # to ask for zone reports for the source fonts for the VF font.
903            # You can't easily change blue values in a VF font.
904            hint_vf_font(options, path, out_path)
905        else:
906            fonts.append(font)
907            paths.append(path)
908            outpaths.append(out_path)
909
910    if fonts:
911        if fonts[0].isCID():
912            options.noFlex = True  # Flex hinting in CJK fonts doed bad things.
913            # For CFF fonts, being a CID font is a good indicator of being CJK.
914
915        if options.reference_font:
916            hint_with_reference_font(options, fonts, paths, outpaths)
917        else:
918            hint_regular_fonts(options, fonts, paths, outpaths)
919