svgPathPen.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. from typing import Callable
  2. from fontTools.pens.basePen import BasePen
  3. def pointToString(pt, ntos=str):
  4. return " ".join(ntos(i) for i in pt)
  5. class SVGPathPen(BasePen):
  6. """Pen to draw SVG path d commands.
  7. Args:
  8. glyphSet: a dictionary of drawable glyph objects keyed by name
  9. used to resolve component references in composite glyphs.
  10. ntos: a callable that takes a number and returns a string, to
  11. customize how numbers are formatted (default: str).
  12. :Example:
  13. .. code-block::
  14. >>> pen = SVGPathPen(None)
  15. >>> pen.moveTo((0, 0))
  16. >>> pen.lineTo((1, 1))
  17. >>> pen.curveTo((2, 2), (3, 3), (4, 4))
  18. >>> pen.closePath()
  19. >>> pen.getCommands()
  20. 'M0 0 1 1C2 2 3 3 4 4Z'
  21. Note:
  22. Fonts have a coordinate system where Y grows up, whereas in SVG,
  23. Y grows down. As such, rendering path data from this pen in
  24. SVG typically results in upside-down glyphs. You can fix this
  25. by wrapping the data from this pen in an SVG group element with
  26. transform, or wrap this pen in a transform pen. For example:
  27. .. code-block:: python
  28. spen = svgPathPen.SVGPathPen(glyphset)
  29. pen= TransformPen(spen , (1, 0, 0, -1, 0, 0))
  30. glyphset[glyphname].draw(pen)
  31. print(tpen.getCommands())
  32. """
  33. def __init__(self, glyphSet, ntos: Callable[[float], str] = str):
  34. BasePen.__init__(self, glyphSet)
  35. self._commands = []
  36. self._lastCommand = None
  37. self._lastX = None
  38. self._lastY = None
  39. self._ntos = ntos
  40. def _handleAnchor(self):
  41. """
  42. >>> pen = SVGPathPen(None)
  43. >>> pen.moveTo((0, 0))
  44. >>> pen.moveTo((10, 10))
  45. >>> pen._commands
  46. ['M10 10']
  47. """
  48. if self._lastCommand == "M":
  49. self._commands.pop(-1)
  50. def _moveTo(self, pt):
  51. """
  52. >>> pen = SVGPathPen(None)
  53. >>> pen.moveTo((0, 0))
  54. >>> pen._commands
  55. ['M0 0']
  56. >>> pen = SVGPathPen(None)
  57. >>> pen.moveTo((10, 0))
  58. >>> pen._commands
  59. ['M10 0']
  60. >>> pen = SVGPathPen(None)
  61. >>> pen.moveTo((0, 10))
  62. >>> pen._commands
  63. ['M0 10']
  64. """
  65. self._handleAnchor()
  66. t = "M%s" % (pointToString(pt, self._ntos))
  67. self._commands.append(t)
  68. self._lastCommand = "M"
  69. self._lastX, self._lastY = pt
  70. def _lineTo(self, pt):
  71. """
  72. # duplicate point
  73. >>> pen = SVGPathPen(None)
  74. >>> pen.moveTo((10, 10))
  75. >>> pen.lineTo((10, 10))
  76. >>> pen._commands
  77. ['M10 10']
  78. # vertical line
  79. >>> pen = SVGPathPen(None)
  80. >>> pen.moveTo((10, 10))
  81. >>> pen.lineTo((10, 0))
  82. >>> pen._commands
  83. ['M10 10', 'V0']
  84. # horizontal line
  85. >>> pen = SVGPathPen(None)
  86. >>> pen.moveTo((10, 10))
  87. >>> pen.lineTo((0, 10))
  88. >>> pen._commands
  89. ['M10 10', 'H0']
  90. # basic
  91. >>> pen = SVGPathPen(None)
  92. >>> pen.lineTo((70, 80))
  93. >>> pen._commands
  94. ['L70 80']
  95. # basic following a moveto
  96. >>> pen = SVGPathPen(None)
  97. >>> pen.moveTo((0, 0))
  98. >>> pen.lineTo((10, 10))
  99. >>> pen._commands
  100. ['M0 0', ' 10 10']
  101. """
  102. x, y = pt
  103. # duplicate point
  104. if x == self._lastX and y == self._lastY:
  105. return
  106. # vertical line
  107. elif x == self._lastX:
  108. cmd = "V"
  109. pts = self._ntos(y)
  110. # horizontal line
  111. elif y == self._lastY:
  112. cmd = "H"
  113. pts = self._ntos(x)
  114. # previous was a moveto
  115. elif self._lastCommand == "M":
  116. cmd = None
  117. pts = " " + pointToString(pt, self._ntos)
  118. # basic
  119. else:
  120. cmd = "L"
  121. pts = pointToString(pt, self._ntos)
  122. # write the string
  123. t = ""
  124. if cmd:
  125. t += cmd
  126. self._lastCommand = cmd
  127. t += pts
  128. self._commands.append(t)
  129. # store for future reference
  130. self._lastX, self._lastY = pt
  131. def _curveToOne(self, pt1, pt2, pt3):
  132. """
  133. >>> pen = SVGPathPen(None)
  134. >>> pen.curveTo((10, 20), (30, 40), (50, 60))
  135. >>> pen._commands
  136. ['C10 20 30 40 50 60']
  137. """
  138. t = "C"
  139. t += pointToString(pt1, self._ntos) + " "
  140. t += pointToString(pt2, self._ntos) + " "
  141. t += pointToString(pt3, self._ntos)
  142. self._commands.append(t)
  143. self._lastCommand = "C"
  144. self._lastX, self._lastY = pt3
  145. def _qCurveToOne(self, pt1, pt2):
  146. """
  147. >>> pen = SVGPathPen(None)
  148. >>> pen.qCurveTo((10, 20), (30, 40))
  149. >>> pen._commands
  150. ['Q10 20 30 40']
  151. >>> from fontTools.misc.roundTools import otRound
  152. >>> pen = SVGPathPen(None, ntos=lambda v: str(otRound(v)))
  153. >>> pen.qCurveTo((3, 3), (7, 5), (11, 4))
  154. >>> pen._commands
  155. ['Q3 3 5 4', 'Q7 5 11 4']
  156. """
  157. assert pt2 is not None
  158. t = "Q"
  159. t += pointToString(pt1, self._ntos) + " "
  160. t += pointToString(pt2, self._ntos)
  161. self._commands.append(t)
  162. self._lastCommand = "Q"
  163. self._lastX, self._lastY = pt2
  164. def _closePath(self):
  165. """
  166. >>> pen = SVGPathPen(None)
  167. >>> pen.closePath()
  168. >>> pen._commands
  169. ['Z']
  170. """
  171. self._commands.append("Z")
  172. self._lastCommand = "Z"
  173. self._lastX = self._lastY = None
  174. def _endPath(self):
  175. """
  176. >>> pen = SVGPathPen(None)
  177. >>> pen.endPath()
  178. >>> pen._commands
  179. []
  180. """
  181. self._lastCommand = None
  182. self._lastX = self._lastY = None
  183. def getCommands(self):
  184. return "".join(self._commands)
  185. def main(args=None):
  186. """Generate per-character SVG from font and text"""
  187. if args is None:
  188. import sys
  189. args = sys.argv[1:]
  190. from fontTools.ttLib import TTFont
  191. import argparse
  192. parser = argparse.ArgumentParser(
  193. "fonttools pens.svgPathPen", description="Generate SVG from text"
  194. )
  195. parser.add_argument("font", metavar="font.ttf", help="Font file.")
  196. parser.add_argument("text", metavar="text", nargs="?", help="Text string.")
  197. parser.add_argument(
  198. "-y",
  199. metavar="<number>",
  200. help="Face index into a collection to open. Zero based.",
  201. )
  202. parser.add_argument(
  203. "--glyphs",
  204. metavar="whitespace-separated list of glyph names",
  205. type=str,
  206. help="Glyphs to show. Exclusive with text option",
  207. )
  208. parser.add_argument(
  209. "--variations",
  210. metavar="AXIS=LOC",
  211. default="",
  212. help="List of space separated locations. A location consist in "
  213. "the name of a variation axis, followed by '=' and a number. E.g.: "
  214. "wght=700 wdth=80. The default is the location of the base master.",
  215. )
  216. options = parser.parse_args(args)
  217. fontNumber = int(options.y) if options.y is not None else 0
  218. font = TTFont(options.font, fontNumber=fontNumber)
  219. text = options.text
  220. glyphs = options.glyphs
  221. location = {}
  222. for tag_v in options.variations.split():
  223. fields = tag_v.split("=")
  224. tag = fields[0].strip()
  225. v = float(fields[1])
  226. location[tag] = v
  227. hhea = font["hhea"]
  228. ascent, descent = hhea.ascent, hhea.descent
  229. glyphset = font.getGlyphSet(location=location)
  230. cmap = font["cmap"].getBestCmap()
  231. if glyphs is not None and text is not None:
  232. raise ValueError("Options --glyphs and --text are exclusive")
  233. if glyphs is None:
  234. glyphs = " ".join(cmap[ord(u)] for u in text)
  235. glyphs = glyphs.split()
  236. s = ""
  237. width = 0
  238. for g in glyphs:
  239. glyph = glyphset[g]
  240. pen = SVGPathPen(glyphset)
  241. glyph.draw(pen)
  242. commands = pen.getCommands()
  243. s += '<g transform="translate(%d %d) scale(1 -1)"><path d="%s"/></g>\n' % (
  244. width,
  245. ascent,
  246. commands,
  247. )
  248. width += glyph.width
  249. print('<?xml version="1.0" encoding="UTF-8"?>')
  250. print(
  251. '<svg width="%d" height="%d" xmlns="http://www.w3.org/2000/svg">'
  252. % (width, ascent - descent)
  253. )
  254. print(s, end="")
  255. print("</svg>")
  256. if __name__ == "__main__":
  257. import sys
  258. if len(sys.argv) == 1:
  259. import doctest
  260. sys.exit(doctest.testmod().failed)
  261. sys.exit(main())