diff options
author | robot-contrib <[email protected]> | 2023-12-08 21:12:48 +0300 |
---|---|---|
committer | robot-contrib <[email protected]> | 2023-12-08 23:47:25 +0300 |
commit | 460528e80f26d04487dc242b7333d45bbeb43a4d (patch) | |
tree | fbc5feb120951c22582e83a53d107849abea94a4 /contrib/python/fonttools/fontTools/varLib/interpolatable.py | |
parent | ae5dc4d4d872ed4c4e9d11191ddd01bcbe171a55 (diff) |
Update contrib/python/fonttools to 4.45.1
Diffstat (limited to 'contrib/python/fonttools/fontTools/varLib/interpolatable.py')
-rw-r--r-- | contrib/python/fonttools/fontTools/varLib/interpolatable.py | 600 |
1 files changed, 436 insertions, 164 deletions
diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatable.py b/contrib/python/fonttools/fontTools/varLib/interpolatable.py index 74dd15b9689..9b72b4f5029 100644 --- a/contrib/python/fonttools/fontTools/varLib/interpolatable.py +++ b/contrib/python/fonttools/fontTools/varLib/interpolatable.py @@ -13,10 +13,11 @@ from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen from fontTools.pens.momentsPen import OpenContourError from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation from fontTools.misc.fixedTools import floatToFixedToStr +from fontTools.misc.transform import Transform from collections import defaultdict, deque from functools import wraps from pprint import pformat -from math import sqrt, copysign +from math import sqrt, copysign, atan2, pi import itertools import logging @@ -96,9 +97,15 @@ def _vdiff_hypot2_complex(v0, v1): for x0, x1 in zip(v0, v1): d = x1 - x0 s += d.real * d.real + d.imag * d.imag + # This does the same but seems to be slower: + # s += (d * d.conjugate()).real return s +def _hypot2_complex(d): + return d.real * d.real + d.imag * d.imag + + def _matching_cost(G, matching): return sum(G[i][j] for i, j in enumerate(matching)) @@ -153,6 +160,9 @@ except ImportError: def _contour_vector_from_stats(stats): + # Don't change the order of items here. + # It's okay to add to the end, but otherwise, other + # code depends on it. Search for "covariance". size = sqrt(abs(stats.area)) return ( copysign((size), stats.area), @@ -171,32 +181,41 @@ def _points_characteristic_bits(points): return bits +_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4 + + def _points_complex_vector(points): vector = [] + if not points: + return vector points = [complex(*pt) for pt, _ in points] n = len(points) - points.extend(points[:2]) + assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4 + points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1]) + while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR: + points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1]) for i in range(n): - p0 = points[i] + # The weights are magic numbers. # The point itself + p0 = points[i] vector.append(p0) - # The distance to the next point; - # Emphasized by 2 empirically + # The vector to the next point p1 = points[i + 1] d0 = p1 - p0 - vector.append(d0 * 2) + vector.append(d0 * 3) - """ - # The angle to the next point, as a cross product; - # Square root of, to match dimentionality of distance. + # The turn vector p2 = points[i + 2] d1 = p2 - p1 + vector.append(d1 - d0) + + # The angle to the next point, as a cross product; + # Square root of, to match dimentionality of distance. cross = d0.real * d1.imag - d0.imag * d1.real cross = copysign(sqrt(abs(cross)), cross) - vector.append(cross) - """ + vector.append(cross * 4) return vector @@ -291,6 +310,7 @@ def test_gen( *, locations=None, tolerance=0.95, + show_all=False, ): if names is None: names = glyphsets @@ -318,17 +338,24 @@ def test_gen( allControlVectors = [] allNodeTypes = [] allContourIsomorphisms = [] + allContourPoints = [] 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): + for master_idx, (glyph, glyphset, name) in enumerate( + zip(allGlyphs, glyphsets, names) + ): if glyph is None: if not ignore_missing: - yield (glyph_name, {"type": "missing", "master": name}) + yield ( + glyph_name, + {"type": "missing", "master": name, "master_idx": master_idx}, + ) allNodeTypes.append(None) allControlVectors.append(None) allGreenVectors.append(None) allContourIsomorphisms.append(None) + allContourPoints.append(None) continue perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset) @@ -342,11 +369,13 @@ def test_gen( contourControlVectors = [] contourGreenVectors = [] contourIsomorphisms = [] + contourPoints = [] nodeTypes = [] allNodeTypes.append(nodeTypes) allControlVectors.append(contourControlVectors) allGreenVectors.append(contourGreenVectors) allContourIsomorphisms.append(contourIsomorphisms) + allContourPoints.append(contourPoints) for ix, contour in enumerate(contourPens): contourOps = tuple(op for op, arg in contour.value) nodeTypes.append(contourOps) @@ -359,7 +388,12 @@ def test_gen( except OpenContourError as e: yield ( glyph_name, - {"master": name, "contour": ix, "type": "open_path"}, + { + "master": name, + "master_idx": master_idx, + "contour": ix, + "type": "open_path", + }, ) continue contourGreenVectors.append(_contour_vector_from_stats(greenStats)) @@ -385,6 +419,8 @@ def test_gen( # Add mirrored rotations _add_isomorphisms(points.value, isomorphisms, True) + contourPoints.append(points.value) + matchings = [None] * len(allControlVectors) for m1idx in order: @@ -396,15 +432,20 @@ def test_gen( if allNodeTypes[m0idx] is None: continue + showed = False + m1 = allNodeTypes[m1idx] m0 = allNodeTypes[m0idx] if len(m0) != len(m1): + showed = True yield ( glyph_name, { "type": "path_count", "master_1": names[m0idx], "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, "value_1": len(m0), "value_2": len(m1), }, @@ -416,6 +457,7 @@ def test_gen( if nodes1 == nodes2: continue if len(nodes1) != len(nodes2): + showed = True yield ( glyph_name, { @@ -423,6 +465,8 @@ def test_gen( "path": pathIx, "master_1": names[m0idx], "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, "value_1": len(nodes1), "value_2": len(nodes2), }, @@ -430,6 +474,7 @@ def test_gen( continue for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)): if n1 != n2: + showed = True yield ( glyph_name, { @@ -438,6 +483,8 @@ def test_gen( "node": nodeIx, "master_1": names[m0idx], "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, "value_1": n1, "value_2": n2, }, @@ -504,12 +551,15 @@ def test_gen( if matching_cost < identity_cost * tolerance: # print(matching_cost_control / identity_cost_control, matching_cost_green / identity_cost_green) + showed = True yield ( glyph_name, { "type": "contour_order", "master_1": names[m0idx], "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, "value_1": list(range(len(m0Control))), "value_2": matching, }, @@ -519,38 +569,194 @@ def test_gen( m1 = allContourIsomorphisms[m1idx] m0 = allContourIsomorphisms[m0idx] + # If contour-order is wrong, adjust it + if matchings[m1idx] is not None and m1: # m1 is empty for composite glyphs + m1 = [m1[i] for i in matchings[m1idx]] + 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 + # We already reported this; or nothing to do; or not compatible + # after reordering above. continue c0 = contour0[0] + # Next few lines duplicated below. 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: + # c0 is the first isomorphism of the m0 master + # contour1 is list of all isomorphisms of the m1 master + # + # If the two shapes are both circle-ish and slightly + # rotated, we detect wrong start point. This is for + # example the case hundreds of times in + # RobotoSerif-Italic[GRAD,opsz,wdth,wght].ttf + # + # If the proposed point is only one off from the first + # point (and not reversed), try harder: + # + # Find the major eigenvector of the covariance matrix, + # and rotate the contours by that angle. Then find the + # closest point again. If it matches this time, let it + # pass. + + proposed_point = contour1[min_cost_idx][1] 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 + num_points = len(allContourPoints[m1idx][ix]) + leeway = 3 + okay = False + if not reverse and ( + proposed_point <= leeway + or proposed_point >= num_points - leeway ): - continue + # Try harder + + m0Vectors = allGreenVectors[m1idx][ix] + m1Vectors = allGreenVectors[m1idx][ix] + + # Recover the covariance matrix from the GreenVectors. + # This is a 2x2 matrix. + transforms = [] + for vector in (m0Vectors, m1Vectors): + meanX = vector[1] + meanY = vector[2] + stddevX = vector[3] / 2 + stddevY = vector[4] / 2 + correlation = vector[5] / abs(vector[0]) + + # https://cookierobotics.com/007/ + a = stddevX * stddevX # VarianceX + c = stddevY * stddevY # VarianceY + b = correlation * stddevX * stddevY # Covariance + + delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5 + lambda1 = (a + c) * 0.5 + delta # Major eigenvalue + lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue + theta = ( + atan2(lambda1 - a, b) + if b != 0 + else (pi * 0.5 if a < c else 0) + ) + trans = Transform() + trans = trans.translate(meanX, meanY) + trans = trans.rotate(theta) + trans = trans.scale(sqrt(lambda1), sqrt(lambda2)) + transforms.append(trans) + + trans = transforms[0] + new_c0 = ( + [ + complex(*trans.transformPoint((pt.real, pt.imag))) + for pt in c0[0] + ], + ) + c0[1:] + trans = transforms[1] + new_contour1 = [] + for c1 in contour1: + new_c1 = ( + [ + complex(*trans.transformPoint((pt.real, pt.imag))) + for pt in c1[0] + ], + ) + c1[1:] + new_contour1.append(new_c1) + + # Next few lines duplicate from above. + costs = [ + _vdiff_hypot2_complex(new_c0[0], new_c1[0]) + for new_c1 in new_contour1 + ] + min_cost_idx, min_cost = min( + enumerate(costs), key=lambda x: x[1] + ) + first_cost = costs[0] + # Only accept a perfect match + if min_cost_idx == 0: + okay = True - 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, - }, - ) + if not okay: + showed = True + yield ( + glyph_name, + { + "type": "wrong_start_point", + "contour": ix, + "master_1": names[m0idx], + "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, + "value_1": 0, + "value_2": proposed_point, + "reversed": reverse, + }, + ) + else: + # If first_cost is Too Largeā¢, do further inspection. + # This can happen specially in the case of TrueType + # fonts, where the original contour had wrong start point, + # but because of the cubic->quadratic conversion, we don't + # have many isomorphisms to work with. + + # The threshold here is all black magic. It's just here to + # speed things up so we don't end up doing a full matching + # on every contour that is correct. + threshold = ( + len(c0[0]) * (allControlVectors[m0idx][ix][0] * 0.5) ** 2 / 4 + ) # Magic only + c1 = contour1[min_cost_idx] + + # If point counts are different it's because of the contour + # reordering above. We can in theory still try, but our + # bipartite-matching implementations currently assume + # equal number of vertices on both sides. I'm lazy to update + # all three different implementations! + + if len(c0[0]) == len(c1[0]) and first_cost > threshold: + # Do a quick(!) matching between the points. If it's way off, + # flag it. This can happen specially in the case of TrueType + # fonts, where the original contour had wrong start point, but + # because of the cubic->quadratic conversion, we don't have many + # isomorphisms. + points0 = c0[0][::_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR] + points1 = c1[0][::_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR] + + graph = [ + [_hypot2_complex(p0 - p1) for p1 in points1] + for p0 in points0 + ] + matching, matching_cost = min_cost_perfect_bipartite_matching( + graph + ) + identity_cost = sum(graph[i][i] for i in range(len(graph))) + + if matching_cost < identity_cost / 8: # Heuristic + # print(matching_cost, identity_cost, matching) + showed = True + yield ( + glyph_name, + { + "type": "wrong_structure", + "contour": ix, + "master_1": names[m0idx], + "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, + }, + ) + + if show_all and not showed: + yield ( + glyph_name, + { + "type": "nothing", + "master_1": names[m0idx], + "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, + }, + ) @wraps(test_gen) @@ -585,6 +791,11 @@ def main(args=None): help="Space-separate name of glyphs to check", ) parser.add_argument( + "--show-all", + action="store_true", + help="Show all glyph pairs, even if no problems are found", + ) + parser.add_argument( "--tolerance", action="store", type=float, @@ -627,6 +838,13 @@ def main(args=None): nargs="+", help="Input a single variable font / DesignSpace / Glyphs file, or multiple TTF/UFO files", ) + parser.add_argument( + "--name", + metavar="NAME", + type=str, + action="append", + help="Name of the master to use in the report. If not provided, all are used.", + ) parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.") args = parser.parse_args(args) @@ -643,6 +861,8 @@ def main(args=None): names = [] locations = [] + original_args_inputs = tuple(args.inputs) + if len(args.inputs) == 1: designspace = None if args.inputs[0].endswith(".designspace"): @@ -721,7 +941,7 @@ def main(args=None): locTuple = tuple(loc) if locTuple not in ttGlyphSets: ttGlyphSets[locTuple] = font.getGlyphSet( - location=locDict, normalized=True + location=locDict, normalized=True, recalcBounds=False ) recursivelyAddGlyph( @@ -776,8 +996,19 @@ def main(args=None): glyphset = font glyphsets.append({k: glyphset[k] for k in glyphset.keys()}) - if len(glyphsets) == 1: - return None + if args.name: + accepted_names = set(args.name) + glyphsets = [ + glyphset + for name, glyphset in zip(names, glyphsets) + if name in accepted_names + ] + locations = [ + location + for name, location in zip(names, locations) + if name in accepted_names + ] + names = [name for name in names if name in accepted_names] if not glyphs: glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()])) @@ -793,140 +1024,181 @@ def main(args=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, - locations=locations, - ignore_missing=args.ignore_missing, - tolerance=args.tolerance or 0.95, - ) - problems = defaultdict(list) + try: + log.info("Running on %d glyphsets", len(glyphsets)) + log.info("Locations: %s", pformat(locations)) + problems_gen = test_gen( + glyphsets, + glyphs=glyphs, + names=names, + locations=locations, + ignore_missing=args.ignore_missing, + tolerance=args.tolerance or 0.95, + show_all=args.show_all, + ) + problems = defaultdict(list) - f = sys.stdout if args.output is None else open(args.output, "w") + f = sys.stdout if args.output is None else open(args.output, "w") - if not args.quiet: - if args.json: - import json + if not args.quiet: + if args.json: + import json - for glyphname, problem in problems_gen: - problems[glyphname].append(problem) + for glyphname, problem in problems_gen: + problems[glyphname].append(problem) - print(json.dumps(problems), file=f) - else: - last_glyphname = None - for glyphname, p in problems_gen: - problems[glyphname].append(p) + 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:", file=f) - last_glyphname = glyphname - last_masters = None + if glyphname != last_glyphname: + print(f"Glyph {glyphname} was not compatible:", file=f) + last_glyphname = glyphname + last_master_idxs = 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"], file=f) - if p["type"] == "open_path": - 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"]), - file=f, - ) - 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"], - ), - file=f, - ) - 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"], - ), - file=f, - ) - 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"], - ), - file=f, + master_idxs = ( + (p["master_idx"]) + if "master_idx" in p + else (p["master_1_idx"], p["master_2_idx"]) ) - if p["type"] == "wrong_start_point": - print( - " 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"], - 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 master_idxs != last_master_idxs: + master_names = ( + (p["master"]) + if "master" in p + else (p["master_1"], p["master_2"]) + ) + print(f" Masters: %s:" % ", ".join(master_names), file=f) + last_master_idxs = master_idxs + + if p["type"] == "missing": + print( + " Glyph was missing in master %s" % p["master"], file=f + ) + elif p["type"] == "open_path": + print( + " Glyph has an open path in master %s" % p["master"], + file=f, + ) + elif 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"], + ), + file=f, + ) + elif 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"], + ), + file=f, + ) + elif 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"], + ), + file=f, + ) + elif 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"], + ), + file=f, + ) + elif p["type"] == "wrong_start_point": + print( + " 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"], + p["reversed"], + ), + file=f, + ) + elif p["type"] == "wrong_structure": + print( + " Contour %d structures differ: %s, %s" + % ( + p["contour"], + p["master_1"], + p["master_2"], + ), + file=f, + ) + elif p["type"] == "nothing": + print( + " Nothing wrong between %s and %s" + % ( + p["master_1"], + p["master_2"], + ), + 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") + + except Exception as e: + e.args += original_args_inputs + log.error(e) + raise if problems: return problems |