interpolatable.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148
  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 "ERROR"))
  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"):
  664. from fontTools.ttLib import TTFont
  665. font = TTFont(args.inputs[0])
  666. upem = font["head"].unitsPerEm
  667. if "gvar" in font:
  668. # Is variable font
  669. fvar = font["fvar"]
  670. axisMapping = {}
  671. for axis in fvar.axes:
  672. axisMapping[axis.axisTag] = {
  673. -1: axis.minValue,
  674. 0: axis.defaultValue,
  675. 1: axis.maxValue,
  676. }
  677. normalized = False
  678. if "avar" in font:
  679. avar = font["avar"]
  680. if getattr(avar.table, "VarStore", None):
  681. axisMapping = {tag: {-1: -1, 0: 0, 1: 1} for tag in axisMapping}
  682. normalized = True
  683. else:
  684. for axisTag, segments in avar.segments.items():
  685. fvarMapping = axisMapping[axisTag].copy()
  686. for location, value in segments.items():
  687. axisMapping[axisTag][value] = piecewiseLinearMap(
  688. location, fvarMapping
  689. )
  690. gvar = font["gvar"]
  691. glyf = font["glyf"]
  692. # Gather all glyphs at their "master" locations
  693. ttGlyphSets = {}
  694. glyphsets = defaultdict(dict)
  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. names = ["''"]
  713. fonts = [font.getGlyphSet()]
  714. locations = [{}]
  715. axis_triples = {a: (-1, 0, +1) for a in sorted(axisMapping.keys())}
  716. for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)):
  717. name = (
  718. "'"
  719. + " ".join(
  720. "%s=%s"
  721. % (
  722. k,
  723. floatToFixedToStr(
  724. piecewiseLinearMap(v, axisMapping[k]), 14
  725. ),
  726. )
  727. for k, v in locTuple
  728. )
  729. + "'"
  730. )
  731. if normalized:
  732. name += " (normalized)"
  733. names.append(name)
  734. fonts.append(glyphsets[locTuple])
  735. locations.append(dict(locTuple))
  736. args.ignore_missing = True
  737. args.inputs = []
  738. if not locations:
  739. locations = [{} for _ in fonts]
  740. for filename in args.inputs:
  741. if filename.endswith(".ufo"):
  742. from fontTools.ufoLib import UFOReader
  743. font = UFOReader(filename)
  744. info = SimpleNamespace()
  745. font.readInfo(info)
  746. upem = info.unitsPerEm
  747. fonts.append(font)
  748. else:
  749. from fontTools.ttLib import TTFont
  750. font = TTFont(filename)
  751. upem = font["head"].unitsPerEm
  752. fonts.append(font)
  753. names.append(basename(filename).rsplit(".", 1)[0])
  754. glyphsets = []
  755. for font in fonts:
  756. if hasattr(font, "getGlyphSet"):
  757. glyphset = font.getGlyphSet()
  758. else:
  759. glyphset = font
  760. glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
  761. if args.name:
  762. accepted_names = set(args.name)
  763. glyphsets = [
  764. glyphset
  765. for name, glyphset in zip(names, glyphsets)
  766. if name in accepted_names
  767. ]
  768. locations = [
  769. location
  770. for name, location in zip(names, locations)
  771. if name in accepted_names
  772. ]
  773. names = [name for name in names if name in accepted_names]
  774. if not glyphs:
  775. glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()]))
  776. glyphsSet = set(glyphs)
  777. for glyphset in glyphsets:
  778. glyphSetGlyphNames = set(glyphset.keys())
  779. diff = glyphsSet - glyphSetGlyphNames
  780. if diff:
  781. for gn in diff:
  782. glyphset[gn] = None
  783. # Normalize locations
  784. locations = [
  785. {
  786. **normalizeLocation(loc, axis_triples),
  787. **{k: v for k, v in loc.items() if k in discrete_axes},
  788. }
  789. for loc in locations
  790. ]
  791. tolerance = args.tolerance or DEFAULT_TOLERANCE
  792. kinkiness = args.kinkiness if args.kinkiness is not None else DEFAULT_KINKINESS
  793. try:
  794. log.info("Running on %d glyphsets", len(glyphsets))
  795. log.info("Locations: %s", pformat(locations))
  796. problems_gen = test_gen(
  797. glyphsets,
  798. glyphs=glyphs,
  799. names=names,
  800. locations=locations,
  801. upem=upem,
  802. ignore_missing=args.ignore_missing,
  803. tolerance=tolerance,
  804. kinkiness=kinkiness,
  805. show_all=args.show_all,
  806. discrete_axes=discrete_axes,
  807. )
  808. problems = defaultdict(list)
  809. f = (
  810. sys.stdout
  811. if args.output is None
  812. else open(ensure_parent_dir(args.output), "w")
  813. )
  814. if not args.quiet:
  815. if args.json:
  816. import json
  817. for glyphname, problem in problems_gen:
  818. problems[glyphname].append(problem)
  819. print(json.dumps(problems), file=f)
  820. else:
  821. last_glyphname = None
  822. for glyphname, p in problems_gen:
  823. problems[glyphname].append(p)
  824. if glyphname != last_glyphname:
  825. print(f"Glyph {glyphname} was not compatible:", file=f)
  826. last_glyphname = glyphname
  827. last_master_idxs = None
  828. master_idxs = (
  829. (p["master_idx"],)
  830. if "master_idx" in p
  831. else (p["master_1_idx"], p["master_2_idx"])
  832. )
  833. if master_idxs != last_master_idxs:
  834. master_names = (
  835. (p["master"],)
  836. if "master" in p
  837. else (p["master_1"], p["master_2"])
  838. )
  839. print(f" Masters: %s:" % ", ".join(master_names), file=f)
  840. last_master_idxs = master_idxs
  841. if p["type"] == InterpolatableProblem.MISSING:
  842. print(
  843. " Glyph was missing in master %s" % p["master"], file=f
  844. )
  845. elif p["type"] == InterpolatableProblem.OPEN_PATH:
  846. print(
  847. " Glyph has an open path in master %s" % p["master"],
  848. file=f,
  849. )
  850. elif p["type"] == InterpolatableProblem.PATH_COUNT:
  851. print(
  852. " Path count differs: %i in %s, %i in %s"
  853. % (
  854. p["value_1"],
  855. p["master_1"],
  856. p["value_2"],
  857. p["master_2"],
  858. ),
  859. file=f,
  860. )
  861. elif p["type"] == InterpolatableProblem.NODE_COUNT:
  862. print(
  863. " Node count differs in path %i: %i in %s, %i in %s"
  864. % (
  865. p["path"],
  866. p["value_1"],
  867. p["master_1"],
  868. p["value_2"],
  869. p["master_2"],
  870. ),
  871. file=f,
  872. )
  873. elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY:
  874. print(
  875. " Node %o incompatible in path %i: %s in %s, %s in %s"
  876. % (
  877. p["node"],
  878. p["path"],
  879. p["value_1"],
  880. p["master_1"],
  881. p["value_2"],
  882. p["master_2"],
  883. ),
  884. file=f,
  885. )
  886. elif p["type"] == InterpolatableProblem.CONTOUR_ORDER:
  887. print(
  888. " Contour order differs: %s in %s, %s in %s"
  889. % (
  890. p["value_1"],
  891. p["master_1"],
  892. p["value_2"],
  893. p["master_2"],
  894. ),
  895. file=f,
  896. )
  897. elif p["type"] == InterpolatableProblem.WRONG_START_POINT:
  898. print(
  899. " Contour %d start point differs: %s in %s, %s in %s; reversed: %s"
  900. % (
  901. p["contour"],
  902. p["value_1"],
  903. p["master_1"],
  904. p["value_2"],
  905. p["master_2"],
  906. p["reversed"],
  907. ),
  908. file=f,
  909. )
  910. elif p["type"] == InterpolatableProblem.UNDERWEIGHT:
  911. print(
  912. " Contour %d interpolation is underweight: %s, %s"
  913. % (
  914. p["contour"],
  915. p["master_1"],
  916. p["master_2"],
  917. ),
  918. file=f,
  919. )
  920. elif p["type"] == InterpolatableProblem.OVERWEIGHT:
  921. print(
  922. " Contour %d interpolation is overweight: %s, %s"
  923. % (
  924. p["contour"],
  925. p["master_1"],
  926. p["master_2"],
  927. ),
  928. file=f,
  929. )
  930. elif p["type"] == InterpolatableProblem.KINK:
  931. print(
  932. " Contour %d has a kink at %s: %s, %s"
  933. % (
  934. p["contour"],
  935. p["value"],
  936. p["master_1"],
  937. p["master_2"],
  938. ),
  939. file=f,
  940. )
  941. elif p["type"] == InterpolatableProblem.NOTHING:
  942. print(
  943. " Showing %s and %s"
  944. % (
  945. p["master_1"],
  946. p["master_2"],
  947. ),
  948. file=f,
  949. )
  950. else:
  951. for glyphname, problem in problems_gen:
  952. problems[glyphname].append(problem)
  953. problems = sort_problems(problems)
  954. for p in "ps", "pdf":
  955. arg = getattr(args, p)
  956. if arg is None:
  957. continue
  958. log.info("Writing %s to %s", p.upper(), arg)
  959. from .interpolatablePlot import InterpolatablePS, InterpolatablePDF
  960. PlotterClass = InterpolatablePS if p == "ps" else InterpolatablePDF
  961. with PlotterClass(
  962. ensure_parent_dir(arg), glyphsets=glyphsets, names=names
  963. ) as doc:
  964. doc.add_title_page(
  965. original_args_inputs, tolerance=tolerance, kinkiness=kinkiness
  966. )
  967. if problems:
  968. doc.add_summary(problems)
  969. doc.add_problems(problems)
  970. if not problems and not args.quiet:
  971. doc.draw_cupcake()
  972. if problems:
  973. doc.add_index()
  974. doc.add_table_of_contents()
  975. if args.html:
  976. log.info("Writing HTML to %s", args.html)
  977. from .interpolatablePlot import InterpolatableSVG
  978. svgs = []
  979. glyph_starts = {}
  980. with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg:
  981. svg.add_title_page(
  982. original_args_inputs,
  983. show_tolerance=False,
  984. tolerance=tolerance,
  985. kinkiness=kinkiness,
  986. )
  987. for glyph, glyph_problems in problems.items():
  988. glyph_starts[len(svgs)] = glyph
  989. svg.add_problems(
  990. {glyph: glyph_problems},
  991. show_tolerance=False,
  992. show_page_number=False,
  993. )
  994. if not problems and not args.quiet:
  995. svg.draw_cupcake()
  996. import base64
  997. with open(ensure_parent_dir(args.html), "wb") as f:
  998. f.write(b"<!DOCTYPE html>\n")
  999. f.write(
  1000. b'<html><body align="center" style="font-family: sans-serif; text-color: #222">\n'
  1001. )
  1002. f.write(b"<title>fonttools varLib.interpolatable report</title>\n")
  1003. for i, svg in enumerate(svgs):
  1004. if i in glyph_starts:
  1005. f.write(f"<h1>Glyph {glyph_starts[i]}</h1>\n".encode("utf-8"))
  1006. f.write("<img src='data:image/svg+xml;base64,".encode("utf-8"))
  1007. f.write(base64.b64encode(svg))
  1008. f.write(b"' />\n")
  1009. f.write(b"<hr>\n")
  1010. f.write(b"</body></html>\n")
  1011. except Exception as e:
  1012. e.args += original_args_inputs
  1013. log.error(e)
  1014. raise
  1015. if problems:
  1016. return problems
  1017. if __name__ == "__main__":
  1018. import sys
  1019. problems = main()
  1020. sys.exit(int(bool(problems)))