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 .interpolatableHelpers import * 10from .interpolatableTestContourOrder import test_contour_order 11from .interpolatableTestStartingPoint import test_starting_point 12from fontTools.pens.recordingPen import ( 13 RecordingPen, 14 DecomposingRecordingPen, 15 lerpRecordings, 16) 17from fontTools.pens.transformPen import TransformPen 18from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen 19from fontTools.pens.momentsPen import OpenContourError 20from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation 21from fontTools.misc.fixedTools import floatToFixedToStr 22from fontTools.misc.transform import Transform 23from collections import defaultdict 24from types import SimpleNamespace 25from functools import wraps 26from pprint import pformat 27from math import sqrt, atan2, pi 28import logging 29import os 30 31log = logging.getLogger("fontTools.varLib.interpolatable") 32 33DEFAULT_TOLERANCE = 0.95 34DEFAULT_KINKINESS = 0.5 35DEFAULT_KINKINESS_LENGTH = 0.002 # ratio of UPEM 36DEFAULT_UPEM = 1000 37 38 39class Glyph: 40 ITEMS = ( 41 "recordings", 42 "greenStats", 43 "controlStats", 44 "greenVectors", 45 "controlVectors", 46 "nodeTypes", 47 "isomorphisms", 48 "points", 49 "openContours", 50 ) 51 52 def __init__(self, glyphname, glyphset): 53 self.name = glyphname 54 for item in self.ITEMS: 55 setattr(self, item, []) 56 self._populate(glyphset) 57 58 def _fill_in(self, ix): 59 for item in self.ITEMS: 60 if len(getattr(self, item)) == ix: 61 getattr(self, item).append(None) 62 63 def _populate(self, glyphset): 64 glyph = glyphset[self.name] 65 self.doesnt_exist = glyph is None 66 if self.doesnt_exist: 67 return 68 69 perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset) 70 try: 71 glyph.draw(perContourPen, outputImpliedClosingLine=True) 72 except TypeError: 73 glyph.draw(perContourPen) 74 self.recordings = perContourPen.value 75 del perContourPen 76 77 for ix, contour in enumerate(self.recordings): 78 nodeTypes = [op for op, arg in contour.value] 79 self.nodeTypes.append(nodeTypes) 80 81 greenStats = StatisticsPen(glyphset=glyphset) 82 controlStats = StatisticsControlPen(glyphset=glyphset) 83 try: 84 contour.replay(greenStats) 85 contour.replay(controlStats) 86 self.openContours.append(False) 87 except OpenContourError as e: 88 self.openContours.append(True) 89 self._fill_in(ix) 90 continue 91 self.greenStats.append(greenStats) 92 self.controlStats.append(controlStats) 93 self.greenVectors.append(contour_vector_from_stats(greenStats)) 94 self.controlVectors.append(contour_vector_from_stats(controlStats)) 95 96 # Check starting point 97 if nodeTypes[0] == "addComponent": 98 self._fill_in(ix) 99 continue 100 101 assert nodeTypes[0] == "moveTo" 102 assert nodeTypes[-1] in ("closePath", "endPath") 103 points = SimpleRecordingPointPen() 104 converter = SegmentToPointPen(points, False) 105 contour.replay(converter) 106 # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve; 107 # now check all rotations and mirror-rotations of the contour and build list of isomorphic 108 # possible starting points. 109 self.points.append(points.value) 110 111 isomorphisms = [] 112 self.isomorphisms.append(isomorphisms) 113 114 # Add rotations 115 add_isomorphisms(points.value, isomorphisms, False) 116 # Add mirrored rotations 117 add_isomorphisms(points.value, isomorphisms, True) 118 119 def draw(self, pen, countor_idx=None): 120 if countor_idx is None: 121 for contour in self.recordings: 122 contour.draw(pen) 123 else: 124 self.recordings[countor_idx].draw(pen) 125 126 127def test_gen( 128 glyphsets, 129 glyphs=None, 130 names=None, 131 ignore_missing=False, 132 *, 133 locations=None, 134 tolerance=DEFAULT_TOLERANCE, 135 kinkiness=DEFAULT_KINKINESS, 136 upem=DEFAULT_UPEM, 137 show_all=False, 138): 139 if tolerance >= 10: 140 tolerance *= 0.01 141 assert 0 <= tolerance <= 1 142 if kinkiness >= 10: 143 kinkiness *= 0.01 144 assert 0 <= kinkiness 145 146 names = names or [repr(g) for g in glyphsets] 147 148 if glyphs is None: 149 # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order 150 # ... risks the sparse master being the first one, and only processing a subset of the glyphs 151 glyphs = {g for glyphset in glyphsets for g in glyphset.keys()} 152 153 parents, order = find_parents_and_order(glyphsets, locations) 154 155 def grand_parent(i, glyphname): 156 if i is None: 157 return None 158 i = parents[i] 159 if i is None: 160 return None 161 while parents[i] is not None and glyphsets[i][glyphname] is None: 162 i = parents[i] 163 return i 164 165 for glyph_name in glyphs: 166 log.info("Testing glyph %s", glyph_name) 167 allGlyphs = [Glyph(glyph_name, glyphset) for glyphset in glyphsets] 168 if len([1 for glyph in allGlyphs if glyph is not None]) <= 1: 169 continue 170 for master_idx, (glyph, glyphset, name) in enumerate( 171 zip(allGlyphs, glyphsets, names) 172 ): 173 if glyph.doesnt_exist: 174 if not ignore_missing: 175 yield ( 176 glyph_name, 177 { 178 "type": InterpolatableProblem.MISSING, 179 "master": name, 180 "master_idx": master_idx, 181 }, 182 ) 183 continue 184 185 has_open = False 186 for ix, open in enumerate(glyph.openContours): 187 if not open: 188 continue 189 has_open = True 190 yield ( 191 glyph_name, 192 { 193 "type": InterpolatableProblem.OPEN_PATH, 194 "master": name, 195 "master_idx": master_idx, 196 "contour": ix, 197 }, 198 ) 199 if has_open: 200 continue 201 202 matchings = [None] * len(glyphsets) 203 204 for m1idx in order: 205 glyph1 = allGlyphs[m1idx] 206 if glyph1 is None or not glyph1.nodeTypes: 207 continue 208 m0idx = grand_parent(m1idx, glyph_name) 209 if m0idx is None: 210 continue 211 glyph0 = allGlyphs[m0idx] 212 if glyph0 is None or not glyph0.nodeTypes: 213 continue 214 215 # 216 # Basic compatibility checks 217 # 218 219 m1 = glyph0.nodeTypes 220 m0 = glyph1.nodeTypes 221 if len(m0) != len(m1): 222 yield ( 223 glyph_name, 224 { 225 "type": InterpolatableProblem.PATH_COUNT, 226 "master_1": names[m0idx], 227 "master_2": names[m1idx], 228 "master_1_idx": m0idx, 229 "master_2_idx": m1idx, 230 "value_1": len(m0), 231 "value_2": len(m1), 232 }, 233 ) 234 continue 235 236 if m0 != m1: 237 for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)): 238 if nodes1 == nodes2: 239 continue 240 if len(nodes1) != len(nodes2): 241 yield ( 242 glyph_name, 243 { 244 "type": InterpolatableProblem.NODE_COUNT, 245 "path": pathIx, 246 "master_1": names[m0idx], 247 "master_2": names[m1idx], 248 "master_1_idx": m0idx, 249 "master_2_idx": m1idx, 250 "value_1": len(nodes1), 251 "value_2": len(nodes2), 252 }, 253 ) 254 continue 255 for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)): 256 if n1 != n2: 257 yield ( 258 glyph_name, 259 { 260 "type": InterpolatableProblem.NODE_INCOMPATIBILITY, 261 "path": pathIx, 262 "node": nodeIx, 263 "master_1": names[m0idx], 264 "master_2": names[m1idx], 265 "master_1_idx": m0idx, 266 "master_2_idx": m1idx, 267 "value_1": n1, 268 "value_2": n2, 269 }, 270 ) 271 continue 272 273 # 274 # InterpolatableProblem.CONTOUR_ORDER check 275 # 276 277 this_tolerance, matching = test_contour_order(glyph0, glyph1) 278 if this_tolerance < tolerance: 279 yield ( 280 glyph_name, 281 { 282 "type": InterpolatableProblem.CONTOUR_ORDER, 283 "master_1": names[m0idx], 284 "master_2": names[m1idx], 285 "master_1_idx": m0idx, 286 "master_2_idx": m1idx, 287 "value_1": list(range(len(matching))), 288 "value_2": matching, 289 "tolerance": this_tolerance, 290 }, 291 ) 292 matchings[m1idx] = matching 293 294 # 295 # wrong-start-point / weight check 296 # 297 298 m0Isomorphisms = glyph0.isomorphisms 299 m1Isomorphisms = glyph1.isomorphisms 300 m0Vectors = glyph0.greenVectors 301 m1Vectors = glyph1.greenVectors 302 recording0 = glyph0.recordings 303 recording1 = glyph1.recordings 304 305 # If contour-order is wrong, adjust it 306 matching = matchings[m1idx] 307 if ( 308 matching is not None and m1Isomorphisms 309 ): # m1 is empty for composite glyphs 310 m1Isomorphisms = [m1Isomorphisms[i] for i in matching] 311 m1Vectors = [m1Vectors[i] for i in matching] 312 recording1 = [recording1[i] for i in matching] 313 314 midRecording = [] 315 for c0, c1 in zip(recording0, recording1): 316 try: 317 r = RecordingPen() 318 r.value = list(lerpRecordings(c0.value, c1.value)) 319 midRecording.append(r) 320 except ValueError: 321 # Mismatch because of the reordering above 322 midRecording.append(None) 323 324 for ix, (contour0, contour1) in enumerate( 325 zip(m0Isomorphisms, m1Isomorphisms) 326 ): 327 if ( 328 contour0 is None 329 or contour1 is None 330 or len(contour0) == 0 331 or len(contour0) != len(contour1) 332 ): 333 # We already reported this; or nothing to do; or not compatible 334 # after reordering above. 335 continue 336 337 this_tolerance, proposed_point, reverse = test_starting_point( 338 glyph0, glyph1, ix, tolerance, matching 339 ) 340 341 if this_tolerance < tolerance: 342 yield ( 343 glyph_name, 344 { 345 "type": InterpolatableProblem.WRONG_START_POINT, 346 "contour": ix, 347 "master_1": names[m0idx], 348 "master_2": names[m1idx], 349 "master_1_idx": m0idx, 350 "master_2_idx": m1idx, 351 "value_1": 0, 352 "value_2": proposed_point, 353 "reversed": reverse, 354 "tolerance": this_tolerance, 355 }, 356 ) 357 358 # Weight check. 359 # 360 # If contour could be mid-interpolated, and the two 361 # contours have the same area sign, proceeed. 362 # 363 # The sign difference can happen if it's a weirdo 364 # self-intersecting contour; ignore it. 365 contour = midRecording[ix] 366 367 if contour and (m0Vectors[ix][0] < 0) == (m1Vectors[ix][0] < 0): 368 midStats = StatisticsPen(glyphset=None) 369 contour.replay(midStats) 370 371 midVector = contour_vector_from_stats(midStats) 372 373 m0Vec = m0Vectors[ix] 374 m1Vec = m1Vectors[ix] 375 size0 = m0Vec[0] * m0Vec[0] 376 size1 = m1Vec[0] * m1Vec[0] 377 midSize = midVector[0] * midVector[0] 378 379 for overweight, problem_type in enumerate( 380 ( 381 InterpolatableProblem.UNDERWEIGHT, 382 InterpolatableProblem.OVERWEIGHT, 383 ) 384 ): 385 if overweight: 386 expectedSize = max(size0, size1) 387 continue 388 else: 389 expectedSize = sqrt(size0 * size1) 390 391 log.debug( 392 "%s: actual size %g; threshold size %g, master sizes: %g, %g", 393 problem_type, 394 midSize, 395 expectedSize, 396 size0, 397 size1, 398 ) 399 400 if ( 401 not overweight and expectedSize * tolerance > midSize + 1e-5 402 ) or (overweight and 1e-5 + expectedSize / tolerance < midSize): 403 try: 404 if overweight: 405 this_tolerance = expectedSize / midSize 406 else: 407 this_tolerance = midSize / expectedSize 408 except ZeroDivisionError: 409 this_tolerance = 0 410 log.debug("tolerance %g", this_tolerance) 411 yield ( 412 glyph_name, 413 { 414 "type": problem_type, 415 "contour": ix, 416 "master_1": names[m0idx], 417 "master_2": names[m1idx], 418 "master_1_idx": m0idx, 419 "master_2_idx": m1idx, 420 "tolerance": this_tolerance, 421 }, 422 ) 423 424 # 425 # "kink" detector 426 # 427 m0 = glyph0.points 428 m1 = glyph1.points 429 430 # If contour-order is wrong, adjust it 431 if matchings[m1idx] is not None and m1: # m1 is empty for composite glyphs 432 m1 = [m1[i] for i in matchings[m1idx]] 433 434 t = 0.1 # ~sin(radian(6)) for tolerance 0.95 435 deviation_threshold = ( 436 upem * DEFAULT_KINKINESS_LENGTH * DEFAULT_KINKINESS / kinkiness 437 ) 438 439 for ix, (contour0, contour1) in enumerate(zip(m0, m1)): 440 if ( 441 contour0 is None 442 or contour1 is None 443 or len(contour0) == 0 444 or len(contour0) != len(contour1) 445 ): 446 # We already reported this; or nothing to do; or not compatible 447 # after reordering above. 448 continue 449 450 # Walk the contour, keeping track of three consecutive points, with 451 # middle one being an on-curve. If the three are co-linear then 452 # check for kinky-ness. 453 for i in range(len(contour0)): 454 pt0 = contour0[i] 455 pt1 = contour1[i] 456 if not pt0[1] or not pt1[1]: 457 # Skip off-curves 458 continue 459 pt0_prev = contour0[i - 1] 460 pt1_prev = contour1[i - 1] 461 pt0_next = contour0[(i + 1) % len(contour0)] 462 pt1_next = contour1[(i + 1) % len(contour1)] 463 464 if pt0_prev[1] and pt1_prev[1]: 465 # At least one off-curve is required 466 continue 467 if pt0_prev[1] and pt1_prev[1]: 468 # At least one off-curve is required 469 continue 470 471 pt0 = complex(*pt0[0]) 472 pt1 = complex(*pt1[0]) 473 pt0_prev = complex(*pt0_prev[0]) 474 pt1_prev = complex(*pt1_prev[0]) 475 pt0_next = complex(*pt0_next[0]) 476 pt1_next = complex(*pt1_next[0]) 477 478 # We have three consecutive points. Check whether 479 # they are colinear. 480 d0_prev = pt0 - pt0_prev 481 d0_next = pt0_next - pt0 482 d1_prev = pt1 - pt1_prev 483 d1_next = pt1_next - pt1 484 485 sin0 = d0_prev.real * d0_next.imag - d0_prev.imag * d0_next.real 486 sin1 = d1_prev.real * d1_next.imag - d1_prev.imag * d1_next.real 487 try: 488 sin0 /= abs(d0_prev) * abs(d0_next) 489 sin1 /= abs(d1_prev) * abs(d1_next) 490 except ZeroDivisionError: 491 continue 492 493 if abs(sin0) > t or abs(sin1) > t: 494 # Not colinear / not smooth. 495 continue 496 497 # Check the mid-point is actually, well, in the middle. 498 dot0 = d0_prev.real * d0_next.real + d0_prev.imag * d0_next.imag 499 dot1 = d1_prev.real * d1_next.real + d1_prev.imag * d1_next.imag 500 if dot0 < 0 or dot1 < 0: 501 # Sharp corner. 502 continue 503 504 # Fine, if handle ratios are similar... 505 r0 = abs(d0_prev) / (abs(d0_prev) + abs(d0_next)) 506 r1 = abs(d1_prev) / (abs(d1_prev) + abs(d1_next)) 507 r_diff = abs(r0 - r1) 508 if abs(r_diff) < t: 509 # Smooth enough. 510 continue 511 512 mid = (pt0 + pt1) / 2 513 mid_prev = (pt0_prev + pt1_prev) / 2 514 mid_next = (pt0_next + pt1_next) / 2 515 516 mid_d0 = mid - mid_prev 517 mid_d1 = mid_next - mid 518 519 sin_mid = mid_d0.real * mid_d1.imag - mid_d0.imag * mid_d1.real 520 try: 521 sin_mid /= abs(mid_d0) * abs(mid_d1) 522 except ZeroDivisionError: 523 continue 524 525 # ...or if the angles are similar. 526 if abs(sin_mid) * (tolerance * kinkiness) <= t: 527 # Smooth enough. 528 continue 529 530 # How visible is the kink? 531 532 cross = sin_mid * abs(mid_d0) * abs(mid_d1) 533 arc_len = abs(mid_d0 + mid_d1) 534 deviation = abs(cross / arc_len) 535 if deviation < deviation_threshold: 536 continue 537 deviation_ratio = deviation / arc_len 538 if deviation_ratio > t: 539 continue 540 541 this_tolerance = t / (abs(sin_mid) * kinkiness) 542 543 log.debug( 544 "kink: deviation %g; deviation_ratio %g; sin_mid %g; r_diff %g", 545 deviation, 546 deviation_ratio, 547 sin_mid, 548 r_diff, 549 ) 550 log.debug("tolerance %g", this_tolerance) 551 yield ( 552 glyph_name, 553 { 554 "type": InterpolatableProblem.KINK, 555 "contour": ix, 556 "master_1": names[m0idx], 557 "master_2": names[m1idx], 558 "master_1_idx": m0idx, 559 "master_2_idx": m1idx, 560 "value": i, 561 "tolerance": this_tolerance, 562 }, 563 ) 564 565 # 566 # --show-all 567 # 568 569 if show_all: 570 yield ( 571 glyph_name, 572 { 573 "type": InterpolatableProblem.NOTHING, 574 "master_1": names[m0idx], 575 "master_2": names[m1idx], 576 "master_1_idx": m0idx, 577 "master_2_idx": m1idx, 578 }, 579 ) 580 581 582@wraps(test_gen) 583def test(*args, **kwargs): 584 problems = defaultdict(list) 585 for glyphname, problem in test_gen(*args, **kwargs): 586 problems[glyphname].append(problem) 587 return problems 588 589 590def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf): 591 if glyphname in glyphset: 592 return 593 glyphset[glyphname] = ttGlyphSet[glyphname] 594 595 for component in getattr(glyf[glyphname], "components", []): 596 recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf) 597 598 599def ensure_parent_dir(path): 600 dirname = os.path.dirname(path) 601 if dirname: 602 os.makedirs(dirname, exist_ok=True) 603 return path 604 605 606def main(args=None): 607 """Test for interpolatability issues between fonts""" 608 import argparse 609 import sys 610 611 parser = argparse.ArgumentParser( 612 "fonttools varLib.interpolatable", 613 description=main.__doc__, 614 ) 615 parser.add_argument( 616 "--glyphs", 617 action="store", 618 help="Space-separate name of glyphs to check", 619 ) 620 parser.add_argument( 621 "--show-all", 622 action="store_true", 623 help="Show all glyph pairs, even if no problems are found", 624 ) 625 parser.add_argument( 626 "--tolerance", 627 action="store", 628 type=float, 629 help="Error tolerance. Between 0 and 1. Default %s" % DEFAULT_TOLERANCE, 630 ) 631 parser.add_argument( 632 "--kinkiness", 633 action="store", 634 type=float, 635 help="How aggressively report kinks. Default %s" % DEFAULT_KINKINESS, 636 ) 637 parser.add_argument( 638 "--json", 639 action="store_true", 640 help="Output report in JSON format", 641 ) 642 parser.add_argument( 643 "--pdf", 644 action="store", 645 help="Output report in PDF format", 646 ) 647 parser.add_argument( 648 "--ps", 649 action="store", 650 help="Output report in PostScript format", 651 ) 652 parser.add_argument( 653 "--html", 654 action="store", 655 help="Output report in HTML format", 656 ) 657 parser.add_argument( 658 "--quiet", 659 action="store_true", 660 help="Only exit with code 1 or 0, no output", 661 ) 662 parser.add_argument( 663 "--output", 664 action="store", 665 help="Output file for the problem report; Default: stdout", 666 ) 667 parser.add_argument( 668 "--ignore-missing", 669 action="store_true", 670 help="Will not report glyphs missing from sparse masters as errors", 671 ) 672 parser.add_argument( 673 "inputs", 674 metavar="FILE", 675 type=str, 676 nargs="+", 677 help="Input a single variable font / DesignSpace / Glyphs file, or multiple TTF/UFO files", 678 ) 679 parser.add_argument( 680 "--name", 681 metavar="NAME", 682 type=str, 683 action="append", 684 help="Name of the master to use in the report. If not provided, all are used.", 685 ) 686 parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.") 687 parser.add_argument("--debug", action="store_true", help="Run with debug output.") 688 689 args = parser.parse_args(args) 690 691 from fontTools import configLogger 692 693 configLogger(level=("INFO" if args.verbose else "ERROR")) 694 if args.debug: 695 configLogger(level="DEBUG") 696 697 glyphs = args.glyphs.split() if args.glyphs else None 698 699 from os.path import basename 700 701 fonts = [] 702 names = [] 703 locations = [] 704 upem = DEFAULT_UPEM 705 706 original_args_inputs = tuple(args.inputs) 707 708 if len(args.inputs) == 1: 709 designspace = None 710 if args.inputs[0].endswith(".designspace"): 711 from fontTools.designspaceLib import DesignSpaceDocument 712 713 designspace = DesignSpaceDocument.fromfile(args.inputs[0]) 714 args.inputs = [master.path for master in designspace.sources] 715 locations = [master.location for master in designspace.sources] 716 axis_triples = { 717 a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes 718 } 719 axis_mappings = {a.name: a.map for a in designspace.axes} 720 axis_triples = { 721 k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv) 722 for k, vv in axis_triples.items() 723 } 724 725 elif args.inputs[0].endswith((".glyphs", ".glyphspackage")): 726 from glyphsLib import GSFont, to_designspace 727 728 gsfont = GSFont(args.inputs[0]) 729 upem = gsfont.upm 730 designspace = to_designspace(gsfont) 731 fonts = [source.font for source in designspace.sources] 732 names = ["%s-%s" % (f.info.familyName, f.info.styleName) for f in fonts] 733 args.inputs = [] 734 locations = [master.location for master in designspace.sources] 735 axis_triples = { 736 a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes 737 } 738 axis_mappings = {a.name: a.map for a in designspace.axes} 739 axis_triples = { 740 k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv) 741 for k, vv in axis_triples.items() 742 } 743 744 elif args.inputs[0].endswith(".ttf"): 745 from fontTools.ttLib import TTFont 746 747 font = TTFont(args.inputs[0]) 748 upem = font["head"].unitsPerEm 749 if "gvar" in font: 750 # Is variable font 751 752 axisMapping = {} 753 fvar = font["fvar"] 754 for axis in fvar.axes: 755 axisMapping[axis.axisTag] = { 756 -1: axis.minValue, 757 0: axis.defaultValue, 758 1: axis.maxValue, 759 } 760 if "avar" in font: 761 avar = font["avar"] 762 for axisTag, segments in avar.segments.items(): 763 fvarMapping = axisMapping[axisTag].copy() 764 for location, value in segments.items(): 765 axisMapping[axisTag][value] = piecewiseLinearMap( 766 location, fvarMapping 767 ) 768 769 gvar = font["gvar"] 770 glyf = font["glyf"] 771 # Gather all glyphs at their "master" locations 772 ttGlyphSets = {} 773 glyphsets = defaultdict(dict) 774 775 if glyphs is None: 776 glyphs = sorted(gvar.variations.keys()) 777 for glyphname in glyphs: 778 for var in gvar.variations[glyphname]: 779 locDict = {} 780 loc = [] 781 for tag, val in sorted(var.axes.items()): 782 locDict[tag] = val[1] 783 loc.append((tag, val[1])) 784 785 locTuple = tuple(loc) 786 if locTuple not in ttGlyphSets: 787 ttGlyphSets[locTuple] = font.getGlyphSet( 788 location=locDict, normalized=True, recalcBounds=False 789 ) 790 791 recursivelyAddGlyph( 792 glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf 793 ) 794 795 names = ["''"] 796 fonts = [font.getGlyphSet()] 797 locations = [{}] 798 axis_triples = {a: (-1, 0, +1) for a in sorted(axisMapping.keys())} 799 for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)): 800 name = ( 801 "'" 802 + " ".join( 803 "%s=%s" 804 % ( 805 k, 806 floatToFixedToStr( 807 piecewiseLinearMap(v, axisMapping[k]), 14 808 ), 809 ) 810 for k, v in locTuple 811 ) 812 + "'" 813 ) 814 names.append(name) 815 fonts.append(glyphsets[locTuple]) 816 locations.append(dict(locTuple)) 817 args.ignore_missing = True 818 args.inputs = [] 819 820 if not locations: 821 locations = [{} for _ in fonts] 822 823 for filename in args.inputs: 824 if filename.endswith(".ufo"): 825 from fontTools.ufoLib import UFOReader 826 827 font = UFOReader(filename) 828 info = SimpleNamespace() 829 font.readInfo(info) 830 upem = info.unitsPerEm 831 fonts.append(font) 832 else: 833 from fontTools.ttLib import TTFont 834 835 font = TTFont(filename) 836 upem = font["head"].unitsPerEm 837 fonts.append(font) 838 839 names.append(basename(filename).rsplit(".", 1)[0]) 840 841 glyphsets = [] 842 for font in fonts: 843 if hasattr(font, "getGlyphSet"): 844 glyphset = font.getGlyphSet() 845 else: 846 glyphset = font 847 glyphsets.append({k: glyphset[k] for k in glyphset.keys()}) 848 849 if args.name: 850 accepted_names = set(args.name) 851 glyphsets = [ 852 glyphset 853 for name, glyphset in zip(names, glyphsets) 854 if name in accepted_names 855 ] 856 locations = [ 857 location 858 for name, location in zip(names, locations) 859 if name in accepted_names 860 ] 861 names = [name for name in names if name in accepted_names] 862 863 if not glyphs: 864 glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()])) 865 866 glyphsSet = set(glyphs) 867 for glyphset in glyphsets: 868 glyphSetGlyphNames = set(glyphset.keys()) 869 diff = glyphsSet - glyphSetGlyphNames 870 if diff: 871 for gn in diff: 872 glyphset[gn] = None 873 874 # Normalize locations 875 locations = [normalizeLocation(loc, axis_triples) for loc in locations] 876 tolerance = args.tolerance or DEFAULT_TOLERANCE 877 kinkiness = args.kinkiness if args.kinkiness is not None else DEFAULT_KINKINESS 878 879 try: 880 log.info("Running on %d glyphsets", len(glyphsets)) 881 log.info("Locations: %s", pformat(locations)) 882 problems_gen = test_gen( 883 glyphsets, 884 glyphs=glyphs, 885 names=names, 886 locations=locations, 887 upem=upem, 888 ignore_missing=args.ignore_missing, 889 tolerance=tolerance, 890 kinkiness=kinkiness, 891 show_all=args.show_all, 892 ) 893 problems = defaultdict(list) 894 895 f = ( 896 sys.stdout 897 if args.output is None 898 else open(ensure_parent_dir(args.output), "w") 899 ) 900 901 if not args.quiet: 902 if args.json: 903 import json 904 905 for glyphname, problem in problems_gen: 906 problems[glyphname].append(problem) 907 908 print(json.dumps(problems), file=f) 909 else: 910 last_glyphname = None 911 for glyphname, p in problems_gen: 912 problems[glyphname].append(p) 913 914 if glyphname != last_glyphname: 915 print(f"Glyph {glyphname} was not compatible:", file=f) 916 last_glyphname = glyphname 917 last_master_idxs = None 918 919 master_idxs = ( 920 (p["master_idx"]) 921 if "master_idx" in p 922 else (p["master_1_idx"], p["master_2_idx"]) 923 ) 924 if master_idxs != last_master_idxs: 925 master_names = ( 926 (p["master"]) 927 if "master" in p 928 else (p["master_1"], p["master_2"]) 929 ) 930 print(f" Masters: %s:" % ", ".join(master_names), file=f) 931 last_master_idxs = master_idxs 932 933 if p["type"] == InterpolatableProblem.MISSING: 934 print( 935 " Glyph was missing in master %s" % p["master"], file=f 936 ) 937 elif p["type"] == InterpolatableProblem.OPEN_PATH: 938 print( 939 " Glyph has an open path in master %s" % p["master"], 940 file=f, 941 ) 942 elif p["type"] == InterpolatableProblem.PATH_COUNT: 943 print( 944 " Path count differs: %i in %s, %i in %s" 945 % ( 946 p["value_1"], 947 p["master_1"], 948 p["value_2"], 949 p["master_2"], 950 ), 951 file=f, 952 ) 953 elif p["type"] == InterpolatableProblem.NODE_COUNT: 954 print( 955 " Node count differs in path %i: %i in %s, %i in %s" 956 % ( 957 p["path"], 958 p["value_1"], 959 p["master_1"], 960 p["value_2"], 961 p["master_2"], 962 ), 963 file=f, 964 ) 965 elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY: 966 print( 967 " Node %o incompatible in path %i: %s in %s, %s in %s" 968 % ( 969 p["node"], 970 p["path"], 971 p["value_1"], 972 p["master_1"], 973 p["value_2"], 974 p["master_2"], 975 ), 976 file=f, 977 ) 978 elif p["type"] == InterpolatableProblem.CONTOUR_ORDER: 979 print( 980 " Contour order differs: %s in %s, %s in %s" 981 % ( 982 p["value_1"], 983 p["master_1"], 984 p["value_2"], 985 p["master_2"], 986 ), 987 file=f, 988 ) 989 elif p["type"] == InterpolatableProblem.WRONG_START_POINT: 990 print( 991 " Contour %d start point differs: %s in %s, %s in %s; reversed: %s" 992 % ( 993 p["contour"], 994 p["value_1"], 995 p["master_1"], 996 p["value_2"], 997 p["master_2"], 998 p["reversed"], 999 ), 1000 file=f, 1001 ) 1002 elif p["type"] == InterpolatableProblem.UNDERWEIGHT: 1003 print( 1004 " Contour %d interpolation is underweight: %s, %s" 1005 % ( 1006 p["contour"], 1007 p["master_1"], 1008 p["master_2"], 1009 ), 1010 file=f, 1011 ) 1012 elif p["type"] == InterpolatableProblem.OVERWEIGHT: 1013 print( 1014 " Contour %d interpolation is overweight: %s, %s" 1015 % ( 1016 p["contour"], 1017 p["master_1"], 1018 p["master_2"], 1019 ), 1020 file=f, 1021 ) 1022 elif p["type"] == InterpolatableProblem.KINK: 1023 print( 1024 " Contour %d has a kink at %s: %s, %s" 1025 % ( 1026 p["contour"], 1027 p["value"], 1028 p["master_1"], 1029 p["master_2"], 1030 ), 1031 file=f, 1032 ) 1033 elif p["type"] == InterpolatableProblem.NOTHING: 1034 print( 1035 " Showing %s and %s" 1036 % ( 1037 p["master_1"], 1038 p["master_2"], 1039 ), 1040 file=f, 1041 ) 1042 else: 1043 for glyphname, problem in problems_gen: 1044 problems[glyphname].append(problem) 1045 1046 problems = sort_problems(problems) 1047 1048 for p in "ps", "pdf": 1049 arg = getattr(args, p) 1050 if arg is None: 1051 continue 1052 log.info("Writing %s to %s", p.upper(), arg) 1053 from .interpolatablePlot import InterpolatablePS, InterpolatablePDF 1054 1055 PlotterClass = InterpolatablePS if p == "ps" else InterpolatablePDF 1056 1057 with PlotterClass( 1058 ensure_parent_dir(arg), glyphsets=glyphsets, names=names 1059 ) as doc: 1060 doc.add_title_page( 1061 original_args_inputs, tolerance=tolerance, kinkiness=kinkiness 1062 ) 1063 if problems: 1064 doc.add_summary(problems) 1065 doc.add_problems(problems) 1066 if not problems and not args.quiet: 1067 doc.draw_cupcake() 1068 if problems: 1069 doc.add_index() 1070 doc.add_table_of_contents() 1071 1072 if args.html: 1073 log.info("Writing HTML to %s", args.html) 1074 from .interpolatablePlot import InterpolatableSVG 1075 1076 svgs = [] 1077 glyph_starts = {} 1078 with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg: 1079 svg.add_title_page( 1080 original_args_inputs, 1081 show_tolerance=False, 1082 tolerance=tolerance, 1083 kinkiness=kinkiness, 1084 ) 1085 for glyph, glyph_problems in problems.items(): 1086 glyph_starts[len(svgs)] = glyph 1087 svg.add_problems( 1088 {glyph: glyph_problems}, 1089 show_tolerance=False, 1090 show_page_number=False, 1091 ) 1092 if not problems and not args.quiet: 1093 svg.draw_cupcake() 1094 1095 import base64 1096 1097 with open(ensure_parent_dir(args.html), "wb") as f: 1098 f.write(b"<!DOCTYPE html>\n") 1099 f.write( 1100 b'<html><body align="center" style="font-family: sans-serif; text-color: #222">\n' 1101 ) 1102 f.write(b"<title>fonttools varLib.interpolatable report</title>\n") 1103 for i, svg in enumerate(svgs): 1104 if i in glyph_starts: 1105 f.write(f"<h1>Glyph {glyph_starts[i]}</h1>\n".encode("utf-8")) 1106 f.write("<img src='data:image/svg+xml;base64,".encode("utf-8")) 1107 f.write(base64.b64encode(svg)) 1108 f.write(b"' />\n") 1109 f.write(b"<hr>\n") 1110 f.write(b"</body></html>\n") 1111 1112 except Exception as e: 1113 e.args += original_args_inputs 1114 log.error(e) 1115 raise 1116 1117 if problems: 1118 return problems 1119 1120 1121if __name__ == "__main__": 1122 import sys 1123 1124 problems = main() 1125 sys.exit(int(bool(problems))) 1126