interpolatable.py 41 KB

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