Created
November 23, 2025 22:43
-
-
Save okay-type/7762236338faa105d0968da14544a112 to your computer and use it in GitHub Desktop.
A fontTools pen that draws an interpolatable SVG (H and V segments are written as L, always adds the final point)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from typing import Callable | |
| from fontTools.pens.basePen import BasePen | |
| def pointToString(pt, ntos=str): | |
| return " ".join(ntos(i) for i in pt) | |
| class SVGPathPenOverlapped(BasePen): | |
| """Pen to draw SVG path d commands. | |
| Args: | |
| glyphSet: a dictionary of drawable glyph objects keyed by name | |
| used to resolve component references in composite glyphs. | |
| ntos: a callable that takes a number and returns a string, to | |
| customize how numbers are formatted (default: str). | |
| :Example: | |
| .. code-block:: | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((0, 0)) | |
| >>> pen.lineTo((1, 1)) | |
| >>> pen.curveTo((2, 2), (3, 3), (4, 4)) | |
| >>> pen.closePath() | |
| >>> pen.getCommands() | |
| 'M0 0 1 1C2 2 3 3 4 4Z' | |
| Note: | |
| Fonts have a coordinate system where Y grows up, whereas in SVG, | |
| Y grows down. As such, rendering path data from this pen in | |
| SVG typically results in upside-down glyphs. You can fix this | |
| by wrapping the data from this pen in an SVG group element with | |
| transform, or wrap this pen in a transform pen. For example: | |
| .. code-block:: python | |
| spen = SVGPathPenOverlapped.SVGPathPenOverlapped(glyphset) | |
| pen= TransformPen(spen , (1, 0, 0, -1, 0, 0)) | |
| glyphset[glyphname].draw(pen) | |
| print(tpen.getCommands()) | |
| """ | |
| def __init__(self, glyphSet, ntos: Callable[[float], str] = str): | |
| BasePen.__init__(self, glyphSet) | |
| self._commands = [] | |
| self._lastCommand = None | |
| self._lastX = None | |
| self._lastY = None | |
| self._ntos = ntos | |
| self._firstX = None | |
| self._firstY = None | |
| def _handleAnchor(self): | |
| """ | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((0, 0)) | |
| >>> pen.moveTo((10, 10)) | |
| >>> pen._commands | |
| ['M10 10'] | |
| """ | |
| if self._lastCommand == " M ": | |
| self._commands.pop(-1) | |
| def _moveTo(self, pt): | |
| """ | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((0, 0)) | |
| >>> pen._commands | |
| ['M0 0'] | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((10, 0)) | |
| >>> pen._commands | |
| ['M10 0'] | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((0, 10)) | |
| >>> pen._commands | |
| ['M0 10'] | |
| """ | |
| self._handleAnchor() | |
| t = " M %s" % (pointToString(pt, self._ntos)) | |
| self._commands.append(t) | |
| self._lastCommand = " M " | |
| self._lastX, self._lastY = pt | |
| self._firstX, self._firstY = pt | |
| def _lineTo(self, pt): | |
| """ | |
| # duplicate point | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((10, 10)) | |
| >>> pen.lineTo((10, 10)) | |
| >>> pen._commands | |
| ['M10 10'] | |
| # vertical line | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((10, 10)) | |
| >>> pen.lineTo((10, 0)) | |
| >>> pen._commands | |
| ['M10 10', 'V0'] | |
| # horizontal line | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((10, 10)) | |
| >>> pen.lineTo((0, 10)) | |
| >>> pen._commands | |
| ['M10 10', 'H0'] | |
| # basic | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.lineTo((70, 80)) | |
| >>> pen._commands | |
| ['L70 80'] | |
| # basic following a moveto | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.moveTo((0, 0)) | |
| >>> pen.lineTo((10, 10)) | |
| >>> pen._commands | |
| ['M0 0', ' 10 10'] | |
| """ | |
| x, y = pt | |
| # duplicate point | |
| if x == self._lastX and y == self._lastY: | |
| cmd = " L " | |
| pts = "" + pointToString(pt, self._ntos) | |
| # vertical line | |
| elif x == self._lastX: | |
| cmd = " L " | |
| pts = "" + pointToString(pt, self._ntos) | |
| # horizontal line | |
| elif y == self._lastY: | |
| cmd = " L " | |
| pts = "" + pointToString(pt, self._ntos) | |
| # previous was a moveto | |
| elif self._lastCommand == " M ": | |
| cmd = None | |
| pts = " " + pointToString(pt, self._ntos) | |
| # basic | |
| else: | |
| cmd = " L " | |
| pts = "" + pointToString(pt, self._ntos) | |
| # write the string | |
| t = "" | |
| if cmd: | |
| t += cmd | |
| self._lastCommand = cmd | |
| t += pts | |
| self._commands.append(t) | |
| # store for future reference | |
| self._lastX, self._lastY = pt | |
| def _curveToOne(self, pt1, pt2, pt3): | |
| """ | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.curveTo((10, 20), (30, 40), (50, 60)) | |
| >>> pen._commands | |
| ['C10 20 30 40 50 60'] | |
| """ | |
| t = " C " | |
| t += pointToString(pt1, self._ntos) + " " | |
| t += pointToString(pt2, self._ntos) + " " | |
| t += pointToString(pt3, self._ntos) | |
| self._commands.append(t) | |
| self._lastCommand = " C " | |
| self._lastX, self._lastY = pt3 | |
| def _qCurveToOne(self, pt1, pt2): | |
| """ | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.qCurveTo((10, 20), (30, 40)) | |
| >>> pen._commands | |
| ['Q10 20 30 40'] | |
| >>> from fontTools.misc.roundTools import otRound | |
| >>> pen = SVGPathPenOverlapped(None, ntos=lambda v: str(otRound(v))) | |
| >>> pen.qCurveTo((3, 3), (7, 5), (11, 4)) | |
| >>> pen._commands | |
| ['Q3 3 5 4', 'Q7 5 11 4'] | |
| """ | |
| assert pt2 is not None | |
| t = " Q " | |
| t += pointToString(pt1, self._ntos) + " " | |
| t += pointToString(pt2, self._ntos) | |
| self._commands.append(t) | |
| self._lastCommand = " Q " | |
| self._lastX, self._lastY = pt2 | |
| def _closePath(self): | |
| """ | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.closePath() | |
| >>> pen._commands | |
| ['Z'] | |
| """ | |
| if self._lastCommand == " L " and self._lastX != self._firstX or self._lastY != self._firstY: | |
| cmd = " L " | |
| pts = "" + pointToString((self._firstX, self._firstY), self._ntos) | |
| # write the string | |
| t = cmd | |
| self._lastCommand = cmd | |
| t += pts | |
| self._commands.append(t) | |
| # store for future reference | |
| self._lastX, self._lastY = (self._firstX, self._firstY) | |
| self._commands.append(" Z") | |
| self._lastCommand = " Z" | |
| self._lastX = self._lastY = None | |
| def _endPath(self): | |
| """ | |
| >>> pen = SVGPathPenOverlapped(None) | |
| >>> pen.endPath() | |
| >>> pen._commands | |
| [] | |
| """ | |
| self._lastCommand = None | |
| self._lastX = self._lastY = None | |
| def getCommands(self): | |
| return "".join(self._commands) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment