interpolatablePlot.py 43 KB

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