fonttools/Lib/fontTools/varLib/interpolatable.py
Simon Cozens 584555a344 fix tests
2020-11-20 11:18:54 +00:00

374 lines
12 KiB
Python

"""
Tool to find wrong contour order between different masters, and
other interpolatability (or lack thereof) issues.
Call as:
$ fonttools varLib.interpolatable font1 font2 ...
"""
from fontTools.pens.basePen import AbstractPen, BasePen
from fontTools.pens.recordingPen import RecordingPen
from fontTools.pens.statisticsPen import StatisticsPen
from collections import OrderedDict
import itertools
import sys
class PerContourPen(BasePen):
def __init__(self, Pen, glyphset=None):
BasePen.__init__(self, glyphset)
self._glyphset = glyphset
self._Pen = Pen
self._pen = None
self.value = []
def _moveTo(self, p0):
self._newItem()
self._pen.moveTo(p0)
def _lineTo(self, p1):
self._pen.lineTo(p1)
def _qCurveToOne(self, p1, p2):
self._pen.qCurveTo(p1, p2)
def _curveToOne(self, p1, p2, p3):
self._pen.curveTo(p1, p2, p3)
def _closePath(self):
self._pen.closePath()
self._pen = None
def _endPath(self):
self._pen.endPath()
self._pen = None
def _newItem(self):
self._pen = pen = self._Pen()
self.value.append(pen)
class PerContourOrComponentPen(PerContourPen):
def addComponent(self, glyphName, transformation):
self._newItem()
self.value[-1].addComponent(glyphName, transformation)
def _vdiff(v0, v1):
return tuple(b - a for a, b in zip(v0, v1))
def _vlen(vec):
v = 0
for x in vec:
v += x * x
return v
def _matching_cost(G, matching):
return sum(G[i][j] for i, j in enumerate(matching))
def min_cost_perfect_bipartite_matching(G):
n = len(G)
try:
from scipy.optimize import linear_sum_assignment
rows, cols = linear_sum_assignment(G)
assert (rows == list(range(n))).all()
return list(cols), _matching_cost(G, cols)
except ImportError:
pass
try:
from munkres import Munkres
cols = [None] * n
for row, col in Munkres().compute(G):
cols[row] = col
return cols, _matching_cost(G, cols)
except ImportError:
pass
if n > 6:
raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
# Otherwise just brute-force
permutations = itertools.permutations(range(n))
best = list(next(permutations))
best_cost = _matching_cost(G, best)
for p in permutations:
cost = _matching_cost(G, p)
if cost < best_cost:
best, best_cost = list(p), cost
return best, best_cost
def test(glyphsets, glyphs=None, names=None):
if names is None:
names = glyphsets
if glyphs is None:
glyphs = glyphsets[0].keys()
hist = []
problems = OrderedDict()
def add_problem(glyphname, problem):
if not glyphname in problems:
problems[glyphname] = []
problems[glyphname].append(problem)
for glyph_name in glyphs:
# print()
# print(glyph_name)
try:
allVectors = []
allNodeTypes = []
for glyphset, name in zip(glyphsets, names):
# print('.', end='')
if glyph_name not in glyphset:
add_problem(glyph_name, {"type": "missing", "master": name})
continue
glyph = glyphset[glyph_name]
perContourPen = PerContourOrComponentPen(
RecordingPen, glyphset=glyphset
)
glyph.draw(perContourPen)
contourPens = perContourPen.value
del perContourPen
contourVectors = []
nodeTypes = []
allNodeTypes.append(nodeTypes)
allVectors.append(contourVectors)
for ix, contour in enumerate(contourPens):
nodeTypes.append(
tuple(instruction[0] for instruction in contour.value)
)
stats = StatisticsPen(glyphset=glyphset)
try:
contour.replay(stats)
except NotImplementedError as e:
add_problem(
glyph_name,
{"master": name, "contour": ix, "type": "open_path"},
)
continue
size = abs(stats.area) ** 0.5 * 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 each master against the next one in the list.
for i, (m0, m1) in enumerate(zip(allNodeTypes[:-1], allNodeTypes[1:])):
if len(m0) != len(m1):
add_problem(
glyph_name,
{
"type": "path_count",
"master_1": names[i],
"master_2": names[i + 1],
"value_1": len(m0),
"value_2": len(m1),
},
)
if m0 == m1:
continue
for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
if nodes1 == nodes2:
continue
if len(nodes1) != len(nodes2):
add_problem(
glyph_name,
{
"type": "node_count",
"path": pathIx,
"master_1": names[i],
"master_2": names[i + 1],
"value_1": len(nodes1),
"value_2": len(nodes2),
},
)
continue
for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
if n1 != n2:
add_problem(
glyph_name,
{
"type": "node_incompatibility",
"path": pathIx,
"node": nodeIx,
"master_1": names[i],
"master_2": names[i + 1],
"value_1": n1,
"value_2": n2,
},
)
continue
for i, (m0, m1) in enumerate(zip(allVectors[:-1], allVectors[1:])):
if len(m0) != len(m1):
# We already reported this
continue
if not m0:
continue
costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0]
matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
if matching != list(range(len(m0))):
add_problem(
glyph_name,
{
"type": "contour_order",
"master_1": names[i],
"master_2": names[i + 1],
"value_1": list(range(len(m0))),
"value_2": matching,
},
)
break
upem = 2048
item_cost = round(
(matching_cost / len(m0) / len(m0[0])) ** 0.5 / upem * 100
)
hist.append(item_cost)
threshold = 7
if item_cost >= threshold:
add_problem(
glyph_name,
{
"type": "high_cost",
"master_1": names[i],
"master_2": names[i + 1],
"value_1": item_cost,
"value_2": threshold,
},
)
except ValueError as e:
add_problem(
glyph_name,
{"type": "math_error", "master": name, "error": e},
)
return problems
def main(args=None):
"""Test for interpolatability issues between fonts"""
import argparse
parser = argparse.ArgumentParser(
"fonttools varLib.interpolatable",
description=main.__doc__,
)
parser.add_argument(
"--json",
action="store_true",
help="Output report in JSON format",
)
parser.add_argument(
"inputs", metavar="FILE", type=str, nargs="+", help="Input TTF/UFO files"
)
args = parser.parse_args(args)
glyphs = None
# glyphs = ['uni08DB', 'uniFD76']
# glyphs = ['uni08DE', 'uni0034']
# glyphs = ['uni08DE', 'uni0034', 'uni0751', 'uni0753', 'uni0754', 'uni08A4', 'uni08A4.fina', 'uni08A5.fina']
from os.path import basename
names = [basename(filename).rsplit(".", 1)[0] for filename in args.inputs]
fonts = []
for filename in args.inputs:
if filename.endswith(".ufo"):
from fontTools.ufoLib import UFOReader
fonts.append(UFOReader(filename))
else:
from fontTools.ttLib import TTFont
fonts.append(TTFont(filename))
glyphsets = [font.getGlyphSet() for font in fonts]
problems = test(glyphsets, glyphs=glyphs, names=names)
if args.json:
import json
print(json.dumps(problems))
else:
for glyph, glyph_problems in problems.items():
print(f"Glyph {glyph} was not compatible: ")
for p in glyph_problems:
if p["type"] == "missing":
print(" Glyph was missing in master %s" % p["master"])
if p["type"] == "open_path":
print(" Glyph has an open path in master %s" % p["master"])
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"])
)
if p["type"] == "node_count":
print(
" Node count differs in path %i: %i in %s, %i in %s"
% (
p["path"],
p["value_1"],
p["master_1"],
p["value_2"],
p["master_2"],
)
)
if p["type"] == "node_incompatibility":
print(
" Node %o incompatible in path %i: %s in %s, %s in %s"
% (
p["node"],
p["path"],
p["value_1"],
p["master_1"],
p["value_2"],
p["master_2"],
)
)
if p["type"] == "contour_order":
print(
" Contour order differs: %s in %s, %s in %s"
% (
p["value_1"],
p["master_1"],
p["value_2"],
p["master_2"],
)
)
if p["type"] == "high_cost":
print(
" Interpolation has high cost: cost of %s to %s = %i, threshold %i"
% (
p["master_1"],
p["master_2"],
p["value_1"],
p["value_2"],
)
)
if problems:
return problems
if __name__ == "__main__":
import sys
problems = main()
sys.exit(int(bool(problems)))