1""" 2Tool to find wrong contour order between different masters, and 3other interpolatability (or lack thereof) issues. 4 5Call as: 6$ fonttools varLib.interpolatable font1 font2 ... 7""" 8 9from fontTools.pens.basePen import AbstractPen, BasePen 10from fontTools.pens.recordingPen import RecordingPen 11from fontTools.pens.statisticsPen import StatisticsPen 12from fontTools.pens.momentsPen import OpenContourError 13from collections import OrderedDict 14import itertools 15import sys 16 17 18class PerContourPen(BasePen): 19 def __init__(self, Pen, glyphset=None): 20 BasePen.__init__(self, glyphset) 21 self._glyphset = glyphset 22 self._Pen = Pen 23 self._pen = None 24 self.value = [] 25 26 def _moveTo(self, p0): 27 self._newItem() 28 self._pen.moveTo(p0) 29 30 def _lineTo(self, p1): 31 self._pen.lineTo(p1) 32 33 def _qCurveToOne(self, p1, p2): 34 self._pen.qCurveTo(p1, p2) 35 36 def _curveToOne(self, p1, p2, p3): 37 self._pen.curveTo(p1, p2, p3) 38 39 def _closePath(self): 40 self._pen.closePath() 41 self._pen = None 42 43 def _endPath(self): 44 self._pen.endPath() 45 self._pen = None 46 47 def _newItem(self): 48 self._pen = pen = self._Pen() 49 self.value.append(pen) 50 51 52class PerContourOrComponentPen(PerContourPen): 53 def addComponent(self, glyphName, transformation): 54 self._newItem() 55 self.value[-1].addComponent(glyphName, transformation) 56 57 58def _vdiff(v0, v1): 59 return tuple(b - a for a, b in zip(v0, v1)) 60 61 62def _vlen(vec): 63 v = 0 64 for x in vec: 65 v += x * x 66 return v 67 68 69def _matching_cost(G, matching): 70 return sum(G[i][j] for i, j in enumerate(matching)) 71 72 73def min_cost_perfect_bipartite_matching(G): 74 n = len(G) 75 try: 76 from scipy.optimize import linear_sum_assignment 77 78 rows, cols = linear_sum_assignment(G) 79 assert (rows == list(range(n))).all() 80 return list(cols), _matching_cost(G, cols) 81 except ImportError: 82 pass 83 84 try: 85 from munkres import Munkres 86 87 cols = [None] * n 88 for row, col in Munkres().compute(G): 89 cols[row] = col 90 return cols, _matching_cost(G, cols) 91 except ImportError: 92 pass 93 94 if n > 6: 95 raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'") 96 97 # Otherwise just brute-force 98 permutations = itertools.permutations(range(n)) 99 best = list(next(permutations)) 100 best_cost = _matching_cost(G, best) 101 for p in permutations: 102 cost = _matching_cost(G, p) 103 if cost < best_cost: 104 best, best_cost = list(p), cost 105 return best, best_cost 106 107 108def test(glyphsets, glyphs=None, names=None): 109 110 if names is None: 111 names = glyphsets 112 if glyphs is None: 113 glyphs = glyphsets[0].keys() 114 115 hist = [] 116 problems = OrderedDict() 117 118 def add_problem(glyphname, problem): 119 problems.setdefault(glyphname, []).append(problem) 120 121 for glyph_name in glyphs: 122 # print() 123 # print(glyph_name) 124 125 try: 126 allVectors = [] 127 allNodeTypes = [] 128 for glyphset, name in zip(glyphsets, names): 129 # print('.', end='') 130 if glyph_name not in glyphset: 131 add_problem(glyph_name, {"type": "missing", "master": name}) 132 continue 133 glyph = glyphset[glyph_name] 134 135 perContourPen = PerContourOrComponentPen( 136 RecordingPen, glyphset=glyphset 137 ) 138 glyph.draw(perContourPen) 139 contourPens = perContourPen.value 140 del perContourPen 141 142 contourVectors = [] 143 nodeTypes = [] 144 allNodeTypes.append(nodeTypes) 145 allVectors.append(contourVectors) 146 for ix, contour in enumerate(contourPens): 147 nodeTypes.append( 148 tuple(instruction[0] for instruction in contour.value) 149 ) 150 stats = StatisticsPen(glyphset=glyphset) 151 try: 152 contour.replay(stats) 153 except OpenContourError as e: 154 add_problem( 155 glyph_name, 156 {"master": name, "contour": ix, "type": "open_path"}, 157 ) 158 continue 159 size = abs(stats.area) ** 0.5 * 0.5 160 vector = ( 161 int(size), 162 int(stats.meanX), 163 int(stats.meanY), 164 int(stats.stddevX * 2), 165 int(stats.stddevY * 2), 166 int(stats.correlation * size), 167 ) 168 contourVectors.append(vector) 169 # print(vector) 170 171 # Check each master against the next one in the list. 172 for i, (m0, m1) in enumerate(zip(allNodeTypes[:-1], allNodeTypes[1:])): 173 if len(m0) != len(m1): 174 add_problem( 175 glyph_name, 176 { 177 "type": "path_count", 178 "master_1": names[i], 179 "master_2": names[i + 1], 180 "value_1": len(m0), 181 "value_2": len(m1), 182 }, 183 ) 184 if m0 == m1: 185 continue 186 for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)): 187 if nodes1 == nodes2: 188 continue 189 if len(nodes1) != len(nodes2): 190 add_problem( 191 glyph_name, 192 { 193 "type": "node_count", 194 "path": pathIx, 195 "master_1": names[i], 196 "master_2": names[i + 1], 197 "value_1": len(nodes1), 198 "value_2": len(nodes2), 199 }, 200 ) 201 continue 202 for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)): 203 if n1 != n2: 204 add_problem( 205 glyph_name, 206 { 207 "type": "node_incompatibility", 208 "path": pathIx, 209 "node": nodeIx, 210 "master_1": names[i], 211 "master_2": names[i + 1], 212 "value_1": n1, 213 "value_2": n2, 214 }, 215 ) 216 continue 217 218 for i, (m0, m1) in enumerate(zip(allVectors[:-1], allVectors[1:])): 219 if len(m0) != len(m1): 220 # We already reported this 221 continue 222 if not m0: 223 continue 224 costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0] 225 matching, matching_cost = min_cost_perfect_bipartite_matching(costs) 226 if matching != list(range(len(m0))): 227 add_problem( 228 glyph_name, 229 { 230 "type": "contour_order", 231 "master_1": names[i], 232 "master_2": names[i + 1], 233 "value_1": list(range(len(m0))), 234 "value_2": matching, 235 }, 236 ) 237 break 238 upem = 2048 239 item_cost = round( 240 (matching_cost / len(m0) / len(m0[0])) ** 0.5 / upem * 100 241 ) 242 hist.append(item_cost) 243 threshold = 7 244 if item_cost >= threshold: 245 add_problem( 246 glyph_name, 247 { 248 "type": "high_cost", 249 "master_1": names[i], 250 "master_2": names[i + 1], 251 "value_1": item_cost, 252 "value_2": threshold, 253 }, 254 ) 255 256 except ValueError as e: 257 add_problem( 258 glyph_name, 259 {"type": "math_error", "master": name, "error": e}, 260 ) 261 return problems 262 263 264def main(args=None): 265 """Test for interpolatability issues between fonts""" 266 import argparse 267 268 parser = argparse.ArgumentParser( 269 "fonttools varLib.interpolatable", 270 description=main.__doc__, 271 ) 272 parser.add_argument( 273 "--json", 274 action="store_true", 275 help="Output report in JSON format", 276 ) 277 parser.add_argument( 278 "inputs", metavar="FILE", type=str, nargs="+", help="Input TTF/UFO files" 279 ) 280 281 args = parser.parse_args(args) 282 glyphs = None 283 # glyphs = ['uni08DB', 'uniFD76'] 284 # glyphs = ['uni08DE', 'uni0034'] 285 # glyphs = ['uni08DE', 'uni0034', 'uni0751', 'uni0753', 'uni0754', 'uni08A4', 'uni08A4.fina', 'uni08A5.fina'] 286 287 from os.path import basename 288 289 names = [basename(filename).rsplit(".", 1)[0] for filename in args.inputs] 290 291 fonts = [] 292 for filename in args.inputs: 293 if filename.endswith(".ufo"): 294 from fontTools.ufoLib import UFOReader 295 296 fonts.append(UFOReader(filename)) 297 else: 298 from fontTools.ttLib import TTFont 299 300 fonts.append(TTFont(filename)) 301 302 glyphsets = [font.getGlyphSet() for font in fonts] 303 problems = test(glyphsets, glyphs=glyphs, names=names) 304 if args.json: 305 import json 306 307 print(json.dumps(problems)) 308 else: 309 for glyph, glyph_problems in problems.items(): 310 print(f"Glyph {glyph} was not compatible: ") 311 for p in glyph_problems: 312 if p["type"] == "missing": 313 print(" Glyph was missing in master %s" % p["master"]) 314 if p["type"] == "open_path": 315 print(" Glyph has an open path in master %s" % p["master"]) 316 if p["type"] == "path_count": 317 print( 318 " Path count differs: %i in %s, %i in %s" 319 % (p["value_1"], p["master_1"], p["value_2"], p["master_2"]) 320 ) 321 if p["type"] == "node_count": 322 print( 323 " Node count differs in path %i: %i in %s, %i in %s" 324 % ( 325 p["path"], 326 p["value_1"], 327 p["master_1"], 328 p["value_2"], 329 p["master_2"], 330 ) 331 ) 332 if p["type"] == "node_incompatibility": 333 print( 334 " Node %o incompatible in path %i: %s in %s, %s in %s" 335 % ( 336 p["node"], 337 p["path"], 338 p["value_1"], 339 p["master_1"], 340 p["value_2"], 341 p["master_2"], 342 ) 343 ) 344 if p["type"] == "contour_order": 345 print( 346 " Contour order differs: %s in %s, %s in %s" 347 % ( 348 p["value_1"], 349 p["master_1"], 350 p["value_2"], 351 p["master_2"], 352 ) 353 ) 354 if p["type"] == "high_cost": 355 print( 356 " Interpolation has high cost: cost of %s to %s = %i, threshold %i" 357 % ( 358 p["master_1"], 359 p["master_2"], 360 p["value_1"], 361 p["value_2"], 362 ) 363 ) 364 if problems: 365 return problems 366 367 368if __name__ == "__main__": 369 import sys 370 371 problems = main() 372 sys.exit(int(bool(problems))) 373