xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/interpolatable.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""
2Tool to find wrong contour order between different masters, and
3other interpolatability (or lack thereof) issues.
4
5Call as:
6$ fonttools varLib.interpolatable font1 font2 ...
7"""
8
9from .interpolatableHelpers import *
10from .interpolatableTestContourOrder import test_contour_order
11from .interpolatableTestStartingPoint import test_starting_point
12from fontTools.pens.recordingPen import (
13    RecordingPen,
14    DecomposingRecordingPen,
15    lerpRecordings,
16)
17from fontTools.pens.transformPen import TransformPen
18from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen
19from fontTools.pens.momentsPen import OpenContourError
20from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation
21from fontTools.misc.fixedTools import floatToFixedToStr
22from fontTools.misc.transform import Transform
23from collections import defaultdict
24from types import SimpleNamespace
25from functools import wraps
26from pprint import pformat
27from math import sqrt, atan2, pi
28import logging
29import os
30
31log = logging.getLogger("fontTools.varLib.interpolatable")
32
33DEFAULT_TOLERANCE = 0.95
34DEFAULT_KINKINESS = 0.5
35DEFAULT_KINKINESS_LENGTH = 0.002  # ratio of UPEM
36DEFAULT_UPEM = 1000
37
38
39class Glyph:
40    ITEMS = (
41        "recordings",
42        "greenStats",
43        "controlStats",
44        "greenVectors",
45        "controlVectors",
46        "nodeTypes",
47        "isomorphisms",
48        "points",
49        "openContours",
50    )
51
52    def __init__(self, glyphname, glyphset):
53        self.name = glyphname
54        for item in self.ITEMS:
55            setattr(self, item, [])
56        self._populate(glyphset)
57
58    def _fill_in(self, ix):
59        for item in self.ITEMS:
60            if len(getattr(self, item)) == ix:
61                getattr(self, item).append(None)
62
63    def _populate(self, glyphset):
64        glyph = glyphset[self.name]
65        self.doesnt_exist = glyph is None
66        if self.doesnt_exist:
67            return
68
69        perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
70        try:
71            glyph.draw(perContourPen, outputImpliedClosingLine=True)
72        except TypeError:
73            glyph.draw(perContourPen)
74        self.recordings = perContourPen.value
75        del perContourPen
76
77        for ix, contour in enumerate(self.recordings):
78            nodeTypes = [op for op, arg in contour.value]
79            self.nodeTypes.append(nodeTypes)
80
81            greenStats = StatisticsPen(glyphset=glyphset)
82            controlStats = StatisticsControlPen(glyphset=glyphset)
83            try:
84                contour.replay(greenStats)
85                contour.replay(controlStats)
86                self.openContours.append(False)
87            except OpenContourError as e:
88                self.openContours.append(True)
89                self._fill_in(ix)
90                continue
91            self.greenStats.append(greenStats)
92            self.controlStats.append(controlStats)
93            self.greenVectors.append(contour_vector_from_stats(greenStats))
94            self.controlVectors.append(contour_vector_from_stats(controlStats))
95
96            # Check starting point
97            if nodeTypes[0] == "addComponent":
98                self._fill_in(ix)
99                continue
100
101            assert nodeTypes[0] == "moveTo"
102            assert nodeTypes[-1] in ("closePath", "endPath")
103            points = SimpleRecordingPointPen()
104            converter = SegmentToPointPen(points, False)
105            contour.replay(converter)
106            # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve;
107            # now check all rotations and mirror-rotations of the contour and build list of isomorphic
108            # possible starting points.
109            self.points.append(points.value)
110
111            isomorphisms = []
112            self.isomorphisms.append(isomorphisms)
113
114            # Add rotations
115            add_isomorphisms(points.value, isomorphisms, False)
116            # Add mirrored rotations
117            add_isomorphisms(points.value, isomorphisms, True)
118
119    def draw(self, pen, countor_idx=None):
120        if countor_idx is None:
121            for contour in self.recordings:
122                contour.draw(pen)
123        else:
124            self.recordings[countor_idx].draw(pen)
125
126
127def test_gen(
128    glyphsets,
129    glyphs=None,
130    names=None,
131    ignore_missing=False,
132    *,
133    locations=None,
134    tolerance=DEFAULT_TOLERANCE,
135    kinkiness=DEFAULT_KINKINESS,
136    upem=DEFAULT_UPEM,
137    show_all=False,
138):
139    if tolerance >= 10:
140        tolerance *= 0.01
141    assert 0 <= tolerance <= 1
142    if kinkiness >= 10:
143        kinkiness *= 0.01
144    assert 0 <= kinkiness
145
146    names = names or [repr(g) for g in glyphsets]
147
148    if glyphs is None:
149        # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order
150        # ... risks the sparse master being the first one, and only processing a subset of the glyphs
151        glyphs = {g for glyphset in glyphsets for g in glyphset.keys()}
152
153    parents, order = find_parents_and_order(glyphsets, locations)
154
155    def grand_parent(i, glyphname):
156        if i is None:
157            return None
158        i = parents[i]
159        if i is None:
160            return None
161        while parents[i] is not None and glyphsets[i][glyphname] is None:
162            i = parents[i]
163        return i
164
165    for glyph_name in glyphs:
166        log.info("Testing glyph %s", glyph_name)
167        allGlyphs = [Glyph(glyph_name, glyphset) for glyphset in glyphsets]
168        if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
169            continue
170        for master_idx, (glyph, glyphset, name) in enumerate(
171            zip(allGlyphs, glyphsets, names)
172        ):
173            if glyph.doesnt_exist:
174                if not ignore_missing:
175                    yield (
176                        glyph_name,
177                        {
178                            "type": InterpolatableProblem.MISSING,
179                            "master": name,
180                            "master_idx": master_idx,
181                        },
182                    )
183                continue
184
185            has_open = False
186            for ix, open in enumerate(glyph.openContours):
187                if not open:
188                    continue
189                has_open = True
190                yield (
191                    glyph_name,
192                    {
193                        "type": InterpolatableProblem.OPEN_PATH,
194                        "master": name,
195                        "master_idx": master_idx,
196                        "contour": ix,
197                    },
198                )
199            if has_open:
200                continue
201
202        matchings = [None] * len(glyphsets)
203
204        for m1idx in order:
205            glyph1 = allGlyphs[m1idx]
206            if glyph1 is None or not glyph1.nodeTypes:
207                continue
208            m0idx = grand_parent(m1idx, glyph_name)
209            if m0idx is None:
210                continue
211            glyph0 = allGlyphs[m0idx]
212            if glyph0 is None or not glyph0.nodeTypes:
213                continue
214
215            #
216            # Basic compatibility checks
217            #
218
219            m1 = glyph0.nodeTypes
220            m0 = glyph1.nodeTypes
221            if len(m0) != len(m1):
222                yield (
223                    glyph_name,
224                    {
225                        "type": InterpolatableProblem.PATH_COUNT,
226                        "master_1": names[m0idx],
227                        "master_2": names[m1idx],
228                        "master_1_idx": m0idx,
229                        "master_2_idx": m1idx,
230                        "value_1": len(m0),
231                        "value_2": len(m1),
232                    },
233                )
234                continue
235
236            if m0 != m1:
237                for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
238                    if nodes1 == nodes2:
239                        continue
240                    if len(nodes1) != len(nodes2):
241                        yield (
242                            glyph_name,
243                            {
244                                "type": InterpolatableProblem.NODE_COUNT,
245                                "path": pathIx,
246                                "master_1": names[m0idx],
247                                "master_2": names[m1idx],
248                                "master_1_idx": m0idx,
249                                "master_2_idx": m1idx,
250                                "value_1": len(nodes1),
251                                "value_2": len(nodes2),
252                            },
253                        )
254                        continue
255                    for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
256                        if n1 != n2:
257                            yield (
258                                glyph_name,
259                                {
260                                    "type": InterpolatableProblem.NODE_INCOMPATIBILITY,
261                                    "path": pathIx,
262                                    "node": nodeIx,
263                                    "master_1": names[m0idx],
264                                    "master_2": names[m1idx],
265                                    "master_1_idx": m0idx,
266                                    "master_2_idx": m1idx,
267                                    "value_1": n1,
268                                    "value_2": n2,
269                                },
270                            )
271                            continue
272
273            #
274            # InterpolatableProblem.CONTOUR_ORDER check
275            #
276
277            this_tolerance, matching = test_contour_order(glyph0, glyph1)
278            if this_tolerance < tolerance:
279                yield (
280                    glyph_name,
281                    {
282                        "type": InterpolatableProblem.CONTOUR_ORDER,
283                        "master_1": names[m0idx],
284                        "master_2": names[m1idx],
285                        "master_1_idx": m0idx,
286                        "master_2_idx": m1idx,
287                        "value_1": list(range(len(matching))),
288                        "value_2": matching,
289                        "tolerance": this_tolerance,
290                    },
291                )
292                matchings[m1idx] = matching
293
294            #
295            # wrong-start-point / weight check
296            #
297
298            m0Isomorphisms = glyph0.isomorphisms
299            m1Isomorphisms = glyph1.isomorphisms
300            m0Vectors = glyph0.greenVectors
301            m1Vectors = glyph1.greenVectors
302            recording0 = glyph0.recordings
303            recording1 = glyph1.recordings
304
305            # If contour-order is wrong, adjust it
306            matching = matchings[m1idx]
307            if (
308                matching is not None and m1Isomorphisms
309            ):  # m1 is empty for composite glyphs
310                m1Isomorphisms = [m1Isomorphisms[i] for i in matching]
311                m1Vectors = [m1Vectors[i] for i in matching]
312                recording1 = [recording1[i] for i in matching]
313
314            midRecording = []
315            for c0, c1 in zip(recording0, recording1):
316                try:
317                    r = RecordingPen()
318                    r.value = list(lerpRecordings(c0.value, c1.value))
319                    midRecording.append(r)
320                except ValueError:
321                    # Mismatch because of the reordering above
322                    midRecording.append(None)
323
324            for ix, (contour0, contour1) in enumerate(
325                zip(m0Isomorphisms, m1Isomorphisms)
326            ):
327                if (
328                    contour0 is None
329                    or contour1 is None
330                    or len(contour0) == 0
331                    or len(contour0) != len(contour1)
332                ):
333                    # We already reported this; or nothing to do; or not compatible
334                    # after reordering above.
335                    continue
336
337                this_tolerance, proposed_point, reverse = test_starting_point(
338                    glyph0, glyph1, ix, tolerance, matching
339                )
340
341                if this_tolerance < tolerance:
342                    yield (
343                        glyph_name,
344                        {
345                            "type": InterpolatableProblem.WRONG_START_POINT,
346                            "contour": ix,
347                            "master_1": names[m0idx],
348                            "master_2": names[m1idx],
349                            "master_1_idx": m0idx,
350                            "master_2_idx": m1idx,
351                            "value_1": 0,
352                            "value_2": proposed_point,
353                            "reversed": reverse,
354                            "tolerance": this_tolerance,
355                        },
356                    )
357
358                # Weight check.
359                #
360                # If contour could be mid-interpolated, and the two
361                # contours have the same area sign, proceeed.
362                #
363                # The sign difference can happen if it's a weirdo
364                # self-intersecting contour; ignore it.
365                contour = midRecording[ix]
366
367                if contour and (m0Vectors[ix][0] < 0) == (m1Vectors[ix][0] < 0):
368                    midStats = StatisticsPen(glyphset=None)
369                    contour.replay(midStats)
370
371                    midVector = contour_vector_from_stats(midStats)
372
373                    m0Vec = m0Vectors[ix]
374                    m1Vec = m1Vectors[ix]
375                    size0 = m0Vec[0] * m0Vec[0]
376                    size1 = m1Vec[0] * m1Vec[0]
377                    midSize = midVector[0] * midVector[0]
378
379                    for overweight, problem_type in enumerate(
380                        (
381                            InterpolatableProblem.UNDERWEIGHT,
382                            InterpolatableProblem.OVERWEIGHT,
383                        )
384                    ):
385                        if overweight:
386                            expectedSize = max(size0, size1)
387                            continue
388                        else:
389                            expectedSize = sqrt(size0 * size1)
390
391                        log.debug(
392                            "%s: actual size %g; threshold size %g, master sizes: %g, %g",
393                            problem_type,
394                            midSize,
395                            expectedSize,
396                            size0,
397                            size1,
398                        )
399
400                        if (
401                            not overweight and expectedSize * tolerance > midSize + 1e-5
402                        ) or (overweight and 1e-5 + expectedSize / tolerance < midSize):
403                            try:
404                                if overweight:
405                                    this_tolerance = expectedSize / midSize
406                                else:
407                                    this_tolerance = midSize / expectedSize
408                            except ZeroDivisionError:
409                                this_tolerance = 0
410                            log.debug("tolerance %g", this_tolerance)
411                            yield (
412                                glyph_name,
413                                {
414                                    "type": problem_type,
415                                    "contour": ix,
416                                    "master_1": names[m0idx],
417                                    "master_2": names[m1idx],
418                                    "master_1_idx": m0idx,
419                                    "master_2_idx": m1idx,
420                                    "tolerance": this_tolerance,
421                                },
422                            )
423
424            #
425            # "kink" detector
426            #
427            m0 = glyph0.points
428            m1 = glyph1.points
429
430            # If contour-order is wrong, adjust it
431            if matchings[m1idx] is not None and m1:  # m1 is empty for composite glyphs
432                m1 = [m1[i] for i in matchings[m1idx]]
433
434            t = 0.1  # ~sin(radian(6)) for tolerance 0.95
435            deviation_threshold = (
436                upem * DEFAULT_KINKINESS_LENGTH * DEFAULT_KINKINESS / kinkiness
437            )
438
439            for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
440                if (
441                    contour0 is None
442                    or contour1 is None
443                    or len(contour0) == 0
444                    or len(contour0) != len(contour1)
445                ):
446                    # We already reported this; or nothing to do; or not compatible
447                    # after reordering above.
448                    continue
449
450                # Walk the contour, keeping track of three consecutive points, with
451                # middle one being an on-curve. If the three are co-linear then
452                # check for kinky-ness.
453                for i in range(len(contour0)):
454                    pt0 = contour0[i]
455                    pt1 = contour1[i]
456                    if not pt0[1] or not pt1[1]:
457                        # Skip off-curves
458                        continue
459                    pt0_prev = contour0[i - 1]
460                    pt1_prev = contour1[i - 1]
461                    pt0_next = contour0[(i + 1) % len(contour0)]
462                    pt1_next = contour1[(i + 1) % len(contour1)]
463
464                    if pt0_prev[1] and pt1_prev[1]:
465                        # At least one off-curve is required
466                        continue
467                    if pt0_prev[1] and pt1_prev[1]:
468                        # At least one off-curve is required
469                        continue
470
471                    pt0 = complex(*pt0[0])
472                    pt1 = complex(*pt1[0])
473                    pt0_prev = complex(*pt0_prev[0])
474                    pt1_prev = complex(*pt1_prev[0])
475                    pt0_next = complex(*pt0_next[0])
476                    pt1_next = complex(*pt1_next[0])
477
478                    # We have three consecutive points. Check whether
479                    # they are colinear.
480                    d0_prev = pt0 - pt0_prev
481                    d0_next = pt0_next - pt0
482                    d1_prev = pt1 - pt1_prev
483                    d1_next = pt1_next - pt1
484
485                    sin0 = d0_prev.real * d0_next.imag - d0_prev.imag * d0_next.real
486                    sin1 = d1_prev.real * d1_next.imag - d1_prev.imag * d1_next.real
487                    try:
488                        sin0 /= abs(d0_prev) * abs(d0_next)
489                        sin1 /= abs(d1_prev) * abs(d1_next)
490                    except ZeroDivisionError:
491                        continue
492
493                    if abs(sin0) > t or abs(sin1) > t:
494                        # Not colinear / not smooth.
495                        continue
496
497                    # Check the mid-point is actually, well, in the middle.
498                    dot0 = d0_prev.real * d0_next.real + d0_prev.imag * d0_next.imag
499                    dot1 = d1_prev.real * d1_next.real + d1_prev.imag * d1_next.imag
500                    if dot0 < 0 or dot1 < 0:
501                        # Sharp corner.
502                        continue
503
504                    # Fine, if handle ratios are similar...
505                    r0 = abs(d0_prev) / (abs(d0_prev) + abs(d0_next))
506                    r1 = abs(d1_prev) / (abs(d1_prev) + abs(d1_next))
507                    r_diff = abs(r0 - r1)
508                    if abs(r_diff) < t:
509                        # Smooth enough.
510                        continue
511
512                    mid = (pt0 + pt1) / 2
513                    mid_prev = (pt0_prev + pt1_prev) / 2
514                    mid_next = (pt0_next + pt1_next) / 2
515
516                    mid_d0 = mid - mid_prev
517                    mid_d1 = mid_next - mid
518
519                    sin_mid = mid_d0.real * mid_d1.imag - mid_d0.imag * mid_d1.real
520                    try:
521                        sin_mid /= abs(mid_d0) * abs(mid_d1)
522                    except ZeroDivisionError:
523                        continue
524
525                    # ...or if the angles are similar.
526                    if abs(sin_mid) * (tolerance * kinkiness) <= t:
527                        # Smooth enough.
528                        continue
529
530                    # How visible is the kink?
531
532                    cross = sin_mid * abs(mid_d0) * abs(mid_d1)
533                    arc_len = abs(mid_d0 + mid_d1)
534                    deviation = abs(cross / arc_len)
535                    if deviation < deviation_threshold:
536                        continue
537                    deviation_ratio = deviation / arc_len
538                    if deviation_ratio > t:
539                        continue
540
541                    this_tolerance = t / (abs(sin_mid) * kinkiness)
542
543                    log.debug(
544                        "kink: deviation %g; deviation_ratio %g; sin_mid %g; r_diff %g",
545                        deviation,
546                        deviation_ratio,
547                        sin_mid,
548                        r_diff,
549                    )
550                    log.debug("tolerance %g", this_tolerance)
551                    yield (
552                        glyph_name,
553                        {
554                            "type": InterpolatableProblem.KINK,
555                            "contour": ix,
556                            "master_1": names[m0idx],
557                            "master_2": names[m1idx],
558                            "master_1_idx": m0idx,
559                            "master_2_idx": m1idx,
560                            "value": i,
561                            "tolerance": this_tolerance,
562                        },
563                    )
564
565            #
566            # --show-all
567            #
568
569            if show_all:
570                yield (
571                    glyph_name,
572                    {
573                        "type": InterpolatableProblem.NOTHING,
574                        "master_1": names[m0idx],
575                        "master_2": names[m1idx],
576                        "master_1_idx": m0idx,
577                        "master_2_idx": m1idx,
578                    },
579                )
580
581
582@wraps(test_gen)
583def test(*args, **kwargs):
584    problems = defaultdict(list)
585    for glyphname, problem in test_gen(*args, **kwargs):
586        problems[glyphname].append(problem)
587    return problems
588
589
590def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf):
591    if glyphname in glyphset:
592        return
593    glyphset[glyphname] = ttGlyphSet[glyphname]
594
595    for component in getattr(glyf[glyphname], "components", []):
596        recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf)
597
598
599def ensure_parent_dir(path):
600    dirname = os.path.dirname(path)
601    if dirname:
602        os.makedirs(dirname, exist_ok=True)
603    return path
604
605
606def main(args=None):
607    """Test for interpolatability issues between fonts"""
608    import argparse
609    import sys
610
611    parser = argparse.ArgumentParser(
612        "fonttools varLib.interpolatable",
613        description=main.__doc__,
614    )
615    parser.add_argument(
616        "--glyphs",
617        action="store",
618        help="Space-separate name of glyphs to check",
619    )
620    parser.add_argument(
621        "--show-all",
622        action="store_true",
623        help="Show all glyph pairs, even if no problems are found",
624    )
625    parser.add_argument(
626        "--tolerance",
627        action="store",
628        type=float,
629        help="Error tolerance. Between 0 and 1. Default %s" % DEFAULT_TOLERANCE,
630    )
631    parser.add_argument(
632        "--kinkiness",
633        action="store",
634        type=float,
635        help="How aggressively report kinks. Default %s" % DEFAULT_KINKINESS,
636    )
637    parser.add_argument(
638        "--json",
639        action="store_true",
640        help="Output report in JSON format",
641    )
642    parser.add_argument(
643        "--pdf",
644        action="store",
645        help="Output report in PDF format",
646    )
647    parser.add_argument(
648        "--ps",
649        action="store",
650        help="Output report in PostScript format",
651    )
652    parser.add_argument(
653        "--html",
654        action="store",
655        help="Output report in HTML format",
656    )
657    parser.add_argument(
658        "--quiet",
659        action="store_true",
660        help="Only exit with code 1 or 0, no output",
661    )
662    parser.add_argument(
663        "--output",
664        action="store",
665        help="Output file for the problem report; Default: stdout",
666    )
667    parser.add_argument(
668        "--ignore-missing",
669        action="store_true",
670        help="Will not report glyphs missing from sparse masters as errors",
671    )
672    parser.add_argument(
673        "inputs",
674        metavar="FILE",
675        type=str,
676        nargs="+",
677        help="Input a single variable font / DesignSpace / Glyphs file, or multiple TTF/UFO files",
678    )
679    parser.add_argument(
680        "--name",
681        metavar="NAME",
682        type=str,
683        action="append",
684        help="Name of the master to use in the report. If not provided, all are used.",
685    )
686    parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.")
687    parser.add_argument("--debug", action="store_true", help="Run with debug output.")
688
689    args = parser.parse_args(args)
690
691    from fontTools import configLogger
692
693    configLogger(level=("INFO" if args.verbose else "ERROR"))
694    if args.debug:
695        configLogger(level="DEBUG")
696
697    glyphs = args.glyphs.split() if args.glyphs else None
698
699    from os.path import basename
700
701    fonts = []
702    names = []
703    locations = []
704    upem = DEFAULT_UPEM
705
706    original_args_inputs = tuple(args.inputs)
707
708    if len(args.inputs) == 1:
709        designspace = None
710        if args.inputs[0].endswith(".designspace"):
711            from fontTools.designspaceLib import DesignSpaceDocument
712
713            designspace = DesignSpaceDocument.fromfile(args.inputs[0])
714            args.inputs = [master.path for master in designspace.sources]
715            locations = [master.location for master in designspace.sources]
716            axis_triples = {
717                a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes
718            }
719            axis_mappings = {a.name: a.map for a in designspace.axes}
720            axis_triples = {
721                k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
722                for k, vv in axis_triples.items()
723            }
724
725        elif args.inputs[0].endswith((".glyphs", ".glyphspackage")):
726            from glyphsLib import GSFont, to_designspace
727
728            gsfont = GSFont(args.inputs[0])
729            upem = gsfont.upm
730            designspace = to_designspace(gsfont)
731            fonts = [source.font for source in designspace.sources]
732            names = ["%s-%s" % (f.info.familyName, f.info.styleName) for f in fonts]
733            args.inputs = []
734            locations = [master.location for master in designspace.sources]
735            axis_triples = {
736                a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes
737            }
738            axis_mappings = {a.name: a.map for a in designspace.axes}
739            axis_triples = {
740                k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
741                for k, vv in axis_triples.items()
742            }
743
744        elif args.inputs[0].endswith(".ttf"):
745            from fontTools.ttLib import TTFont
746
747            font = TTFont(args.inputs[0])
748            upem = font["head"].unitsPerEm
749            if "gvar" in font:
750                # Is variable font
751
752                axisMapping = {}
753                fvar = font["fvar"]
754                for axis in fvar.axes:
755                    axisMapping[axis.axisTag] = {
756                        -1: axis.minValue,
757                        0: axis.defaultValue,
758                        1: axis.maxValue,
759                    }
760                if "avar" in font:
761                    avar = font["avar"]
762                    for axisTag, segments in avar.segments.items():
763                        fvarMapping = axisMapping[axisTag].copy()
764                        for location, value in segments.items():
765                            axisMapping[axisTag][value] = piecewiseLinearMap(
766                                location, fvarMapping
767                            )
768
769                gvar = font["gvar"]
770                glyf = font["glyf"]
771                # Gather all glyphs at their "master" locations
772                ttGlyphSets = {}
773                glyphsets = defaultdict(dict)
774
775                if glyphs is None:
776                    glyphs = sorted(gvar.variations.keys())
777                for glyphname in glyphs:
778                    for var in gvar.variations[glyphname]:
779                        locDict = {}
780                        loc = []
781                        for tag, val in sorted(var.axes.items()):
782                            locDict[tag] = val[1]
783                            loc.append((tag, val[1]))
784
785                        locTuple = tuple(loc)
786                        if locTuple not in ttGlyphSets:
787                            ttGlyphSets[locTuple] = font.getGlyphSet(
788                                location=locDict, normalized=True, recalcBounds=False
789                            )
790
791                        recursivelyAddGlyph(
792                            glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf
793                        )
794
795                names = ["''"]
796                fonts = [font.getGlyphSet()]
797                locations = [{}]
798                axis_triples = {a: (-1, 0, +1) for a in sorted(axisMapping.keys())}
799                for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)):
800                    name = (
801                        "'"
802                        + " ".join(
803                            "%s=%s"
804                            % (
805                                k,
806                                floatToFixedToStr(
807                                    piecewiseLinearMap(v, axisMapping[k]), 14
808                                ),
809                            )
810                            for k, v in locTuple
811                        )
812                        + "'"
813                    )
814                    names.append(name)
815                    fonts.append(glyphsets[locTuple])
816                    locations.append(dict(locTuple))
817                args.ignore_missing = True
818                args.inputs = []
819
820    if not locations:
821        locations = [{} for _ in fonts]
822
823    for filename in args.inputs:
824        if filename.endswith(".ufo"):
825            from fontTools.ufoLib import UFOReader
826
827            font = UFOReader(filename)
828            info = SimpleNamespace()
829            font.readInfo(info)
830            upem = info.unitsPerEm
831            fonts.append(font)
832        else:
833            from fontTools.ttLib import TTFont
834
835            font = TTFont(filename)
836            upem = font["head"].unitsPerEm
837            fonts.append(font)
838
839        names.append(basename(filename).rsplit(".", 1)[0])
840
841    glyphsets = []
842    for font in fonts:
843        if hasattr(font, "getGlyphSet"):
844            glyphset = font.getGlyphSet()
845        else:
846            glyphset = font
847        glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
848
849    if args.name:
850        accepted_names = set(args.name)
851        glyphsets = [
852            glyphset
853            for name, glyphset in zip(names, glyphsets)
854            if name in accepted_names
855        ]
856        locations = [
857            location
858            for name, location in zip(names, locations)
859            if name in accepted_names
860        ]
861        names = [name for name in names if name in accepted_names]
862
863    if not glyphs:
864        glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()]))
865
866    glyphsSet = set(glyphs)
867    for glyphset in glyphsets:
868        glyphSetGlyphNames = set(glyphset.keys())
869        diff = glyphsSet - glyphSetGlyphNames
870        if diff:
871            for gn in diff:
872                glyphset[gn] = None
873
874    # Normalize locations
875    locations = [normalizeLocation(loc, axis_triples) for loc in locations]
876    tolerance = args.tolerance or DEFAULT_TOLERANCE
877    kinkiness = args.kinkiness if args.kinkiness is not None else DEFAULT_KINKINESS
878
879    try:
880        log.info("Running on %d glyphsets", len(glyphsets))
881        log.info("Locations: %s", pformat(locations))
882        problems_gen = test_gen(
883            glyphsets,
884            glyphs=glyphs,
885            names=names,
886            locations=locations,
887            upem=upem,
888            ignore_missing=args.ignore_missing,
889            tolerance=tolerance,
890            kinkiness=kinkiness,
891            show_all=args.show_all,
892        )
893        problems = defaultdict(list)
894
895        f = (
896            sys.stdout
897            if args.output is None
898            else open(ensure_parent_dir(args.output), "w")
899        )
900
901        if not args.quiet:
902            if args.json:
903                import json
904
905                for glyphname, problem in problems_gen:
906                    problems[glyphname].append(problem)
907
908                print(json.dumps(problems), file=f)
909            else:
910                last_glyphname = None
911                for glyphname, p in problems_gen:
912                    problems[glyphname].append(p)
913
914                    if glyphname != last_glyphname:
915                        print(f"Glyph {glyphname} was not compatible:", file=f)
916                        last_glyphname = glyphname
917                        last_master_idxs = None
918
919                    master_idxs = (
920                        (p["master_idx"])
921                        if "master_idx" in p
922                        else (p["master_1_idx"], p["master_2_idx"])
923                    )
924                    if master_idxs != last_master_idxs:
925                        master_names = (
926                            (p["master"])
927                            if "master" in p
928                            else (p["master_1"], p["master_2"])
929                        )
930                        print(f"  Masters: %s:" % ", ".join(master_names), file=f)
931                        last_master_idxs = master_idxs
932
933                    if p["type"] == InterpolatableProblem.MISSING:
934                        print(
935                            "    Glyph was missing in master %s" % p["master"], file=f
936                        )
937                    elif p["type"] == InterpolatableProblem.OPEN_PATH:
938                        print(
939                            "    Glyph has an open path in master %s" % p["master"],
940                            file=f,
941                        )
942                    elif p["type"] == InterpolatableProblem.PATH_COUNT:
943                        print(
944                            "    Path count differs: %i in %s, %i in %s"
945                            % (
946                                p["value_1"],
947                                p["master_1"],
948                                p["value_2"],
949                                p["master_2"],
950                            ),
951                            file=f,
952                        )
953                    elif p["type"] == InterpolatableProblem.NODE_COUNT:
954                        print(
955                            "    Node count differs in path %i: %i in %s, %i in %s"
956                            % (
957                                p["path"],
958                                p["value_1"],
959                                p["master_1"],
960                                p["value_2"],
961                                p["master_2"],
962                            ),
963                            file=f,
964                        )
965                    elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY:
966                        print(
967                            "    Node %o incompatible in path %i: %s in %s, %s in %s"
968                            % (
969                                p["node"],
970                                p["path"],
971                                p["value_1"],
972                                p["master_1"],
973                                p["value_2"],
974                                p["master_2"],
975                            ),
976                            file=f,
977                        )
978                    elif p["type"] == InterpolatableProblem.CONTOUR_ORDER:
979                        print(
980                            "    Contour order differs: %s in %s, %s in %s"
981                            % (
982                                p["value_1"],
983                                p["master_1"],
984                                p["value_2"],
985                                p["master_2"],
986                            ),
987                            file=f,
988                        )
989                    elif p["type"] == InterpolatableProblem.WRONG_START_POINT:
990                        print(
991                            "    Contour %d start point differs: %s in %s, %s in %s; reversed: %s"
992                            % (
993                                p["contour"],
994                                p["value_1"],
995                                p["master_1"],
996                                p["value_2"],
997                                p["master_2"],
998                                p["reversed"],
999                            ),
1000                            file=f,
1001                        )
1002                    elif p["type"] == InterpolatableProblem.UNDERWEIGHT:
1003                        print(
1004                            "    Contour %d interpolation is underweight: %s, %s"
1005                            % (
1006                                p["contour"],
1007                                p["master_1"],
1008                                p["master_2"],
1009                            ),
1010                            file=f,
1011                        )
1012                    elif p["type"] == InterpolatableProblem.OVERWEIGHT:
1013                        print(
1014                            "    Contour %d interpolation is overweight: %s, %s"
1015                            % (
1016                                p["contour"],
1017                                p["master_1"],
1018                                p["master_2"],
1019                            ),
1020                            file=f,
1021                        )
1022                    elif p["type"] == InterpolatableProblem.KINK:
1023                        print(
1024                            "    Contour %d has a kink at %s: %s, %s"
1025                            % (
1026                                p["contour"],
1027                                p["value"],
1028                                p["master_1"],
1029                                p["master_2"],
1030                            ),
1031                            file=f,
1032                        )
1033                    elif p["type"] == InterpolatableProblem.NOTHING:
1034                        print(
1035                            "    Showing %s and %s"
1036                            % (
1037                                p["master_1"],
1038                                p["master_2"],
1039                            ),
1040                            file=f,
1041                        )
1042        else:
1043            for glyphname, problem in problems_gen:
1044                problems[glyphname].append(problem)
1045
1046        problems = sort_problems(problems)
1047
1048        for p in "ps", "pdf":
1049            arg = getattr(args, p)
1050            if arg is None:
1051                continue
1052            log.info("Writing %s to %s", p.upper(), arg)
1053            from .interpolatablePlot import InterpolatablePS, InterpolatablePDF
1054
1055            PlotterClass = InterpolatablePS if p == "ps" else InterpolatablePDF
1056
1057            with PlotterClass(
1058                ensure_parent_dir(arg), glyphsets=glyphsets, names=names
1059            ) as doc:
1060                doc.add_title_page(
1061                    original_args_inputs, tolerance=tolerance, kinkiness=kinkiness
1062                )
1063                if problems:
1064                    doc.add_summary(problems)
1065                doc.add_problems(problems)
1066                if not problems and not args.quiet:
1067                    doc.draw_cupcake()
1068                if problems:
1069                    doc.add_index()
1070                    doc.add_table_of_contents()
1071
1072        if args.html:
1073            log.info("Writing HTML to %s", args.html)
1074            from .interpolatablePlot import InterpolatableSVG
1075
1076            svgs = []
1077            glyph_starts = {}
1078            with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg:
1079                svg.add_title_page(
1080                    original_args_inputs,
1081                    show_tolerance=False,
1082                    tolerance=tolerance,
1083                    kinkiness=kinkiness,
1084                )
1085                for glyph, glyph_problems in problems.items():
1086                    glyph_starts[len(svgs)] = glyph
1087                    svg.add_problems(
1088                        {glyph: glyph_problems},
1089                        show_tolerance=False,
1090                        show_page_number=False,
1091                    )
1092                if not problems and not args.quiet:
1093                    svg.draw_cupcake()
1094
1095            import base64
1096
1097            with open(ensure_parent_dir(args.html), "wb") as f:
1098                f.write(b"<!DOCTYPE html>\n")
1099                f.write(
1100                    b'<html><body align="center" style="font-family: sans-serif; text-color: #222">\n'
1101                )
1102                f.write(b"<title>fonttools varLib.interpolatable report</title>\n")
1103                for i, svg in enumerate(svgs):
1104                    if i in glyph_starts:
1105                        f.write(f"<h1>Glyph {glyph_starts[i]}</h1>\n".encode("utf-8"))
1106                    f.write("<img src='data:image/svg+xml;base64,".encode("utf-8"))
1107                    f.write(base64.b64encode(svg))
1108                    f.write(b"' />\n")
1109                    f.write(b"<hr>\n")
1110                f.write(b"</body></html>\n")
1111
1112    except Exception as e:
1113        e.args += original_args_inputs
1114        log.error(e)
1115        raise
1116
1117    if problems:
1118        return problems
1119
1120
1121if __name__ == "__main__":
1122    import sys
1123
1124    problems = main()
1125    sys.exit(int(bool(problems)))
1126