diff options
author | robot-contrib <robot-contrib@yandex-team.com> | 2023-12-05 15:07:45 +0300 |
---|---|---|
committer | robot-contrib <robot-contrib@yandex-team.com> | 2023-12-05 16:18:28 +0300 |
commit | 1a85d20071d4b1e481452e8d0b81f2e3e888803c (patch) | |
tree | dea60d2096da94e025ed6aefcd672715db9bffea | |
parent | d7830d26621b08d508ba92fccf9e4971cc8ded61 (diff) | |
download | ydb-1a85d20071d4b1e481452e8d0b81f2e3e888803c.tar.gz |
Update contrib/python/fonttools to 4.45.0
-rw-r--r-- | contrib/python/fonttools/.dist-info/METADATA | 10 | ||||
-rw-r--r-- | contrib/python/fonttools/fontTools/__init__.py | 2 | ||||
-rw-r--r-- | contrib/python/fonttools/fontTools/pens/momentsPen.py | 3 | ||||
-rw-r--r-- | contrib/python/fonttools/fontTools/pens/recordingPen.py | 4 | ||||
-rw-r--r-- | contrib/python/fonttools/fontTools/pens/statisticsPen.py | 195 | ||||
-rw-r--r-- | contrib/python/fonttools/fontTools/varLib/interpolatable.py | 682 | ||||
-rw-r--r-- | contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py | 672 | ||||
-rw-r--r-- | contrib/python/fonttools/ya.make | 4 |
8 files changed, 1325 insertions, 247 deletions
diff --git a/contrib/python/fonttools/.dist-info/METADATA b/contrib/python/fonttools/.dist-info/METADATA index 1e4df6685a..86f1760961 100644 --- a/contrib/python/fonttools/.dist-info/METADATA +++ b/contrib/python/fonttools/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: fonttools -Version: 4.44.3 +Version: 4.45.0 Summary: Tools to manipulate font files Home-page: http://github.com/fonttools/fonttools Author: Just van Rossum @@ -366,6 +366,14 @@ Have fun! Changelog ~~~~~~~~~ +4.45.0 (released 2023-11-20) +---------------------------- + +- [varLib.interpolator] Vastly improved algorithms. Also available now is ``--pdf`` + and ``--html`` options to generate a PDF or HTML report of the interpolation issues. + The PDF/HTML report showcases the problematic masters, the interpolated broken + glyph, as well as the proposed fixed version. + 4.44.3 (released 2023-11-15) ---------------------------- diff --git a/contrib/python/fonttools/fontTools/__init__.py b/contrib/python/fonttools/fontTools/__init__.py index 2381f4105e..90b2c2ece6 100644 --- a/contrib/python/fonttools/fontTools/__init__.py +++ b/contrib/python/fonttools/fontTools/__init__.py @@ -3,6 +3,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.44.3" +version = __version__ = "4.45.0" __all__ = ["version", "log", "configLogger"] diff --git a/contrib/python/fonttools/fontTools/pens/momentsPen.py b/contrib/python/fonttools/fontTools/pens/momentsPen.py index dab0d10e2c..4c7ddfe321 100644 --- a/contrib/python/fonttools/fontTools/pens/momentsPen.py +++ b/contrib/python/fonttools/fontTools/pens/momentsPen.py @@ -36,8 +36,7 @@ class MomentsPen(BasePen): def _endPath(self): p0 = self._getCurrentPoint() if p0 != self.__startPoint: - # Green theorem is not defined on open contours. - raise OpenContourError("Green theorem is not defined on open contours.") + raise OpenContourError("Glyph statistics not defined on open contours.") @cython.locals(r0=cython.double) @cython.locals(r1=cython.double) diff --git a/contrib/python/fonttools/fontTools/pens/recordingPen.py b/contrib/python/fonttools/fontTools/pens/recordingPen.py index 6c3b661321..2ed8d32ec7 100644 --- a/contrib/python/fonttools/fontTools/pens/recordingPen.py +++ b/contrib/python/fonttools/fontTools/pens/recordingPen.py @@ -76,6 +76,8 @@ class RecordingPen(AbstractPen): def replay(self, pen): replayRecording(self.value, pen) + draw = replay + class DecomposingRecordingPen(DecomposingPen, RecordingPen): """Same as RecordingPen, except that it doesn't keep components @@ -167,6 +169,8 @@ class RecordingPointPen(AbstractPointPen): for operator, args, kwargs in self.value: getattr(pointPen, operator)(*args, **kwargs) + drawPoints = replay + if __name__ == "__main__": pen = RecordingPen() diff --git a/contrib/python/fonttools/fontTools/pens/statisticsPen.py b/contrib/python/fonttools/fontTools/pens/statisticsPen.py index 39f319e02d..403ef39f9e 100644 --- a/contrib/python/fonttools/fontTools/pens/statisticsPen.py +++ b/contrib/python/fonttools/fontTools/pens/statisticsPen.py @@ -1,46 +1,78 @@ """Pen calculating area, center of mass, variance and standard-deviation, covariance and correlation, and slant, of glyph shapes.""" -import math +from math import sqrt, degrees, atan +from fontTools.pens.basePen import BasePen, OpenContourError from fontTools.pens.momentsPen import MomentsPen -__all__ = ["StatisticsPen"] +__all__ = ["StatisticsPen", "StatisticsControlPen"] -class StatisticsPen(MomentsPen): +class StatisticsBase: + def __init__(self): + self._zero() + + def _zero(self): + self.area = 0 + self.meanX = 0 + self.meanY = 0 + self.varianceX = 0 + self.varianceY = 0 + self.stddevX = 0 + self.stddevY = 0 + self.covariance = 0 + self.correlation = 0 + self.slant = 0 + + def _update(self): + # XXX The variance formulas should never produce a negative value, + # but due to reasons I don't understand, both of our pens do. + # So we take the absolute value here. + self.varianceX = abs(self.varianceX) + self.varianceY = abs(self.varianceY) + + self.stddevX = stddevX = sqrt(self.varianceX) + self.stddevY = stddevY = sqrt(self.varianceY) + + # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) ) + # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient + if stddevX * stddevY == 0: + correlation = float("NaN") + else: + # XXX The above formula should never produce a value outside + # the range [-1, 1], but due to reasons I don't understand, + # (probably the same issue as above), it does. So we clamp. + correlation = self.covariance / (stddevX * stddevY) + correlation = max(-1, min(1, correlation)) + self.correlation = correlation if abs(correlation) > 1e-3 else 0 + + slant = ( + self.covariance / self.varianceY if self.varianceY != 0 else float("NaN") + ) + self.slant = slant if abs(slant) > 1e-3 else 0 + + +class StatisticsPen(StatisticsBase, MomentsPen): """Pen calculating area, center of mass, variance and standard-deviation, covariance and correlation, and slant, of glyph shapes. - Note that all the calculated values are 'signed'. Ie. if the - glyph shape is self-intersecting, the values are not correct - (but well-defined). As such, area will be negative if contour - directions are clockwise. Moreover, variance might be negative - if the shapes are self-intersecting in certain ways.""" + Note that if the glyph shape is self-intersecting, the values + are not correct (but well-defined). Moreover, area will be + negative if contour directions are clockwise.""" def __init__(self, glyphset=None): MomentsPen.__init__(self, glyphset=glyphset) - self.__zero() + StatisticsBase.__init__(self) def _closePath(self): MomentsPen._closePath(self) - self.__update() + self._update() - def __zero(self): - self.meanX = 0 - self.meanY = 0 - self.varianceX = 0 - self.varianceY = 0 - self.stddevX = 0 - self.stddevY = 0 - self.covariance = 0 - self.correlation = 0 - self.slant = 0 - - def __update(self): + def _update(self): area = self.area if not area: - self.__zero() + self._zero() return # Center of mass @@ -48,29 +80,98 @@ class StatisticsPen(MomentsPen): self.meanX = meanX = self.momentX / area self.meanY = meanY = self.momentY / area - # Var(X) = E[X^2] - E[X]^2 - self.varianceX = varianceX = self.momentXX / area - meanX**2 - self.varianceY = varianceY = self.momentYY / area - meanY**2 + # Var(X) = E[X^2] - E[X]^2 + self.varianceX = self.momentXX / area - meanX * meanX + self.varianceY = self.momentYY / area - meanY * meanY - self.stddevX = stddevX = math.copysign(abs(varianceX) ** 0.5, varianceX) - self.stddevY = stddevY = math.copysign(abs(varianceY) ** 0.5, varianceY) + # Covariance(X,Y) = (E[X.Y] - E[X]E[Y]) + self.covariance = self.momentXY / area - meanX * meanY - # Covariance(X,Y) = ( E[X.Y] - E[X]E[Y] ) - self.covariance = covariance = self.momentXY / area - meanX * meanY + StatisticsBase._update(self) - # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) ) - # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient - if stddevX * stddevY == 0: - correlation = float("NaN") + +class StatisticsControlPen(StatisticsBase, BasePen): + + """Pen calculating area, center of mass, variance and + standard-deviation, covariance and correlation, and slant, + of glyph shapes, using the control polygon only. + + Note that if the glyph shape is self-intersecting, the values + are not correct (but well-defined). Moreover, area will be + negative if contour directions are clockwise.""" + + def __init__(self, glyphset=None): + BasePen.__init__(self, glyphset) + StatisticsBase.__init__(self) + self._nodes = [] + + def _moveTo(self, pt): + self._nodes.append(complex(*pt)) + + def _lineTo(self, pt): + self._nodes.append(complex(*pt)) + + def _qCurveToOne(self, pt1, pt2): + for pt in (pt1, pt2): + self._nodes.append(complex(*pt)) + + def _curveToOne(self, pt1, pt2, pt3): + for pt in (pt1, pt2, pt3): + self._nodes.append(complex(*pt)) + + def _closePath(self): + self._update() + + def _endPath(self): + p0 = self._getCurrentPoint() + if p0 != self.__startPoint: + raise OpenContourError("Glyph statistics not defined on open contours.") + + def _update(self): + nodes = self._nodes + n = len(nodes) + + # Triangle formula + self.area = ( + sum( + (p0.real * p1.imag - p1.real * p0.imag) + for p0, p1 in zip(nodes, nodes[1:] + nodes[:1]) + ) + / 2 + ) + + # Center of mass + # https://en.wikipedia.org/wiki/Center_of_mass#A_system_of_particles + sumNodes = sum(nodes) + self.meanX = meanX = sumNodes.real / n + self.meanY = meanY = sumNodes.imag / n + + if n > 1: + # Var(X) = (sum[X^2] - sum[X]^2 / n) / (n - 1) + # https://www.statisticshowto.com/probability-and-statistics/descriptive-statistics/sample-variance/ + self.varianceX = varianceX = ( + sum(p.real * p.real for p in nodes) + - (sumNodes.real * sumNodes.real) / n + ) / (n - 1) + self.varianceY = varianceY = ( + sum(p.imag * p.imag for p in nodes) + - (sumNodes.imag * sumNodes.imag) / n + ) / (n - 1) + + # Covariance(X,Y) = (sum[X.Y] - sum[X].sum[Y] / n) / (n - 1) + self.covariance = covariance = ( + sum(p.real * p.imag for p in nodes) + - (sumNodes.real * sumNodes.imag) / n + ) / (n - 1) else: - correlation = covariance / (stddevX * stddevY) - self.correlation = correlation if abs(correlation) > 1e-3 else 0 + self.varianceX = varianceX = 0 + self.varianceY = varianceY = 0 + self.covariance = covariance = 0 - slant = covariance / varianceY if varianceY != 0 else float("NaN") - self.slant = slant if abs(slant) > 1e-3 else 0 + StatisticsBase._update(self) -def _test(glyphset, upem, glyphs, quiet=False): +def _test(glyphset, upem, glyphs, quiet=False, *, control=False): from fontTools.pens.transformPen import TransformPen from fontTools.misc.transform import Scale @@ -81,7 +182,10 @@ def _test(glyphset, upem, glyphs, quiet=False): slnt_sum_perceptual = 0 for glyph_name in glyphs: glyph = glyphset[glyph_name] - pen = StatisticsPen(glyphset=glyphset) + if control: + pen = StatisticsControlPen(glyphset=glyphset) + else: + pen = StatisticsPen(glyphset=glyphset) transformer = TransformPen(pen, Scale(1.0 / upem)) glyph.draw(transformer) @@ -127,10 +231,10 @@ def _test(glyphset, upem, glyphs, quiet=False): print("width: %g" % (wdth_sum / upem / len(glyphs))) slant = slnt_sum / len(glyphs) print("slant: %g" % slant) - print("slant angle: %g" % -math.degrees(math.atan(slant))) + print("slant angle: %g" % -degrees(atan(slant))) slant_perceptual = slnt_sum_perceptual / wdth_sum print("slant (perceptual): %g" % slant_perceptual) - print("slant (perceptual) angle: %g" % -math.degrees(math.atan(slant_perceptual))) + print("slant (perceptual) angle: %g" % -degrees(atan(slant_perceptual))) def main(args): @@ -155,6 +259,12 @@ def main(args): help="Face index into a collection to open. Zero based.", ) parser.add_argument( + "-c", + "--control", + action="store_true", + help="Use the control-box pen instead of the Green therem.", + ) + parser.add_argument( "-q", "--quiet", action="store_true", help="Only report font-wide statistics." ) parser.add_argument( @@ -188,6 +298,7 @@ def main(args): font["head"].unitsPerEm, glyphs, quiet=options.quiet, + control=options.control, ) diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatable.py b/contrib/python/fonttools/fontTools/varLib/interpolatable.py index 05ed3f768e..74dd15b968 100644 --- a/contrib/python/fonttools/fontTools/varLib/interpolatable.py +++ b/contrib/python/fonttools/fontTools/varLib/interpolatable.py @@ -9,13 +9,15 @@ $ fonttools varLib.interpolatable font1 font2 ... from fontTools.pens.basePen import AbstractPen, BasePen from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen from fontTools.pens.recordingPen import RecordingPen -from fontTools.pens.statisticsPen import StatisticsPen +from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen from fontTools.pens.momentsPen import OpenContourError -from fontTools.varLib.models import piecewiseLinearMap -from collections import defaultdict -import math +from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation +from fontTools.misc.fixedTools import floatToFixedToStr +from collections import defaultdict, deque +from functools import wraps +from pprint import pformat +from math import sqrt, copysign import itertools -import sys import logging log = logging.getLogger("fontTools.varLib.interpolatable") @@ -67,7 +69,7 @@ class PerContourOrComponentPen(PerContourPen): self.value[-1].addComponent(glyphName, transformation) -class RecordingPointPen(AbstractPointPen): +class SimpleRecordingPointPen(AbstractPointPen): def __init__(self): self.value = [] @@ -150,133 +152,266 @@ except ImportError: ) -def test_gen(glyphsets, glyphs=None, names=None, ignore_missing=False): +def _contour_vector_from_stats(stats): + size = sqrt(abs(stats.area)) + return ( + copysign((size), stats.area), + stats.meanX, + stats.meanY, + stats.stddevX * 2, + stats.stddevY * 2, + stats.correlation * size, + ) + + +def _points_characteristic_bits(points): + bits = 0 + for pt, b in reversed(points): + bits = (bits << 1) | b + return bits + + +def _points_complex_vector(points): + vector = [] + points = [complex(*pt) for pt, _ in points] + n = len(points) + points.extend(points[:2]) + for i in range(n): + p0 = points[i] + + # The point itself + vector.append(p0) + + # The distance to the next point; + # Emphasized by 2 empirically + p1 = points[i + 1] + d0 = p1 - p0 + vector.append(d0 * 2) + + """ + # The angle to the next point, as a cross product; + # Square root of, to match dimentionality of distance. + p2 = points[i + 2] + d1 = p2 - p1 + cross = d0.real * d1.imag - d0.imag * d1.real + cross = copysign(sqrt(abs(cross)), cross) + vector.append(cross) + """ + + return vector + + +def _add_isomorphisms(points, isomorphisms, reverse): + reference_bits = _points_characteristic_bits(points) + n = len(points) + + # if points[0][0] == points[-1][0]: + # abort + + if reverse: + points = points[::-1] + bits = _points_characteristic_bits(points) + else: + bits = reference_bits + + vector = _points_complex_vector(points) + + assert len(vector) % n == 0 + mult = len(vector) // n + mask = (1 << n) - 1 + + for i in range(n): + b = ((bits << (n - i)) & mask) | (bits >> i) + if b == reference_bits: + isomorphisms.append( + (_rot_list(vector, -i * mult), n - 1 - i if reverse else i, reverse) + ) + + +def _find_parents_and_order(glyphsets, locations): + parents = [None] + list(range(len(glyphsets) - 1)) + order = list(range(len(glyphsets))) + if locations: + # Order base master first + bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values())) + if bases: + base = next(bases) + logging.info("Base master index %s, location %s", base, locations[base]) + else: + base = 0 + logging.warning("No base master location found") + + # Form a minimum spanning tree of the locations + try: + from scipy.sparse.csgraph import minimum_spanning_tree + + graph = [[0] * len(locations) for _ in range(len(locations))] + axes = set() + for l in locations: + axes.update(l.keys()) + axes = sorted(axes) + vectors = [tuple(l.get(k, 0) for k in axes) for l in locations] + for i, j in itertools.combinations(range(len(locations)), 2): + graph[i][j] = _vdiff_hypot2(vectors[i], vectors[j]) + + tree = minimum_spanning_tree(graph) + rows, cols = tree.nonzero() + graph = defaultdict(set) + for row, col in zip(rows, cols): + graph[row].add(col) + graph[col].add(row) + + # Traverse graph from the base and assign parents + parents = [None] * len(locations) + order = [] + visited = set() + queue = deque([base]) + while queue: + i = queue.popleft() + visited.add(i) + order.append(i) + for j in sorted(graph[i]): + if j not in visited: + parents[j] = i + queue.append(j) + + except ImportError: + pass + + log.info("Parents: %s", parents) + log.info("Order: %s", order) + return parents, order + + +def test_gen( + glyphsets, + glyphs=None, + names=None, + ignore_missing=False, + *, + locations=None, + tolerance=0.95, +): 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 = [] + parents, order = _find_parents_and_order(glyphsets, locations) + + def grand_parent(i, glyphname): + if i is None: + return None + i = parents[i] + if i is None: + return None + while parents[i] is not None and glyphsets[i][glyphname] is None: + i = parents[i] + return i 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: + log.info("Testing glyph %s", glyph_name) + allGreenVectors = [] + allControlVectors = [] + 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: + yield (glyph_name, {"type": "missing", "master": name}) + allNodeTypes.append(None) + allControlVectors.append(None) + allGreenVectors.append(None) + allContourIsomorphisms.append(None) continue - for glyph, glyphset, name in zip(allGlyphs, glyphsets, names): - if glyph is None: - if not ignore_missing: - yield (glyph_name, {"type": "missing", "master": name}) - allNodeTypes.append(None) - allVectors.append(None) - allContourIsomorphisms.append(None) - continue - perContourPen = PerContourOrComponentPen( - RecordingPen, glyphset=glyphset - ) + perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset) + try: + glyph.draw(perContourPen, outputImpliedClosingLine=True) + except TypeError: + glyph.draw(perContourPen) + contourPens = perContourPen.value + del perContourPen + + contourControlVectors = [] + contourGreenVectors = [] + contourIsomorphisms = [] + nodeTypes = [] + allNodeTypes.append(nodeTypes) + allControlVectors.append(contourControlVectors) + allGreenVectors.append(contourGreenVectors) + allContourIsomorphisms.append(contourIsomorphisms) + for ix, contour in enumerate(contourPens): + contourOps = tuple(op for op, arg in contour.value) + nodeTypes.append(contourOps) + + greenStats = StatisticsPen(glyphset=glyphset) + controlStats = StatisticsControlPen(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: - yield ( - 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) - complexPoints = [complex(*pt) for pt, bl in points.value] - for i in range(n): - b = ((bits << i) & mask) | ((bits >> (n - i))) - if b == bits: - isomorphisms.append(_rot_list(complexPoints, i)) - # Add mirrored rotations - mirrored = list(reversed(points.value)) - reversed_bits = 0 - for pt, b in mirrored: - reversed_bits = (reversed_bits << 1) | b - complexPoints = list(reversed(complexPoints)) - for i in range(n): - b = ((reversed_bits << i) & mask) | ((reversed_bits >> (n - i))) - if b == bits: - isomorphisms.append(_rot_list(complexPoints, 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): + contour.replay(greenStats) + contour.replay(controlStats) + except OpenContourError as e: yield ( glyph_name, - { - "type": "path_count", - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": len(m0), - "value_2": len(m1), - }, + {"master": name, "contour": ix, "type": "open_path"}, ) - if m0 == m1: continue + contourGreenVectors.append(_contour_vector_from_stats(greenStats)) + contourControlVectors.append(_contour_vector_from_stats(controlStats)) + + # Check starting point + if contourOps[0] == "addComponent": + continue + assert contourOps[0] == "moveTo" + assert contourOps[-1] in ("closePath", "endPath") + points = SimpleRecordingPointPen() + 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. + + isomorphisms = [] + contourIsomorphisms.append(isomorphisms) + + # Add rotations + _add_isomorphisms(points.value, isomorphisms, False) + # Add mirrored rotations + _add_isomorphisms(points.value, isomorphisms, True) + + matchings = [None] * len(allControlVectors) + + for m1idx in order: + if allNodeTypes[m1idx] is None: + continue + m0idx = grand_parent(m1idx, glyph_name) + if m0idx is None: + continue + if allNodeTypes[m0idx] is None: + continue + + m1 = allNodeTypes[m1idx] + m0 = allNodeTypes[m0idx] + if len(m0) != len(m1): + yield ( + glyph_name, + { + "type": "path_count", + "master_1": names[m0idx], + "master_2": names[m1idx], + "value_1": len(m0), + "value_2": len(m1), + }, + ) + continue + + if m0 != m1: for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)): if nodes1 == nodes2: continue @@ -287,7 +422,7 @@ def test_gen(glyphsets, glyphs=None, names=None, ignore_missing=False): "type": "node_count", "path": pathIx, "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], + "master_2": names[m1idx], "value_1": len(nodes1), "value_2": len(nodes2), }, @@ -302,89 +437,126 @@ def test_gen(glyphsets, glyphs=None, names=None, ignore_missing=False): "path": pathIx, "node": nodeIx, "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], + "master_2": names[m1idx], "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))) + m1Control = allControlVectors[m1idx] + m1Green = allGreenVectors[m1idx] + m0Control = allControlVectors[m0idx] + m0Green = allGreenVectors[m0idx] + if len(m1Control) > 1: + identity_matching = list(range(len(m0Control))) + + # We try matching both the StatisticsControlPen vector + # and the StatisticsPen vector. + # If either method found a identity matching, accept it. + # This is crucial for fonts like Kablammo[MORF].ttf and + # Nabla[EDPT,EHLT].ttf, since they really confuse the + # StatisticsPen vector because of their area=0 contours. + # + # TODO: Optimize by only computing the StatisticsPen vector + # and then checking if it is the identity vector. Only if + # not, compute the StatisticsControlPen vector and check both. + + costsControl = [ + [_vdiff_hypot2(v0, v1) for v1 in m1Control] for v0 in m0Control + ] + ( + matching_control, + matching_cost_control, + ) = min_cost_perfect_bipartite_matching(costsControl) + identity_cost_control = sum( + costsControl[i][i] for i in range(len(m0Control)) + ) + done = matching_cost_control == identity_cost_control + + if not done: + costsGreen = [ + [_vdiff_hypot2(v0, v1) for v1 in m1Green] for v0 in m0Green + ] + ( + matching_green, + matching_cost_green, + ) = min_cost_perfect_bipartite_matching(costsGreen) + identity_cost_green = sum( + costsGreen[i][i] for i in range(len(m0Control)) + ) + done = matching_cost_green == identity_cost_green + + if not done: + # Otherwise, use the worst of the two matchings. if ( - matching != identity_matching - and matching_cost < identity_cost * 0.95 + matching_cost_control / identity_cost_control + < matching_cost_green / identity_cost_green ): + matching = matching_control + matching_cost = matching_cost_control + identity_cost = identity_cost_control + else: + matching = matching_green + matching_cost = matching_cost_green + identity_cost = identity_cost_green + + if matching_cost < identity_cost * tolerance: + # print(matching_cost_control / identity_cost_control, matching_cost_green / identity_cost_green) + yield ( glyph_name, { "type": "contour_order", "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - "value_1": list(range(len(m0))), + "master_2": names[m1idx], + "value_1": list(range(len(m0Control))), "value_2": matching, }, ) - break + matchings[m1idx] = matching - # m0idx should be the index of the first non-None item in allContourIsomorphisms, - # else give it the last item. - m0idx = next( - (i for i, x in enumerate(allContourIsomorphisms) if x is not None), - len(allVectors) - 1, - ) - # m0 is the first non-None item in allContourIsomorphisms, or last one if all None + m1 = allContourIsomorphisms[m1idx] 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 + + for ix, (contour0, contour1) in enumerate(zip(m0, m1)): + if len(contour0) == 0 or len(contour0) != len(contour1): + # We already reported this; or nothing to do + continue + + c0 = contour0[0] + costs = [_vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1] + min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1]) + first_cost = costs[0] + if min_cost < first_cost * tolerance: + reverse = contour1[min_cost_idx][2] + + # If contour-order is wrong, don't report a reversing + if ( + reverse + and matchings[m1idx] is not None + and matchings[m1idx][ix] != ix + ): continue - for ix, (contour0, contour1) in enumerate(zip(m0, m1)): - c0 = contour0[0] - costs = [_vdiff_hypot2_complex(c0, c1) for c1 in contour1] - min_cost = min(costs) - first_cost = costs[0] - if min_cost < first_cost * 0.95: - yield ( - glyph_name, - { - "type": "wrong_start_point", - "contour": ix, - "master_1": names[m0idx], - "master_2": names[m0idx + i + 1], - }, - ) - except ValueError as e: - yield ( - glyph_name, - {"type": "math_error", "master": name, "error": e}, - ) + yield ( + glyph_name, + { + "type": "wrong_start_point", + "contour": ix, + "master_1": names[m0idx], + "master_2": names[m1idx], + "value_1": 0, + "value_2": contour1[min_cost_idx][1], + "reversed": reverse, + }, + ) -def test(glyphsets, glyphs=None, names=None, ignore_missing=False): +@wraps(test_gen) +def test(*args, **kwargs): problems = defaultdict(list) - for glyphname, problem in test_gen(glyphsets, glyphs, names, ignore_missing): + for glyphname, problem in test_gen(*args, **kwargs): problems[glyphname].append(problem) return problems @@ -401,6 +573,7 @@ def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf): def main(args=None): """Test for interpolatability issues between fonts""" import argparse + import sys parser = argparse.ArgumentParser( "fonttools varLib.interpolatable", @@ -412,16 +585,37 @@ def main(args=None): help="Space-separate name of glyphs to check", ) parser.add_argument( + "--tolerance", + action="store", + type=float, + help="Error tolerance. Default 0.95", + ) + parser.add_argument( "--json", action="store_true", help="Output report in JSON format", ) parser.add_argument( + "--pdf", + action="store", + help="Output report in PDF format", + ) + parser.add_argument( + "--html", + action="store", + help="Output report in HTML format", + ) + parser.add_argument( "--quiet", action="store_true", help="Only exit with code 1 or 0, no output", ) parser.add_argument( + "--output", + action="store", + help="Output file for the problem report; Default: stdout", + ) + parser.add_argument( "--ignore-missing", action="store_true", help="Will not report glyphs missing from sparse masters as errors", @@ -447,21 +641,42 @@ def main(args=None): fonts = [] names = [] + locations = [] if len(args.inputs) == 1: + designspace = None if args.inputs[0].endswith(".designspace"): from fontTools.designspaceLib import DesignSpaceDocument designspace = DesignSpaceDocument.fromfile(args.inputs[0]) args.inputs = [master.path for master in designspace.sources] + locations = [master.location for master in designspace.sources] + axis_triples = { + a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes + } + axis_mappings = {a.name: a.map for a in designspace.axes} + axis_triples = { + k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv) + for k, vv in axis_triples.items() + } elif args.inputs[0].endswith(".glyphs"): - from glyphsLib import GSFont, to_ufos + from glyphsLib import GSFont, to_designspace gsfont = GSFont(args.inputs[0]) - fonts.extend(to_ufos(gsfont)) + designspace = to_designspace(gsfont) + fonts = [source.font for source in designspace.sources] names = ["%s-%s" % (f.info.familyName, f.info.styleName) for f in fonts] args.inputs = [] + locations = [master.location for master in designspace.sources] + axis_triples = { + a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes + } + axis_mappings = {a.name: a.map for a in designspace.axes} + axis_triples = { + k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv) + for k, vv in axis_triples.items() + } elif args.inputs[0].endswith(".ttf"): from fontTools.ttLib import TTFont @@ -515,20 +730,32 @@ def main(args=None): names = ["''"] fonts = [font.getGlyphSet()] + locations = [{}] + axis_triples = {a: (-1, 0, +1) for a in sorted(axisMapping.keys())} for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)): name = ( "'" + " ".join( - "%s=%s" % (k, piecewiseLinearMap(v, axisMapping[k])) + "%s=%s" + % ( + k, + floatToFixedToStr( + piecewiseLinearMap(v, axisMapping[k]), 14 + ), + ) for k, v in locTuple ) + "'" ) names.append(name) fonts.append(glyphsets[locTuple]) + locations.append(dict(locTuple)) args.ignore_missing = True args.inputs = [] + if not locations: + locations = [{} for _ in fonts] + for filename in args.inputs: if filename.endswith(".ufo"): from fontTools.ufoLib import UFOReader @@ -549,6 +776,9 @@ def main(args=None): glyphset = font glyphsets.append({k: glyphset[k] for k in glyphset.keys()}) + if len(glyphsets) == 1: + return None + if not glyphs: glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()])) @@ -560,12 +790,23 @@ def main(args=None): for gn in diff: glyphset[gn] = None + # Normalize locations + locations = [normalizeLocation(loc, axis_triples) for loc in locations] + log.info("Running on %d glyphsets", len(glyphsets)) + log.info("Locations: %s", pformat(locations)) problems_gen = test_gen( - glyphsets, glyphs=glyphs, names=names, ignore_missing=args.ignore_missing + glyphsets, + glyphs=glyphs, + names=names, + locations=locations, + ignore_missing=args.ignore_missing, + tolerance=args.tolerance or 0.95, ) problems = defaultdict(list) + f = sys.stdout if args.output is None else open(args.output, "w") + if not args.quiet: if args.json: import json @@ -573,24 +814,35 @@ def main(args=None): for glyphname, problem in problems_gen: problems[glyphname].append(problem) - print(json.dumps(problems)) + print(json.dumps(problems), file=f) else: last_glyphname = None for glyphname, p in problems_gen: problems[glyphname].append(p) if glyphname != last_glyphname: - print(f"Glyph {glyphname} was not compatible: ") + print(f"Glyph {glyphname} was not compatible:", file=f) last_glyphname = glyphname + last_masters = None + + masters = ( + (p["master"]) if "master" in p else (p["master_1"], p["master_2"]) + ) + if masters != last_masters: + print(f" Masters: %s:" % ", ".join(masters), file=f) + last_masters = masters if p["type"] == "missing": - print(" Glyph was missing in master %s" % p["master"]) + print(" Glyph was missing in master %s" % p["master"], file=f) if p["type"] == "open_path": - print(" Glyph has an open path in master %s" % p["master"]) + print( + " Glyph has an open path in master %s" % p["master"], file=f + ) if p["type"] == "path_count": print( " Path count differs: %i in %s, %i in %s" - % (p["value_1"], p["master_1"], p["value_2"], p["master_2"]) + % (p["value_1"], p["master_1"], p["value_2"], p["master_2"]), + file=f, ) if p["type"] == "node_count": print( @@ -601,7 +853,8 @@ def main(args=None): p["master_1"], p["value_2"], p["master_2"], - ) + ), + file=f, ) if p["type"] == "node_incompatibility": print( @@ -613,7 +866,8 @@ def main(args=None): p["master_1"], p["value_2"], p["master_2"], - ) + ), + file=f, ) if p["type"] == "contour_order": print( @@ -623,29 +877,57 @@ def main(args=None): p["master_1"], p["value_2"], p["master_2"], - ) + ), + file=f, ) if p["type"] == "wrong_start_point": print( - " Contour %d start point differs: %s, %s" + " Contour %d start point differs: %s in %s, %s in %s; reversed: %s" % ( p["contour"], + p["value_1"], p["master_1"], + p["value_2"], p["master_2"], - ) - ) - if p["type"] == "math_error": - print( - " Miscellaneous error in %s: %s" - % ( - p["master"], - p["error"], - ) + p["reversed"], + ), + file=f, ) else: for glyphname, problem in problems_gen: problems[glyphname].append(problem) + if args.pdf: + log.info("Writing PDF to %s", args.pdf) + from .interpolatablePlot import InterpolatablePDF + + with InterpolatablePDF(args.pdf, glyphsets=glyphsets, names=names) as pdf: + pdf.add_problems(problems) + if not problems and not args.quiet: + pdf.draw_cupcake() + + if args.html: + log.info("Writing HTML to %s", args.html) + from .interpolatablePlot import InterpolatableSVG + + svgs = [] + with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg: + svg.add_problems(problems) + if not problems and not args.quiet: + svg.draw_cupcake() + + import base64 + + with open(args.html, "wb") as f: + f.write(b"<!DOCTYPE html>\n") + f.write(b"<html><body align=center>\n") + for svg in svgs: + f.write("<img src='data:image/svg+xml;base64,".encode("utf-8")) + f.write(base64.b64encode(svg)) + f.write(b"' />\n") + f.write(b"<hr>\n") + f.write(b"</body></html>\n") + if problems: return problems diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py b/contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py new file mode 100644 index 0000000000..224f60ae64 --- /dev/null +++ b/contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py @@ -0,0 +1,672 @@ +from fontTools.pens.recordingPen import ( + RecordingPen, + DecomposingRecordingPen, + RecordingPointPen, +) +from fontTools.pens.boundsPen import ControlBoundsPen +from fontTools.pens.cairoPen import CairoPen +from fontTools.pens.pointPen import ( + SegmentToPointPen, + PointToSegmentPen, + ReverseContourPointPen, +) +from fontTools.varLib.interpolatable import ( + PerContourOrComponentPen, + SimpleRecordingPointPen, +) +from itertools import cycle +from functools import wraps +from io import BytesIO +import cairo +import math +import logging + +log = logging.getLogger("fontTools.varLib.interpolatable") + + +class LerpGlyphSet: + def __init__(self, glyphset1, glyphset2, factor=0.5): + self.glyphset1 = glyphset1 + self.glyphset2 = glyphset2 + self.factor = factor + + def __getitem__(self, glyphname): + return LerpGlyph(glyphname, self) + + +class LerpGlyph: + def __init__(self, glyphname, glyphset): + self.glyphset = glyphset + self.glyphname = glyphname + + def draw(self, pen): + recording1 = DecomposingRecordingPen(self.glyphset.glyphset1) + self.glyphset.glyphset1[self.glyphname].draw(recording1) + recording2 = DecomposingRecordingPen(self.glyphset.glyphset2) + self.glyphset.glyphset2[self.glyphname].draw(recording2) + + factor = self.glyphset.factor + for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value): + if op1 != op2: + raise ValueError("Mismatching operations: %s, %s" % (op1, op2)) + mid_args = [ + (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor) + for (x1, y1), (x2, y2) in zip(args1, args2) + ] + getattr(pen, op1)(*mid_args) + + +class OverridingDict(dict): + def __init__(self, parent_dict): + self.parent_dict = parent_dict + + def __missing__(self, key): + return self.parent_dict[key] + + +class InterpolatablePlot: + width = 640 + height = 480 + pad = 16 + line_height = 36 + head_color = (0.3, 0.3, 0.3) + label_color = (0.2, 0.2, 0.2) + border_color = (0.9, 0.9, 0.9) + border_width = 1 + fill_color = (0.8, 0.8, 0.8) + stroke_color = (0.1, 0.1, 0.1) + stroke_width = 2 + oncurve_node_color = (0, 0.8, 0) + oncurve_node_diameter = 10 + offcurve_node_color = (0, 0.5, 0) + offcurve_node_diameter = 8 + handle_color = (0.2, 1, 0.2) + handle_width = 1 + other_start_point_color = (0, 0, 1) + reversed_start_point_color = (0, 1, 0) + start_point_color = (1, 0, 0) + start_point_width = 15 + start_handle_width = 5 + start_handle_length = 100 + start_handle_arrow_length = 5 + contour_colors = ((1, 0, 0), (0, 0, 1), (0, 1, 0), (1, 1, 0), (1, 0, 1), (0, 1, 1)) + contour_alpha = 0.5 + cupcake_color = (0.3, 0, 0.3) + cupcake = r""" + ,@. + ,@.@@,. + ,@@,.@@@. @.@@@,. + ,@@. @@@. @@. @@,. + ,@@@.@,.@. @. @@@@,.@.@@,. + ,@@.@. @@.@@. @,. .@’ @’ @@, + ,@@. @. .@@.@@@. @@’ @, +,@. @@. @, +@. @,@@,. , .@@, +@,. .@,@@,. .@@,. , .@@, @, @, +@. .@. @ @@,. , @ + @,.@@. @,. @@,. @. @,. @’ + @@||@,. @’@,. @@,. @@ @,. @’@@, @’ + \\@@@@’ @,. @’@@@@’ @@,. @@@’ //@@@’ + |||||||| @@,. @@’ ||||||| |@@@|@|| || + \\\\\\\ ||@@@|| ||||||| ||||||| // + ||||||| |||||| |||||| |||||| || + \\\\\\ |||||| |||||| |||||| // + |||||| ||||| ||||| ||||| || + \\\\\ ||||| ||||| ||||| // + ||||| |||| ||||| |||| || + \\\\ |||| |||| |||| // + |||||||||||||||||||||||| +""" + shrug_color = (0, 0.3, 0.3) + shrug = r"""\_(")_/""" + + def __init__(self, out, glyphsets, names=None, **kwargs): + self.out = out + self.glyphsets = glyphsets + self.names = names or [repr(g) for g in glyphsets] + + for k, v in kwargs.items(): + if not hasattr(self, k): + raise TypeError("Unknown keyword argument: %s" % k) + setattr(self, k, v) + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + pass + + def set_size(self, width, height): + raise NotImplementedError + + def show_page(self): + raise NotImplementedError + + def add_problems(self, problems): + for glyph, glyph_problems in problems.items(): + last_masters = None + current_glyph_problems = [] + for p in glyph_problems: + masters = ( + p["master"] if "master" in p else (p["master_1"], p["master_2"]) + ) + if masters == last_masters: + current_glyph_problems.append(p) + continue + # Flush + if current_glyph_problems: + self.add_problem(glyph, current_glyph_problems) + self.show_page() + current_glyph_problems = [] + last_masters = masters + current_glyph_problems.append(p) + if current_glyph_problems: + self.add_problem(glyph, current_glyph_problems) + self.show_page() + + def add_problem(self, glyphname, problems): + if type(problems) not in (list, tuple): + problems = [problems] + + problem_type = problems[0]["type"] + problem_types = set(problem["type"] for problem in problems) + if not all(pt == problem_type for pt in problem_types): + problem_type = ", ".join(sorted({problem["type"] for problem in problems})) + + log.info("Drawing %s: %s", glyphname, problem_type) + + master_keys = ( + ("master",) if "master" in problems[0] else ("master_1", "master_2") + ) + master_names = [problems[0][k] for k in master_keys] + master_indices = [self.names.index(n) for n in master_names] + + if problem_type == "missing": + sample_glyph = next( + i for i, m in enumerate(self.glyphsets) if m[glyphname] is not None + ) + master_indices.insert(0, sample_glyph) + + total_width = self.width * 2 + 3 * self.pad + total_height = ( + self.pad + + self.line_height + + self.pad + + len(master_indices) * (self.height + self.pad * 2 + self.line_height) + + self.pad + ) + + self.set_size(total_width, total_height) + + x = self.pad + y = self.pad + + self.draw_label(glyphname, x=x, y=y, color=self.head_color, align=0, bold=True) + self.draw_label( + problem_type, + x=x + self.width + self.pad, + y=y, + color=self.head_color, + align=1, + bold=True, + ) + y += self.line_height + self.pad + + for which, master_idx in enumerate(master_indices): + glyphset = self.glyphsets[master_idx] + name = self.names[master_idx] + + self.draw_label(name, x=x, y=y, color=self.label_color, align=0.5) + y += self.line_height + self.pad + + if glyphset[glyphname] is not None: + self.draw_glyph(glyphset, glyphname, problems, which, x=x, y=y) + else: + self.draw_shrug(x=x, y=y) + y += self.height + self.pad + + if any(pt in ("wrong_start_point", "contour_order") for pt in problem_types): + x = self.pad + self.width + self.pad + y = self.pad + y += self.line_height + self.pad + + glyphset1 = self.glyphsets[master_indices[0]] + glyphset2 = self.glyphsets[master_indices[1]] + + # Draw the mid-way of the two masters + + self.draw_label( + "midway interpolation", x=x, y=y, color=self.head_color, align=0.5 + ) + y += self.line_height + self.pad + + midway_glyphset = LerpGlyphSet(glyphset1, glyphset2) + self.draw_glyph( + midway_glyphset, glyphname, {"type": "midway"}, None, x=x, y=y + ) + y += self.height + self.pad + + # Draw the fixed mid-way of the two masters + + self.draw_label("proposed fix", x=x, y=y, color=self.head_color, align=0.5) + y += self.line_height + self.pad + + overriding1 = OverridingDict(glyphset1) + overriding2 = OverridingDict(glyphset2) + perContourPen1 = PerContourOrComponentPen( + RecordingPen, glyphset=overriding1 + ) + perContourPen2 = PerContourOrComponentPen( + RecordingPen, glyphset=overriding2 + ) + glyphset1[glyphname].draw(perContourPen1) + glyphset2[glyphname].draw(perContourPen2) + + for problem in problems: + if problem["type"] == "contour_order": + fixed_contours = [ + perContourPen2.value[i] for i in problems[0]["value_2"] + ] + perContourPen2.value = fixed_contours + + for problem in problems: + if problem["type"] == "wrong_start_point": + # Save the wrong contours + wrongContour1 = perContourPen1.value[problem["contour"]] + wrongContour2 = perContourPen2.value[problem["contour"]] + + # Convert the wrong contours to point pens + points1 = RecordingPointPen() + converter = SegmentToPointPen(points1, False) + wrongContour1.replay(converter) + points2 = RecordingPointPen() + converter = SegmentToPointPen(points2, False) + wrongContour2.replay(converter) + + proposed_start = problem["value_2"] + + # See if we need reversing; fragile but worth a try + if problem["reversed"]: + new_points2 = RecordingPointPen() + reversedPen = ReverseContourPointPen(new_points2) + points2.replay(reversedPen) + points2 = new_points2 + proposed_start = len(points2.value) - 2 - proposed_start + + # Rotate points2 so that the first point is the same as in points1 + beginPath = points2.value[:1] + endPath = points2.value[-1:] + pts = points2.value[1:-1] + pts = pts[proposed_start:] + pts[:proposed_start] + points2.value = beginPath + pts + endPath + + # Convert the point pens back to segment pens + segment1 = RecordingPen() + converter = PointToSegmentPen(segment1, True) + points1.replay(converter) + segment2 = RecordingPen() + converter = PointToSegmentPen(segment2, True) + points2.replay(converter) + + # Replace the wrong contours + wrongContour1.value = segment1.value + wrongContour2.value = segment2.value + + # Assemble + fixed1 = RecordingPen() + fixed2 = RecordingPen() + for contour in perContourPen1.value: + fixed1.value.extend(contour.value) + for contour in perContourPen2.value: + fixed2.value.extend(contour.value) + fixed1.draw = fixed1.replay + fixed2.draw = fixed2.replay + + overriding1[glyphname] = fixed1 + overriding2[glyphname] = fixed2 + + try: + midway_glyphset = LerpGlyphSet(overriding1, overriding2) + self.draw_glyph( + midway_glyphset, glyphname, {"type": "fixed"}, None, x=x, y=y + ) + except ValueError: + self.draw_shrug(x=x, y=y) + y += self.height + self.pad + + def draw_label(self, label, *, x, y, color=(0, 0, 0), align=0, bold=False): + cr = cairo.Context(self.surface) + cr.select_font_face( + "@cairo:", + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_BOLD if bold else cairo.FONT_WEIGHT_NORMAL, + ) + cr.set_font_size(self.line_height) + font_extents = cr.font_extents() + font_size = self.line_height * self.line_height / font_extents[2] + cr.set_font_size(font_size) + font_extents = cr.font_extents() + + cr.set_source_rgb(*color) + + extents = cr.text_extents(label) + if extents.width > self.width: + # Shrink + font_size *= self.width / extents.width + cr.set_font_size(font_size) + font_extents = cr.font_extents() + extents = cr.text_extents(label) + + # Center + label_x = x + (self.width - extents.width) * align + label_y = y + font_extents[0] + cr.move_to(label_x, label_y) + cr.show_text(label) + + def draw_glyph(self, glyphset, glyphname, problems, which, *, x=0, y=0): + if type(problems) not in (list, tuple): + problems = [problems] + + problem_type = problems[0]["type"] + problem_types = set(problem["type"] for problem in problems) + if not all(pt == problem_type for pt in problem_types): + problem_type = "mixed" + glyph = glyphset[glyphname] + + recording = RecordingPen() + glyph.draw(recording) + + boundsPen = ControlBoundsPen(glyphset) + recording.replay(boundsPen) + + glyph_width = boundsPen.bounds[2] - boundsPen.bounds[0] + glyph_height = boundsPen.bounds[3] - boundsPen.bounds[1] + + scale = min(self.width / glyph_width, self.height / glyph_height) + + cr = cairo.Context(self.surface) + cr.translate(x, y) + # Center + cr.translate( + (self.width - glyph_width * scale) / 2, + (self.height - glyph_height * scale) / 2, + ) + cr.scale(scale, -scale) + cr.translate(-boundsPen.bounds[0], -boundsPen.bounds[3]) + + if self.border_color: + cr.set_source_rgb(*self.border_color) + cr.rectangle( + boundsPen.bounds[0], boundsPen.bounds[1], glyph_width, glyph_height + ) + cr.set_line_width(self.border_width / scale) + cr.stroke() + + if self.fill_color and problem_type != "open_path": + pen = CairoPen(glyphset, cr) + recording.replay(pen) + cr.set_source_rgb(*self.fill_color) + cr.fill() + + if self.stroke_color: + pen = CairoPen(glyphset, cr) + recording.replay(pen) + cr.set_source_rgb(*self.stroke_color) + cr.set_line_width(self.stroke_width / scale) + cr.stroke() + + if problem_type in ("node_count", "node_incompatibility"): + cr.set_line_cap(cairo.LINE_CAP_ROUND) + + # Oncurve nodes + for segment, args in recording.value: + if not args: + continue + x, y = args[-1] + cr.move_to(x, y) + cr.line_to(x, y) + cr.set_source_rgb(*self.oncurve_node_color) + cr.set_line_width(self.oncurve_node_diameter / scale) + cr.stroke() + + # Offcurve nodes + for segment, args in recording.value: + for x, y in args[:-1]: + cr.move_to(x, y) + cr.line_to(x, y) + cr.set_source_rgb(*self.offcurve_node_color) + cr.set_line_width(self.offcurve_node_diameter / scale) + cr.stroke() + + # Handles + for segment, args in recording.value: + if not args: + pass + elif segment in ("moveTo", "lineTo"): + cr.move_to(*args[0]) + elif segment == "qCurveTo": + for x, y in args: + cr.line_to(x, y) + cr.new_sub_path() + cr.move_to(*args[-1]) + elif segment == "curveTo": + cr.line_to(*args[0]) + cr.new_sub_path() + cr.move_to(*args[1]) + cr.line_to(*args[2]) + cr.new_sub_path() + cr.move_to(*args[-1]) + else: + assert False + + cr.set_source_rgb(*self.handle_color) + cr.set_line_width(self.handle_width / scale) + cr.stroke() + + for problem in problems: + if problem["type"] == "contour_order": + matching = problem["value_2"] + colors = cycle(self.contour_colors) + perContourPen = PerContourOrComponentPen( + RecordingPen, glyphset=glyphset + ) + recording.replay(perContourPen) + for i, contour in enumerate(perContourPen.value): + if matching[i] == i: + continue + color = next(colors) + contour.replay(CairoPen(glyphset, cr)) + cr.set_source_rgba(*color, self.contour_alpha) + cr.fill() + + for problem in problems: + if problem["type"] == "wrong_start_point": + idx = problem["contour"] + + # Draw suggested point + if which == 1: + perContourPen = PerContourOrComponentPen( + RecordingPen, glyphset=glyphset + ) + recording.replay(perContourPen) + points = SimpleRecordingPointPen() + converter = SegmentToPointPen(points, False) + perContourPen.value[idx].replay(converter) + targetPoint = points.value[problem["value_2"]][0] + cr.move_to(*targetPoint) + cr.line_to(*targetPoint) + cr.set_line_cap(cairo.LINE_CAP_ROUND) + cr.set_source_rgb(*self.other_start_point_color) + cr.set_line_width(self.start_point_width / scale) + cr.stroke() + + # Draw start point + cr.set_line_cap(cairo.LINE_CAP_ROUND) + i = 0 + for segment, args in recording.value: + if segment == "moveTo": + if i == idx: + cr.move_to(*args[0]) + cr.line_to(*args[0]) + i += 1 + + if which == 0 or not problem["reversed"]: + cr.set_source_rgb(*self.start_point_color) + else: + cr.set_source_rgb(*self.reversed_start_point_color) + cr.set_line_width(self.start_point_width / scale) + cr.stroke() + + # Draw arrow + cr.set_line_cap(cairo.LINE_CAP_SQUARE) + first_pt = None + i = 0 + for segment, args in recording.value: + if segment == "moveTo": + first_pt = args[0] + continue + if first_pt is None: + continue + second_pt = args[0] + + if i == idx: + first_pt = complex(*first_pt) + second_pt = complex(*second_pt) + length = abs(second_pt - first_pt) + if length: + # Draw handle + length *= scale + second_pt = ( + first_pt + + (second_pt - first_pt) + / length + * self.start_handle_length + ) + cr.move_to(first_pt.real, first_pt.imag) + cr.line_to(second_pt.real, second_pt.imag) + # Draw arrowhead + cr.save() + cr.translate(second_pt.real, second_pt.imag) + cr.rotate( + math.atan2( + second_pt.imag - first_pt.imag, + second_pt.real - first_pt.real, + ) + ) + cr.scale(1 / scale, 1 / scale) + cr.translate(self.start_handle_width, 0) + cr.move_to(0, 0) + cr.line_to( + -self.start_handle_arrow_length, + -self.start_handle_arrow_length, + ) + cr.line_to( + -self.start_handle_arrow_length, + self.start_handle_arrow_length, + ) + cr.close_path() + cr.restore() + + first_pt = None + i += 1 + + cr.set_line_width(self.start_handle_width / scale) + cr.stroke() + + def draw_cupcake(self): + self.set_size(self.width, self.height) + cupcake = self.cupcake.splitlines() + cr = cairo.Context(self.surface) + cr.set_source_rgb(*self.cupcake_color) + cr.set_font_size(self.line_height) + cr.select_font_face( + "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL + ) + width = 0 + height = 0 + for line in cupcake: + extents = cr.text_extents(line) + width = max(width, extents.width) + height += extents.height + if not width: + return + cr.scale(self.width / width, self.height / height) + for line in cupcake: + cr.translate(0, cr.text_extents(line).height) + cr.move_to(0, 0) + cr.show_text(line) + + def draw_shrug(self, x=0, y=0): + cr = cairo.Context(self.surface) + cr.translate(x, y) + cr.set_source_rgb(*self.shrug_color) + cr.set_font_size(self.line_height) + cr.select_font_face( + "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL + ) + extents = cr.text_extents(self.shrug) + if not extents.width: + return + cr.translate(0, self.height * 0.6) + scale = self.width / extents.width + cr.scale(scale, scale) + cr.move_to(-extents.x_bearing, 0) + cr.show_text(self.shrug) + + +class InterpolatablePostscriptLike(InterpolatablePlot): + @wraps(InterpolatablePlot.__init__) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __exit__(self, type, value, traceback): + self.surface.finish() + + def set_size(self, width, height): + self.surface.set_size(width, height) + + def show_page(self): + self.surface.show_page() + + def __enter__(self): + self.surface = cairo.PSSurface(self.out, self.width, self.height) + return self + + +class InterpolatablePS(InterpolatablePostscriptLike): + def __enter__(self): + self.surface = cairo.PSSurface(self.out, self.width, self.height) + return self + + +class InterpolatablePDF(InterpolatablePostscriptLike): + def __enter__(self): + self.surface = cairo.PDFSurface(self.out, self.width, self.height) + self.surface.set_metadata( + cairo.PDF_METADATA_CREATOR, "fonttools varLib.interpolatable" + ) + self.surface.set_metadata(cairo.PDF_METADATA_CREATE_DATE, "") + return self + + +class InterpolatableSVG(InterpolatablePlot): + @wraps(InterpolatablePlot.__init__) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __enter__(self): + self.surface = None + return self + + def __exit__(self, type, value, traceback): + if self.surface is not None: + self.show_page() + + def set_size(self, width, height): + self.sink = BytesIO() + self.surface = cairo.SVGSurface(self.sink, width, height) + + def show_page(self): + self.surface.finish() + self.out.append(self.sink.getvalue()) + self.surface = None diff --git a/contrib/python/fonttools/ya.make b/contrib/python/fonttools/ya.make index 9f3e3eecde..fbda7f8c97 100644 --- a/contrib/python/fonttools/ya.make +++ b/contrib/python/fonttools/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(4.44.3) +VERSION(4.45.0) LICENSE(MIT) @@ -13,6 +13,7 @@ NO_CHECK_IMPORTS( fontTools.pens.* fontTools.ttLib.removeOverlaps fontTools.ufoLib.* + fontTools.varLib.interpolatablePlot fontTools.varLib.plot ) @@ -298,6 +299,7 @@ PY_SRCS( fontTools/varLib/instancer/names.py fontTools/varLib/instancer/solver.py fontTools/varLib/interpolatable.py + fontTools/varLib/interpolatablePlot.py fontTools/varLib/interpolate_layout.py fontTools/varLib/iup.py fontTools/varLib/merger.py |