Browse Source

Update contrib/python/fonttools to 4.45.1

robot-contrib 1 year ago
parent
commit
460528e80f

+ 9 - 2
contrib/python/fonttools/.dist-info/METADATA

@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: fonttools
-Version: 4.45.0
+Version: 4.45.1
 Summary: Tools to manipulate font files
 Home-page: http://github.com/fonttools/fonttools
 Author: Just van Rossum
@@ -366,10 +366,17 @@ Have fun!
 Changelog
 ~~~~~~~~~
 
+4.45.1 (released 2023-11-23)
+----------------------------
+
+- [varLib.interpolatable] Various bugfixes and improvements, better reporting, reduced
+  false positives.
+- [ttGlyphSet] Added option to not recalculate glyf bounds (#3348).
+
 4.45.0 (released 2023-11-20)
 ----------------------------
 
-- [varLib.interpolator] Vastly improved algorithms. Also available now is ``--pdf``
+- [varLib.interpolatable] Vastly improved algorithms. Also available now is ``--pdf``
   and ``--html`` options to generate a PDF or HTML report of the interpolation issues.
   The PDF/HTML report showcases the problematic masters, the interpolated broken
   glyph, as well as the proposed fixed version.

+ 1 - 1
contrib/python/fonttools/fontTools/__init__.py

@@ -3,6 +3,6 @@ from fontTools.misc.loggingTools import configLogger
 
 log = logging.getLogger(__name__)
 
-version = __version__ = "4.45.0"
+version = __version__ = "4.45.1"
 
 __all__ = ["version", "log", "configLogger"]

+ 4 - 2
contrib/python/fonttools/fontTools/ttLib/ttFont.py

@@ -735,7 +735,9 @@ class TTFont(object):
         else:
             raise KeyError(tag)
 
-    def getGlyphSet(self, preferCFF=True, location=None, normalized=False):
+    def getGlyphSet(
+        self, preferCFF=True, location=None, normalized=False, recalcBounds=True
+    ):
         """Return a generic GlyphSet, which is a dict-like object
         mapping glyph names to glyph objects. The returned glyph objects
         have a ``.draw()`` method that supports the Pen protocol, and will
@@ -766,7 +768,7 @@ class TTFont(object):
         if ("CFF " in self or "CFF2" in self) and (preferCFF or "glyf" not in self):
             return _TTGlyphSetCFF(self, location)
         elif "glyf" in self:
-            return _TTGlyphSetGlyf(self, location)
+            return _TTGlyphSetGlyf(self, location, recalcBounds=recalcBounds)
         else:
             raise TTLibError("Font contains no outlines")
 

+ 13 - 8
contrib/python/fonttools/fontTools/ttLib/ttGlyphSet.py

@@ -17,7 +17,8 @@ class _TTGlyphSet(Mapping):
     glyph shape from TrueType or CFF.
     """
 
-    def __init__(self, font, location, glyphsMapping):
+    def __init__(self, font, location, glyphsMapping, *, recalcBounds=True):
+        self.recalcBounds = recalcBounds
         self.font = font
         self.defaultLocationNormalized = (
             {axis.axisTag: 0 for axis in self.font["fvar"].axes}
@@ -89,13 +90,13 @@ class _TTGlyphSet(Mapping):
 
 
 class _TTGlyphSetGlyf(_TTGlyphSet):
-    def __init__(self, font, location):
+    def __init__(self, font, location, recalcBounds=True):
         self.glyfTable = font["glyf"]
-        super().__init__(font, location, self.glyfTable)
+        super().__init__(font, location, self.glyfTable, recalcBounds=recalcBounds)
         self.gvarTable = font.get("gvar")
 
     def __getitem__(self, glyphName):
-        return _TTGlyphGlyf(self, glyphName)
+        return _TTGlyphGlyf(self, glyphName, recalcBounds=self.recalcBounds)
 
 
 class _TTGlyphSetCFF(_TTGlyphSet):
@@ -129,9 +130,10 @@ class _TTGlyph(ABC):
     attributes.
     """
 
-    def __init__(self, glyphSet, glyphName):
+    def __init__(self, glyphSet, glyphName, *, recalcBounds=True):
         self.glyphSet = glyphSet
         self.name = glyphName
+        self.recalcBounds = recalcBounds
         self.width, self.lsb = glyphSet.hMetrics[glyphName]
         if glyphSet.vMetrics is not None:
             self.height, self.tsb = glyphSet.vMetrics[glyphName]
@@ -258,7 +260,9 @@ class _TTGlyphGlyf(_TTGlyph):
             coordinates += GlyphCoordinates(delta) * scalar
 
         glyph = copy(glyfTable[self.name])  # Shallow copy
-        width, lsb, height, tsb = _setCoordinates(glyph, coordinates, glyfTable)
+        width, lsb, height, tsb = _setCoordinates(
+            glyph, coordinates, glyfTable, recalcBounds=self.recalcBounds
+        )
         self.lsb = lsb
         self.tsb = tsb
         if glyphSet.hvarTable is None:
@@ -276,7 +280,7 @@ class _TTGlyphCFF(_TTGlyph):
         self.glyphSet.charStrings[self.name].draw(pen, self.glyphSet.blender)
 
 
-def _setCoordinates(glyph, coord, glyfTable):
+def _setCoordinates(glyph, coord, glyfTable, *, recalcBounds=True):
     # Handle phantom points for (left, right, top, bottom) positions.
     assert len(coord) >= 4
     leftSideX = coord[-4][0]
@@ -304,7 +308,8 @@ def _setCoordinates(glyph, coord, glyfTable):
         assert len(coord) == len(glyph.coordinates)
         glyph.coordinates = coord
 
-    glyph.recalcBounds(glyfTable)
+    if recalcBounds:
+        glyph.recalcBounds(glyfTable)
 
     horizontalAdvanceWidth = otRound(rightSideX - leftSideX)
     verticalAdvanceWidth = otRound(topSideY - bottomSideY)

+ 436 - 164
contrib/python/fonttools/fontTools/varLib/interpolatable.py

@@ -13,10 +13,11 @@ from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen
 from fontTools.pens.momentsPen import OpenContourError
 from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation
 from fontTools.misc.fixedTools import floatToFixedToStr
+from fontTools.misc.transform import Transform
 from collections import defaultdict, deque
 from functools import wraps
 from pprint import pformat
-from math import sqrt, copysign
+from math import sqrt, copysign, atan2, pi
 import itertools
 import logging
 
@@ -96,9 +97,15 @@ def _vdiff_hypot2_complex(v0, v1):
     for x0, x1 in zip(v0, v1):
         d = x1 - x0
         s += d.real * d.real + d.imag * d.imag
+        # This does the same but seems to be slower:
+        # s += (d * d.conjugate()).real
     return s
 
 
+def _hypot2_complex(d):
+    return d.real * d.real + d.imag * d.imag
+
+
 def _matching_cost(G, matching):
     return sum(G[i][j] for i, j in enumerate(matching))
 
@@ -153,6 +160,9 @@ except ImportError:
 
 
 def _contour_vector_from_stats(stats):
+    # Don't change the order of items here.
+    # It's okay to add to the end, but otherwise, other
+    # code depends on it. Search for "covariance".
     size = sqrt(abs(stats.area))
     return (
         copysign((size), stats.area),
@@ -171,32 +181,41 @@ def _points_characteristic_bits(points):
     return bits
 
 
+_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4
+
+
 def _points_complex_vector(points):
     vector = []
+    if not points:
+        return vector
     points = [complex(*pt) for pt, _ in points]
     n = len(points)
-    points.extend(points[:2])
+    assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4
+    points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
+    while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR:
+        points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
     for i in range(n):
-        p0 = points[i]
+        # The weights are magic numbers.
 
         # The point itself
+        p0 = points[i]
         vector.append(p0)
 
-        # The distance to the next point;
-        # Emphasized by 2 empirically
+        # The vector to the next point
         p1 = points[i + 1]
         d0 = p1 - p0
-        vector.append(d0 * 2)
+        vector.append(d0 * 3)
 
-        """
-        # The angle to the next point, as a cross product;
-        # Square root of, to match dimentionality of distance.
+        # The turn vector
         p2 = points[i + 2]
         d1 = p2 - p1
+        vector.append(d1 - d0)
+
+        # The angle to the next point, as a cross product;
+        # Square root of, to match dimentionality of distance.
         cross = d0.real * d1.imag - d0.imag * d1.real
         cross = copysign(sqrt(abs(cross)), cross)
-        vector.append(cross)
-        """
+        vector.append(cross * 4)
 
     return vector
 
@@ -291,6 +310,7 @@ def test_gen(
     *,
     locations=None,
     tolerance=0.95,
+    show_all=False,
 ):
     if names is None:
         names = glyphsets
@@ -318,17 +338,24 @@ def test_gen(
         allControlVectors = []
         allNodeTypes = []
         allContourIsomorphisms = []
+        allContourPoints = []
         allGlyphs = [glyphset[glyph_name] for glyphset in glyphsets]
         if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
             continue
-        for glyph, glyphset, name in zip(allGlyphs, glyphsets, names):
+        for master_idx, (glyph, glyphset, name) in enumerate(
+            zip(allGlyphs, glyphsets, names)
+        ):
             if glyph is None:
                 if not ignore_missing:
-                    yield (glyph_name, {"type": "missing", "master": name})
+                    yield (
+                        glyph_name,
+                        {"type": "missing", "master": name, "master_idx": master_idx},
+                    )
                 allNodeTypes.append(None)
                 allControlVectors.append(None)
                 allGreenVectors.append(None)
                 allContourIsomorphisms.append(None)
+                allContourPoints.append(None)
                 continue
 
             perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
@@ -342,11 +369,13 @@ def test_gen(
             contourControlVectors = []
             contourGreenVectors = []
             contourIsomorphisms = []
+            contourPoints = []
             nodeTypes = []
             allNodeTypes.append(nodeTypes)
             allControlVectors.append(contourControlVectors)
             allGreenVectors.append(contourGreenVectors)
             allContourIsomorphisms.append(contourIsomorphisms)
+            allContourPoints.append(contourPoints)
             for ix, contour in enumerate(contourPens):
                 contourOps = tuple(op for op, arg in contour.value)
                 nodeTypes.append(contourOps)
@@ -359,7 +388,12 @@ def test_gen(
                 except OpenContourError as e:
                     yield (
                         glyph_name,
-                        {"master": name, "contour": ix, "type": "open_path"},
+                        {
+                            "master": name,
+                            "master_idx": master_idx,
+                            "contour": ix,
+                            "type": "open_path",
+                        },
                     )
                     continue
                 contourGreenVectors.append(_contour_vector_from_stats(greenStats))
@@ -385,6 +419,8 @@ def test_gen(
                 # Add mirrored rotations
                 _add_isomorphisms(points.value, isomorphisms, True)
 
+                contourPoints.append(points.value)
+
         matchings = [None] * len(allControlVectors)
 
         for m1idx in order:
@@ -396,15 +432,20 @@ def test_gen(
             if allNodeTypes[m0idx] is None:
                 continue
 
+            showed = False
+
             m1 = allNodeTypes[m1idx]
             m0 = allNodeTypes[m0idx]
             if len(m0) != len(m1):
+                showed = True
                 yield (
                     glyph_name,
                     {
                         "type": "path_count",
                         "master_1": names[m0idx],
                         "master_2": names[m1idx],
+                        "master_1_idx": m0idx,
+                        "master_2_idx": m1idx,
                         "value_1": len(m0),
                         "value_2": len(m1),
                     },
@@ -416,6 +457,7 @@ def test_gen(
                     if nodes1 == nodes2:
                         continue
                     if len(nodes1) != len(nodes2):
+                        showed = True
                         yield (
                             glyph_name,
                             {
@@ -423,6 +465,8 @@ def test_gen(
                                 "path": pathIx,
                                 "master_1": names[m0idx],
                                 "master_2": names[m1idx],
+                                "master_1_idx": m0idx,
+                                "master_2_idx": m1idx,
                                 "value_1": len(nodes1),
                                 "value_2": len(nodes2),
                             },
@@ -430,6 +474,7 @@ def test_gen(
                         continue
                     for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
                         if n1 != n2:
+                            showed = True
                             yield (
                                 glyph_name,
                                 {
@@ -438,6 +483,8 @@ def test_gen(
                                     "node": nodeIx,
                                     "master_1": names[m0idx],
                                     "master_2": names[m1idx],
+                                    "master_1_idx": m0idx,
+                                    "master_2_idx": m1idx,
                                     "value_1": n1,
                                     "value_2": n2,
                                 },
@@ -504,12 +551,15 @@ def test_gen(
                     if matching_cost < identity_cost * tolerance:
                         # print(matching_cost_control / identity_cost_control, matching_cost_green / identity_cost_green)
 
+                        showed = True
                         yield (
                             glyph_name,
                             {
                                 "type": "contour_order",
                                 "master_1": names[m0idx],
                                 "master_2": names[m1idx],
+                                "master_1_idx": m0idx,
+                                "master_2_idx": m1idx,
                                 "value_1": list(range(len(m0Control))),
                                 "value_2": matching,
                             },
@@ -519,38 +569,194 @@ def test_gen(
             m1 = allContourIsomorphisms[m1idx]
             m0 = allContourIsomorphisms[m0idx]
 
+            # If contour-order is wrong, adjust it
+            if matchings[m1idx] is not None and m1:  # m1 is empty for composite glyphs
+                m1 = [m1[i] for i in matchings[m1idx]]
+
             for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
                 if len(contour0) == 0 or len(contour0) != len(contour1):
-                    # We already reported this; or nothing to do
+                    # We already reported this; or nothing to do; or not compatible
+                    # after reordering above.
                     continue
 
                 c0 = contour0[0]
+                # Next few lines duplicated below.
                 costs = [_vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1]
                 min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1])
                 first_cost = costs[0]
+
                 if min_cost < first_cost * tolerance:
+                    # c0 is the first isomorphism of the m0 master
+                    # contour1 is list of all isomorphisms of the m1 master
+                    #
+                    # If the two shapes are both circle-ish and slightly
+                    # rotated, we detect wrong start point. This is for
+                    # example the case hundreds of times in
+                    # RobotoSerif-Italic[GRAD,opsz,wdth,wght].ttf
+                    #
+                    # If the proposed point is only one off from the first
+                    # point (and not reversed), try harder:
+                    #
+                    # Find the major eigenvector of the covariance matrix,
+                    # and rotate the contours by that angle. Then find the
+                    # closest point again.  If it matches this time, let it
+                    # pass.
+
+                    proposed_point = contour1[min_cost_idx][1]
                     reverse = contour1[min_cost_idx][2]
-
-                    # If contour-order is wrong, don't report a reversing
-                    if (
-                        reverse
-                        and matchings[m1idx] is not None
-                        and matchings[m1idx][ix] != ix
+                    num_points = len(allContourPoints[m1idx][ix])
+                    leeway = 3
+                    okay = False
+                    if not reverse and (
+                        proposed_point <= leeway
+                        or proposed_point >= num_points - leeway
                     ):
-                        continue
+                        # Try harder
+
+                        m0Vectors = allGreenVectors[m1idx][ix]
+                        m1Vectors = allGreenVectors[m1idx][ix]
+
+                        # Recover the covariance matrix from the GreenVectors.
+                        # This is a 2x2 matrix.
+                        transforms = []
+                        for vector in (m0Vectors, m1Vectors):
+                            meanX = vector[1]
+                            meanY = vector[2]
+                            stddevX = vector[3] / 2
+                            stddevY = vector[4] / 2
+                            correlation = vector[5] / abs(vector[0])
+
+                            # https://cookierobotics.com/007/
+                            a = stddevX * stddevX  # VarianceX
+                            c = stddevY * stddevY  # VarianceY
+                            b = correlation * stddevX * stddevY  # Covariance
+
+                            delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5
+                            lambda1 = (a + c) * 0.5 + delta  # Major eigenvalue
+                            lambda2 = (a + c) * 0.5 - delta  # Minor eigenvalue
+                            theta = (
+                                atan2(lambda1 - a, b)
+                                if b != 0
+                                else (pi * 0.5 if a < c else 0)
+                            )
+                            trans = Transform()
+                            trans = trans.translate(meanX, meanY)
+                            trans = trans.rotate(theta)
+                            trans = trans.scale(sqrt(lambda1), sqrt(lambda2))
+                            transforms.append(trans)
+
+                        trans = transforms[0]
+                        new_c0 = (
+                            [
+                                complex(*trans.transformPoint((pt.real, pt.imag)))
+                                for pt in c0[0]
+                            ],
+                        ) + c0[1:]
+                        trans = transforms[1]
+                        new_contour1 = []
+                        for c1 in contour1:
+                            new_c1 = (
+                                [
+                                    complex(*trans.transformPoint((pt.real, pt.imag)))
+                                    for pt in c1[0]
+                                ],
+                            ) + c1[1:]
+                            new_contour1.append(new_c1)
+
+                        # Next few lines duplicate from above.
+                        costs = [
+                            _vdiff_hypot2_complex(new_c0[0], new_c1[0])
+                            for new_c1 in new_contour1
+                        ]
+                        min_cost_idx, min_cost = min(
+                            enumerate(costs), key=lambda x: x[1]
+                        )
+                        first_cost = costs[0]
+                        # Only accept a perfect match
+                        if min_cost_idx == 0:
+                            okay = True
 
-                    yield (
-                        glyph_name,
-                        {
-                            "type": "wrong_start_point",
-                            "contour": ix,
-                            "master_1": names[m0idx],
-                            "master_2": names[m1idx],
-                            "value_1": 0,
-                            "value_2": contour1[min_cost_idx][1],
-                            "reversed": reverse,
-                        },
-                    )
+                    if not okay:
+                        showed = True
+                        yield (
+                            glyph_name,
+                            {
+                                "type": "wrong_start_point",
+                                "contour": ix,
+                                "master_1": names[m0idx],
+                                "master_2": names[m1idx],
+                                "master_1_idx": m0idx,
+                                "master_2_idx": m1idx,
+                                "value_1": 0,
+                                "value_2": proposed_point,
+                                "reversed": reverse,
+                            },
+                        )
+                else:
+                    # If first_cost is Too Large™, do further inspection.
+                    # This can happen specially in the case of TrueType
+                    # fonts, where the original contour had wrong start point,
+                    # but because of the cubic->quadratic conversion, we don't
+                    # have many isomorphisms to work with.
+
+                    # The threshold here is all black magic. It's just here to
+                    # speed things up so we don't end up doing a full matching
+                    # on every contour that is correct.
+                    threshold = (
+                        len(c0[0]) * (allControlVectors[m0idx][ix][0] * 0.5) ** 2 / 4
+                    )  # Magic only
+                    c1 = contour1[min_cost_idx]
+
+                    # If point counts are different it's because of the contour
+                    # reordering above. We can in theory still try, but our
+                    # bipartite-matching implementations currently assume
+                    # equal number of vertices on both sides. I'm lazy to update
+                    # all three different implementations!
+
+                    if len(c0[0]) == len(c1[0]) and first_cost > threshold:
+                        # Do a quick(!) matching between the points. If it's way off,
+                        # flag it. This can happen specially in the case of TrueType
+                        # fonts, where the original contour had wrong start point, but
+                        # because of the cubic->quadratic conversion, we don't have many
+                        # isomorphisms.
+                        points0 = c0[0][::_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR]
+                        points1 = c1[0][::_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR]
+
+                        graph = [
+                            [_hypot2_complex(p0 - p1) for p1 in points1]
+                            for p0 in points0
+                        ]
+                        matching, matching_cost = min_cost_perfect_bipartite_matching(
+                            graph
+                        )
+                        identity_cost = sum(graph[i][i] for i in range(len(graph)))
+
+                        if matching_cost < identity_cost / 8:  # Heuristic
+                            # print(matching_cost, identity_cost, matching)
+                            showed = True
+                            yield (
+                                glyph_name,
+                                {
+                                    "type": "wrong_structure",
+                                    "contour": ix,
+                                    "master_1": names[m0idx],
+                                    "master_2": names[m1idx],
+                                    "master_1_idx": m0idx,
+                                    "master_2_idx": m1idx,
+                                },
+                            )
+
+            if show_all and not showed:
+                yield (
+                    glyph_name,
+                    {
+                        "type": "nothing",
+                        "master_1": names[m0idx],
+                        "master_2": names[m1idx],
+                        "master_1_idx": m0idx,
+                        "master_2_idx": m1idx,
+                    },
+                )
 
 
 @wraps(test_gen)
@@ -584,6 +790,11 @@ def main(args=None):
         action="store",
         help="Space-separate name of glyphs to check",
     )
+    parser.add_argument(
+        "--show-all",
+        action="store_true",
+        help="Show all glyph pairs, even if no problems are found",
+    )
     parser.add_argument(
         "--tolerance",
         action="store",
@@ -627,6 +838,13 @@ def main(args=None):
         nargs="+",
         help="Input a single variable font / DesignSpace / Glyphs file, or multiple TTF/UFO files",
     )
+    parser.add_argument(
+        "--name",
+        metavar="NAME",
+        type=str,
+        action="append",
+        help="Name of the master to use in the report. If not provided, all are used.",
+    )
     parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.")
 
     args = parser.parse_args(args)
@@ -643,6 +861,8 @@ def main(args=None):
     names = []
     locations = []
 
+    original_args_inputs = tuple(args.inputs)
+
     if len(args.inputs) == 1:
         designspace = None
         if args.inputs[0].endswith(".designspace"):
@@ -721,7 +941,7 @@ def main(args=None):
                         locTuple = tuple(loc)
                         if locTuple not in ttGlyphSets:
                             ttGlyphSets[locTuple] = font.getGlyphSet(
-                                location=locDict, normalized=True
+                                location=locDict, normalized=True, recalcBounds=False
                             )
 
                         recursivelyAddGlyph(
@@ -776,8 +996,19 @@ def main(args=None):
             glyphset = font
         glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
 
-    if len(glyphsets) == 1:
-        return None
+    if args.name:
+        accepted_names = set(args.name)
+        glyphsets = [
+            glyphset
+            for name, glyphset in zip(names, glyphsets)
+            if name in accepted_names
+        ]
+        locations = [
+            location
+            for name, location in zip(names, locations)
+            if name in accepted_names
+        ]
+        names = [name for name in names if name in accepted_names]
 
     if not glyphs:
         glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()]))
@@ -793,140 +1024,181 @@ def main(args=None):
     # Normalize locations
     locations = [normalizeLocation(loc, axis_triples) for loc in locations]
 
-    log.info("Running on %d glyphsets", len(glyphsets))
-    log.info("Locations: %s", pformat(locations))
-    problems_gen = test_gen(
-        glyphsets,
-        glyphs=glyphs,
-        names=names,
-        locations=locations,
-        ignore_missing=args.ignore_missing,
-        tolerance=args.tolerance or 0.95,
-    )
-    problems = defaultdict(list)
+    try:
+        log.info("Running on %d glyphsets", len(glyphsets))
+        log.info("Locations: %s", pformat(locations))
+        problems_gen = test_gen(
+            glyphsets,
+            glyphs=glyphs,
+            names=names,
+            locations=locations,
+            ignore_missing=args.ignore_missing,
+            tolerance=args.tolerance or 0.95,
+            show_all=args.show_all,
+        )
+        problems = defaultdict(list)
 
-    f = sys.stdout if args.output is None else open(args.output, "w")
+        f = sys.stdout if args.output is None else open(args.output, "w")
 
-    if not args.quiet:
-        if args.json:
-            import json
+        if not args.quiet:
+            if args.json:
+                import json
 
-            for glyphname, problem in problems_gen:
-                problems[glyphname].append(problem)
+                for glyphname, problem in problems_gen:
+                    problems[glyphname].append(problem)
 
-            print(json.dumps(problems), file=f)
-        else:
-            last_glyphname = None
-            for glyphname, p in problems_gen:
-                problems[glyphname].append(p)
+                print(json.dumps(problems), file=f)
+            else:
+                last_glyphname = None
+                for glyphname, p in problems_gen:
+                    problems[glyphname].append(p)
 
-                if glyphname != last_glyphname:
-                    print(f"Glyph {glyphname} was not compatible:", file=f)
-                    last_glyphname = glyphname
-                    last_masters = None
+                    if glyphname != last_glyphname:
+                        print(f"Glyph {glyphname} was not compatible:", file=f)
+                        last_glyphname = glyphname
+                        last_master_idxs = None
 
-                masters = (
-                    (p["master"]) if "master" in p else (p["master_1"], p["master_2"])
-                )
-                if masters != last_masters:
-                    print(f"  Masters: %s:" % ", ".join(masters), file=f)
-                    last_masters = masters
-
-                if p["type"] == "missing":
-                    print("    Glyph was missing in master %s" % p["master"], file=f)
-                if p["type"] == "open_path":
-                    print(
-                        "    Glyph has an open path in master %s" % p["master"], file=f
-                    )
-                if p["type"] == "path_count":
-                    print(
-                        "    Path count differs: %i in %s, %i in %s"
-                        % (p["value_1"], p["master_1"], p["value_2"], p["master_2"]),
-                        file=f,
-                    )
-                if p["type"] == "node_count":
-                    print(
-                        "    Node count differs in path %i: %i in %s, %i in %s"
-                        % (
-                            p["path"],
-                            p["value_1"],
-                            p["master_1"],
-                            p["value_2"],
-                            p["master_2"],
-                        ),
-                        file=f,
-                    )
-                if p["type"] == "node_incompatibility":
-                    print(
-                        "    Node %o incompatible in path %i: %s in %s, %s in %s"
-                        % (
-                            p["node"],
-                            p["path"],
-                            p["value_1"],
-                            p["master_1"],
-                            p["value_2"],
-                            p["master_2"],
-                        ),
-                        file=f,
-                    )
-                if p["type"] == "contour_order":
-                    print(
-                        "    Contour order differs: %s in %s, %s in %s"
-                        % (
-                            p["value_1"],
-                            p["master_1"],
-                            p["value_2"],
-                            p["master_2"],
-                        ),
-                        file=f,
+                    master_idxs = (
+                        (p["master_idx"])
+                        if "master_idx" in p
+                        else (p["master_1_idx"], p["master_2_idx"])
                     )
-                if p["type"] == "wrong_start_point":
-                    print(
-                        "    Contour %d start point differs: %s in %s, %s in %s; reversed: %s"
-                        % (
-                            p["contour"],
-                            p["value_1"],
-                            p["master_1"],
-                            p["value_2"],
-                            p["master_2"],
-                            p["reversed"],
-                        ),
-                        file=f,
-                    )
-    else:
-        for glyphname, problem in problems_gen:
-            problems[glyphname].append(problem)
-
-    if args.pdf:
-        log.info("Writing PDF to %s", args.pdf)
-        from .interpolatablePlot import InterpolatablePDF
-
-        with InterpolatablePDF(args.pdf, glyphsets=glyphsets, names=names) as pdf:
-            pdf.add_problems(problems)
-            if not problems and not args.quiet:
-                pdf.draw_cupcake()
-
-    if args.html:
-        log.info("Writing HTML to %s", args.html)
-        from .interpolatablePlot import InterpolatableSVG
-
-        svgs = []
-        with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg:
-            svg.add_problems(problems)
-            if not problems and not args.quiet:
-                svg.draw_cupcake()
-
-        import base64
-
-        with open(args.html, "wb") as f:
-            f.write(b"<!DOCTYPE html>\n")
-            f.write(b"<html><body align=center>\n")
-            for svg in svgs:
-                f.write("<img src='data:image/svg+xml;base64,".encode("utf-8"))
-                f.write(base64.b64encode(svg))
-                f.write(b"' />\n")
-                f.write(b"<hr>\n")
-            f.write(b"</body></html>\n")
+                    if master_idxs != last_master_idxs:
+                        master_names = (
+                            (p["master"])
+                            if "master" in p
+                            else (p["master_1"], p["master_2"])
+                        )
+                        print(f"  Masters: %s:" % ", ".join(master_names), file=f)
+                        last_master_idxs = master_idxs
+
+                    if p["type"] == "missing":
+                        print(
+                            "    Glyph was missing in master %s" % p["master"], file=f
+                        )
+                    elif p["type"] == "open_path":
+                        print(
+                            "    Glyph has an open path in master %s" % p["master"],
+                            file=f,
+                        )
+                    elif p["type"] == "path_count":
+                        print(
+                            "    Path count differs: %i in %s, %i in %s"
+                            % (
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == "node_count":
+                        print(
+                            "    Node count differs in path %i: %i in %s, %i in %s"
+                            % (
+                                p["path"],
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == "node_incompatibility":
+                        print(
+                            "    Node %o incompatible in path %i: %s in %s, %s in %s"
+                            % (
+                                p["node"],
+                                p["path"],
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == "contour_order":
+                        print(
+                            "    Contour order differs: %s in %s, %s in %s"
+                            % (
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == "wrong_start_point":
+                        print(
+                            "    Contour %d start point differs: %s in %s, %s in %s; reversed: %s"
+                            % (
+                                p["contour"],
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                                p["reversed"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == "wrong_structure":
+                        print(
+                            "    Contour %d structures differ: %s, %s"
+                            % (
+                                p["contour"],
+                                p["master_1"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == "nothing":
+                        print(
+                            "    Nothing wrong between %s and %s"
+                            % (
+                                p["master_1"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+        else:
+            for glyphname, problem in problems_gen:
+                problems[glyphname].append(problem)
+
+        if args.pdf:
+            log.info("Writing PDF to %s", args.pdf)
+            from .interpolatablePlot import InterpolatablePDF
+
+            with InterpolatablePDF(args.pdf, glyphsets=glyphsets, names=names) as pdf:
+                pdf.add_problems(problems)
+                if not problems and not args.quiet:
+                    pdf.draw_cupcake()
+
+        if args.html:
+            log.info("Writing HTML to %s", args.html)
+            from .interpolatablePlot import InterpolatableSVG
+
+            svgs = []
+            with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg:
+                svg.add_problems(problems)
+                if not problems and not args.quiet:
+                    svg.draw_cupcake()
+
+            import base64
+
+            with open(args.html, "wb") as f:
+                f.write(b"<!DOCTYPE html>\n")
+                f.write(b"<html><body align=center>\n")
+                for svg in svgs:
+                    f.write("<img src='data:image/svg+xml;base64,".encode("utf-8"))
+                    f.write(base64.b64encode(svg))
+                    f.write(b"' />\n")
+                    f.write(b"<hr>\n")
+                f.write(b"</body></html>\n")
+
+    except Exception as e:
+        e.args += original_args_inputs
+        log.error(e)
+        raise
 
     if problems:
         return problems

+ 47 - 20
contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py

@@ -98,16 +98,16 @@ class InterpolatablePlot:
                   ,@@,.@@@.  @.@@@,.
                 ,@@. @@@.     @@. @@,.
         ,@@@.@,.@.              @.  @@@@,.@.@@,.
-   ,@@.@.     @@.@@.            @,.    .@’ @’  @@,
- ,@@. @.          .@@.@@@.  @@                  @,
+   ,@@.@.     @@.@@.            @,.    .@' @'  @@,
+ ,@@. @.          .@@.@@@.  @@'                  @,
 ,@.  @@.                                          @,
 @.     @,@@,.     ,                             .@@,
 @,.       .@,@@,.         .@@,.  ,       .@@,  @, @,
 @.                             .@. @ @@,.    ,      @
- @,.@@.     @,.      @@,.      @.           @,.    @
-  @@||@,.  @’@,.       @@,.  @@ @,.        @’@@,  @’
-     \\@@@@’  @,.      @’@@@@’   @@,.   @@@’ //@@@’
-      |||||||| @@,.  @@ |||||||  |@@@|@||  ||
+ @,.@@.     @,.      @@,.      @.           @,.    @'
+  @@||@,.  @'@,.       @@,.  @@ @,.        @'@@,  @'
+     \\@@@@'  @,.      @'@@@@'   @@,.   @@@' //@@@'
+      |||||||| @@,.  @@' |||||||  |@@@|@||  ||
        \\\\\\\  ||@@@||  |||||||  |||||||  //
         |||||||  ||||||  ||||||   ||||||  ||
          \\\\\\  ||||||  ||||||  ||||||  //
@@ -148,7 +148,9 @@ class InterpolatablePlot:
             current_glyph_problems = []
             for p in glyph_problems:
                 masters = (
-                    p["master"] if "master" in p else (p["master_1"], p["master_2"])
+                    p["master_idx"]
+                    if "master_idx" in p
+                    else (p["master_1_idx"], p["master_2_idx"])
                 )
                 if masters == last_masters:
                     current_glyph_problems.append(p)
@@ -176,10 +178,11 @@ class InterpolatablePlot:
         log.info("Drawing %s: %s", glyphname, problem_type)
 
         master_keys = (
-            ("master",) if "master" in problems[0] else ("master_1", "master_2")
+            ("master_idx",)
+            if "master_idx" in problems[0]
+            else ("master_1_idx", "master_2_idx")
         )
-        master_names = [problems[0][k] for k in master_keys]
-        master_indices = [self.names.index(n) for n in master_names]
+        master_indices = [problems[0][k] for k in master_keys]
 
         if problem_type == "missing":
             sample_glyph = next(
@@ -225,7 +228,10 @@ class InterpolatablePlot:
                 self.draw_shrug(x=x, y=y)
             y += self.height + self.pad
 
-        if any(pt in ("wrong_start_point", "contour_order") for pt in problem_types):
+        if any(
+            pt in ("nothing", "wrong_start_point", "contour_order", "wrong_structure")
+            for pt in problem_types
+        ):
             x = self.pad + self.width + self.pad
             y = self.pad
             y += self.line_height + self.pad
@@ -251,6 +257,10 @@ class InterpolatablePlot:
             self.draw_label("proposed fix", x=x, y=y, color=self.head_color, align=0.5)
             y += self.line_height + self.pad
 
+            if problem_type == "wrong_structure":
+                self.draw_shrug(x=x, y=y)
+                return
+
             overriding1 = OverridingDict(glyphset1)
             overriding2 = OverridingDict(glyphset2)
             perContourPen1 = PerContourOrComponentPen(
@@ -382,7 +392,16 @@ class InterpolatablePlot:
         glyph_width = boundsPen.bounds[2] - boundsPen.bounds[0]
         glyph_height = boundsPen.bounds[3] - boundsPen.bounds[1]
 
-        scale = min(self.width / glyph_width, self.height / glyph_height)
+        scale = None
+        if glyph_width:
+            scale = self.width / glyph_width
+        if glyph_height:
+            if scale is None:
+                scale = self.height / glyph_height
+            else:
+                scale = min(scale, self.height / glyph_height)
+        if scale is None:
+            scale = 1
 
         cr = cairo.Context(self.surface)
         cr.translate(x, y)
@@ -415,7 +434,12 @@ class InterpolatablePlot:
             cr.set_line_width(self.stroke_width / scale)
             cr.stroke()
 
-        if problem_type in ("node_count", "node_incompatibility"):
+        if problem_type in (
+            "nothing",
+            "node_count",
+            "node_incompatibility",
+            "wrong_structure",
+        ):
             cr.set_line_cap(cairo.LINE_CAP_ROUND)
 
             # Oncurve nodes
@@ -463,6 +487,7 @@ class InterpolatablePlot:
             cr.set_line_width(self.handle_width / scale)
             cr.stroke()
 
+        matching = None
         for problem in problems:
             if problem["type"] == "contour_order":
                 matching = problem["value_2"]
@@ -480,18 +505,20 @@ class InterpolatablePlot:
                     cr.fill()
 
         for problem in problems:
-            if problem["type"] == "wrong_start_point":
-                idx = problem["contour"]
+            if problem["type"] in ("nothing", "wrong_start_point", "wrong_structure"):
+                idx = problem.get("contour")
 
                 # Draw suggested point
-                if which == 1:
+                if idx is not None and which == 1 and "value_2" in problem:
                     perContourPen = PerContourOrComponentPen(
                         RecordingPen, glyphset=glyphset
                     )
                     recording.replay(perContourPen)
                     points = SimpleRecordingPointPen()
                     converter = SegmentToPointPen(points, False)
-                    perContourPen.value[idx].replay(converter)
+                    perContourPen.value[
+                        idx if matching is None else matching[idx]
+                    ].replay(converter)
                     targetPoint = points.value[problem["value_2"]][0]
                     cr.move_to(*targetPoint)
                     cr.line_to(*targetPoint)
@@ -505,12 +532,12 @@ class InterpolatablePlot:
                 i = 0
                 for segment, args in recording.value:
                     if segment == "moveTo":
-                        if i == idx:
+                        if idx is None or i == idx:
                             cr.move_to(*args[0])
                             cr.line_to(*args[0])
                         i += 1
 
-                if which == 0 or not problem["reversed"]:
+                if which == 0 or not problem.get("reversed"):
                     cr.set_source_rgb(*self.start_point_color)
                 else:
                     cr.set_source_rgb(*self.reversed_start_point_color)
@@ -529,7 +556,7 @@ class InterpolatablePlot:
                         continue
                     second_pt = args[0]
 
-                    if i == idx:
+                    if idx is None or i == idx:
                         first_pt = complex(*first_pt)
                         second_pt = complex(*second_pt)
                         length = abs(second_pt - first_pt)

+ 1 - 1
contrib/python/fonttools/ya.make

@@ -2,7 +2,7 @@
 
 PY3_LIBRARY()
 
-VERSION(4.45.0)
+VERSION(4.45.1)
 
 LICENSE(MIT)