interpolatablePlot.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264
  1. from .interpolatableHelpers import *
  2. from fontTools.ttLib import TTFont
  3. from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
  4. from fontTools.pens.recordingPen import (
  5. RecordingPen,
  6. DecomposingRecordingPen,
  7. RecordingPointPen,
  8. )
  9. from fontTools.pens.boundsPen import ControlBoundsPen
  10. from fontTools.pens.cairoPen import CairoPen
  11. from fontTools.pens.pointPen import (
  12. SegmentToPointPen,
  13. PointToSegmentPen,
  14. ReverseContourPointPen,
  15. )
  16. from itertools import cycle
  17. from io import BytesIO
  18. import cairo
  19. import math
  20. import os
  21. import logging
  22. log = logging.getLogger("fontTools.varLib.interpolatable")
  23. class OverridingDict(dict):
  24. def __init__(self, parent_dict):
  25. self.parent_dict = parent_dict
  26. def __missing__(self, key):
  27. return self.parent_dict[key]
  28. class InterpolatablePlot:
  29. width = 8.5 * 72
  30. height = 11 * 72
  31. pad = 0.1 * 72
  32. title_font_size = 24
  33. font_size = 16
  34. page_number = 1
  35. head_color = (0.3, 0.3, 0.3)
  36. label_color = (0.2, 0.2, 0.2)
  37. border_color = (0.9, 0.9, 0.9)
  38. border_width = 0.5
  39. fill_color = (0.8, 0.8, 0.8)
  40. stroke_color = (0.1, 0.1, 0.1)
  41. stroke_width = 1
  42. oncurve_node_color = (0, 0.8, 0, 0.7)
  43. oncurve_node_diameter = 6
  44. offcurve_node_color = (0, 0.5, 0, 0.7)
  45. offcurve_node_diameter = 4
  46. handle_color = (0, 0.5, 0, 0.7)
  47. handle_width = 0.5
  48. corrected_start_point_color = (0, 0.9, 0, 0.7)
  49. corrected_start_point_size = 7
  50. wrong_start_point_color = (1, 0, 0, 0.7)
  51. start_point_color = (0, 0, 1, 0.7)
  52. start_arrow_length = 9
  53. kink_point_size = 7
  54. kink_point_color = (1, 0, 1, 0.7)
  55. kink_circle_size = 15
  56. kink_circle_stroke_width = 1
  57. kink_circle_color = (1, 0, 1, 0.7)
  58. contour_colors = ((1, 0, 0), (0, 0, 1), (0, 1, 0), (1, 1, 0), (1, 0, 1), (0, 1, 1))
  59. contour_alpha = 0.5
  60. weight_issue_contour_color = (0, 0, 0, 0.4)
  61. no_issues_label = "Your font's good! Have a cupcake..."
  62. no_issues_label_color = (0, 0.5, 0)
  63. cupcake_color = (0.3, 0, 0.3)
  64. cupcake = r"""
  65. ,@.
  66. ,@.@@,.
  67. ,@@,.@@@. @.@@@,.
  68. ,@@. @@@. @@. @@,.
  69. ,@@@.@,.@. @. @@@@,.@.@@,.
  70. ,@@.@. @@.@@. @,. .@' @' @@,
  71. ,@@. @. .@@.@@@. @@' @,
  72. ,@. @@. @,
  73. @. @,@@,. , .@@,
  74. @,. .@,@@,. .@@,. , .@@, @, @,
  75. @. .@. @ @@,. , @
  76. @,.@@. @,. @@,. @. @,. @'
  77. @@||@,. @'@,. @@,. @@ @,. @'@@, @'
  78. \\@@@@' @,. @'@@@@' @@,. @@@' //@@@'
  79. |||||||| @@,. @@' ||||||| |@@@|@|| ||
  80. \\\\\\\ ||@@@|| ||||||| ||||||| //
  81. ||||||| |||||| |||||| |||||| ||
  82. \\\\\\ |||||| |||||| |||||| //
  83. |||||| ||||| ||||| ||||| ||
  84. \\\\\ ||||| ||||| ||||| //
  85. ||||| |||| ||||| |||| ||
  86. \\\\ |||| |||| |||| //
  87. ||||||||||||||||||||||||
  88. """
  89. emoticon_color = (0, 0.3, 0.3)
  90. shrug = r"""\_(")_/"""
  91. underweight = r"""
  92. o
  93. /|\
  94. / \
  95. """
  96. overweight = r"""
  97. o
  98. /O\
  99. / \
  100. """
  101. yay = r""" \o/ """
  102. def __init__(self, out, glyphsets, names=None, **kwargs):
  103. self.out = out
  104. self.glyphsets = glyphsets
  105. self.names = names or [repr(g) for g in glyphsets]
  106. self.toc = {}
  107. for k, v in kwargs.items():
  108. if not hasattr(self, k):
  109. raise TypeError("Unknown keyword argument: %s" % k)
  110. setattr(self, k, v)
  111. self.panel_width = self.width / 2 - self.pad * 3
  112. self.panel_height = (
  113. self.height / 2 - self.pad * 6 - self.font_size * 2 - self.title_font_size
  114. )
  115. def __enter__(self):
  116. return self
  117. def __exit__(self, type, value, traceback):
  118. pass
  119. def show_page(self):
  120. self.page_number += 1
  121. def add_title_page(
  122. self, files, *, show_tolerance=True, tolerance=None, kinkiness=None
  123. ):
  124. pad = self.pad
  125. width = self.width - 3 * self.pad
  126. x = y = pad
  127. self.draw_label(
  128. "Problem report for:",
  129. x=x,
  130. y=y,
  131. bold=True,
  132. width=width,
  133. font_size=self.title_font_size,
  134. )
  135. y += self.title_font_size
  136. import hashlib
  137. for file in files:
  138. base_file = os.path.basename(file)
  139. y += self.font_size + self.pad
  140. self.draw_label(base_file, x=x, y=y, bold=True, width=width)
  141. y += self.font_size + self.pad
  142. try:
  143. with open(file, "rb") as f:
  144. h = hashlib.sha1(f.read()).hexdigest()
  145. self.draw_label("sha1: %s" % h, x=x + pad, y=y, width=width)
  146. y += self.font_size
  147. except IsADirectoryError:
  148. pass
  149. if file.endswith(".ttf"):
  150. ttFont = TTFont(file)
  151. name = ttFont["name"] if "name" in ttFont else None
  152. if name:
  153. for what, nameIDs in (
  154. ("Family name", (21, 16, 1)),
  155. ("Version", (5,)),
  156. ):
  157. n = name.getFirstDebugName(nameIDs)
  158. if n is None:
  159. continue
  160. self.draw_label(
  161. "%s: %s" % (what, n), x=x + pad, y=y, width=width
  162. )
  163. y += self.font_size + self.pad
  164. elif file.endswith((".glyphs", ".glyphspackage")):
  165. from glyphsLib import GSFont
  166. f = GSFont(file)
  167. for what, field in (
  168. ("Family name", "familyName"),
  169. ("VersionMajor", "versionMajor"),
  170. ("VersionMinor", "_versionMinor"),
  171. ):
  172. self.draw_label(
  173. "%s: %s" % (what, getattr(f, field)),
  174. x=x + pad,
  175. y=y,
  176. width=width,
  177. )
  178. y += self.font_size + self.pad
  179. self.draw_legend(
  180. show_tolerance=show_tolerance, tolerance=tolerance, kinkiness=kinkiness
  181. )
  182. self.show_page()
  183. def draw_legend(self, *, show_tolerance=True, tolerance=None, kinkiness=None):
  184. cr = cairo.Context(self.surface)
  185. x = self.pad
  186. y = self.height - self.pad - self.font_size * 2
  187. width = self.width - 2 * self.pad
  188. xx = x + self.pad * 2
  189. xxx = x + self.pad * 4
  190. if show_tolerance:
  191. self.draw_label(
  192. "Tolerance: badness; closer to zero the worse", x=xxx, y=y, width=width
  193. )
  194. y -= self.pad + self.font_size
  195. self.draw_label("Underweight contours", x=xxx, y=y, width=width)
  196. cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
  197. cr.set_source_rgb(*self.fill_color)
  198. cr.fill_preserve()
  199. if self.stroke_color:
  200. cr.set_source_rgb(*self.stroke_color)
  201. cr.set_line_width(self.stroke_width)
  202. cr.stroke_preserve()
  203. cr.set_source_rgba(*self.weight_issue_contour_color)
  204. cr.fill()
  205. y -= self.pad + self.font_size
  206. self.draw_label(
  207. "Colored contours: contours with the wrong order", x=xxx, y=y, width=width
  208. )
  209. cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
  210. if self.fill_color:
  211. cr.set_source_rgb(*self.fill_color)
  212. cr.fill_preserve()
  213. if self.stroke_color:
  214. cr.set_source_rgb(*self.stroke_color)
  215. cr.set_line_width(self.stroke_width)
  216. cr.stroke_preserve()
  217. cr.set_source_rgba(*self.contour_colors[0], self.contour_alpha)
  218. cr.fill()
  219. y -= self.pad + self.font_size
  220. self.draw_label("Kink artifact", x=xxx, y=y, width=width)
  221. self.draw_circle(
  222. cr,
  223. x=xx,
  224. y=y + self.font_size * 0.5,
  225. diameter=self.kink_circle_size,
  226. stroke_width=self.kink_circle_stroke_width,
  227. color=self.kink_circle_color,
  228. )
  229. y -= self.pad + self.font_size
  230. self.draw_label("Point causing kink in the contour", x=xxx, y=y, width=width)
  231. self.draw_dot(
  232. cr,
  233. x=xx,
  234. y=y + self.font_size * 0.5,
  235. diameter=self.kink_point_size,
  236. color=self.kink_point_color,
  237. )
  238. y -= self.pad + self.font_size
  239. self.draw_label("Suggested new contour start point", x=xxx, y=y, width=width)
  240. self.draw_dot(
  241. cr,
  242. x=xx,
  243. y=y + self.font_size * 0.5,
  244. diameter=self.corrected_start_point_size,
  245. color=self.corrected_start_point_color,
  246. )
  247. y -= self.pad + self.font_size
  248. self.draw_label(
  249. "Contour start point in contours with wrong direction",
  250. x=xxx,
  251. y=y,
  252. width=width,
  253. )
  254. self.draw_arrow(
  255. cr,
  256. x=xx - self.start_arrow_length * 0.3,
  257. y=y + self.font_size * 0.5,
  258. color=self.wrong_start_point_color,
  259. )
  260. y -= self.pad + self.font_size
  261. self.draw_label(
  262. "Contour start point when the first two points overlap",
  263. x=xxx,
  264. y=y,
  265. width=width,
  266. )
  267. self.draw_dot(
  268. cr,
  269. x=xx,
  270. y=y + self.font_size * 0.5,
  271. diameter=self.corrected_start_point_size,
  272. color=self.start_point_color,
  273. )
  274. y -= self.pad + self.font_size
  275. self.draw_label("Contour start point and direction", x=xxx, y=y, width=width)
  276. self.draw_arrow(
  277. cr,
  278. x=xx - self.start_arrow_length * 0.3,
  279. y=y + self.font_size * 0.5,
  280. color=self.start_point_color,
  281. )
  282. y -= self.pad + self.font_size
  283. self.draw_label("Legend:", x=x, y=y, width=width, bold=True)
  284. y -= self.pad + self.font_size
  285. if kinkiness is not None:
  286. self.draw_label(
  287. "Kink-reporting aggressiveness: %g" % kinkiness,
  288. x=xxx,
  289. y=y,
  290. width=width,
  291. )
  292. y -= self.pad + self.font_size
  293. if tolerance is not None:
  294. self.draw_label(
  295. "Error tolerance: %g" % tolerance,
  296. x=xxx,
  297. y=y,
  298. width=width,
  299. )
  300. y -= self.pad + self.font_size
  301. self.draw_label("Parameters:", x=x, y=y, width=width, bold=True)
  302. y -= self.pad + self.font_size
  303. def add_summary(self, problems):
  304. pad = self.pad
  305. width = self.width - 3 * self.pad
  306. height = self.height - 2 * self.pad
  307. x = y = pad
  308. self.draw_label(
  309. "Summary of problems",
  310. x=x,
  311. y=y,
  312. bold=True,
  313. width=width,
  314. font_size=self.title_font_size,
  315. )
  316. y += self.title_font_size
  317. glyphs_per_problem = defaultdict(set)
  318. for glyphname, glyph_problems in sorted(problems.items()):
  319. for problem in glyph_problems:
  320. glyphs_per_problem[problem["type"]].add(glyphname)
  321. if InterpolatableProblem.NOTHING in glyphs_per_problem:
  322. del glyphs_per_problem[InterpolatableProblem.NOTHING]
  323. for problem_type in sorted(
  324. glyphs_per_problem, key=lambda x: InterpolatableProblem.severity[x]
  325. ):
  326. y += self.font_size
  327. self.draw_label(
  328. "%s: %d" % (problem_type, len(glyphs_per_problem[problem_type])),
  329. x=x,
  330. y=y,
  331. width=width,
  332. bold=True,
  333. )
  334. y += self.font_size
  335. for glyphname in sorted(glyphs_per_problem[problem_type]):
  336. if y + self.font_size > height:
  337. self.show_page()
  338. y = self.font_size + pad
  339. self.draw_label(glyphname, x=x + 2 * pad, y=y, width=width - 2 * pad)
  340. y += self.font_size
  341. self.show_page()
  342. def _add_listing(self, title, items):
  343. pad = self.pad
  344. width = self.width - 2 * self.pad
  345. height = self.height - 2 * self.pad
  346. x = y = pad
  347. self.draw_label(
  348. title, x=x, y=y, bold=True, width=width, font_size=self.title_font_size
  349. )
  350. y += self.title_font_size + self.pad
  351. last_glyphname = None
  352. for page_no, (glyphname, problems) in items:
  353. if glyphname == last_glyphname:
  354. continue
  355. last_glyphname = glyphname
  356. if y + self.font_size > height:
  357. self.show_page()
  358. y = self.font_size + pad
  359. self.draw_label(glyphname, x=x + 5 * pad, y=y, width=width - 2 * pad)
  360. self.draw_label(str(page_no), x=x, y=y, width=4 * pad, align=1)
  361. y += self.font_size
  362. self.show_page()
  363. def add_table_of_contents(self):
  364. self._add_listing("Table of contents", sorted(self.toc.items()))
  365. def add_index(self):
  366. self._add_listing("Index", sorted(self.toc.items(), key=lambda x: x[1][0]))
  367. def add_problems(self, problems, *, show_tolerance=True, show_page_number=True):
  368. for glyph, glyph_problems in problems.items():
  369. last_masters = None
  370. current_glyph_problems = []
  371. for p in glyph_problems:
  372. masters = (
  373. p["master_idx"]
  374. if "master_idx" in p
  375. else (p["master_1_idx"], p["master_2_idx"])
  376. )
  377. if masters == last_masters:
  378. current_glyph_problems.append(p)
  379. continue
  380. # Flush
  381. if current_glyph_problems:
  382. self.add_problem(
  383. glyph,
  384. current_glyph_problems,
  385. show_tolerance=show_tolerance,
  386. show_page_number=show_page_number,
  387. )
  388. self.show_page()
  389. current_glyph_problems = []
  390. last_masters = masters
  391. current_glyph_problems.append(p)
  392. if current_glyph_problems:
  393. self.add_problem(
  394. glyph,
  395. current_glyph_problems,
  396. show_tolerance=show_tolerance,
  397. show_page_number=show_page_number,
  398. )
  399. self.show_page()
  400. def add_problem(
  401. self, glyphname, problems, *, show_tolerance=True, show_page_number=True
  402. ):
  403. if type(problems) not in (list, tuple):
  404. problems = [problems]
  405. self.toc[self.page_number] = (glyphname, problems)
  406. problem_type = problems[0]["type"]
  407. problem_types = set(problem["type"] for problem in problems)
  408. if not all(pt == problem_type for pt in problem_types):
  409. problem_type = ", ".join(sorted({problem["type"] for problem in problems}))
  410. log.info("Drawing %s: %s", glyphname, problem_type)
  411. master_keys = (
  412. ("master_idx",)
  413. if "master_idx" in problems[0]
  414. else ("master_1_idx", "master_2_idx")
  415. )
  416. master_indices = [problems[0][k] for k in master_keys]
  417. if problem_type == InterpolatableProblem.MISSING:
  418. sample_glyph = next(
  419. i for i, m in enumerate(self.glyphsets) if m[glyphname] is not None
  420. )
  421. master_indices.insert(0, sample_glyph)
  422. x = self.pad
  423. y = self.pad
  424. self.draw_label(
  425. "Glyph name: " + glyphname,
  426. x=x,
  427. y=y,
  428. color=self.head_color,
  429. align=0,
  430. bold=True,
  431. font_size=self.title_font_size,
  432. )
  433. tolerance = min(p.get("tolerance", 1) for p in problems)
  434. if tolerance < 1 and show_tolerance:
  435. self.draw_label(
  436. "tolerance: %.2f" % tolerance,
  437. x=x,
  438. y=y,
  439. width=self.width - 2 * self.pad,
  440. align=1,
  441. bold=True,
  442. )
  443. y += self.title_font_size + self.pad
  444. self.draw_label(
  445. "Problems: " + problem_type,
  446. x=x,
  447. y=y,
  448. width=self.width - 2 * self.pad,
  449. color=self.head_color,
  450. bold=True,
  451. )
  452. y += self.font_size + self.pad * 2
  453. scales = []
  454. for which, master_idx in enumerate(master_indices):
  455. glyphset = self.glyphsets[master_idx]
  456. name = self.names[master_idx]
  457. self.draw_label(
  458. name,
  459. x=x,
  460. y=y,
  461. color=self.label_color,
  462. width=self.panel_width,
  463. align=0.5,
  464. )
  465. y += self.font_size + self.pad
  466. if glyphset[glyphname] is not None:
  467. scales.append(
  468. self.draw_glyph(glyphset, glyphname, problems, which, x=x, y=y)
  469. )
  470. else:
  471. self.draw_emoticon(self.shrug, x=x, y=y)
  472. y += self.panel_height + self.font_size + self.pad
  473. if any(
  474. pt
  475. in (
  476. InterpolatableProblem.NOTHING,
  477. InterpolatableProblem.WRONG_START_POINT,
  478. InterpolatableProblem.CONTOUR_ORDER,
  479. InterpolatableProblem.KINK,
  480. InterpolatableProblem.UNDERWEIGHT,
  481. InterpolatableProblem.OVERWEIGHT,
  482. )
  483. for pt in problem_types
  484. ):
  485. x = self.pad + self.panel_width + self.pad
  486. y = self.pad
  487. y += self.title_font_size + self.pad * 2
  488. y += self.font_size + self.pad
  489. glyphset1 = self.glyphsets[master_indices[0]]
  490. glyphset2 = self.glyphsets[master_indices[1]]
  491. # Draw the mid-way of the two masters
  492. self.draw_label(
  493. "midway interpolation",
  494. x=x,
  495. y=y,
  496. color=self.head_color,
  497. width=self.panel_width,
  498. align=0.5,
  499. )
  500. y += self.font_size + self.pad
  501. midway_glyphset = LerpGlyphSet(glyphset1, glyphset2)
  502. self.draw_glyph(
  503. midway_glyphset,
  504. glyphname,
  505. [{"type": "midway"}]
  506. + [
  507. p
  508. for p in problems
  509. if p["type"]
  510. in (
  511. InterpolatableProblem.KINK,
  512. InterpolatableProblem.UNDERWEIGHT,
  513. InterpolatableProblem.OVERWEIGHT,
  514. )
  515. ],
  516. None,
  517. x=x,
  518. y=y,
  519. scale=min(scales),
  520. )
  521. y += self.panel_height + self.font_size + self.pad
  522. if any(
  523. pt
  524. in (
  525. InterpolatableProblem.WRONG_START_POINT,
  526. InterpolatableProblem.CONTOUR_ORDER,
  527. InterpolatableProblem.KINK,
  528. )
  529. for pt in problem_types
  530. ):
  531. # Draw the proposed fix
  532. self.draw_label(
  533. "proposed fix",
  534. x=x,
  535. y=y,
  536. color=self.head_color,
  537. width=self.panel_width,
  538. align=0.5,
  539. )
  540. y += self.font_size + self.pad
  541. overriding1 = OverridingDict(glyphset1)
  542. overriding2 = OverridingDict(glyphset2)
  543. perContourPen1 = PerContourOrComponentPen(
  544. RecordingPen, glyphset=overriding1
  545. )
  546. perContourPen2 = PerContourOrComponentPen(
  547. RecordingPen, glyphset=overriding2
  548. )
  549. glyphset1[glyphname].draw(perContourPen1)
  550. glyphset2[glyphname].draw(perContourPen2)
  551. for problem in problems:
  552. if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
  553. fixed_contours = [
  554. perContourPen2.value[i] for i in problems[0]["value_2"]
  555. ]
  556. perContourPen2.value = fixed_contours
  557. for problem in problems:
  558. if problem["type"] == InterpolatableProblem.WRONG_START_POINT:
  559. # Save the wrong contours
  560. wrongContour1 = perContourPen1.value[problem["contour"]]
  561. wrongContour2 = perContourPen2.value[problem["contour"]]
  562. # Convert the wrong contours to point pens
  563. points1 = RecordingPointPen()
  564. converter = SegmentToPointPen(points1, False)
  565. wrongContour1.replay(converter)
  566. points2 = RecordingPointPen()
  567. converter = SegmentToPointPen(points2, False)
  568. wrongContour2.replay(converter)
  569. proposed_start = problem["value_2"]
  570. # See if we need reversing; fragile but worth a try
  571. if problem["reversed"]:
  572. new_points2 = RecordingPointPen()
  573. reversedPen = ReverseContourPointPen(new_points2)
  574. points2.replay(reversedPen)
  575. points2 = new_points2
  576. proposed_start = len(points2.value) - 2 - proposed_start
  577. # Rotate points2 so that the first point is the same as in points1
  578. beginPath = points2.value[:1]
  579. endPath = points2.value[-1:]
  580. pts = points2.value[1:-1]
  581. pts = pts[proposed_start:] + pts[:proposed_start]
  582. points2.value = beginPath + pts + endPath
  583. # Convert the point pens back to segment pens
  584. segment1 = RecordingPen()
  585. converter = PointToSegmentPen(segment1, True)
  586. points1.replay(converter)
  587. segment2 = RecordingPen()
  588. converter = PointToSegmentPen(segment2, True)
  589. points2.replay(converter)
  590. # Replace the wrong contours
  591. wrongContour1.value = segment1.value
  592. wrongContour2.value = segment2.value
  593. perContourPen1.value[problem["contour"]] = wrongContour1
  594. perContourPen2.value[problem["contour"]] = wrongContour2
  595. for problem in problems:
  596. # If we have a kink, try to fix it.
  597. if problem["type"] == InterpolatableProblem.KINK:
  598. # Save the wrong contours
  599. wrongContour1 = perContourPen1.value[problem["contour"]]
  600. wrongContour2 = perContourPen2.value[problem["contour"]]
  601. # Convert the wrong contours to point pens
  602. points1 = RecordingPointPen()
  603. converter = SegmentToPointPen(points1, False)
  604. wrongContour1.replay(converter)
  605. points2 = RecordingPointPen()
  606. converter = SegmentToPointPen(points2, False)
  607. wrongContour2.replay(converter)
  608. i = problem["value"]
  609. # Position points to be around the same ratio
  610. # beginPath / endPath dance
  611. j = i + 1
  612. pt0 = points1.value[j][1][0]
  613. pt1 = points2.value[j][1][0]
  614. j_prev = (i - 1) % (len(points1.value) - 2) + 1
  615. pt0_prev = points1.value[j_prev][1][0]
  616. pt1_prev = points2.value[j_prev][1][0]
  617. j_next = (i + 1) % (len(points1.value) - 2) + 1
  618. pt0_next = points1.value[j_next][1][0]
  619. pt1_next = points2.value[j_next][1][0]
  620. pt0 = complex(*pt0)
  621. pt1 = complex(*pt1)
  622. pt0_prev = complex(*pt0_prev)
  623. pt1_prev = complex(*pt1_prev)
  624. pt0_next = complex(*pt0_next)
  625. pt1_next = complex(*pt1_next)
  626. # Find the ratio of the distance between the points
  627. r0 = abs(pt0 - pt0_prev) / abs(pt0_next - pt0_prev)
  628. r1 = abs(pt1 - pt1_prev) / abs(pt1_next - pt1_prev)
  629. r_mid = (r0 + r1) / 2
  630. pt0 = pt0_prev + r_mid * (pt0_next - pt0_prev)
  631. pt1 = pt1_prev + r_mid * (pt1_next - pt1_prev)
  632. points1.value[j] = (
  633. points1.value[j][0],
  634. (((pt0.real, pt0.imag),) + points1.value[j][1][1:]),
  635. points1.value[j][2],
  636. )
  637. points2.value[j] = (
  638. points2.value[j][0],
  639. (((pt1.real, pt1.imag),) + points2.value[j][1][1:]),
  640. points2.value[j][2],
  641. )
  642. # Convert the point pens back to segment pens
  643. segment1 = RecordingPen()
  644. converter = PointToSegmentPen(segment1, True)
  645. points1.replay(converter)
  646. segment2 = RecordingPen()
  647. converter = PointToSegmentPen(segment2, True)
  648. points2.replay(converter)
  649. # Replace the wrong contours
  650. wrongContour1.value = segment1.value
  651. wrongContour2.value = segment2.value
  652. # Assemble
  653. fixed1 = RecordingPen()
  654. fixed2 = RecordingPen()
  655. for contour in perContourPen1.value:
  656. fixed1.value.extend(contour.value)
  657. for contour in perContourPen2.value:
  658. fixed2.value.extend(contour.value)
  659. fixed1.draw = fixed1.replay
  660. fixed2.draw = fixed2.replay
  661. overriding1[glyphname] = fixed1
  662. overriding2[glyphname] = fixed2
  663. try:
  664. midway_glyphset = LerpGlyphSet(overriding1, overriding2)
  665. self.draw_glyph(
  666. midway_glyphset,
  667. glyphname,
  668. {"type": "fixed"},
  669. None,
  670. x=x,
  671. y=y,
  672. scale=min(scales),
  673. )
  674. except ValueError:
  675. self.draw_emoticon(self.shrug, x=x, y=y)
  676. y += self.panel_height + self.pad
  677. else:
  678. emoticon = self.shrug
  679. if InterpolatableProblem.UNDERWEIGHT in problem_types:
  680. emoticon = self.underweight
  681. elif InterpolatableProblem.OVERWEIGHT in problem_types:
  682. emoticon = self.overweight
  683. elif InterpolatableProblem.NOTHING in problem_types:
  684. emoticon = self.yay
  685. self.draw_emoticon(emoticon, x=x, y=y)
  686. if show_page_number:
  687. self.draw_label(
  688. str(self.page_number),
  689. x=0,
  690. y=self.height - self.font_size - self.pad,
  691. width=self.width,
  692. color=self.head_color,
  693. align=0.5,
  694. )
  695. def draw_label(
  696. self,
  697. label,
  698. *,
  699. x=0,
  700. y=0,
  701. color=(0, 0, 0),
  702. align=0,
  703. bold=False,
  704. width=None,
  705. height=None,
  706. font_size=None,
  707. ):
  708. if width is None:
  709. width = self.width
  710. if height is None:
  711. height = self.height
  712. if font_size is None:
  713. font_size = self.font_size
  714. cr = cairo.Context(self.surface)
  715. cr.select_font_face(
  716. "@cairo:",
  717. cairo.FONT_SLANT_NORMAL,
  718. cairo.FONT_WEIGHT_BOLD if bold else cairo.FONT_WEIGHT_NORMAL,
  719. )
  720. cr.set_font_size(font_size)
  721. font_extents = cr.font_extents()
  722. font_size = font_size * font_size / font_extents[2]
  723. cr.set_font_size(font_size)
  724. font_extents = cr.font_extents()
  725. cr.set_source_rgb(*color)
  726. extents = cr.text_extents(label)
  727. if extents.width > width:
  728. # Shrink
  729. font_size *= width / extents.width
  730. cr.set_font_size(font_size)
  731. font_extents = cr.font_extents()
  732. extents = cr.text_extents(label)
  733. # Center
  734. label_x = x + (width - extents.width) * align
  735. label_y = y + font_extents[0]
  736. cr.move_to(label_x, label_y)
  737. cr.show_text(label)
  738. def draw_glyph(self, glyphset, glyphname, problems, which, *, x=0, y=0, scale=None):
  739. if type(problems) not in (list, tuple):
  740. problems = [problems]
  741. midway = any(problem["type"] == "midway" for problem in problems)
  742. problem_type = problems[0]["type"]
  743. problem_types = set(problem["type"] for problem in problems)
  744. if not all(pt == problem_type for pt in problem_types):
  745. problem_type = "mixed"
  746. glyph = glyphset[glyphname]
  747. recording = RecordingPen()
  748. glyph.draw(recording)
  749. decomposedRecording = DecomposingRecordingPen(glyphset)
  750. glyph.draw(decomposedRecording)
  751. boundsPen = ControlBoundsPen(glyphset)
  752. decomposedRecording.replay(boundsPen)
  753. bounds = boundsPen.bounds
  754. if bounds is None:
  755. bounds = (0, 0, 0, 0)
  756. glyph_width = bounds[2] - bounds[0]
  757. glyph_height = bounds[3] - bounds[1]
  758. if glyph_width:
  759. if scale is None:
  760. scale = self.panel_width / glyph_width
  761. else:
  762. scale = min(scale, self.panel_width / glyph_width)
  763. if glyph_height:
  764. if scale is None:
  765. scale = self.panel_height / glyph_height
  766. else:
  767. scale = min(scale, self.panel_height / glyph_height)
  768. if scale is None:
  769. scale = 1
  770. cr = cairo.Context(self.surface)
  771. cr.translate(x, y)
  772. # Center
  773. cr.translate(
  774. (self.panel_width - glyph_width * scale) / 2,
  775. (self.panel_height - glyph_height * scale) / 2,
  776. )
  777. cr.scale(scale, -scale)
  778. cr.translate(-bounds[0], -bounds[3])
  779. if self.border_color:
  780. cr.set_source_rgb(*self.border_color)
  781. cr.rectangle(bounds[0], bounds[1], glyph_width, glyph_height)
  782. cr.set_line_width(self.border_width / scale)
  783. cr.stroke()
  784. if self.fill_color or self.stroke_color:
  785. pen = CairoPen(glyphset, cr)
  786. decomposedRecording.replay(pen)
  787. if self.fill_color and problem_type != InterpolatableProblem.OPEN_PATH:
  788. cr.set_source_rgb(*self.fill_color)
  789. cr.fill_preserve()
  790. if self.stroke_color:
  791. cr.set_source_rgb(*self.stroke_color)
  792. cr.set_line_width(self.stroke_width / scale)
  793. cr.stroke_preserve()
  794. cr.new_path()
  795. if (
  796. InterpolatableProblem.UNDERWEIGHT in problem_types
  797. or InterpolatableProblem.OVERWEIGHT in problem_types
  798. ):
  799. perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
  800. recording.replay(perContourPen)
  801. for problem in problems:
  802. if problem["type"] in (
  803. InterpolatableProblem.UNDERWEIGHT,
  804. InterpolatableProblem.OVERWEIGHT,
  805. ):
  806. contour = perContourPen.value[problem["contour"]]
  807. contour.replay(CairoPen(glyphset, cr))
  808. cr.set_source_rgba(*self.weight_issue_contour_color)
  809. cr.fill()
  810. if any(
  811. t in problem_types
  812. for t in {
  813. InterpolatableProblem.NOTHING,
  814. InterpolatableProblem.NODE_COUNT,
  815. InterpolatableProblem.NODE_INCOMPATIBILITY,
  816. }
  817. ):
  818. cr.set_line_cap(cairo.LINE_CAP_ROUND)
  819. # Oncurve nodes
  820. for segment, args in decomposedRecording.value:
  821. if not args:
  822. continue
  823. x, y = args[-1]
  824. cr.move_to(x, y)
  825. cr.line_to(x, y)
  826. cr.set_source_rgba(*self.oncurve_node_color)
  827. cr.set_line_width(self.oncurve_node_diameter / scale)
  828. cr.stroke()
  829. # Offcurve nodes
  830. for segment, args in decomposedRecording.value:
  831. if not args:
  832. continue
  833. for x, y in args[:-1]:
  834. cr.move_to(x, y)
  835. cr.line_to(x, y)
  836. cr.set_source_rgba(*self.offcurve_node_color)
  837. cr.set_line_width(self.offcurve_node_diameter / scale)
  838. cr.stroke()
  839. # Handles
  840. for segment, args in decomposedRecording.value:
  841. if not args:
  842. pass
  843. elif segment in ("moveTo", "lineTo"):
  844. cr.move_to(*args[0])
  845. elif segment == "qCurveTo":
  846. for x, y in args:
  847. cr.line_to(x, y)
  848. cr.new_sub_path()
  849. cr.move_to(*args[-1])
  850. elif segment == "curveTo":
  851. cr.line_to(*args[0])
  852. cr.new_sub_path()
  853. cr.move_to(*args[1])
  854. cr.line_to(*args[2])
  855. cr.new_sub_path()
  856. cr.move_to(*args[-1])
  857. else:
  858. continue
  859. cr.set_source_rgba(*self.handle_color)
  860. cr.set_line_width(self.handle_width / scale)
  861. cr.stroke()
  862. matching = None
  863. for problem in problems:
  864. if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
  865. matching = problem["value_2"]
  866. colors = cycle(self.contour_colors)
  867. perContourPen = PerContourOrComponentPen(
  868. RecordingPen, glyphset=glyphset
  869. )
  870. recording.replay(perContourPen)
  871. for i, contour in enumerate(perContourPen.value):
  872. if matching[i] == i:
  873. continue
  874. color = next(colors)
  875. contour.replay(CairoPen(glyphset, cr))
  876. cr.set_source_rgba(*color, self.contour_alpha)
  877. cr.fill()
  878. for problem in problems:
  879. if problem["type"] in (
  880. InterpolatableProblem.NOTHING,
  881. InterpolatableProblem.WRONG_START_POINT,
  882. ):
  883. idx = problem.get("contour")
  884. # Draw suggested point
  885. if idx is not None and which == 1 and "value_2" in problem:
  886. perContourPen = PerContourOrComponentPen(
  887. RecordingPen, glyphset=glyphset
  888. )
  889. decomposedRecording.replay(perContourPen)
  890. points = SimpleRecordingPointPen()
  891. converter = SegmentToPointPen(points, False)
  892. perContourPen.value[
  893. idx if matching is None else matching[idx]
  894. ].replay(converter)
  895. targetPoint = points.value[problem["value_2"]][0]
  896. cr.save()
  897. cr.translate(*targetPoint)
  898. cr.scale(1 / scale, 1 / scale)
  899. self.draw_dot(
  900. cr,
  901. diameter=self.corrected_start_point_size,
  902. color=self.corrected_start_point_color,
  903. )
  904. cr.restore()
  905. # Draw start-point arrow
  906. if which == 0 or not problem.get("reversed"):
  907. color = self.start_point_color
  908. else:
  909. color = self.wrong_start_point_color
  910. first_pt = None
  911. i = 0
  912. cr.save()
  913. for segment, args in decomposedRecording.value:
  914. if segment == "moveTo":
  915. first_pt = args[0]
  916. continue
  917. if first_pt is None:
  918. continue
  919. if segment == "closePath":
  920. second_pt = first_pt
  921. else:
  922. second_pt = args[0]
  923. if idx is None or i == idx:
  924. cr.save()
  925. first_pt = complex(*first_pt)
  926. second_pt = complex(*second_pt)
  927. length = abs(second_pt - first_pt)
  928. cr.translate(first_pt.real, first_pt.imag)
  929. if length:
  930. # Draw arrowhead
  931. cr.rotate(
  932. math.atan2(
  933. second_pt.imag - first_pt.imag,
  934. second_pt.real - first_pt.real,
  935. )
  936. )
  937. cr.scale(1 / scale, 1 / scale)
  938. self.draw_arrow(cr, color=color)
  939. else:
  940. # Draw circle
  941. cr.scale(1 / scale, 1 / scale)
  942. self.draw_dot(
  943. cr,
  944. diameter=self.corrected_start_point_size,
  945. color=color,
  946. )
  947. cr.restore()
  948. if idx is not None:
  949. break
  950. first_pt = None
  951. i += 1
  952. cr.restore()
  953. if problem["type"] == InterpolatableProblem.KINK:
  954. idx = problem.get("contour")
  955. perContourPen = PerContourOrComponentPen(
  956. RecordingPen, glyphset=glyphset
  957. )
  958. decomposedRecording.replay(perContourPen)
  959. points = SimpleRecordingPointPen()
  960. converter = SegmentToPointPen(points, False)
  961. perContourPen.value[idx if matching is None else matching[idx]].replay(
  962. converter
  963. )
  964. targetPoint = points.value[problem["value"]][0]
  965. cr.save()
  966. cr.translate(*targetPoint)
  967. cr.scale(1 / scale, 1 / scale)
  968. if midway:
  969. self.draw_circle(
  970. cr,
  971. diameter=self.kink_circle_size,
  972. stroke_width=self.kink_circle_stroke_width,
  973. color=self.kink_circle_color,
  974. )
  975. else:
  976. self.draw_dot(
  977. cr,
  978. diameter=self.kink_point_size,
  979. color=self.kink_point_color,
  980. )
  981. cr.restore()
  982. return scale
  983. def draw_dot(self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10):
  984. cr.save()
  985. cr.set_line_width(diameter)
  986. cr.set_line_cap(cairo.LINE_CAP_ROUND)
  987. cr.move_to(x, y)
  988. cr.line_to(x, y)
  989. if len(color) == 3:
  990. color = color + (1,)
  991. cr.set_source_rgba(*color)
  992. cr.stroke()
  993. cr.restore()
  994. def draw_circle(
  995. self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10, stroke_width=1
  996. ):
  997. cr.save()
  998. cr.set_line_width(stroke_width)
  999. cr.set_line_cap(cairo.LINE_CAP_SQUARE)
  1000. cr.arc(x, y, diameter / 2, 0, 2 * math.pi)
  1001. if len(color) == 3:
  1002. color = color + (1,)
  1003. cr.set_source_rgba(*color)
  1004. cr.stroke()
  1005. cr.restore()
  1006. def draw_arrow(self, cr, *, x=0, y=0, color=(0, 0, 0)):
  1007. cr.save()
  1008. if len(color) == 3:
  1009. color = color + (1,)
  1010. cr.set_source_rgba(*color)
  1011. cr.translate(self.start_arrow_length + x, y)
  1012. cr.move_to(0, 0)
  1013. cr.line_to(
  1014. -self.start_arrow_length,
  1015. -self.start_arrow_length * 0.4,
  1016. )
  1017. cr.line_to(
  1018. -self.start_arrow_length,
  1019. self.start_arrow_length * 0.4,
  1020. )
  1021. cr.close_path()
  1022. cr.fill()
  1023. cr.restore()
  1024. def draw_text(self, text, *, x=0, y=0, color=(0, 0, 0), width=None, height=None):
  1025. if width is None:
  1026. width = self.width
  1027. if height is None:
  1028. height = self.height
  1029. text = text.splitlines()
  1030. cr = cairo.Context(self.surface)
  1031. cr.set_source_rgb(*color)
  1032. cr.set_font_size(self.font_size)
  1033. cr.select_font_face(
  1034. "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL
  1035. )
  1036. text_width = 0
  1037. text_height = 0
  1038. font_extents = cr.font_extents()
  1039. font_font_size = font_extents[2]
  1040. font_ascent = font_extents[0]
  1041. for line in text:
  1042. extents = cr.text_extents(line)
  1043. text_width = max(text_width, extents.x_advance)
  1044. text_height += font_font_size
  1045. if not text_width:
  1046. return
  1047. cr.translate(x, y)
  1048. scale = min(width / text_width, height / text_height)
  1049. # center
  1050. cr.translate(
  1051. (width - text_width * scale) / 2, (height - text_height * scale) / 2
  1052. )
  1053. cr.scale(scale, scale)
  1054. cr.translate(0, font_ascent)
  1055. for line in text:
  1056. cr.move_to(0, 0)
  1057. cr.show_text(line)
  1058. cr.translate(0, font_font_size)
  1059. def draw_cupcake(self):
  1060. self.draw_label(
  1061. self.no_issues_label,
  1062. x=self.pad,
  1063. y=self.pad,
  1064. color=self.no_issues_label_color,
  1065. width=self.width - 2 * self.pad,
  1066. align=0.5,
  1067. bold=True,
  1068. font_size=self.title_font_size,
  1069. )
  1070. self.draw_text(
  1071. self.cupcake,
  1072. x=self.pad,
  1073. y=self.pad + self.font_size,
  1074. width=self.width - 2 * self.pad,
  1075. height=self.height - 2 * self.pad - self.font_size,
  1076. color=self.cupcake_color,
  1077. )
  1078. def draw_emoticon(self, emoticon, x=0, y=0):
  1079. self.draw_text(
  1080. emoticon,
  1081. x=x,
  1082. y=y,
  1083. color=self.emoticon_color,
  1084. width=self.panel_width,
  1085. height=self.panel_height,
  1086. )
  1087. class InterpolatablePostscriptLike(InterpolatablePlot):
  1088. def __exit__(self, type, value, traceback):
  1089. self.surface.finish()
  1090. def show_page(self):
  1091. super().show_page()
  1092. self.surface.show_page()
  1093. class InterpolatablePS(InterpolatablePostscriptLike):
  1094. def __enter__(self):
  1095. self.surface = cairo.PSSurface(self.out, self.width, self.height)
  1096. return self
  1097. class InterpolatablePDF(InterpolatablePostscriptLike):
  1098. def __enter__(self):
  1099. self.surface = cairo.PDFSurface(self.out, self.width, self.height)
  1100. self.surface.set_metadata(
  1101. cairo.PDF_METADATA_CREATOR, "fonttools varLib.interpolatable"
  1102. )
  1103. self.surface.set_metadata(cairo.PDF_METADATA_CREATE_DATE, "")
  1104. return self
  1105. class InterpolatableSVG(InterpolatablePlot):
  1106. def __enter__(self):
  1107. self.sink = BytesIO()
  1108. self.surface = cairo.SVGSurface(self.sink, self.width, self.height)
  1109. return self
  1110. def __exit__(self, type, value, traceback):
  1111. if self.surface is not None:
  1112. self.show_page()
  1113. def show_page(self):
  1114. super().show_page()
  1115. self.surface.finish()
  1116. self.out.append(self.sink.getvalue())
  1117. self.sink = BytesIO()
  1118. self.surface = cairo.SVGSurface(self.sink, self.width, self.height)