xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/interpolatablePlot.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from .interpolatableHelpers import *
2from fontTools.ttLib import TTFont
3from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
4from fontTools.pens.recordingPen import (
5    RecordingPen,
6    DecomposingRecordingPen,
7    RecordingPointPen,
8)
9from fontTools.pens.boundsPen import ControlBoundsPen
10from fontTools.pens.cairoPen import CairoPen
11from fontTools.pens.pointPen import (
12    SegmentToPointPen,
13    PointToSegmentPen,
14    ReverseContourPointPen,
15)
16from fontTools.varLib.interpolatableHelpers import (
17    PerContourOrComponentPen,
18    SimpleRecordingPointPen,
19)
20from itertools import cycle
21from functools import wraps
22from io import BytesIO
23import cairo
24import math
25import os
26import logging
27
28log = logging.getLogger("fontTools.varLib.interpolatable")
29
30
31class OverridingDict(dict):
32    def __init__(self, parent_dict):
33        self.parent_dict = parent_dict
34
35    def __missing__(self, key):
36        return self.parent_dict[key]
37
38
39class InterpolatablePlot:
40    width = 8.5 * 72
41    height = 11 * 72
42    pad = 0.1 * 72
43    title_font_size = 24
44    font_size = 16
45    page_number = 1
46    head_color = (0.3, 0.3, 0.3)
47    label_color = (0.2, 0.2, 0.2)
48    border_color = (0.9, 0.9, 0.9)
49    border_width = 0.5
50    fill_color = (0.8, 0.8, 0.8)
51    stroke_color = (0.1, 0.1, 0.1)
52    stroke_width = 1
53    oncurve_node_color = (0, 0.8, 0, 0.7)
54    oncurve_node_diameter = 6
55    offcurve_node_color = (0, 0.5, 0, 0.7)
56    offcurve_node_diameter = 4
57    handle_color = (0, 0.5, 0, 0.7)
58    handle_width = 0.5
59    corrected_start_point_color = (0, 0.9, 0, 0.7)
60    corrected_start_point_size = 7
61    wrong_start_point_color = (1, 0, 0, 0.7)
62    start_point_color = (0, 0, 1, 0.7)
63    start_arrow_length = 9
64    kink_point_size = 7
65    kink_point_color = (1, 0, 1, 0.7)
66    kink_circle_size = 15
67    kink_circle_stroke_width = 1
68    kink_circle_color = (1, 0, 1, 0.7)
69    contour_colors = ((1, 0, 0), (0, 0, 1), (0, 1, 0), (1, 1, 0), (1, 0, 1), (0, 1, 1))
70    contour_alpha = 0.5
71    weight_issue_contour_color = (0, 0, 0, 0.4)
72    no_issues_label = "Your font's good! Have a cupcake..."
73    no_issues_label_color = (0, 0.5, 0)
74    cupcake_color = (0.3, 0, 0.3)
75    cupcake = r"""
76                          ,@.
77                        ,@.@@,.
78                  ,@@,.@@@.  @.@@@,.
79                ,@@. @@@.     @@. @@,.
80        ,@@@.@,.@.              @.  @@@@,.@.@@,.
81   ,@@.@.     @@.@@.            @,.    .@' @'  @@,
82 ,@@. @.          .@@.@@@.  @@'                  @,
83,@.  @@.                                          @,
84@.     @,@@,.     ,                             .@@,
85@,.       .@,@@,.         .@@,.  ,       .@@,  @, @,
86@.                             .@. @ @@,.    ,      @
87 @,.@@.     @,.      @@,.      @.           @,.    @'
88  @@||@,.  @'@,.       @@,.  @@ @,.        @'@@,  @'
89     \\@@@@'  @,.      @'@@@@'   @@,.   @@@' //@@@'
90      |||||||| @@,.  @@' |||||||  |@@@|@||  ||
91       \\\\\\\  ||@@@||  |||||||  |||||||  //
92        |||||||  ||||||  ||||||   ||||||  ||
93         \\\\\\  ||||||  ||||||  ||||||  //
94          ||||||  |||||  |||||   |||||  ||
95           \\\\\  |||||  |||||  |||||  //
96            |||||  ||||  |||||  ||||  ||
97             \\\\  ||||  ||||  ||||  //
98              ||||||||||||||||||||||||
99"""
100    emoticon_color = (0, 0.3, 0.3)
101    shrug = r"""\_(")_/"""
102    underweight = r"""
103 o
104/|\
105/ \
106"""
107    overweight = r"""
108 o
109/O\
110/ \
111"""
112    yay = r""" \o/ """
113
114    def __init__(self, out, glyphsets, names=None, **kwargs):
115        self.out = out
116        self.glyphsets = glyphsets
117        self.names = names or [repr(g) for g in glyphsets]
118        self.toc = {}
119
120        for k, v in kwargs.items():
121            if not hasattr(self, k):
122                raise TypeError("Unknown keyword argument: %s" % k)
123            setattr(self, k, v)
124
125        self.panel_width = self.width / 2 - self.pad * 3
126        self.panel_height = (
127            self.height / 2 - self.pad * 6 - self.font_size * 2 - self.title_font_size
128        )
129
130    def __enter__(self):
131        return self
132
133    def __exit__(self, type, value, traceback):
134        pass
135
136    def show_page(self):
137        self.page_number += 1
138
139    def add_title_page(
140        self, files, *, show_tolerance=True, tolerance=None, kinkiness=None
141    ):
142        pad = self.pad
143        width = self.width - 3 * self.pad
144        height = self.height - 2 * self.pad
145        x = y = pad
146
147        self.draw_label(
148            "Problem report for:",
149            x=x,
150            y=y,
151            bold=True,
152            width=width,
153            font_size=self.title_font_size,
154        )
155        y += self.title_font_size
156
157        import hashlib
158
159        for file in files:
160            base_file = os.path.basename(file)
161            y += self.font_size + self.pad
162            self.draw_label(base_file, x=x, y=y, bold=True, width=width)
163            y += self.font_size + self.pad
164
165            try:
166                h = hashlib.sha1(open(file, "rb").read()).hexdigest()
167                self.draw_label("sha1: %s" % h, x=x + pad, y=y, width=width)
168                y += self.font_size
169            except IsADirectoryError:
170                pass
171
172            if file.endswith(".ttf"):
173                ttFont = TTFont(file)
174                name = ttFont["name"] if "name" in ttFont else None
175                if name:
176                    for what, nameIDs in (
177                        ("Family name", (21, 16, 1)),
178                        ("Version", (5,)),
179                    ):
180                        n = name.getFirstDebugName(nameIDs)
181                        if n is None:
182                            continue
183                        self.draw_label(
184                            "%s: %s" % (what, n), x=x + pad, y=y, width=width
185                        )
186                        y += self.font_size + self.pad
187            elif file.endswith((".glyphs", ".glyphspackage")):
188                from glyphsLib import GSFont
189
190                f = GSFont(file)
191                for what, field in (
192                    ("Family name", "familyName"),
193                    ("VersionMajor", "versionMajor"),
194                    ("VersionMinor", "_versionMinor"),
195                ):
196                    self.draw_label(
197                        "%s: %s" % (what, getattr(f, field)),
198                        x=x + pad,
199                        y=y,
200                        width=width,
201                    )
202                    y += self.font_size + self.pad
203
204        self.draw_legend(
205            show_tolerance=show_tolerance, tolerance=tolerance, kinkiness=kinkiness
206        )
207        self.show_page()
208
209    def draw_legend(self, *, show_tolerance=True, tolerance=None, kinkiness=None):
210        cr = cairo.Context(self.surface)
211
212        x = self.pad
213        y = self.height - self.pad - self.font_size * 2
214        width = self.width - 2 * self.pad
215
216        xx = x + self.pad * 2
217        xxx = x + self.pad * 4
218
219        if show_tolerance:
220            self.draw_label(
221                "Tolerance: badness; closer to zero the worse", x=xxx, y=y, width=width
222            )
223            y -= self.pad + self.font_size
224
225        self.draw_label("Underweight contours", x=xxx, y=y, width=width)
226        cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
227        cr.set_source_rgb(*self.fill_color)
228        cr.fill_preserve()
229        if self.stroke_color:
230            cr.set_source_rgb(*self.stroke_color)
231            cr.set_line_width(self.stroke_width)
232            cr.stroke_preserve()
233        cr.set_source_rgba(*self.weight_issue_contour_color)
234        cr.fill()
235        y -= self.pad + self.font_size
236
237        self.draw_label(
238            "Colored contours: contours with the wrong order", x=xxx, y=y, width=width
239        )
240        cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
241        if self.fill_color:
242            cr.set_source_rgb(*self.fill_color)
243            cr.fill_preserve()
244        if self.stroke_color:
245            cr.set_source_rgb(*self.stroke_color)
246            cr.set_line_width(self.stroke_width)
247            cr.stroke_preserve()
248        cr.set_source_rgba(*self.contour_colors[0], self.contour_alpha)
249        cr.fill()
250        y -= self.pad + self.font_size
251
252        self.draw_label("Kink artifact", x=xxx, y=y, width=width)
253        self.draw_circle(
254            cr,
255            x=xx,
256            y=y + self.font_size * 0.5,
257            diameter=self.kink_circle_size,
258            stroke_width=self.kink_circle_stroke_width,
259            color=self.kink_circle_color,
260        )
261        y -= self.pad + self.font_size
262
263        self.draw_label("Point causing kink in the contour", x=xxx, y=y, width=width)
264        self.draw_dot(
265            cr,
266            x=xx,
267            y=y + self.font_size * 0.5,
268            diameter=self.kink_point_size,
269            color=self.kink_point_color,
270        )
271        y -= self.pad + self.font_size
272
273        self.draw_label("Suggested new contour start point", x=xxx, y=y, width=width)
274        self.draw_dot(
275            cr,
276            x=xx,
277            y=y + self.font_size * 0.5,
278            diameter=self.corrected_start_point_size,
279            color=self.corrected_start_point_color,
280        )
281        y -= self.pad + self.font_size
282
283        self.draw_label(
284            "Contour start point in contours with wrong direction",
285            x=xxx,
286            y=y,
287            width=width,
288        )
289        self.draw_arrow(
290            cr,
291            x=xx - self.start_arrow_length * 0.3,
292            y=y + self.font_size * 0.5,
293            color=self.wrong_start_point_color,
294        )
295        y -= self.pad + self.font_size
296
297        self.draw_label(
298            "Contour start point when the first two points overlap",
299            x=xxx,
300            y=y,
301            width=width,
302        )
303        self.draw_dot(
304            cr,
305            x=xx,
306            y=y + self.font_size * 0.5,
307            diameter=self.corrected_start_point_size,
308            color=self.start_point_color,
309        )
310        y -= self.pad + self.font_size
311
312        self.draw_label("Contour start point and direction", x=xxx, y=y, width=width)
313        self.draw_arrow(
314            cr,
315            x=xx - self.start_arrow_length * 0.3,
316            y=y + self.font_size * 0.5,
317            color=self.start_point_color,
318        )
319        y -= self.pad + self.font_size
320
321        self.draw_label("Legend:", x=x, y=y, width=width, bold=True)
322        y -= self.pad + self.font_size
323
324        if kinkiness is not None:
325            self.draw_label(
326                "Kink-reporting aggressiveness: %g" % kinkiness,
327                x=xxx,
328                y=y,
329                width=width,
330            )
331            y -= self.pad + self.font_size
332
333        if tolerance is not None:
334            self.draw_label(
335                "Error tolerance: %g" % tolerance,
336                x=xxx,
337                y=y,
338                width=width,
339            )
340            y -= self.pad + self.font_size
341
342        self.draw_label("Parameters:", x=x, y=y, width=width, bold=True)
343        y -= self.pad + self.font_size
344
345    def add_summary(self, problems):
346        pad = self.pad
347        width = self.width - 3 * self.pad
348        height = self.height - 2 * self.pad
349        x = y = pad
350
351        self.draw_label(
352            "Summary of problems",
353            x=x,
354            y=y,
355            bold=True,
356            width=width,
357            font_size=self.title_font_size,
358        )
359        y += self.title_font_size
360
361        glyphs_per_problem = defaultdict(set)
362        for glyphname, problems in sorted(problems.items()):
363            for problem in problems:
364                glyphs_per_problem[problem["type"]].add(glyphname)
365
366        if "nothing" in glyphs_per_problem:
367            del glyphs_per_problem["nothing"]
368
369        for problem_type in sorted(
370            glyphs_per_problem, key=lambda x: InterpolatableProblem.severity[x]
371        ):
372            y += self.font_size
373            self.draw_label(
374                "%s: %d" % (problem_type, len(glyphs_per_problem[problem_type])),
375                x=x,
376                y=y,
377                width=width,
378                bold=True,
379            )
380            y += self.font_size
381
382            for glyphname in sorted(glyphs_per_problem[problem_type]):
383                if y + self.font_size > height:
384                    self.show_page()
385                    y = self.font_size + pad
386                self.draw_label(glyphname, x=x + 2 * pad, y=y, width=width - 2 * pad)
387                y += self.font_size
388
389        self.show_page()
390
391    def _add_listing(self, title, items):
392        pad = self.pad
393        width = self.width - 2 * self.pad
394        height = self.height - 2 * self.pad
395        x = y = pad
396
397        self.draw_label(
398            title, x=x, y=y, bold=True, width=width, font_size=self.title_font_size
399        )
400        y += self.title_font_size + self.pad
401
402        last_glyphname = None
403        for page_no, (glyphname, problems) in items:
404            if glyphname == last_glyphname:
405                continue
406            last_glyphname = glyphname
407            if y + self.font_size > height:
408                self.show_page()
409                y = self.font_size + pad
410            self.draw_label(glyphname, x=x + 5 * pad, y=y, width=width - 2 * pad)
411            self.draw_label(str(page_no), x=x, y=y, width=4 * pad, align=1)
412            y += self.font_size
413
414        self.show_page()
415
416    def add_table_of_contents(self):
417        self._add_listing("Table of contents", sorted(self.toc.items()))
418
419    def add_index(self):
420        self._add_listing("Index", sorted(self.toc.items(), key=lambda x: x[1][0]))
421
422    def add_problems(self, problems, *, show_tolerance=True, show_page_number=True):
423        for glyph, glyph_problems in problems.items():
424            last_masters = None
425            current_glyph_problems = []
426            for p in glyph_problems:
427                masters = (
428                    p["master_idx"]
429                    if "master_idx" in p
430                    else (p["master_1_idx"], p["master_2_idx"])
431                )
432                if masters == last_masters:
433                    current_glyph_problems.append(p)
434                    continue
435                # Flush
436                if current_glyph_problems:
437                    self.add_problem(
438                        glyph,
439                        current_glyph_problems,
440                        show_tolerance=show_tolerance,
441                        show_page_number=show_page_number,
442                    )
443                    self.show_page()
444                    current_glyph_problems = []
445                last_masters = masters
446                current_glyph_problems.append(p)
447            if current_glyph_problems:
448                self.add_problem(
449                    glyph,
450                    current_glyph_problems,
451                    show_tolerance=show_tolerance,
452                    show_page_number=show_page_number,
453                )
454                self.show_page()
455
456    def add_problem(
457        self, glyphname, problems, *, show_tolerance=True, show_page_number=True
458    ):
459        if type(problems) not in (list, tuple):
460            problems = [problems]
461
462        self.toc[self.page_number] = (glyphname, problems)
463
464        problem_type = problems[0]["type"]
465        problem_types = set(problem["type"] for problem in problems)
466        if not all(pt == problem_type for pt in problem_types):
467            problem_type = ", ".join(sorted({problem["type"] for problem in problems}))
468
469        log.info("Drawing %s: %s", glyphname, problem_type)
470
471        master_keys = (
472            ("master_idx",)
473            if "master_idx" in problems[0]
474            else ("master_1_idx", "master_2_idx")
475        )
476        master_indices = [problems[0][k] for k in master_keys]
477
478        if problem_type == InterpolatableProblem.MISSING:
479            sample_glyph = next(
480                i for i, m in enumerate(self.glyphsets) if m[glyphname] is not None
481            )
482            master_indices.insert(0, sample_glyph)
483
484        x = self.pad
485        y = self.pad
486
487        self.draw_label(
488            "Glyph name: " + glyphname,
489            x=x,
490            y=y,
491            color=self.head_color,
492            align=0,
493            bold=True,
494            font_size=self.title_font_size,
495        )
496        tolerance = min(p.get("tolerance", 1) for p in problems)
497        if tolerance < 1 and show_tolerance:
498            self.draw_label(
499                "tolerance: %.2f" % tolerance,
500                x=x,
501                y=y,
502                width=self.width - 2 * self.pad,
503                align=1,
504                bold=True,
505            )
506        y += self.title_font_size + self.pad
507        self.draw_label(
508            "Problems: " + problem_type,
509            x=x,
510            y=y,
511            width=self.width - 2 * self.pad,
512            color=self.head_color,
513            bold=True,
514        )
515        y += self.font_size + self.pad * 2
516
517        scales = []
518        for which, master_idx in enumerate(master_indices):
519            glyphset = self.glyphsets[master_idx]
520            name = self.names[master_idx]
521
522            self.draw_label(
523                name,
524                x=x,
525                y=y,
526                color=self.label_color,
527                width=self.panel_width,
528                align=0.5,
529            )
530            y += self.font_size + self.pad
531
532            if glyphset[glyphname] is not None:
533                scales.append(
534                    self.draw_glyph(glyphset, glyphname, problems, which, x=x, y=y)
535                )
536            else:
537                self.draw_emoticon(self.shrug, x=x, y=y)
538            y += self.panel_height + self.font_size + self.pad
539
540        if any(
541            pt
542            in (
543                InterpolatableProblem.NOTHING,
544                InterpolatableProblem.WRONG_START_POINT,
545                InterpolatableProblem.CONTOUR_ORDER,
546                InterpolatableProblem.KINK,
547                InterpolatableProblem.UNDERWEIGHT,
548                InterpolatableProblem.OVERWEIGHT,
549            )
550            for pt in problem_types
551        ):
552            x = self.pad + self.panel_width + self.pad
553            y = self.pad
554            y += self.title_font_size + self.pad * 2
555            y += self.font_size + self.pad
556
557            glyphset1 = self.glyphsets[master_indices[0]]
558            glyphset2 = self.glyphsets[master_indices[1]]
559
560            # Draw the mid-way of the two masters
561
562            self.draw_label(
563                "midway interpolation",
564                x=x,
565                y=y,
566                color=self.head_color,
567                width=self.panel_width,
568                align=0.5,
569            )
570            y += self.font_size + self.pad
571
572            midway_glyphset = LerpGlyphSet(glyphset1, glyphset2)
573            self.draw_glyph(
574                midway_glyphset,
575                glyphname,
576                [{"type": "midway"}]
577                + [
578                    p
579                    for p in problems
580                    if p["type"]
581                    in (
582                        InterpolatableProblem.KINK,
583                        InterpolatableProblem.UNDERWEIGHT,
584                        InterpolatableProblem.OVERWEIGHT,
585                    )
586                ],
587                None,
588                x=x,
589                y=y,
590                scale=min(scales),
591            )
592
593            y += self.panel_height + self.font_size + self.pad
594
595        if any(
596            pt
597            in (
598                InterpolatableProblem.WRONG_START_POINT,
599                InterpolatableProblem.CONTOUR_ORDER,
600                InterpolatableProblem.KINK,
601            )
602            for pt in problem_types
603        ):
604            # Draw the proposed fix
605
606            self.draw_label(
607                "proposed fix",
608                x=x,
609                y=y,
610                color=self.head_color,
611                width=self.panel_width,
612                align=0.5,
613            )
614            y += self.font_size + self.pad
615
616            overriding1 = OverridingDict(glyphset1)
617            overriding2 = OverridingDict(glyphset2)
618            perContourPen1 = PerContourOrComponentPen(
619                RecordingPen, glyphset=overriding1
620            )
621            perContourPen2 = PerContourOrComponentPen(
622                RecordingPen, glyphset=overriding2
623            )
624            glyphset1[glyphname].draw(perContourPen1)
625            glyphset2[glyphname].draw(perContourPen2)
626
627            for problem in problems:
628                if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
629                    fixed_contours = [
630                        perContourPen2.value[i] for i in problems[0]["value_2"]
631                    ]
632                    perContourPen2.value = fixed_contours
633
634            for problem in problems:
635                if problem["type"] == InterpolatableProblem.WRONG_START_POINT:
636                    # Save the wrong contours
637                    wrongContour1 = perContourPen1.value[problem["contour"]]
638                    wrongContour2 = perContourPen2.value[problem["contour"]]
639
640                    # Convert the wrong contours to point pens
641                    points1 = RecordingPointPen()
642                    converter = SegmentToPointPen(points1, False)
643                    wrongContour1.replay(converter)
644                    points2 = RecordingPointPen()
645                    converter = SegmentToPointPen(points2, False)
646                    wrongContour2.replay(converter)
647
648                    proposed_start = problem["value_2"]
649
650                    # See if we need reversing; fragile but worth a try
651                    if problem["reversed"]:
652                        new_points2 = RecordingPointPen()
653                        reversedPen = ReverseContourPointPen(new_points2)
654                        points2.replay(reversedPen)
655                        points2 = new_points2
656                        proposed_start = len(points2.value) - 2 - proposed_start
657
658                    # Rotate points2 so that the first point is the same as in points1
659                    beginPath = points2.value[:1]
660                    endPath = points2.value[-1:]
661                    pts = points2.value[1:-1]
662                    pts = pts[proposed_start:] + pts[:proposed_start]
663                    points2.value = beginPath + pts + endPath
664
665                    # Convert the point pens back to segment pens
666                    segment1 = RecordingPen()
667                    converter = PointToSegmentPen(segment1, True)
668                    points1.replay(converter)
669                    segment2 = RecordingPen()
670                    converter = PointToSegmentPen(segment2, True)
671                    points2.replay(converter)
672
673                    # Replace the wrong contours
674                    wrongContour1.value = segment1.value
675                    wrongContour2.value = segment2.value
676                    perContourPen1.value[problem["contour"]] = wrongContour1
677                    perContourPen2.value[problem["contour"]] = wrongContour2
678
679            for problem in problems:
680                # If we have a kink, try to fix it.
681                if problem["type"] == InterpolatableProblem.KINK:
682                    # Save the wrong contours
683                    wrongContour1 = perContourPen1.value[problem["contour"]]
684                    wrongContour2 = perContourPen2.value[problem["contour"]]
685
686                    # Convert the wrong contours to point pens
687                    points1 = RecordingPointPen()
688                    converter = SegmentToPointPen(points1, False)
689                    wrongContour1.replay(converter)
690                    points2 = RecordingPointPen()
691                    converter = SegmentToPointPen(points2, False)
692                    wrongContour2.replay(converter)
693
694                    i = problem["value"]
695
696                    # Position points to be around the same ratio
697                    # beginPath / endPath dance
698                    j = i + 1
699                    pt0 = points1.value[j][1][0]
700                    pt1 = points2.value[j][1][0]
701                    j_prev = (i - 1) % (len(points1.value) - 2) + 1
702                    pt0_prev = points1.value[j_prev][1][0]
703                    pt1_prev = points2.value[j_prev][1][0]
704                    j_next = (i + 1) % (len(points1.value) - 2) + 1
705                    pt0_next = points1.value[j_next][1][0]
706                    pt1_next = points2.value[j_next][1][0]
707
708                    pt0 = complex(*pt0)
709                    pt1 = complex(*pt1)
710                    pt0_prev = complex(*pt0_prev)
711                    pt1_prev = complex(*pt1_prev)
712                    pt0_next = complex(*pt0_next)
713                    pt1_next = complex(*pt1_next)
714
715                    # Find the ratio of the distance between the points
716                    r0 = abs(pt0 - pt0_prev) / abs(pt0_next - pt0_prev)
717                    r1 = abs(pt1 - pt1_prev) / abs(pt1_next - pt1_prev)
718                    r_mid = (r0 + r1) / 2
719
720                    pt0 = pt0_prev + r_mid * (pt0_next - pt0_prev)
721                    pt1 = pt1_prev + r_mid * (pt1_next - pt1_prev)
722
723                    points1.value[j] = (
724                        points1.value[j][0],
725                        (((pt0.real, pt0.imag),) + points1.value[j][1][1:]),
726                        points1.value[j][2],
727                    )
728                    points2.value[j] = (
729                        points2.value[j][0],
730                        (((pt1.real, pt1.imag),) + points2.value[j][1][1:]),
731                        points2.value[j][2],
732                    )
733
734                    # Convert the point pens back to segment pens
735                    segment1 = RecordingPen()
736                    converter = PointToSegmentPen(segment1, True)
737                    points1.replay(converter)
738                    segment2 = RecordingPen()
739                    converter = PointToSegmentPen(segment2, True)
740                    points2.replay(converter)
741
742                    # Replace the wrong contours
743                    wrongContour1.value = segment1.value
744                    wrongContour2.value = segment2.value
745
746            # Assemble
747            fixed1 = RecordingPen()
748            fixed2 = RecordingPen()
749            for contour in perContourPen1.value:
750                fixed1.value.extend(contour.value)
751            for contour in perContourPen2.value:
752                fixed2.value.extend(contour.value)
753            fixed1.draw = fixed1.replay
754            fixed2.draw = fixed2.replay
755
756            overriding1[glyphname] = fixed1
757            overriding2[glyphname] = fixed2
758
759            try:
760                midway_glyphset = LerpGlyphSet(overriding1, overriding2)
761                self.draw_glyph(
762                    midway_glyphset,
763                    glyphname,
764                    {"type": "fixed"},
765                    None,
766                    x=x,
767                    y=y,
768                    scale=min(scales),
769                )
770            except ValueError:
771                self.draw_emoticon(self.shrug, x=x, y=y)
772            y += self.panel_height + self.pad
773
774        else:
775            emoticon = self.shrug
776            if InterpolatableProblem.UNDERWEIGHT in problem_types:
777                emoticon = self.underweight
778            elif InterpolatableProblem.OVERWEIGHT in problem_types:
779                emoticon = self.overweight
780            elif InterpolatableProblem.NOTHING in problem_types:
781                emoticon = self.yay
782            self.draw_emoticon(emoticon, x=x, y=y)
783
784        if show_page_number:
785            self.draw_label(
786                str(self.page_number),
787                x=0,
788                y=self.height - self.font_size - self.pad,
789                width=self.width,
790                color=self.head_color,
791                align=0.5,
792            )
793
794    def draw_label(
795        self,
796        label,
797        *,
798        x=0,
799        y=0,
800        color=(0, 0, 0),
801        align=0,
802        bold=False,
803        width=None,
804        height=None,
805        font_size=None,
806    ):
807        if width is None:
808            width = self.width
809        if height is None:
810            height = self.height
811        if font_size is None:
812            font_size = self.font_size
813        cr = cairo.Context(self.surface)
814        cr.select_font_face(
815            "@cairo:",
816            cairo.FONT_SLANT_NORMAL,
817            cairo.FONT_WEIGHT_BOLD if bold else cairo.FONT_WEIGHT_NORMAL,
818        )
819        cr.set_font_size(font_size)
820        font_extents = cr.font_extents()
821        font_size = font_size * font_size / font_extents[2]
822        cr.set_font_size(font_size)
823        font_extents = cr.font_extents()
824
825        cr.set_source_rgb(*color)
826
827        extents = cr.text_extents(label)
828        if extents.width > width:
829            # Shrink
830            font_size *= width / extents.width
831            cr.set_font_size(font_size)
832            font_extents = cr.font_extents()
833            extents = cr.text_extents(label)
834
835        # Center
836        label_x = x + (width - extents.width) * align
837        label_y = y + font_extents[0]
838        cr.move_to(label_x, label_y)
839        cr.show_text(label)
840
841    def draw_glyph(self, glyphset, glyphname, problems, which, *, x=0, y=0, scale=None):
842        if type(problems) not in (list, tuple):
843            problems = [problems]
844
845        midway = any(problem["type"] == "midway" for problem in problems)
846        problem_type = problems[0]["type"]
847        problem_types = set(problem["type"] for problem in problems)
848        if not all(pt == problem_type for pt in problem_types):
849            problem_type = "mixed"
850        glyph = glyphset[glyphname]
851
852        recording = RecordingPen()
853        glyph.draw(recording)
854        decomposedRecording = DecomposingRecordingPen(glyphset)
855        glyph.draw(decomposedRecording)
856
857        boundsPen = ControlBoundsPen(glyphset)
858        decomposedRecording.replay(boundsPen)
859        bounds = boundsPen.bounds
860        if bounds is None:
861            bounds = (0, 0, 0, 0)
862
863        glyph_width = bounds[2] - bounds[0]
864        glyph_height = bounds[3] - bounds[1]
865
866        if glyph_width:
867            if scale is None:
868                scale = self.panel_width / glyph_width
869            else:
870                scale = min(scale, self.panel_height / glyph_height)
871        if glyph_height:
872            if scale is None:
873                scale = self.panel_height / glyph_height
874            else:
875                scale = min(scale, self.panel_height / glyph_height)
876        if scale is None:
877            scale = 1
878
879        cr = cairo.Context(self.surface)
880        cr.translate(x, y)
881        # Center
882        cr.translate(
883            (self.panel_width - glyph_width * scale) / 2,
884            (self.panel_height - glyph_height * scale) / 2,
885        )
886        cr.scale(scale, -scale)
887        cr.translate(-bounds[0], -bounds[3])
888
889        if self.border_color:
890            cr.set_source_rgb(*self.border_color)
891            cr.rectangle(bounds[0], bounds[1], glyph_width, glyph_height)
892            cr.set_line_width(self.border_width / scale)
893            cr.stroke()
894
895        if self.fill_color or self.stroke_color:
896            pen = CairoPen(glyphset, cr)
897            decomposedRecording.replay(pen)
898
899            if self.fill_color and problem_type != InterpolatableProblem.OPEN_PATH:
900                cr.set_source_rgb(*self.fill_color)
901                cr.fill_preserve()
902
903            if self.stroke_color:
904                cr.set_source_rgb(*self.stroke_color)
905                cr.set_line_width(self.stroke_width / scale)
906                cr.stroke_preserve()
907
908            cr.new_path()
909
910        if (
911            InterpolatableProblem.UNDERWEIGHT in problem_types
912            or InterpolatableProblem.OVERWEIGHT in problem_types
913        ):
914            perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
915            recording.replay(perContourPen)
916            for problem in problems:
917                if problem["type"] in (
918                    InterpolatableProblem.UNDERWEIGHT,
919                    InterpolatableProblem.OVERWEIGHT,
920                ):
921                    contour = perContourPen.value[problem["contour"]]
922                    contour.replay(CairoPen(glyphset, cr))
923                    cr.set_source_rgba(*self.weight_issue_contour_color)
924                    cr.fill()
925
926        if any(
927            t in problem_types
928            for t in {
929                InterpolatableProblem.NOTHING,
930                InterpolatableProblem.NODE_COUNT,
931                InterpolatableProblem.NODE_INCOMPATIBILITY,
932            }
933        ):
934            cr.set_line_cap(cairo.LINE_CAP_ROUND)
935
936            # Oncurve nodes
937            for segment, args in decomposedRecording.value:
938                if not args:
939                    continue
940                x, y = args[-1]
941                cr.move_to(x, y)
942                cr.line_to(x, y)
943            cr.set_source_rgba(*self.oncurve_node_color)
944            cr.set_line_width(self.oncurve_node_diameter / scale)
945            cr.stroke()
946
947            # Offcurve nodes
948            for segment, args in decomposedRecording.value:
949                if not args:
950                    continue
951                for x, y in args[:-1]:
952                    cr.move_to(x, y)
953                    cr.line_to(x, y)
954            cr.set_source_rgba(*self.offcurve_node_color)
955            cr.set_line_width(self.offcurve_node_diameter / scale)
956            cr.stroke()
957
958            # Handles
959            for segment, args in decomposedRecording.value:
960                if not args:
961                    pass
962                elif segment in ("moveTo", "lineTo"):
963                    cr.move_to(*args[0])
964                elif segment == "qCurveTo":
965                    for x, y in args:
966                        cr.line_to(x, y)
967                    cr.new_sub_path()
968                    cr.move_to(*args[-1])
969                elif segment == "curveTo":
970                    cr.line_to(*args[0])
971                    cr.new_sub_path()
972                    cr.move_to(*args[1])
973                    cr.line_to(*args[2])
974                    cr.new_sub_path()
975                    cr.move_to(*args[-1])
976                else:
977                    continue
978
979            cr.set_source_rgba(*self.handle_color)
980            cr.set_line_width(self.handle_width / scale)
981            cr.stroke()
982
983        matching = None
984        for problem in problems:
985            if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
986                matching = problem["value_2"]
987                colors = cycle(self.contour_colors)
988                perContourPen = PerContourOrComponentPen(
989                    RecordingPen, glyphset=glyphset
990                )
991                recording.replay(perContourPen)
992                for i, contour in enumerate(perContourPen.value):
993                    if matching[i] == i:
994                        continue
995                    color = next(colors)
996                    contour.replay(CairoPen(glyphset, cr))
997                    cr.set_source_rgba(*color, self.contour_alpha)
998                    cr.fill()
999
1000        for problem in problems:
1001            if problem["type"] in (
1002                InterpolatableProblem.NOTHING,
1003                InterpolatableProblem.WRONG_START_POINT,
1004            ):
1005                idx = problem.get("contour")
1006
1007                # Draw suggested point
1008                if idx is not None and which == 1 and "value_2" in problem:
1009                    perContourPen = PerContourOrComponentPen(
1010                        RecordingPen, glyphset=glyphset
1011                    )
1012                    decomposedRecording.replay(perContourPen)
1013                    points = SimpleRecordingPointPen()
1014                    converter = SegmentToPointPen(points, False)
1015                    perContourPen.value[
1016                        idx if matching is None else matching[idx]
1017                    ].replay(converter)
1018                    targetPoint = points.value[problem["value_2"]][0]
1019                    cr.save()
1020                    cr.translate(*targetPoint)
1021                    cr.scale(1 / scale, 1 / scale)
1022                    self.draw_dot(
1023                        cr,
1024                        diameter=self.corrected_start_point_size,
1025                        color=self.corrected_start_point_color,
1026                    )
1027                    cr.restore()
1028
1029                # Draw start-point arrow
1030                if which == 0 or not problem.get("reversed"):
1031                    color = self.start_point_color
1032                else:
1033                    color = self.wrong_start_point_color
1034                first_pt = None
1035                i = 0
1036                cr.save()
1037                for segment, args in decomposedRecording.value:
1038                    if segment == "moveTo":
1039                        first_pt = args[0]
1040                        continue
1041                    if first_pt is None:
1042                        continue
1043                    if segment == "closePath":
1044                        second_pt = first_pt
1045                    else:
1046                        second_pt = args[0]
1047
1048                    if idx is None or i == idx:
1049                        cr.save()
1050                        first_pt = complex(*first_pt)
1051                        second_pt = complex(*second_pt)
1052                        length = abs(second_pt - first_pt)
1053                        cr.translate(first_pt.real, first_pt.imag)
1054                        if length:
1055                            # Draw arrowhead
1056                            cr.rotate(
1057                                math.atan2(
1058                                    second_pt.imag - first_pt.imag,
1059                                    second_pt.real - first_pt.real,
1060                                )
1061                            )
1062                            cr.scale(1 / scale, 1 / scale)
1063                            self.draw_arrow(cr, color=color)
1064                        else:
1065                            # Draw circle
1066                            cr.scale(1 / scale, 1 / scale)
1067                            self.draw_dot(
1068                                cr,
1069                                diameter=self.corrected_start_point_size,
1070                                color=color,
1071                            )
1072                        cr.restore()
1073
1074                        if idx is not None:
1075                            break
1076
1077                    first_pt = None
1078                    i += 1
1079
1080                cr.restore()
1081
1082            if problem["type"] == InterpolatableProblem.KINK:
1083                idx = problem.get("contour")
1084                perContourPen = PerContourOrComponentPen(
1085                    RecordingPen, glyphset=glyphset
1086                )
1087                decomposedRecording.replay(perContourPen)
1088                points = SimpleRecordingPointPen()
1089                converter = SegmentToPointPen(points, False)
1090                perContourPen.value[idx if matching is None else matching[idx]].replay(
1091                    converter
1092                )
1093
1094                targetPoint = points.value[problem["value"]][0]
1095                cr.save()
1096                cr.translate(*targetPoint)
1097                cr.scale(1 / scale, 1 / scale)
1098                if midway:
1099                    self.draw_circle(
1100                        cr,
1101                        diameter=self.kink_circle_size,
1102                        stroke_width=self.kink_circle_stroke_width,
1103                        color=self.kink_circle_color,
1104                    )
1105                else:
1106                    self.draw_dot(
1107                        cr,
1108                        diameter=self.kink_point_size,
1109                        color=self.kink_point_color,
1110                    )
1111                cr.restore()
1112
1113        return scale
1114
1115    def draw_dot(self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10):
1116        cr.save()
1117        cr.set_line_width(diameter)
1118        cr.set_line_cap(cairo.LINE_CAP_ROUND)
1119        cr.move_to(x, y)
1120        cr.line_to(x, y)
1121        if len(color) == 3:
1122            color = color + (1,)
1123        cr.set_source_rgba(*color)
1124        cr.stroke()
1125        cr.restore()
1126
1127    def draw_circle(
1128        self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10, stroke_width=1
1129    ):
1130        cr.save()
1131        cr.set_line_width(stroke_width)
1132        cr.set_line_cap(cairo.LINE_CAP_SQUARE)
1133        cr.arc(x, y, diameter / 2, 0, 2 * math.pi)
1134        if len(color) == 3:
1135            color = color + (1,)
1136        cr.set_source_rgba(*color)
1137        cr.stroke()
1138        cr.restore()
1139
1140    def draw_arrow(self, cr, *, x=0, y=0, color=(0, 0, 0)):
1141        cr.save()
1142        if len(color) == 3:
1143            color = color + (1,)
1144        cr.set_source_rgba(*color)
1145        cr.translate(self.start_arrow_length + x, y)
1146        cr.move_to(0, 0)
1147        cr.line_to(
1148            -self.start_arrow_length,
1149            -self.start_arrow_length * 0.4,
1150        )
1151        cr.line_to(
1152            -self.start_arrow_length,
1153            self.start_arrow_length * 0.4,
1154        )
1155        cr.close_path()
1156        cr.fill()
1157        cr.restore()
1158
1159    def draw_text(self, text, *, x=0, y=0, color=(0, 0, 0), width=None, height=None):
1160        if width is None:
1161            width = self.width
1162        if height is None:
1163            height = self.height
1164
1165        text = text.splitlines()
1166        cr = cairo.Context(self.surface)
1167        cr.set_source_rgb(*color)
1168        cr.set_font_size(self.font_size)
1169        cr.select_font_face(
1170            "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL
1171        )
1172        text_width = 0
1173        text_height = 0
1174        font_extents = cr.font_extents()
1175        font_font_size = font_extents[2]
1176        font_ascent = font_extents[0]
1177        for line in text:
1178            extents = cr.text_extents(line)
1179            text_width = max(text_width, extents.x_advance)
1180            text_height += font_font_size
1181        if not text_width:
1182            return
1183        cr.translate(x, y)
1184        scale = min(width / text_width, height / text_height)
1185        # center
1186        cr.translate(
1187            (width - text_width * scale) / 2, (height - text_height * scale) / 2
1188        )
1189        cr.scale(scale, scale)
1190
1191        cr.translate(0, font_ascent)
1192        for line in text:
1193            cr.move_to(0, 0)
1194            cr.show_text(line)
1195            cr.translate(0, font_font_size)
1196
1197    def draw_cupcake(self):
1198        self.draw_label(
1199            self.no_issues_label,
1200            x=self.pad,
1201            y=self.pad,
1202            color=self.no_issues_label_color,
1203            width=self.width - 2 * self.pad,
1204            align=0.5,
1205            bold=True,
1206            font_size=self.title_font_size,
1207        )
1208
1209        self.draw_text(
1210            self.cupcake,
1211            x=self.pad,
1212            y=self.pad + self.font_size,
1213            width=self.width - 2 * self.pad,
1214            height=self.height - 2 * self.pad - self.font_size,
1215            color=self.cupcake_color,
1216        )
1217
1218    def draw_emoticon(self, emoticon, x=0, y=0):
1219        self.draw_text(
1220            emoticon,
1221            x=x,
1222            y=y,
1223            color=self.emoticon_color,
1224            width=self.panel_width,
1225            height=self.panel_height,
1226        )
1227
1228
1229class InterpolatablePostscriptLike(InterpolatablePlot):
1230    def __exit__(self, type, value, traceback):
1231        self.surface.finish()
1232
1233    def show_page(self):
1234        super().show_page()
1235        self.surface.show_page()
1236
1237
1238class InterpolatablePS(InterpolatablePostscriptLike):
1239    def __enter__(self):
1240        self.surface = cairo.PSSurface(self.out, self.width, self.height)
1241        return self
1242
1243
1244class InterpolatablePDF(InterpolatablePostscriptLike):
1245    def __enter__(self):
1246        self.surface = cairo.PDFSurface(self.out, self.width, self.height)
1247        self.surface.set_metadata(
1248            cairo.PDF_METADATA_CREATOR, "fonttools varLib.interpolatable"
1249        )
1250        self.surface.set_metadata(cairo.PDF_METADATA_CREATE_DATE, "")
1251        return self
1252
1253
1254class InterpolatableSVG(InterpolatablePlot):
1255    def __enter__(self):
1256        self.sink = BytesIO()
1257        self.surface = cairo.SVGSurface(self.sink, self.width, self.height)
1258        return self
1259
1260    def __exit__(self, type, value, traceback):
1261        if self.surface is not None:
1262            self.show_page()
1263
1264    def show_page(self):
1265        super().show_page()
1266        self.surface.finish()
1267        self.out.append(self.sink.getvalue())
1268        self.sink = BytesIO()
1269        self.surface = cairo.SVGSurface(self.sink, self.width, self.height)
1270