ufo.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. # Copyright 2015 Google Inc. All Rights Reserved.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. """Converts cubic bezier curves to quadratic splines.
  15. Conversion is performed such that the quadratic splines keep the same end-curve
  16. tangents as the original cubics. The approach is iterative, increasing the
  17. number of segments for a spline until the error gets below a bound.
  18. Respective curves from multiple fonts will be converted at once to ensure that
  19. the resulting splines are interpolation-compatible.
  20. """
  21. import logging
  22. from fontTools.pens.basePen import AbstractPen
  23. from fontTools.pens.pointPen import PointToSegmentPen
  24. from fontTools.pens.reverseContourPen import ReverseContourPen
  25. from . import curves_to_quadratic
  26. from .errors import (
  27. UnequalZipLengthsError,
  28. IncompatibleSegmentNumberError,
  29. IncompatibleSegmentTypesError,
  30. IncompatibleGlyphsError,
  31. IncompatibleFontsError,
  32. )
  33. __all__ = ["fonts_to_quadratic", "font_to_quadratic"]
  34. # The default approximation error below is a relative value (1/1000 of the EM square).
  35. # Later on, we convert it to absolute font units by multiplying it by a font's UPEM
  36. # (see fonts_to_quadratic).
  37. DEFAULT_MAX_ERR = 0.001
  38. CURVE_TYPE_LIB_KEY = "com.github.googlei18n.cu2qu.curve_type"
  39. logger = logging.getLogger(__name__)
  40. _zip = zip
  41. def zip(*args):
  42. """Ensure each argument to zip has the same length. Also make sure a list is
  43. returned for python 2/3 compatibility.
  44. """
  45. if len(set(len(a) for a in args)) != 1:
  46. raise UnequalZipLengthsError(*args)
  47. return list(_zip(*args))
  48. class GetSegmentsPen(AbstractPen):
  49. """Pen to collect segments into lists of points for conversion.
  50. Curves always include their initial on-curve point, so some points are
  51. duplicated between segments.
  52. """
  53. def __init__(self):
  54. self._last_pt = None
  55. self.segments = []
  56. def _add_segment(self, tag, *args):
  57. if tag in ["move", "line", "qcurve", "curve"]:
  58. self._last_pt = args[-1]
  59. self.segments.append((tag, args))
  60. def moveTo(self, pt):
  61. self._add_segment("move", pt)
  62. def lineTo(self, pt):
  63. self._add_segment("line", pt)
  64. def qCurveTo(self, *points):
  65. self._add_segment("qcurve", self._last_pt, *points)
  66. def curveTo(self, *points):
  67. self._add_segment("curve", self._last_pt, *points)
  68. def closePath(self):
  69. self._add_segment("close")
  70. def endPath(self):
  71. self._add_segment("end")
  72. def addComponent(self, glyphName, transformation):
  73. pass
  74. def _get_segments(glyph):
  75. """Get a glyph's segments as extracted by GetSegmentsPen."""
  76. pen = GetSegmentsPen()
  77. # glyph.draw(pen)
  78. # We can't simply draw the glyph with the pen, but we must initialize the
  79. # PointToSegmentPen explicitly with outputImpliedClosingLine=True.
  80. # By default PointToSegmentPen does not outputImpliedClosingLine -- unless
  81. # last and first point on closed contour are duplicated. Because we are
  82. # converting multiple glyphs at the same time, we want to make sure
  83. # this function returns the same number of segments, whether or not
  84. # the last and first point overlap.
  85. # https://github.com/googlefonts/fontmake/issues/572
  86. # https://github.com/fonttools/fonttools/pull/1720
  87. pointPen = PointToSegmentPen(pen, outputImpliedClosingLine=True)
  88. glyph.drawPoints(pointPen)
  89. return pen.segments
  90. def _set_segments(glyph, segments, reverse_direction):
  91. """Draw segments as extracted by GetSegmentsPen back to a glyph."""
  92. glyph.clearContours()
  93. pen = glyph.getPen()
  94. if reverse_direction:
  95. pen = ReverseContourPen(pen)
  96. for tag, args in segments:
  97. if tag == "move":
  98. pen.moveTo(*args)
  99. elif tag == "line":
  100. pen.lineTo(*args)
  101. elif tag == "curve":
  102. pen.curveTo(*args[1:])
  103. elif tag == "qcurve":
  104. pen.qCurveTo(*args[1:])
  105. elif tag == "close":
  106. pen.closePath()
  107. elif tag == "end":
  108. pen.endPath()
  109. else:
  110. raise AssertionError('Unhandled segment type "%s"' % tag)
  111. def _segments_to_quadratic(segments, max_err, stats, all_quadratic=True):
  112. """Return quadratic approximations of cubic segments."""
  113. assert all(s[0] == "curve" for s in segments), "Non-cubic given to convert"
  114. new_points = curves_to_quadratic([s[1] for s in segments], max_err, all_quadratic)
  115. n = len(new_points[0])
  116. assert all(len(s) == n for s in new_points[1:]), "Converted incompatibly"
  117. spline_length = str(n - 2)
  118. stats[spline_length] = stats.get(spline_length, 0) + 1
  119. if all_quadratic or n == 3:
  120. return [("qcurve", p) for p in new_points]
  121. else:
  122. return [("curve", p) for p in new_points]
  123. def _glyphs_to_quadratic(glyphs, max_err, reverse_direction, stats, all_quadratic=True):
  124. """Do the actual conversion of a set of compatible glyphs, after arguments
  125. have been set up.
  126. Return True if the glyphs were modified, else return False.
  127. """
  128. try:
  129. segments_by_location = zip(*[_get_segments(g) for g in glyphs])
  130. except UnequalZipLengthsError:
  131. raise IncompatibleSegmentNumberError(glyphs)
  132. if not any(segments_by_location):
  133. return False
  134. # always modify input glyphs if reverse_direction is True
  135. glyphs_modified = reverse_direction
  136. new_segments_by_location = []
  137. incompatible = {}
  138. for i, segments in enumerate(segments_by_location):
  139. tag = segments[0][0]
  140. if not all(s[0] == tag for s in segments[1:]):
  141. incompatible[i] = [s[0] for s in segments]
  142. elif tag == "curve":
  143. new_segments = _segments_to_quadratic(
  144. segments, max_err, stats, all_quadratic
  145. )
  146. if all_quadratic or new_segments != segments:
  147. glyphs_modified = True
  148. segments = new_segments
  149. new_segments_by_location.append(segments)
  150. if glyphs_modified:
  151. new_segments_by_glyph = zip(*new_segments_by_location)
  152. for glyph, new_segments in zip(glyphs, new_segments_by_glyph):
  153. _set_segments(glyph, new_segments, reverse_direction)
  154. if incompatible:
  155. raise IncompatibleSegmentTypesError(glyphs, segments=incompatible)
  156. return glyphs_modified
  157. def glyphs_to_quadratic(
  158. glyphs, max_err=None, reverse_direction=False, stats=None, all_quadratic=True
  159. ):
  160. """Convert the curves of a set of compatible of glyphs to quadratic.
  161. All curves will be converted to quadratic at once, ensuring interpolation
  162. compatibility. If this is not required, calling glyphs_to_quadratic with one
  163. glyph at a time may yield slightly more optimized results.
  164. Return True if glyphs were modified, else return False.
  165. Raises IncompatibleGlyphsError if glyphs have non-interpolatable outlines.
  166. """
  167. if stats is None:
  168. stats = {}
  169. if not max_err:
  170. # assume 1000 is the default UPEM
  171. max_err = DEFAULT_MAX_ERR * 1000
  172. if isinstance(max_err, (list, tuple)):
  173. max_errors = max_err
  174. else:
  175. max_errors = [max_err] * len(glyphs)
  176. assert len(max_errors) == len(glyphs)
  177. return _glyphs_to_quadratic(
  178. glyphs, max_errors, reverse_direction, stats, all_quadratic
  179. )
  180. def fonts_to_quadratic(
  181. fonts,
  182. max_err_em=None,
  183. max_err=None,
  184. reverse_direction=False,
  185. stats=None,
  186. dump_stats=False,
  187. remember_curve_type=True,
  188. all_quadratic=True,
  189. ):
  190. """Convert the curves of a collection of fonts to quadratic.
  191. All curves will be converted to quadratic at once, ensuring interpolation
  192. compatibility. If this is not required, calling fonts_to_quadratic with one
  193. font at a time may yield slightly more optimized results.
  194. Return True if fonts were modified, else return False.
  195. By default, cu2qu stores the curve type in the fonts' lib, under a private
  196. key "com.github.googlei18n.cu2qu.curve_type", and will not try to convert
  197. them again if the curve type is already set to "quadratic".
  198. Setting 'remember_curve_type' to False disables this optimization.
  199. Raises IncompatibleFontsError if same-named glyphs from different fonts
  200. have non-interpolatable outlines.
  201. """
  202. if remember_curve_type:
  203. curve_types = {f.lib.get(CURVE_TYPE_LIB_KEY, "cubic") for f in fonts}
  204. if len(curve_types) == 1:
  205. curve_type = next(iter(curve_types))
  206. if curve_type in ("quadratic", "mixed"):
  207. logger.info("Curves already converted to quadratic")
  208. return False
  209. elif curve_type == "cubic":
  210. pass # keep converting
  211. else:
  212. raise NotImplementedError(curve_type)
  213. elif len(curve_types) > 1:
  214. # going to crash later if they do differ
  215. logger.warning("fonts may contain different curve types")
  216. if stats is None:
  217. stats = {}
  218. if max_err_em and max_err:
  219. raise TypeError("Only one of max_err and max_err_em can be specified.")
  220. if not (max_err_em or max_err):
  221. max_err_em = DEFAULT_MAX_ERR
  222. if isinstance(max_err, (list, tuple)):
  223. assert len(max_err) == len(fonts)
  224. max_errors = max_err
  225. elif max_err:
  226. max_errors = [max_err] * len(fonts)
  227. if isinstance(max_err_em, (list, tuple)):
  228. assert len(fonts) == len(max_err_em)
  229. max_errors = [f.info.unitsPerEm * e for f, e in zip(fonts, max_err_em)]
  230. elif max_err_em:
  231. max_errors = [f.info.unitsPerEm * max_err_em for f in fonts]
  232. modified = False
  233. glyph_errors = {}
  234. for name in set().union(*(f.keys() for f in fonts)):
  235. glyphs = []
  236. cur_max_errors = []
  237. for font, error in zip(fonts, max_errors):
  238. if name in font:
  239. glyphs.append(font[name])
  240. cur_max_errors.append(error)
  241. try:
  242. modified |= _glyphs_to_quadratic(
  243. glyphs, cur_max_errors, reverse_direction, stats, all_quadratic
  244. )
  245. except IncompatibleGlyphsError as exc:
  246. logger.error(exc)
  247. glyph_errors[name] = exc
  248. if glyph_errors:
  249. raise IncompatibleFontsError(glyph_errors)
  250. if modified and dump_stats:
  251. spline_lengths = sorted(stats.keys())
  252. logger.info(
  253. "New spline lengths: %s"
  254. % (", ".join("%s: %d" % (l, stats[l]) for l in spline_lengths))
  255. )
  256. if remember_curve_type:
  257. for font in fonts:
  258. curve_type = font.lib.get(CURVE_TYPE_LIB_KEY, "cubic")
  259. new_curve_type = "quadratic" if all_quadratic else "mixed"
  260. if curve_type != new_curve_type:
  261. font.lib[CURVE_TYPE_LIB_KEY] = new_curve_type
  262. modified = True
  263. return modified
  264. def glyph_to_quadratic(glyph, **kwargs):
  265. """Convenience wrapper around glyphs_to_quadratic, for just one glyph.
  266. Return True if the glyph was modified, else return False.
  267. """
  268. return glyphs_to_quadratic([glyph], **kwargs)
  269. def font_to_quadratic(font, **kwargs):
  270. """Convenience wrapper around fonts_to_quadratic, for just one font.
  271. Return True if the font was modified, else return False.
  272. """
  273. return fonts_to_quadratic([font], **kwargs)