aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorrobot-contrib <robot-contrib@yandex-team.com>2023-12-05 15:07:45 +0300
committerrobot-contrib <robot-contrib@yandex-team.com>2023-12-05 16:18:28 +0300
commit1a85d20071d4b1e481452e8d0b81f2e3e888803c (patch)
treedea60d2096da94e025ed6aefcd672715db9bffea
parentd7830d26621b08d508ba92fccf9e4971cc8ded61 (diff)
downloadydb-1a85d20071d4b1e481452e8d0b81f2e3e888803c.tar.gz
Update contrib/python/fonttools to 4.45.0
-rw-r--r--contrib/python/fonttools/.dist-info/METADATA10
-rw-r--r--contrib/python/fonttools/fontTools/__init__.py2
-rw-r--r--contrib/python/fonttools/fontTools/pens/momentsPen.py3
-rw-r--r--contrib/python/fonttools/fontTools/pens/recordingPen.py4
-rw-r--r--contrib/python/fonttools/fontTools/pens/statisticsPen.py195
-rw-r--r--contrib/python/fonttools/fontTools/varLib/interpolatable.py682
-rw-r--r--contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py672
-rw-r--r--contrib/python/fonttools/ya.make4
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