From 636295a7ee558c5712a719c632a91d6c65bfc6d8 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 12 Oct 2023 16:03:22 -0600 Subject: [PATCH 01/13] [varLib.interpolatable] Speed up working on variable fonts Only check each glyph at its own "master" locations instead of at all master locations across the font. Incomplete. Fixes https://github.com/fonttools/fontbakery/discussions/4301 --- Lib/fontTools/varLib/interpolatable.py | 42 ++++++++++++++------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index d5428c200..60eafc181 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 @@ -444,30 +444,34 @@ 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(): + # Gather all glyphs at their "master" locations + ttGlyphSets = {} + glyphsets = defaultdict(dict) + + for glyphname, variations in gvar.variations.items(): for var in variations: + 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 + ) + glyphsets[locTuple][glyphname] = ttGlyphSets[locTuple][ + glyphname + ] + + 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: From 739868f78ed15c8601921301a43875a1ac52e81a Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 12 Oct 2023 18:49:58 -0400 Subject: [PATCH 02/13] [varLib.interpolatable] Skip "drawing" glyphs with only one master Speeds up Handjet another 10x. Part of fixing https://github.com/fonttools/fontbakery/discussions/4301 --- Lib/fontTools/varLib/interpolatable.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 60eafc181..3c15b4352 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -158,8 +158,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: From 408dea84bc367f1e4238e9404588bd5837905735 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 12 Oct 2023 19:09:50 -0400 Subject: [PATCH 03/13] [varLib.interpolatable] Speed up complex_vlen() --- Lib/fontTools/varLib/interpolatable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 3c15b4352..c15f49f18 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -95,7 +95,7 @@ def _vlen(vec): def _complex_vlen(vec): v = 0 for x in vec: - v += abs(x) * abs(x) + v += x.real * x.real + x.imag * x.imag return v From 5eff55e654995c7b5f149b6fb0cb86c8741e8e47 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 12 Oct 2023 19:23:51 -0400 Subject: [PATCH 04/13] [varLib.interpolatable] Don't iterate over all glyphs if not needed --- Lib/fontTools/varLib/interpolatable.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index c15f49f18..d81614907 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -450,8 +450,10 @@ def main(args=None): ttGlyphSets = {} glyphsets = defaultdict(dict) - for glyphname, variations in gvar.variations.items(): - for var in variations: + if glyphs is None: + glyphs = gvar.variations.keys() + for glyphname in glyphs: + for var in gvar.variations[glyphname]: locDict = {} loc = [] for tag, val in sorted(var.axes.items()): From 89b6b95ba9fc0d12e366b6fead3d5ae90ac0f02b Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 12 Oct 2023 20:07:57 -0400 Subject: [PATCH 05/13] [varLib.interpolatable] Combine vdiff() and vlen() Faster. --- Lib/fontTools/varLib/interpolatable.py | 31 ++++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index d81614907..97fb43eaa 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -81,22 +81,20 @@ 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 += x.real * x.real + x.imag * x.imag - 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): @@ -162,7 +160,6 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): 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}) @@ -318,7 +315,7 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): continue if not m0: continue - costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0] + 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))) @@ -356,7 +353,7 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): 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) + v for v in (_vdiff_hypot2_complex(c0, c1) for c1 in contour1) ] min_cost = min(costs) first_cost = costs[0] From ca11333babcb0262f7fdfb6e3fb6b5e1697e59ba Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 12 Oct 2023 20:45:23 -0400 Subject: [PATCH 06/13] [varLib.interpolatable] Close over component glyphs ...when processing variable font. Continuation of 636295a7ee558c5712a719c632a91d6c65bfc6d8 Part of fixing https://github.com/fonttools/fontbakery/discussions/4301 --- Lib/fontTools/varLib/interpolatable.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 97fb43eaa..6cbd4a147 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -376,6 +376,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 @@ -443,6 +452,7 @@ def main(args=None): if "gvar" in font: # Is variable font gvar = font["gvar"] + glyf = font["glyf"] # Gather all glyphs at their "master" locations ttGlyphSets = {} glyphsets = defaultdict(dict) @@ -463,9 +473,9 @@ def main(args=None): location=locDict, normalized=True ) - glyphsets[locTuple][glyphname] = ttGlyphSets[locTuple][ - glyphname - ] + recursivelyAddGlyph( + glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf + ) names = ["()"] fonts = [font.getGlyphSet()] From 54bf9723a88e944fbaa2f944d747c964a6ca06b9 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 12 Oct 2023 20:57:28 -0400 Subject: [PATCH 07/13] [varLib.interpolatable] Sort glyphs by glyphname by default --- Lib/fontTools/varLib/interpolatable.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 6cbd4a147..da25f2bf6 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -423,7 +423,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 @@ -458,7 +458,7 @@ def main(args=None): glyphsets = defaultdict(dict) if glyphs is None: - glyphs = gvar.variations.keys() + glyphs = sorted(gvar.variations.keys()) for glyphname in glyphs: for var in gvar.variations[glyphname]: locDict = {} @@ -506,11 +506,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 From 9f42783d6fee8ae4f692669bb6b66c3910a673c1 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 14 Oct 2023 17:01:08 -0400 Subject: [PATCH 08/13] [varLib.interpolatable] Reduce imports in bipartite-matching Choose the right implementation upfront --- Lib/fontTools/varLib/interpolatable.py | 46 +++++++++++++++++--------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index da25f2bf6..34d7e668e 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -101,26 +101,23 @@ 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'") @@ -136,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 From d29856b2e4c2f56943567e4c1f224b2c90929b59 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 14 Oct 2023 17:09:27 -0400 Subject: [PATCH 09/13] [varLib.interpolatable] Micro-optimize a check --- Lib/fontTools/varLib/interpolatable.py | 102 ++++++++++++------------- 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 34d7e668e..64e46a343 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -321,33 +321,32 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): ) # m0 is the first non-None item in allVectors, or the first item if all are 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 = [[_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 + if m0: + 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 @@ -356,31 +355,30 @@ 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 (_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], - }, - ) + 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( From 4714c37028f462b29159ef7f7186e51ef68c20e1 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 14 Oct 2023 17:25:52 -0400 Subject: [PATCH 10/13] [varLib.interpolatable] Speed up m0idx finding Don't use list.index() which would compare vectors... --- Lib/fontTools/varLib/interpolatable.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 64e46a343..4f39e89ed 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -260,11 +260,9 @@ 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) - ) - # m0 is the first non-None item in allNodeTypes, or the first item if all are 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 last one if all None m0 = allNodeTypes[m0idx] for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): if m1 is None: @@ -315,11 +313,9 @@ 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) - ) - # m0 is the first non-None item in allVectors, or the first item if all are 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 last one if all None m0 = allVectors[m0idx] if m0: for i, m1 in enumerate(allVectors[m0idx + 1 :]): From b01fbf2785bb70940d32ad35f008de7468bae18b Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 14 Oct 2023 17:42:08 -0400 Subject: [PATCH 11/13] [varLib.interpolatable] Skip comparing contour orders if only 0 or 1 --- Lib/fontTools/varLib/interpolatable.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 4f39e89ed..39e7416f7 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -261,7 +261,10 @@ 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 last item. - m0idx = next((i for i,x in enumerate(allNodeTypes) if x is not None), len(allNodeTypes) - 1) + 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 last one if all None m0 = allNodeTypes[m0idx] for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): @@ -314,10 +317,13 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): # m0idx should be the index of the first non-None item in allVectors, # else give it the last item. - m0idx = next((i for i,x in enumerate(allVectors) if x is not None), len(allVectors) - 1) + 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 last one if all None m0 = allVectors[m0idx] - if m0: + if m0 is not None and len(m0) > 1: for i, m1 in enumerate(allVectors[m0idx + 1 :]): if m1 is None: continue @@ -361,7 +367,8 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False): 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) + v + for v in (_vdiff_hypot2_complex(c0, c1) for c1 in contour1) ] min_cost = min(costs) first_cost = costs[0] From 0914c6c0eefb1317aba95779fb469e138b19f365 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 14 Oct 2023 17:51:48 -0400 Subject: [PATCH 12/13] [varLib.interpolatable] Cythonize Doesn't save much; only 5% in RobotoFlex. Probably going to revert. --- Lib/fontTools/varLib/_interpolatable.py | 419 ++++++++++++++++++++++++ Lib/fontTools/varLib/interpolatable.py | 385 +--------------------- setup.py | 6 + 3 files changed, 427 insertions(+), 383 deletions(-) create mode 100644 Lib/fontTools/varLib/_interpolatable.py diff --git a/Lib/fontTools/varLib/_interpolatable.py b/Lib/fontTools/varLib/_interpolatable.py new file mode 100644 index 000000000..3a770de95 --- /dev/null +++ b/Lib/fontTools/varLib/_interpolatable.py @@ -0,0 +1,419 @@ +""" +Tool to find wrong contour order between different masters, and +other interpolatability (or lack thereof) issues. + +Call as: +$ fonttools varLib.interpolatable font1 font2 ... +""" + +try: + import cython + + COMPILED = cython.compiled +except (AttributeError, ImportError): + # if cython not installed, use mock module with no-op decorators and types + from fontTools.misc import cython + + COMPILED = False + + +from fontTools.pens.basePen import AbstractPen, BasePen +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 +import math +import itertools +import sys + + +def _rot_list(l, k): + """Rotate list by k items forward. Ie. item at position 0 will be + at position k in returned list. Negative k is allowed.""" + n = len(l) + k %= n + if not k: + return l + return l[n - k :] + l[: n - k] + + +class PerContourPen(BasePen): + def __init__(self, Pen, glyphset=None): + BasePen.__init__(self, glyphset) + self._glyphset = glyphset + self._Pen = Pen + self._pen = None + self.value = [] + + def _moveTo(self, p0): + self._newItem() + self._pen.moveTo(p0) + + def _lineTo(self, p1): + self._pen.lineTo(p1) + + def _qCurveToOne(self, p1, p2): + self._pen.qCurveTo(p1, p2) + + def _curveToOne(self, p1, p2, p3): + self._pen.curveTo(p1, p2, p3) + + def _closePath(self): + self._pen.closePath() + self._pen = None + + def _endPath(self): + self._pen.endPath() + self._pen = None + + def _newItem(self): + self._pen = pen = self._Pen() + self.value.append(pen) + + +class PerContourOrComponentPen(PerContourPen): + def addComponent(self, glyphName, transformation): + self._newItem() + self.value[-1].addComponent(glyphName, transformation) + + +class RecordingPointPen(BasePen): + def __init__(self): + self.value = [] + + def beginPath(self, identifier=None, **kwargs): + pass + + def endPath(self) -> None: + pass + + def addPoint(self, pt, segmentType=None): + self.value.append((pt, False if segmentType is None else True)) + + +@cython.cfunc +@cython.inline +@cython.locals( + s=cython.double, + d=cython.double, + x0=cython.double, + x1=cython.double, +) +def _vdiff_hypot2(v0, v1): + s = 0 + for x0, x1 in zip(v0, v1): + d = x1 - x0 + s += d * d + return s + + +@cython.cfunc +@cython.inline +@cython.locals( + s=cython.double, + d=cython.complex, + x0=cython.complex, + x1=cython.complex, +) +def _vdiff_hypot2_complex(v0, v1): + s = 0 + d = 0 # Make Cython happy + 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_scipy(G): + n = len(G) + rows, cols = linear_sum_assignment(G) + assert (rows == list(range(n))).all() + return list(cols), _matching_cost(G, cols) + + +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) + + +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'") + + # Otherwise just brute-force + permutations = itertools.permutations(range(n)) + best = list(next(permutations)) + best_cost = _matching_cost(G, best) + for p in permutations: + cost = _matching_cost(G, p) + if cost < best_cost: + best, best_cost = list(p), cost + 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 + if glyphs is None: + # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order + # ... risks the sparse master being the first one, and only processing a subset of the glyphs + glyphs = {g for glyphset in glyphsets for g in glyphset.keys()} + + hist = [] + problems = OrderedDict() + + def add_problem(glyphname, problem): + problems.setdefault(glyphname, []).append(problem) + + for glyph_name in glyphs: + try: + m0idx = 0 + allVectors = [] + allNodeTypes = [] + allContourIsomorphisms = [] + 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}) + allNodeTypes.append(None) + allVectors.append(None) + allContourIsomorphisms.append(None) + continue + + perContourPen = PerContourOrComponentPen( + RecordingPen, glyphset=glyphset + ) + try: + glyph.draw(perContourPen, outputImpliedClosingLine=True) + except TypeError: + glyph.draw(perContourPen) + contourPens = perContourPen.value + del perContourPen + + contourVectors = [] + contourIsomorphisms = [] + nodeTypes = [] + allNodeTypes.append(nodeTypes) + allVectors.append(contourVectors) + allContourIsomorphisms.append(contourIsomorphisms) + for ix, contour in enumerate(contourPens): + nodeVecs = tuple(instruction[0] for instruction in contour.value) + nodeTypes.append(nodeVecs) + + stats = StatisticsPen(glyphset=glyphset) + try: + contour.replay(stats) + except OpenContourError as e: + add_problem( + glyph_name, + {"master": name, "contour": ix, "type": "open_path"}, + ) + continue + size = math.sqrt(abs(stats.area)) * 0.5 + vector = ( + int(size), + int(stats.meanX), + int(stats.meanY), + int(stats.stddevX * 2), + int(stats.stddevY * 2), + int(stats.correlation * size), + ) + contourVectors.append(vector) + # print(vector) + + # Check starting point + if nodeVecs[0] == "addComponent": + continue + assert nodeVecs[0] == "moveTo" + assert nodeVecs[-1] in ("closePath", "endPath") + points = RecordingPointPen() + converter = SegmentToPointPen(points, False) + contour.replay(converter) + # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve; + # now check all rotations and mirror-rotations of the contour and build list of isomorphic + # possible starting points. + bits = 0 + for pt, b in points.value: + bits = (bits << 1) | b + n = len(points.value) + mask = (1 << n) - 1 + isomorphisms = [] + contourIsomorphisms.append(isomorphisms) + for i in range(n): + b = ((bits << i) & mask) | ((bits >> (n - i))) + if b == bits: + isomorphisms.append( + _rot_list([complex(*pt) for pt, bl in points.value], i) + ) + # Add mirrored rotations + mirrored = list(reversed(points.value)) + reversed_bits = 0 + for pt, b in mirrored: + reversed_bits = (reversed_bits << 1) | b + for i in range(n): + b = ((reversed_bits << i) & mask) | ((reversed_bits >> (n - i))) + if b == bits: + isomorphisms.append( + _rot_list([complex(*pt) for pt, bl in mirrored], i) + ) + + # m0idx should be the index of the first non-None item in allNodeTypes, + # 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 last one if all None + m0 = allNodeTypes[m0idx] + for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): + if m1 is None: + continue + if len(m0) != len(m1): + add_problem( + glyph_name, + { + "type": "path_count", + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + "value_1": len(m0), + "value_2": len(m1), + }, + ) + if m0 == m1: + continue + for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)): + if nodes1 == nodes2: + continue + if len(nodes1) != len(nodes2): + add_problem( + glyph_name, + { + "type": "node_count", + "path": pathIx, + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + "value_1": len(nodes1), + "value_2": len(nodes2), + }, + ) + continue + for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)): + if n1 != n2: + add_problem( + glyph_name, + { + "type": "node_incompatibility", + "path": pathIx, + "node": nodeIx, + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + "value_1": n1, + "value_2": n2, + }, + ) + continue + + # m0idx should be the index of the first non-None item in allVectors, + # 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 last one if all None + m0 = allVectors[m0idx] + 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 + m0idx = allContourIsomorphisms.index( + next((x for x in allContourIsomorphisms if x is not None), None) + ) + # m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None + m0 = allContourIsomorphisms[m0idx] + 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( + glyph_name, + {"type": "math_error", "master": name, "error": e}, + ) + return problems diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 39e7416f7..8107319eb 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -6,389 +6,8 @@ Call as: $ fonttools varLib.interpolatable font1 font2 ... """ -from fontTools.pens.basePen import AbstractPen, BasePen -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, defaultdict -import math -import itertools -import sys - - -def _rot_list(l, k): - """Rotate list by k items forward. Ie. item at position 0 will be - at position k in returned list. Negative k is allowed.""" - n = len(l) - k %= n - if not k: - return l - return l[n - k :] + l[: n - k] - - -class PerContourPen(BasePen): - def __init__(self, Pen, glyphset=None): - BasePen.__init__(self, glyphset) - self._glyphset = glyphset - self._Pen = Pen - self._pen = None - self.value = [] - - def _moveTo(self, p0): - self._newItem() - self._pen.moveTo(p0) - - def _lineTo(self, p1): - self._pen.lineTo(p1) - - def _qCurveToOne(self, p1, p2): - self._pen.qCurveTo(p1, p2) - - def _curveToOne(self, p1, p2, p3): - self._pen.curveTo(p1, p2, p3) - - def _closePath(self): - self._pen.closePath() - self._pen = None - - def _endPath(self): - self._pen.endPath() - self._pen = None - - def _newItem(self): - self._pen = pen = self._Pen() - self.value.append(pen) - - -class PerContourOrComponentPen(PerContourPen): - def addComponent(self, glyphName, transformation): - self._newItem() - self.value[-1].addComponent(glyphName, transformation) - - -class RecordingPointPen(BasePen): - def __init__(self): - self.value = [] - - def beginPath(self, identifier=None, **kwargs): - pass - - def endPath(self) -> None: - pass - - def addPoint(self, pt, segmentType=None): - self.value.append((pt, False if segmentType is None else True)) - - -def _vdiff_hypot2(v0, v1): - s = 0 - for x0, x1 in zip(v0, v1): - d = x1 - x0 - s += d * d - return s - - -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_scipy(G): - n = len(G) - rows, cols = linear_sum_assignment(G) - assert (rows == list(range(n))).all() - return list(cols), _matching_cost(G, cols) - - -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) - - -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'") - - # Otherwise just brute-force - permutations = itertools.permutations(range(n)) - best = list(next(permutations)) - best_cost = _matching_cost(G, best) - for p in permutations: - cost = _matching_cost(G, p) - if cost < best_cost: - best, best_cost = list(p), cost - 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 - if glyphs is None: - # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order - # ... risks the sparse master being the first one, and only processing a subset of the glyphs - glyphs = {g for glyphset in glyphsets for g in glyphset.keys()} - - hist = [] - problems = OrderedDict() - - def add_problem(glyphname, problem): - problems.setdefault(glyphname, []).append(problem) - - for glyph_name in glyphs: - try: - m0idx = 0 - allVectors = [] - allNodeTypes = [] - allContourIsomorphisms = [] - 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}) - allNodeTypes.append(None) - allVectors.append(None) - allContourIsomorphisms.append(None) - continue - - perContourPen = PerContourOrComponentPen( - RecordingPen, glyphset=glyphset - ) - try: - glyph.draw(perContourPen, outputImpliedClosingLine=True) - except TypeError: - glyph.draw(perContourPen) - contourPens = perContourPen.value - del perContourPen - - contourVectors = [] - contourIsomorphisms = [] - nodeTypes = [] - allNodeTypes.append(nodeTypes) - allVectors.append(contourVectors) - allContourIsomorphisms.append(contourIsomorphisms) - for ix, contour in enumerate(contourPens): - nodeVecs = tuple(instruction[0] for instruction in contour.value) - nodeTypes.append(nodeVecs) - - stats = StatisticsPen(glyphset=glyphset) - try: - contour.replay(stats) - except OpenContourError as e: - add_problem( - glyph_name, - {"master": name, "contour": ix, "type": "open_path"}, - ) - continue - size = math.sqrt(abs(stats.area)) * 0.5 - vector = ( - int(size), - int(stats.meanX), - int(stats.meanY), - int(stats.stddevX * 2), - int(stats.stddevY * 2), - int(stats.correlation * size), - ) - contourVectors.append(vector) - # print(vector) - - # Check starting point - if nodeVecs[0] == "addComponent": - continue - assert nodeVecs[0] == "moveTo" - assert nodeVecs[-1] in ("closePath", "endPath") - points = RecordingPointPen() - converter = SegmentToPointPen(points, False) - contour.replay(converter) - # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve; - # now check all rotations and mirror-rotations of the contour and build list of isomorphic - # possible starting points. - bits = 0 - for pt, b in points.value: - bits = (bits << 1) | b - n = len(points.value) - mask = (1 << n) - 1 - isomorphisms = [] - contourIsomorphisms.append(isomorphisms) - for i in range(n): - b = ((bits << i) & mask) | ((bits >> (n - i))) - if b == bits: - isomorphisms.append( - _rot_list([complex(*pt) for pt, bl in points.value], i) - ) - # Add mirrored rotations - mirrored = list(reversed(points.value)) - reversed_bits = 0 - for pt, b in mirrored: - reversed_bits = (reversed_bits << 1) | b - for i in range(n): - b = ((reversed_bits << i) & mask) | ((reversed_bits >> (n - i))) - if b == bits: - isomorphisms.append( - _rot_list([complex(*pt) for pt, bl in mirrored], i) - ) - - # m0idx should be the index of the first non-None item in allNodeTypes, - # 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 last one if all None - m0 = allNodeTypes[m0idx] - for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): - if m1 is None: - continue - if len(m0) != len(m1): - add_problem( - glyph_name, - { - "type": "path_count", - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": len(m0), - "value_2": len(m1), - }, - ) - if m0 == m1: - continue - for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)): - if nodes1 == nodes2: - continue - if len(nodes1) != len(nodes2): - add_problem( - glyph_name, - { - "type": "node_count", - "path": pathIx, - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": len(nodes1), - "value_2": len(nodes2), - }, - ) - continue - for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)): - if n1 != n2: - add_problem( - glyph_name, - { - "type": "node_incompatibility", - "path": pathIx, - "node": nodeIx, - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": n1, - "value_2": n2, - }, - ) - continue - - # m0idx should be the index of the first non-None item in allVectors, - # 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 last one if all None - m0 = allVectors[m0idx] - 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 - m0idx = allContourIsomorphisms.index( - next((x for x in allContourIsomorphisms if x is not None), None) - ) - # m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None - m0 = allContourIsomorphisms[m0idx] - 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( - glyph_name, - {"type": "math_error", "master": name, "error": e}, - ) - return problems +from ._interpolatable import test +from collections import defaultdict def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf): diff --git a/setup.py b/setup.py index d5bd49c6d..0ff1038bb 100755 --- a/setup.py +++ b/setup.py @@ -82,6 +82,12 @@ if with_cython is True or (with_cython is None and has_cython): ext_modules.append( Extension("fontTools.pens.momentsPen", ["Lib/fontTools/pens/momentsPen.py"]), ) + ext_modules.append( + Extension( + "fontTools.varLib._interpolatable", + ["Lib/fontTools/varLib/_interpolatable.py"], + ), + ) ext_modules.append( Extension("fontTools.varLib.iup", ["Lib/fontTools/varLib/iup.py"]), ) From 2a855f8bd6c4eecbf7eeef39d11fce0d2df4fcba Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 14 Oct 2023 18:58:53 -0400 Subject: [PATCH 13/13] Revert "[varLib.interpolatable] Cythonize" This reverts commit 0914c6c0eefb1317aba95779fb469e138b19f365. --- Lib/fontTools/varLib/_interpolatable.py | 419 ------------------------ Lib/fontTools/varLib/interpolatable.py | 385 +++++++++++++++++++++- setup.py | 6 - 3 files changed, 383 insertions(+), 427 deletions(-) delete mode 100644 Lib/fontTools/varLib/_interpolatable.py diff --git a/Lib/fontTools/varLib/_interpolatable.py b/Lib/fontTools/varLib/_interpolatable.py deleted file mode 100644 index 3a770de95..000000000 --- a/Lib/fontTools/varLib/_interpolatable.py +++ /dev/null @@ -1,419 +0,0 @@ -""" -Tool to find wrong contour order between different masters, and -other interpolatability (or lack thereof) issues. - -Call as: -$ fonttools varLib.interpolatable font1 font2 ... -""" - -try: - import cython - - COMPILED = cython.compiled -except (AttributeError, ImportError): - # if cython not installed, use mock module with no-op decorators and types - from fontTools.misc import cython - - COMPILED = False - - -from fontTools.pens.basePen import AbstractPen, BasePen -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 -import math -import itertools -import sys - - -def _rot_list(l, k): - """Rotate list by k items forward. Ie. item at position 0 will be - at position k in returned list. Negative k is allowed.""" - n = len(l) - k %= n - if not k: - return l - return l[n - k :] + l[: n - k] - - -class PerContourPen(BasePen): - def __init__(self, Pen, glyphset=None): - BasePen.__init__(self, glyphset) - self._glyphset = glyphset - self._Pen = Pen - self._pen = None - self.value = [] - - def _moveTo(self, p0): - self._newItem() - self._pen.moveTo(p0) - - def _lineTo(self, p1): - self._pen.lineTo(p1) - - def _qCurveToOne(self, p1, p2): - self._pen.qCurveTo(p1, p2) - - def _curveToOne(self, p1, p2, p3): - self._pen.curveTo(p1, p2, p3) - - def _closePath(self): - self._pen.closePath() - self._pen = None - - def _endPath(self): - self._pen.endPath() - self._pen = None - - def _newItem(self): - self._pen = pen = self._Pen() - self.value.append(pen) - - -class PerContourOrComponentPen(PerContourPen): - def addComponent(self, glyphName, transformation): - self._newItem() - self.value[-1].addComponent(glyphName, transformation) - - -class RecordingPointPen(BasePen): - def __init__(self): - self.value = [] - - def beginPath(self, identifier=None, **kwargs): - pass - - def endPath(self) -> None: - pass - - def addPoint(self, pt, segmentType=None): - self.value.append((pt, False if segmentType is None else True)) - - -@cython.cfunc -@cython.inline -@cython.locals( - s=cython.double, - d=cython.double, - x0=cython.double, - x1=cython.double, -) -def _vdiff_hypot2(v0, v1): - s = 0 - for x0, x1 in zip(v0, v1): - d = x1 - x0 - s += d * d - return s - - -@cython.cfunc -@cython.inline -@cython.locals( - s=cython.double, - d=cython.complex, - x0=cython.complex, - x1=cython.complex, -) -def _vdiff_hypot2_complex(v0, v1): - s = 0 - d = 0 # Make Cython happy - 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_scipy(G): - n = len(G) - rows, cols = linear_sum_assignment(G) - assert (rows == list(range(n))).all() - return list(cols), _matching_cost(G, cols) - - -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) - - -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'") - - # Otherwise just brute-force - permutations = itertools.permutations(range(n)) - best = list(next(permutations)) - best_cost = _matching_cost(G, best) - for p in permutations: - cost = _matching_cost(G, p) - if cost < best_cost: - best, best_cost = list(p), cost - 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 - if glyphs is None: - # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order - # ... risks the sparse master being the first one, and only processing a subset of the glyphs - glyphs = {g for glyphset in glyphsets for g in glyphset.keys()} - - hist = [] - problems = OrderedDict() - - def add_problem(glyphname, problem): - problems.setdefault(glyphname, []).append(problem) - - for glyph_name in glyphs: - try: - m0idx = 0 - allVectors = [] - allNodeTypes = [] - allContourIsomorphisms = [] - 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}) - allNodeTypes.append(None) - allVectors.append(None) - allContourIsomorphisms.append(None) - continue - - perContourPen = PerContourOrComponentPen( - RecordingPen, glyphset=glyphset - ) - try: - glyph.draw(perContourPen, outputImpliedClosingLine=True) - except TypeError: - glyph.draw(perContourPen) - contourPens = perContourPen.value - del perContourPen - - contourVectors = [] - contourIsomorphisms = [] - nodeTypes = [] - allNodeTypes.append(nodeTypes) - allVectors.append(contourVectors) - allContourIsomorphisms.append(contourIsomorphisms) - for ix, contour in enumerate(contourPens): - nodeVecs = tuple(instruction[0] for instruction in contour.value) - nodeTypes.append(nodeVecs) - - stats = StatisticsPen(glyphset=glyphset) - try: - contour.replay(stats) - except OpenContourError as e: - add_problem( - glyph_name, - {"master": name, "contour": ix, "type": "open_path"}, - ) - continue - size = math.sqrt(abs(stats.area)) * 0.5 - vector = ( - int(size), - int(stats.meanX), - int(stats.meanY), - int(stats.stddevX * 2), - int(stats.stddevY * 2), - int(stats.correlation * size), - ) - contourVectors.append(vector) - # print(vector) - - # Check starting point - if nodeVecs[0] == "addComponent": - continue - assert nodeVecs[0] == "moveTo" - assert nodeVecs[-1] in ("closePath", "endPath") - points = RecordingPointPen() - converter = SegmentToPointPen(points, False) - contour.replay(converter) - # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve; - # now check all rotations and mirror-rotations of the contour and build list of isomorphic - # possible starting points. - bits = 0 - for pt, b in points.value: - bits = (bits << 1) | b - n = len(points.value) - mask = (1 << n) - 1 - isomorphisms = [] - contourIsomorphisms.append(isomorphisms) - for i in range(n): - b = ((bits << i) & mask) | ((bits >> (n - i))) - if b == bits: - isomorphisms.append( - _rot_list([complex(*pt) for pt, bl in points.value], i) - ) - # Add mirrored rotations - mirrored = list(reversed(points.value)) - reversed_bits = 0 - for pt, b in mirrored: - reversed_bits = (reversed_bits << 1) | b - for i in range(n): - b = ((reversed_bits << i) & mask) | ((reversed_bits >> (n - i))) - if b == bits: - isomorphisms.append( - _rot_list([complex(*pt) for pt, bl in mirrored], i) - ) - - # m0idx should be the index of the first non-None item in allNodeTypes, - # 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 last one if all None - m0 = allNodeTypes[m0idx] - for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): - if m1 is None: - continue - if len(m0) != len(m1): - add_problem( - glyph_name, - { - "type": "path_count", - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": len(m0), - "value_2": len(m1), - }, - ) - if m0 == m1: - continue - for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)): - if nodes1 == nodes2: - continue - if len(nodes1) != len(nodes2): - add_problem( - glyph_name, - { - "type": "node_count", - "path": pathIx, - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": len(nodes1), - "value_2": len(nodes2), - }, - ) - continue - for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)): - if n1 != n2: - add_problem( - glyph_name, - { - "type": "node_incompatibility", - "path": pathIx, - "node": nodeIx, - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": n1, - "value_2": n2, - }, - ) - continue - - # m0idx should be the index of the first non-None item in allVectors, - # 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 last one if all None - m0 = allVectors[m0idx] - 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 - m0idx = allContourIsomorphisms.index( - next((x for x in allContourIsomorphisms if x is not None), None) - ) - # m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None - m0 = allContourIsomorphisms[m0idx] - 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( - glyph_name, - {"type": "math_error", "master": name, "error": e}, - ) - return problems diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 8107319eb..39e7416f7 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -6,8 +6,389 @@ Call as: $ fonttools varLib.interpolatable font1 font2 ... """ -from ._interpolatable import test -from collections import defaultdict +from fontTools.pens.basePen import AbstractPen, BasePen +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, defaultdict +import math +import itertools +import sys + + +def _rot_list(l, k): + """Rotate list by k items forward. Ie. item at position 0 will be + at position k in returned list. Negative k is allowed.""" + n = len(l) + k %= n + if not k: + return l + return l[n - k :] + l[: n - k] + + +class PerContourPen(BasePen): + def __init__(self, Pen, glyphset=None): + BasePen.__init__(self, glyphset) + self._glyphset = glyphset + self._Pen = Pen + self._pen = None + self.value = [] + + def _moveTo(self, p0): + self._newItem() + self._pen.moveTo(p0) + + def _lineTo(self, p1): + self._pen.lineTo(p1) + + def _qCurveToOne(self, p1, p2): + self._pen.qCurveTo(p1, p2) + + def _curveToOne(self, p1, p2, p3): + self._pen.curveTo(p1, p2, p3) + + def _closePath(self): + self._pen.closePath() + self._pen = None + + def _endPath(self): + self._pen.endPath() + self._pen = None + + def _newItem(self): + self._pen = pen = self._Pen() + self.value.append(pen) + + +class PerContourOrComponentPen(PerContourPen): + def addComponent(self, glyphName, transformation): + self._newItem() + self.value[-1].addComponent(glyphName, transformation) + + +class RecordingPointPen(BasePen): + def __init__(self): + self.value = [] + + def beginPath(self, identifier=None, **kwargs): + pass + + def endPath(self) -> None: + pass + + def addPoint(self, pt, segmentType=None): + self.value.append((pt, False if segmentType is None else True)) + + +def _vdiff_hypot2(v0, v1): + s = 0 + for x0, x1 in zip(v0, v1): + d = x1 - x0 + s += d * d + return s + + +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_scipy(G): + n = len(G) + rows, cols = linear_sum_assignment(G) + assert (rows == list(range(n))).all() + return list(cols), _matching_cost(G, cols) + + +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) + + +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'") + + # Otherwise just brute-force + permutations = itertools.permutations(range(n)) + best = list(next(permutations)) + best_cost = _matching_cost(G, best) + for p in permutations: + cost = _matching_cost(G, p) + if cost < best_cost: + best, best_cost = list(p), cost + 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 + if glyphs is None: + # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order + # ... risks the sparse master being the first one, and only processing a subset of the glyphs + glyphs = {g for glyphset in glyphsets for g in glyphset.keys()} + + hist = [] + problems = OrderedDict() + + def add_problem(glyphname, problem): + problems.setdefault(glyphname, []).append(problem) + + for glyph_name in glyphs: + try: + m0idx = 0 + allVectors = [] + allNodeTypes = [] + allContourIsomorphisms = [] + 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}) + allNodeTypes.append(None) + allVectors.append(None) + allContourIsomorphisms.append(None) + continue + + perContourPen = PerContourOrComponentPen( + RecordingPen, glyphset=glyphset + ) + try: + glyph.draw(perContourPen, outputImpliedClosingLine=True) + except TypeError: + glyph.draw(perContourPen) + contourPens = perContourPen.value + del perContourPen + + contourVectors = [] + contourIsomorphisms = [] + nodeTypes = [] + allNodeTypes.append(nodeTypes) + allVectors.append(contourVectors) + allContourIsomorphisms.append(contourIsomorphisms) + for ix, contour in enumerate(contourPens): + nodeVecs = tuple(instruction[0] for instruction in contour.value) + nodeTypes.append(nodeVecs) + + stats = StatisticsPen(glyphset=glyphset) + try: + contour.replay(stats) + except OpenContourError as e: + add_problem( + glyph_name, + {"master": name, "contour": ix, "type": "open_path"}, + ) + continue + size = math.sqrt(abs(stats.area)) * 0.5 + vector = ( + int(size), + int(stats.meanX), + int(stats.meanY), + int(stats.stddevX * 2), + int(stats.stddevY * 2), + int(stats.correlation * size), + ) + contourVectors.append(vector) + # print(vector) + + # Check starting point + if nodeVecs[0] == "addComponent": + continue + assert nodeVecs[0] == "moveTo" + assert nodeVecs[-1] in ("closePath", "endPath") + points = RecordingPointPen() + converter = SegmentToPointPen(points, False) + contour.replay(converter) + # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve; + # now check all rotations and mirror-rotations of the contour and build list of isomorphic + # possible starting points. + bits = 0 + for pt, b in points.value: + bits = (bits << 1) | b + n = len(points.value) + mask = (1 << n) - 1 + isomorphisms = [] + contourIsomorphisms.append(isomorphisms) + for i in range(n): + b = ((bits << i) & mask) | ((bits >> (n - i))) + if b == bits: + isomorphisms.append( + _rot_list([complex(*pt) for pt, bl in points.value], i) + ) + # Add mirrored rotations + mirrored = list(reversed(points.value)) + reversed_bits = 0 + for pt, b in mirrored: + reversed_bits = (reversed_bits << 1) | b + for i in range(n): + b = ((reversed_bits << i) & mask) | ((reversed_bits >> (n - i))) + if b == bits: + isomorphisms.append( + _rot_list([complex(*pt) for pt, bl in mirrored], i) + ) + + # m0idx should be the index of the first non-None item in allNodeTypes, + # 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 last one if all None + m0 = allNodeTypes[m0idx] + for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): + if m1 is None: + continue + if len(m0) != len(m1): + add_problem( + glyph_name, + { + "type": "path_count", + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + "value_1": len(m0), + "value_2": len(m1), + }, + ) + if m0 == m1: + continue + for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)): + if nodes1 == nodes2: + continue + if len(nodes1) != len(nodes2): + add_problem( + glyph_name, + { + "type": "node_count", + "path": pathIx, + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + "value_1": len(nodes1), + "value_2": len(nodes2), + }, + ) + continue + for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)): + if n1 != n2: + add_problem( + glyph_name, + { + "type": "node_incompatibility", + "path": pathIx, + "node": nodeIx, + "master_1": names[m0idx], + "master_2": names[m0idx + i + 1], + "value_1": n1, + "value_2": n2, + }, + ) + continue + + # m0idx should be the index of the first non-None item in allVectors, + # 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 last one if all None + m0 = allVectors[m0idx] + 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 + m0idx = allContourIsomorphisms.index( + next((x for x in allContourIsomorphisms if x is not None), None) + ) + # m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None + m0 = allContourIsomorphisms[m0idx] + 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( + glyph_name, + {"type": "math_error", "master": name, "error": e}, + ) + return problems def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf): diff --git a/setup.py b/setup.py index 0ff1038bb..d5bd49c6d 100755 --- a/setup.py +++ b/setup.py @@ -82,12 +82,6 @@ if with_cython is True or (with_cython is None and has_cython): ext_modules.append( Extension("fontTools.pens.momentsPen", ["Lib/fontTools/pens/momentsPen.py"]), ) - ext_modules.append( - Extension( - "fontTools.varLib._interpolatable", - ["Lib/fontTools/varLib/_interpolatable.py"], - ), - ) ext_modules.append( Extension("fontTools.varLib.iup", ["Lib/fontTools/varLib/iup.py"]), )