From 2a855f8bd6c4eecbf7eeef39d11fce0d2df4fcba Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 14 Oct 2023 18:58:53 -0400 Subject: [PATCH] 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"]), )