diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index d5428c200..39e7416f7 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -11,7 +11,7 @@ from fontTools.pens.pointPen import SegmentToPointPen from fontTools.pens.recordingPen import RecordingPen from fontTools.pens.statisticsPen import StatisticsPen from fontTools.pens.momentsPen import OpenContourError -from collections import OrderedDict +from collections import OrderedDict, defaultdict import math import itertools import sys @@ -81,48 +81,43 @@ class RecordingPointPen(BasePen): self.value.append((pt, False if segmentType is None else True)) -def _vdiff(v0, v1): - return tuple(b - a for a, b in zip(v0, v1)) +def _vdiff_hypot2(v0, v1): + s = 0 + for x0, x1 in zip(v0, v1): + d = x1 - x0 + s += d * d + return s -def _vlen(vec): - v = 0 - for x in vec: - v += x * x - return v - - -def _complex_vlen(vec): - v = 0 - for x in vec: - v += abs(x) * abs(x) - return v +def _vdiff_hypot2_complex(v0, v1): + s = 0 + for x0, x1 in zip(v0, v1): + d = x1 - x0 + s += d.real * d.real + d.imag * d.imag + return s def _matching_cost(G, matching): return sum(G[i][j] for i, j in enumerate(matching)) -def min_cost_perfect_bipartite_matching(G): +def min_cost_perfect_bipartite_matching_scipy(G): n = len(G) - try: - from scipy.optimize import linear_sum_assignment + rows, cols = linear_sum_assignment(G) + assert (rows == list(range(n))).all() + return list(cols), _matching_cost(G, cols) - rows, cols = linear_sum_assignment(G) - assert (rows == list(range(n))).all() - return list(cols), _matching_cost(G, cols) - except ImportError: - pass - try: - from munkres import Munkres +def min_cost_perfect_bipartite_matching_munkres(G): + n = len(G) + cols = [None] * n + for row, col in Munkres().compute(G): + cols[row] = col + return cols, _matching_cost(G, cols) - cols = [None] * n - for row, col in Munkres().compute(G): - cols[row] = col - return cols, _matching_cost(G, cols) - except ImportError: - pass + +def min_cost_perfect_bipartite_matching_bruteforce(G): + n = len(G) if n > 6: raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'") @@ -138,6 +133,23 @@ def min_cost_perfect_bipartite_matching(G): return best, best_cost +try: + from scipy.optimize import linear_sum_assignment + + min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy +except ImportError: + try: + from munkres import Munkres + + min_cost_perfect_bipartite_matching = ( + min_cost_perfect_bipartite_matching_munkres + ) + except ImportError: + min_cost_perfect_bipartite_matching = ( + min_cost_perfect_bipartite_matching_bruteforce + ) + + def test(glyphsets, glyphs=None, names=None, ignore_missing=False): if names is None: names = glyphsets @@ -158,9 +170,10 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): allVectors = [] allNodeTypes = [] allContourIsomorphisms = [] - for glyphset, name in zip(glyphsets, names): - glyph = glyphset[glyph_name] - + 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): if glyph is None: if not ignore_missing: add_problem(glyph_name, {"type": "missing", "master": name}) @@ -247,11 +260,12 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): ) # m0idx should be the index of the first non-None item in allNodeTypes, - # else give it the first index of None, which is likely 0 - m0idx = allNodeTypes.index( - next((x for x in allNodeTypes if x is not None), None) + # else give it the last item. + m0idx = next( + (i for i, x in enumerate(allNodeTypes) if x is not None), + len(allNodeTypes) - 1, ) - # m0 is the first non-None item in allNodeTypes, or the first item if all are None + # m0 is the first non-None item in allNodeTypes, or last one if all None m0 = allNodeTypes[m0idx] for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): if m1 is None: @@ -302,39 +316,39 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): continue # m0idx should be the index of the first non-None item in allVectors, - # else give it the first index of None, which is likely 0 - m0idx = allVectors.index( - next((x for x in allVectors if x is not None), None) + # else give it the last item. + m0idx = next( + (i for i, x in enumerate(allVectors) if x is not None), + len(allVectors) - 1, ) - # m0 is the first non-None item in allVectors, or the first item if all are None + # m0 is the first non-None item in allVectors, or last one if all None m0 = allVectors[m0idx] - for i, m1 in enumerate(allVectors[m0idx + 1 :]): - if m1 is None: - continue - if len(m0) != len(m1): - # We already reported this - continue - if not m0: - continue - costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0] - matching, matching_cost = min_cost_perfect_bipartite_matching(costs) - identity_matching = list(range(len(m0))) - identity_cost = sum(costs[i][i] for i in range(len(m0))) - if ( - matching != identity_matching - and matching_cost < identity_cost * 0.95 - ): - add_problem( - glyph_name, - { - "type": "contour_order", - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": list(range(len(m0))), - "value_2": matching, - }, - ) - break + if m0 is not None and len(m0) > 1: + for i, m1 in enumerate(allVectors[m0idx + 1 :]): + if m1 is None: + continue + if len(m0) != len(m1): + # We already reported this + continue + costs = [[_vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0] + matching, matching_cost = min_cost_perfect_bipartite_matching(costs) + identity_matching = list(range(len(m0))) + identity_cost = sum(costs[i][i] for i in range(len(m0))) + if ( + matching != identity_matching + and matching_cost < identity_cost * 0.95 + ): + add_problem( + glyph_name, + { + "type": "contour_order", + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + "value_1": list(range(len(m0))), + "value_2": matching, + }, + ) + break # m0idx should be the index of the first non-None item in allContourIsomorphisms, # else give it the first index of None, which is likely 0 @@ -343,31 +357,31 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): ) # m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None m0 = allContourIsomorphisms[m0idx] - for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]): - if m1 is None: - continue - if len(m0) != len(m1): - # We already reported this - continue - if not m0: - continue - for ix, (contour0, contour1) in enumerate(zip(m0, m1)): - c0 = contour0[0] - costs = [ - v for v in (_complex_vlen(_vdiff(c0, c1)) for c1 in contour1) - ] - min_cost = min(costs) - first_cost = costs[0] - if min_cost < first_cost * 0.95: - add_problem( - glyph_name, - { - "type": "wrong_start_point", - "contour": ix, - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - }, - ) + if m0: + for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]): + if m1 is None: + continue + if len(m0) != len(m1): + # We already reported this + continue + for ix, (contour0, contour1) in enumerate(zip(m0, m1)): + c0 = contour0[0] + costs = [ + v + for v in (_vdiff_hypot2_complex(c0, c1) for c1 in contour1) + ] + min_cost = min(costs) + first_cost = costs[0] + if min_cost < first_cost * 0.95: + add_problem( + glyph_name, + { + "type": "wrong_start_point", + "contour": ix, + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + }, + ) except ValueError as e: add_problem( @@ -377,6 +391,15 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): return problems +def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf): + if glyphname in glyphset: + return + glyphset[glyphname] = ttGlyphSet[glyphname] + + for component in getattr(glyf[glyphname], "components", []): + recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf) + + def main(args=None): """Test for interpolatability issues between fonts""" import argparse @@ -415,7 +438,7 @@ def main(args=None): args = parser.parse_args(args) - glyphs = set(args.glyphs.split()) if args.glyphs else None + glyphs = args.glyphs.split() if args.glyphs else None from os.path import basename @@ -444,30 +467,37 @@ def main(args=None): if "gvar" in font: # Is variable font gvar = font["gvar"] - # Gather all "master" locations - locs = set() - for variations in gvar.variations.values(): - for var in variations: + glyf = font["glyf"] + # Gather all glyphs at their "master" locations + ttGlyphSets = {} + glyphsets = defaultdict(dict) + + if glyphs is None: + glyphs = sorted(gvar.variations.keys()) + for glyphname in glyphs: + for var in gvar.variations[glyphname]: + locDict = {} loc = [] for tag, val in sorted(var.axes.items()): + locDict[tag] = val[1] loc.append((tag, val[1])) - locs.add(tuple(loc)) - # Rebuild locs as dictionaries - new_locs = [{}] - names.append("()") - for loc in sorted(locs, key=lambda v: (len(v), v)): - names.append(str(loc)) - l = {} - for tag, val in loc: - l[tag] = val - new_locs.append(l) - locs = new_locs - del new_locs - # locs is all master locations now - for loc in locs: - fonts.append(font.getGlyphSet(location=loc, normalized=True)) + locTuple = tuple(loc) + if locTuple not in ttGlyphSets: + ttGlyphSets[locTuple] = font.getGlyphSet( + location=locDict, normalized=True + ) + recursivelyAddGlyph( + glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf + ) + + names = ["()"] + fonts = [font.getGlyphSet()] + for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)): + names.append(str(locTuple)) + fonts.append(glyphsets[locTuple]) + args.ignore_missing = True args.inputs = [] for filename in args.inputs: @@ -491,11 +521,12 @@ def main(args=None): glyphsets.append({k: glyphset[k] for k in glyphset.keys()}) if not glyphs: - glyphs = set([gn for glyphset in glyphsets for gn in glyphset.keys()]) + glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()])) + glyphsSet = set(glyphs) for glyphset in glyphsets: glyphSetGlyphNames = set(glyphset.keys()) - diff = glyphs - glyphSetGlyphNames + diff = glyphsSet - glyphSetGlyphNames if diff: for gn in diff: glyphset[gn] = None