Rewriting varLib.interpolatable to allow for sparse masters + tests
This commit is contained in:
parent
3b9a73ff83
commit
7a86dd325e
@ -137,12 +137,14 @@ def min_cost_perfect_bipartite_matching(G):
|
|||||||
return best, best_cost
|
return best, best_cost
|
||||||
|
|
||||||
|
|
||||||
def test(glyphsets, glyphs=None, names=None):
|
def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
|
||||||
|
|
||||||
if names is None:
|
if names is None:
|
||||||
names = glyphsets
|
names = glyphsets
|
||||||
if glyphs is None:
|
if glyphs is None:
|
||||||
glyphs = glyphsets[0].keys()
|
# `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 = set([g for glyphset in glyphsets for g in glyphset.keys()])
|
||||||
|
|
||||||
hist = []
|
hist = []
|
||||||
problems = OrderedDict()
|
problems = OrderedDict()
|
||||||
@ -151,19 +153,22 @@ def test(glyphsets, glyphs=None, names=None):
|
|||||||
problems.setdefault(glyphname, []).append(problem)
|
problems.setdefault(glyphname, []).append(problem)
|
||||||
|
|
||||||
for glyph_name in glyphs:
|
for glyph_name in glyphs:
|
||||||
# print()
|
|
||||||
# print(glyph_name)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
m0idx = 0
|
||||||
allVectors = []
|
allVectors = []
|
||||||
allNodeTypes = []
|
allNodeTypes = []
|
||||||
allContourIsomorphisms = []
|
allContourIsomorphisms = []
|
||||||
for glyphset, name in zip(glyphsets, names):
|
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]
|
glyph = glyphset[glyph_name]
|
||||||
|
|
||||||
|
if glyph is None:
|
||||||
|
if not ignore_missing:
|
||||||
|
add_problem(glyph_name, {"type": "missing", "master": name})
|
||||||
|
allNodeTypes.append(None)
|
||||||
|
allVectors.append(None)
|
||||||
|
allContourIsomorphisms.append(None)
|
||||||
|
continue
|
||||||
|
|
||||||
perContourPen = PerContourOrComponentPen(
|
perContourPen = PerContourOrComponentPen(
|
||||||
RecordingPen, glyphset=glyphset
|
RecordingPen, glyphset=glyphset
|
||||||
@ -243,105 +248,125 @@ def test(glyphsets, glyphs=None, names=None):
|
|||||||
_rot_list([complex(*pt) for pt, bl in mirrored], i)
|
_rot_list([complex(*pt) for pt, bl in mirrored], i)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check each master against the first on in the list.
|
if any(allNodeTypes):
|
||||||
m0 = allNodeTypes[0]
|
# m0idx should be the index of the first non-None item in allNodeTypes,
|
||||||
for i, m1 in enumerate(allNodeTypes[1:]):
|
# else give it the first index of the empty list, which is likely 0
|
||||||
if len(m0) != len(m1):
|
m0idx = allNodeTypes.index(next((x for x in allNodeTypes if x is not None), None))
|
||||||
add_problem(
|
# m0 is the first non-None item in allNodeTypes, or the first item if all are None
|
||||||
glyph_name,
|
m0 = allNodeTypes[m0idx]
|
||||||
{
|
for i, m1 in enumerate(allNodeTypes[m0idx+1:]):
|
||||||
"type": "path_count",
|
if m1 is None:
|
||||||
"master_1": names[0],
|
|
||||||
"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
|
continue
|
||||||
if len(nodes1) != len(nodes2):
|
if len(m0) != len(m1):
|
||||||
add_problem(
|
add_problem(
|
||||||
glyph_name,
|
glyph_name,
|
||||||
{
|
{
|
||||||
"type": "node_count",
|
"type": "path_count",
|
||||||
"path": pathIx,
|
"master_1": names[m0idx],
|
||||||
"master_1": names[0],
|
"master_2": names[m0idx + i + 1],
|
||||||
"master_2": names[i + 1],
|
"value_1": len(m0),
|
||||||
"value_1": len(nodes1),
|
"value_2": len(m1),
|
||||||
"value_2": len(nodes2),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if m0 == m1:
|
||||||
continue
|
continue
|
||||||
for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
|
for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
|
||||||
if n1 != n2:
|
if nodes1 == nodes2:
|
||||||
|
continue
|
||||||
|
if len(nodes1) != len(nodes2):
|
||||||
add_problem(
|
add_problem(
|
||||||
glyph_name,
|
glyph_name,
|
||||||
{
|
{
|
||||||
"type": "node_incompatibility",
|
"type": "node_count",
|
||||||
"path": pathIx,
|
"path": pathIx,
|
||||||
"node": nodeIx,
|
"master_1": names[m0idx],
|
||||||
"master_1": names[0],
|
"master_2": names[m0idx + i + 1],
|
||||||
"master_2": names[i + 1],
|
"value_1": len(nodes1),
|
||||||
"value_1": n1,
|
"value_2": len(nodes2),
|
||||||
"value_2": n2,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
continue
|
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[0],
|
||||||
|
"master_2": names[m0idx + i + 1],
|
||||||
|
"value_1": n1,
|
||||||
|
"value_2": n2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
m0 = allVectors[0]
|
if any(allVectors):
|
||||||
for i, m1 in enumerate(allVectors[1:]):
|
# m0idx should be the index of the first non-None item in allVectors,
|
||||||
if len(m0) != len(m1):
|
# else give it the first index of the empty list, which is likely 0
|
||||||
# We already reported this
|
m0idx = allVectors.index(next((x for x in allVectors if x is not None), None))
|
||||||
continue
|
# m0 is the first non-None item in allVectors, or the first item if all are None
|
||||||
if not m0:
|
m0 = allVectors[m0idx]
|
||||||
continue
|
for i, m1 in enumerate(allVectors[m0idx+1:]):
|
||||||
costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0]
|
if m1 is None:
|
||||||
matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
|
continue
|
||||||
identity_matching = list(range(len(m0)))
|
if len(m0) != len(m1):
|
||||||
identity_cost = sum(costs[i][i] for i in range(len(m0)))
|
# We already reported this
|
||||||
if (
|
continue
|
||||||
matching != identity_matching
|
if not m0:
|
||||||
and matching_cost < identity_cost * 0.95
|
continue
|
||||||
):
|
costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0]
|
||||||
add_problem(
|
matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
|
||||||
glyph_name,
|
identity_matching = list(range(len(m0)))
|
||||||
{
|
identity_cost = sum(costs[i][i] for i in range(len(m0)))
|
||||||
"type": "contour_order",
|
if (
|
||||||
"master_1": names[0],
|
matching != identity_matching
|
||||||
"master_2": names[i + 1],
|
and matching_cost < identity_cost * 0.95
|
||||||
"value_1": list(range(len(m0))),
|
):
|
||||||
"value_2": matching,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
m0 = allContourIsomorphisms[0]
|
|
||||||
for i, m1 in enumerate(allContourIsomorphisms[1:]):
|
|
||||||
if len(m0) != len(m1):
|
|
||||||
# We already reported this
|
|
||||||
continue
|
|
||||||
if not m0:
|
|
||||||
continue
|
|
||||||
for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
|
|
||||||
c0 = contour0[0]
|
|
||||||
costs = [
|
|
||||||
v for v in (_complex_vlen(_vdiff(c0, c1)) for c1 in contour1)
|
|
||||||
]
|
|
||||||
min_cost = min(costs)
|
|
||||||
first_cost = costs[0]
|
|
||||||
if min_cost < first_cost * 0.95:
|
|
||||||
add_problem(
|
add_problem(
|
||||||
glyph_name,
|
glyph_name,
|
||||||
{
|
{
|
||||||
"type": "wrong_start_point",
|
"type": "contour_order",
|
||||||
"contour": ix,
|
"master_1": names[m0idx],
|
||||||
"master_1": names[0],
|
"master_2": names[m0idx + i + 1],
|
||||||
"master_2": names[i + 1],
|
"value_1": list(range(len(m0))),
|
||||||
|
"value_2": matching,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if any(allContourIsomorphisms):
|
||||||
|
# m0idx should be the index of the first non-None item in allContourIsomorphisms,
|
||||||
|
# else give it the first index of the empty list, which is likely 0
|
||||||
|
m0idx = allContourIsomorphisms.index(next((x for x in allContourIsomorphisms if x is not None), None))
|
||||||
|
# m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None
|
||||||
|
m0 = allContourIsomorphisms[m0idx]
|
||||||
|
for i, m1 in enumerate(allContourIsomorphisms[m0idx+1:]):
|
||||||
|
if m1 is None:
|
||||||
|
continue
|
||||||
|
if len(m0) != len(m1):
|
||||||
|
# We already reported this
|
||||||
|
continue
|
||||||
|
if not m0:
|
||||||
|
continue
|
||||||
|
for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
|
||||||
|
c0 = contour0[0]
|
||||||
|
costs = [
|
||||||
|
v for v in (_complex_vlen(_vdiff(c0, c1)) for c1 in contour1)
|
||||||
|
]
|
||||||
|
min_cost = min(costs)
|
||||||
|
first_cost = costs[0]
|
||||||
|
if min_cost < first_cost * 0.95:
|
||||||
|
add_problem(
|
||||||
|
glyph_name,
|
||||||
|
{
|
||||||
|
"type": "wrong_start_point",
|
||||||
|
"contour": ix,
|
||||||
|
"master_1": names[m0idx],
|
||||||
|
"master_2": names[m0idx + i + 1],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
add_problem(
|
add_problem(
|
||||||
@ -365,7 +390,17 @@ def main(args=None):
|
|||||||
help="Output report in JSON format",
|
help="Output report in JSON format",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"inputs", metavar="FILE", type=str, nargs="+", help="Input TTF/UFO files"
|
"--quiet",
|
||||||
|
action="store_true",
|
||||||
|
help="Only exit with code 1 or 0, no output",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ignore-missing",
|
||||||
|
action="store_true",
|
||||||
|
help="Will not report glyphs missing from sparse masters as errors",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"inputs", metavar="FILE", type=str, nargs="+", help="Input a single DesignSpace/Glyphs file, or multiple TTF/UFO files"
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args(args)
|
args = parser.parse_args(args)
|
||||||
@ -440,70 +475,90 @@ def main(args=None):
|
|||||||
names.append(basename(filename).rsplit(".", 1)[0])
|
names.append(basename(filename).rsplit(".", 1)[0])
|
||||||
|
|
||||||
if hasattr(fonts[0], "getGlyphSet"):
|
if hasattr(fonts[0], "getGlyphSet"):
|
||||||
glyphsets = [font.getGlyphSet() for font in fonts]
|
glyphsets = [dict(font.getGlyphSet().items()) for font in fonts]
|
||||||
else:
|
else:
|
||||||
glyphsets = fonts
|
glyphsets = [dict(font.items()) for font in fonts]
|
||||||
|
|
||||||
|
if not glyphs:
|
||||||
|
glyphs = set([gn for glyphset in glyphsets for gn in glyphset.keys()])
|
||||||
|
|
||||||
|
for glyphset in glyphsets:
|
||||||
|
glyphSetGlyphNames = set(glyphset.keys())
|
||||||
|
diff = glyphs - glyphSetGlyphNames
|
||||||
|
if diff:
|
||||||
|
for gn in diff:
|
||||||
|
glyphset[gn] = None
|
||||||
|
|
||||||
problems = test(glyphsets, glyphs=glyphs, names=names)
|
problems = test(glyphsets, glyphs=glyphs, names=names, ignore_missing=args.ignore_missing)
|
||||||
if args.json:
|
|
||||||
import json
|
if not args.quiet:
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
|
||||||
print(json.dumps(problems))
|
print(json.dumps(problems))
|
||||||
else:
|
else:
|
||||||
for glyph, glyph_problems in problems.items():
|
for glyph, glyph_problems in problems.items():
|
||||||
print(f"Glyph {glyph} was not compatible: ")
|
print(f"Glyph {glyph} was not compatible: ")
|
||||||
for p in glyph_problems:
|
for p in glyph_problems:
|
||||||
if p["type"] == "missing":
|
if p["type"] == "missing":
|
||||||
print(" Glyph was missing in master %s" % p["master"])
|
print(" Glyph was missing in master %s" % p["master"])
|
||||||
if p["type"] == "open_path":
|
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"])
|
||||||
if p["type"] == "path_count":
|
if p["type"] == "path_count":
|
||||||
print(
|
print(
|
||||||
" Path count differs: %i in %s, %i in %s"
|
" 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"])
|
||||||
)
|
|
||||||
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_count":
|
||||||
if p["type"] == "node_incompatibility":
|
print(
|
||||||
print(
|
" Node count differs in path %i: %i in %s, %i in %s"
|
||||||
" Node %o incompatible in path %i: %s in %s, %s in %s"
|
% (
|
||||||
% (
|
p["path"],
|
||||||
p["node"],
|
p["value_1"],
|
||||||
p["path"],
|
p["master_1"],
|
||||||
p["value_1"],
|
p["value_2"],
|
||||||
p["master_1"],
|
p["master_2"],
|
||||||
p["value_2"],
|
)
|
||||||
p["master_2"],
|
|
||||||
)
|
)
|
||||||
)
|
if p["type"] == "node_incompatibility":
|
||||||
if p["type"] == "contour_order":
|
print(
|
||||||
print(
|
" Node %o incompatible in path %i: %s in %s, %s in %s"
|
||||||
" Contour order differs: %s in %s, %s in %s"
|
% (
|
||||||
% (
|
p["node"],
|
||||||
p["value_1"],
|
p["path"],
|
||||||
p["master_1"],
|
p["value_1"],
|
||||||
p["value_2"],
|
p["master_1"],
|
||||||
p["master_2"],
|
p["value_2"],
|
||||||
|
p["master_2"],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
if p["type"] == "contour_order":
|
||||||
if p["type"] == "wrong_start_point":
|
print(
|
||||||
print(
|
" Contour order differs: %s in %s, %s in %s"
|
||||||
" Contour %d start point differs: %s, %s"
|
% (
|
||||||
% (
|
p["value_1"],
|
||||||
p["contour"],
|
p["master_1"],
|
||||||
p["master_1"],
|
p["value_2"],
|
||||||
p["master_2"],
|
p["master_2"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if p["type"] == "wrong_start_point":
|
||||||
|
print(
|
||||||
|
" Contour %d start point differs: %s, %s"
|
||||||
|
% (
|
||||||
|
p["contour"],
|
||||||
|
p["master_1"],
|
||||||
|
p["master_2"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if p["type"] == "math_error":
|
||||||
|
print(
|
||||||
|
" Miscellaneous error in %s: %s"
|
||||||
|
% (
|
||||||
|
p["master"],
|
||||||
|
p["error"],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
if problems:
|
if problems:
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
@ -92,6 +92,31 @@ class InterpolatableTest(unittest.TestCase):
|
|||||||
|
|
||||||
otf_paths = self.get_file_list(self.tempdir, suffix)
|
otf_paths = self.get_file_list(self.tempdir, suffix)
|
||||||
self.assertIsNone(interpolatable_main(otf_paths))
|
self.assertIsNone(interpolatable_main(otf_paths))
|
||||||
|
|
||||||
|
def test_sparse_interpolatable_ttfs(self):
|
||||||
|
suffix = ".ttf"
|
||||||
|
ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
|
||||||
|
|
||||||
|
self.temp_dir()
|
||||||
|
ttx_paths = self.get_file_list(ttx_dir, ".ttx", "SparseMasters-")
|
||||||
|
for path in ttx_paths:
|
||||||
|
self.compile_font(path, suffix, self.tempdir)
|
||||||
|
|
||||||
|
ttf_paths = self.get_file_list(self.tempdir, suffix)
|
||||||
|
|
||||||
|
# without --ignore-missing
|
||||||
|
problems = interpolatable_main(["--quiet"] + ttf_paths)
|
||||||
|
self.assertEqual(problems['a'], [{'type': 'missing', 'master': 'SparseMasters-Medium'}])
|
||||||
|
self.assertEqual(problems['s'], [{'type': 'missing', 'master': 'SparseMasters-Medium'}])
|
||||||
|
self.assertEqual(problems['edotabove'], [{'type': 'missing', 'master': 'SparseMasters-Medium'}])
|
||||||
|
self.assertEqual(problems['dotabovecomb'], [{'type': 'missing', 'master': 'SparseMasters-Medium'}])
|
||||||
|
|
||||||
|
# normal order, with --ignore-missing
|
||||||
|
self.assertIsNone(interpolatable_main(["--ignore-missing"] + ttf_paths))
|
||||||
|
# purposely putting the sparse master (medium) first
|
||||||
|
self.assertIsNone(interpolatable_main(["--ignore-missing"] + [ttf_paths[1]] + [ttf_paths[0]] + [ttf_paths[2]]))
|
||||||
|
# purposely putting the sparse master (medium) last
|
||||||
|
self.assertIsNone(interpolatable_main(["--ignore-missing"] + [ttf_paths[0]] + [ttf_paths[2]] + [ttf_paths[1]]))
|
||||||
|
|
||||||
def test_interpolatable_varComposite(self):
|
def test_interpolatable_varComposite(self):
|
||||||
input_path = self.get_test_input(
|
input_path = self.get_test_input(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user