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