12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211 |
- """
- 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.pointPen import AbstractPointPen, SegmentToPointPen
- from fontTools.pens.recordingPen import RecordingPen
- 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, atan2, pi
- import itertools
- import logging
- log = logging.getLogger("fontTools.varLib.interpolatable")
- def _rot_list(l, k):
- """Rotate list by k items forward. Ie. item at position 0 will be
- at position k in returned list. Negative k is allowed."""
- return l[-k:] + l[:-k]
- 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)
- class SimpleRecordingPointPen(AbstractPointPen):
- def __init__(self):
- self.value = []
- def beginPath(self, identifier=None, **kwargs):
- pass
- def endPath(self) -> None:
- pass
- def addPoint(self, pt, segmentType=None):
- self.value.append((pt, False if segmentType is None else True))
- def _vdiff_hypot2(v0, v1):
- s = 0
- for x0, x1 in zip(v0, v1):
- d = x1 - x0
- s += d * d
- return s
- def _vdiff_hypot2_complex(v0, v1):
- s = 0
- 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))
- def min_cost_perfect_bipartite_matching_scipy(G):
- n = len(G)
- rows, cols = linear_sum_assignment(G)
- assert (rows == list(range(n))).all()
- return list(cols), _matching_cost(G, cols)
- def min_cost_perfect_bipartite_matching_munkres(G):
- n = len(G)
- cols = [None] * n
- for row, col in Munkres().compute(G):
- cols[row] = col
- return cols, _matching_cost(G, cols)
- def min_cost_perfect_bipartite_matching_bruteforce(G):
- n = len(G)
- 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
- try:
- from scipy.optimize import linear_sum_assignment
- min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy
- except ImportError:
- try:
- from munkres import Munkres
- min_cost_perfect_bipartite_matching = (
- min_cost_perfect_bipartite_matching_munkres
- )
- except ImportError:
- min_cost_perfect_bipartite_matching = (
- min_cost_perfect_bipartite_matching_bruteforce
- )
- 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),
- 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
- _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)
- 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):
- # The weights are magic numbers.
- # The point itself
- p0 = points[i]
- vector.append(p0)
- # The vector to the next point
- p1 = points[i + 1]
- d0 = p1 - p0
- vector.append(d0 * 3)
- # 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 * 4)
- 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,
- show_all=False,
- ):
- 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()}
- 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:
- log.info("Testing glyph %s", glyph_name)
- allGreenVectors = []
- 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 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, "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)
- try:
- glyph.draw(perContourPen, outputImpliedClosingLine=True)
- except TypeError:
- glyph.draw(perContourPen)
- contourPens = perContourPen.value
- del perContourPen
- 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)
- greenStats = StatisticsPen(glyphset=glyphset)
- controlStats = StatisticsControlPen(glyphset=glyphset)
- try:
- contour.replay(greenStats)
- contour.replay(controlStats)
- except OpenContourError as e:
- yield (
- glyph_name,
- {
- "master": name,
- "master_idx": master_idx,
- "contour": ix,
- "type": "open_path",
- },
- )
- 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)
- contourPoints.append(points.value)
- 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
- 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),
- },
- )
- continue
- if m0 != m1:
- for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
- if nodes1 == nodes2:
- continue
- if len(nodes1) != len(nodes2):
- showed = True
- yield (
- glyph_name,
- {
- "type": "node_count",
- "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),
- },
- )
- continue
- for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
- if n1 != n2:
- showed = True
- yield (
- glyph_name,
- {
- "type": "node_incompatibility",
- "path": pathIx,
- "node": nodeIx,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "value_1": n1,
- "value_2": n2,
- },
- )
- continue
- 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_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)
- 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,
- },
- )
- matchings[m1idx] = matching
- 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; 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]
- num_points = len(allContourPoints[m1idx][ix])
- leeway = 3
- okay = False
- if not reverse and (
- proposed_point <= leeway
- or proposed_point >= num_points - leeway
- ):
- # 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
- 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)
- def test(*args, **kwargs):
- problems = defaultdict(list)
- for glyphname, problem in test_gen(*args, **kwargs):
- problems[glyphname].append(problem)
- return problems
- def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf):
- if glyphname in glyphset:
- return
- glyphset[glyphname] = ttGlyphSet[glyphname]
- for component in getattr(glyf[glyphname], "components", []):
- recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf)
- def main(args=None):
- """Test for interpolatability issues between fonts"""
- import argparse
- import sys
- parser = argparse.ArgumentParser(
- "fonttools varLib.interpolatable",
- description=main.__doc__,
- )
- parser.add_argument(
- "--glyphs",
- action="store",
- 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,
- 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",
- )
- parser.add_argument(
- "inputs",
- metavar="FILE",
- type=str,
- 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)
- from fontTools import configLogger
- configLogger(level=("INFO" if args.verbose else "ERROR"))
- glyphs = args.glyphs.split() if args.glyphs else None
- from os.path import basename
- fonts = []
- names = []
- locations = []
- original_args_inputs = tuple(args.inputs)
- 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_designspace
- gsfont = GSFont(args.inputs[0])
- 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
- font = TTFont(args.inputs[0])
- if "gvar" in font:
- # Is variable font
- axisMapping = {}
- fvar = font["fvar"]
- for axis in fvar.axes:
- axisMapping[axis.axisTag] = {
- -1: axis.minValue,
- 0: axis.defaultValue,
- 1: axis.maxValue,
- }
- if "avar" in font:
- avar = font["avar"]
- for axisTag, segments in avar.segments.items():
- fvarMapping = axisMapping[axisTag].copy()
- for location, value in segments.items():
- axisMapping[axisTag][value] = piecewiseLinearMap(
- location, fvarMapping
- )
- gvar = font["gvar"]
- glyf = font["glyf"]
- # Gather all glyphs at their "master" locations
- ttGlyphSets = {}
- glyphsets = defaultdict(dict)
- if glyphs is None:
- glyphs = sorted(gvar.variations.keys())
- for glyphname in glyphs:
- for var in gvar.variations[glyphname]:
- locDict = {}
- loc = []
- for tag, val in sorted(var.axes.items()):
- locDict[tag] = val[1]
- loc.append((tag, val[1]))
- locTuple = tuple(loc)
- if locTuple not in ttGlyphSets:
- ttGlyphSets[locTuple] = font.getGlyphSet(
- location=locDict, normalized=True, recalcBounds=False
- )
- recursivelyAddGlyph(
- glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf
- )
- 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,
- 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
- fonts.append(UFOReader(filename))
- else:
- from fontTools.ttLib import TTFont
- fonts.append(TTFont(filename))
- names.append(basename(filename).rsplit(".", 1)[0])
- glyphsets = []
- for font in fonts:
- if hasattr(font, "getGlyphSet"):
- glyphset = font.getGlyphSet()
- else:
- glyphset = font
- glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
- 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()]))
- glyphsSet = set(glyphs)
- for glyphset in glyphsets:
- glyphSetGlyphNames = set(glyphset.keys())
- diff = glyphsSet - glyphSetGlyphNames
- if diff:
- for gn in diff:
- glyphset[gn] = None
- # Normalize locations
- locations = [normalizeLocation(loc, axis_triples) for loc in locations]
- 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")
- if not args.quiet:
- if args.json:
- import json
- 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)
- if glyphname != last_glyphname:
- print(f"Glyph {glyphname} was not compatible:", file=f)
- last_glyphname = glyphname
- last_master_idxs = None
- master_idxs = (
- (p["master_idx"])
- if "master_idx" in p
- else (p["master_1_idx"], p["master_2_idx"])
- )
- 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
- if __name__ == "__main__":
- import sys
- problems = main()
- sys.exit(int(bool(problems)))
|