interpolatable.py 44 KB

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