Skip to content

Instantly share code, notes, and snippets.

@CatherineH
Last active December 5, 2023 13:41
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save CatherineH/499a312a04582a00e7559ac0c8f133fa to your computer and use it in GitHub Desktop.
Save CatherineH/499a312a04582a00e7559ac0c8f133fa to your computer and use it in GitHub Desktop.
Convert a text character to an SVG path.
from svgpathtools import wsvg, Line, QuadraticBezier, Path
from freetype import Face
def tuple_to_imag(t):
return t[0] + t[1] * 1j
face = Face('./Vera.ttf')
face.set_char_size(48 * 64)
face.load_char('a')
outline = face.glyph.outline
y = [t[1] for t in outline.points]
# flip the points
outline_points = [(p[0], max(y) - p[1]) for p in outline.points]
start, end = 0, 0
paths = []
for i in range(len(outline.contours)):
end = outline.contours[i]
points = outline_points[start:end + 1]
points.append(points[0])
tags = outline.tags[start:end + 1]
tags.append(tags[0])
segments = [[points[0], ], ]
for j in range(1, len(points)):
segments[-1].append(points[j])
if tags[j] and j < (len(points) - 1):
segments.append([points[j], ])
for segment in segments:
if len(segment) == 2:
paths.append(Line(start=tuple_to_imag(segment[0]),
end=tuple_to_imag(segment[1])))
elif len(segment) == 3:
paths.append(QuadraticBezier(start=tuple_to_imag(segment[0]),
control=tuple_to_imag(segment[1]),
end=tuple_to_imag(segment[2])))
elif len(segment) == 4:
C = ((segment[1][0] + segment[2][0]) / 2.0,
(segment[1][1] + segment[2][1]) / 2.0)
paths.append(QuadraticBezier(start=tuple_to_imag(segment[0]),
control=tuple_to_imag(segment[1]),
end=tuple_to_imag(C)))
paths.append(QuadraticBezier(start=tuple_to_imag(C),
control=tuple_to_imag(segment[2]),
end=tuple_to_imag(segment[3])))
else:
print(f"incompatible segment length: {len(segment)}")
start = end + 1
path = Path(*paths)
wsvg(path, filename="text.svg")
@p3t3r67x0
Copy link

Great example! I wonder what to do when you have five segments? Would you mind to give an example how to handle five segments? I am trying to run your script with face.load_char('Ö') which is a German umlaut character and unfortunately return five segments.

@thes0796
Copy link

'./Vera.ttf'
??? location

@CatherineH
Copy link
Author

@thes0796 It's in the debian package ttf-bitstream-vera, which will get put in /usr/share/fonts/truetype/ttf-bitstream-vera/, but you can use any truetype font file.

@runxel
Copy link

runxel commented Nov 16, 2021

@p3t3r67x0 You would need a proper Bezier degree reduction algorithm for these cases, where the Bezier is of higher degrees.
And while any Bezier can always be of a higher degree ("degree elevation"), the inverse can't be said that easily. It would always be an aproximation.

The poor man's solution would be something like the following. However the generated curve is too flat, but at least the tangentiality is preserved.

# ...
elif len(segment) == 5:
    C = ((segment[1][0] + segment[3][0]) / 2.0,
         (segment[1][1] + segment[3][1]) / 2.0)

    paths.append(QuadraticBezier(start=tuple_to_imag(segment[0]),
                                 control=tuple_to_imag(segment[1]),
                                 end=tuple_to_imag(C)))
    paths.append(QuadraticBezier(start=tuple_to_imag(C),
                                 control=tuple_to_imag(segment[3]),
                                 end=tuple_to_imag(segment[4])))

@CatherineH
Copy link
Author

thanks @runxel !

@Fqlox
Copy link

Fqlox commented Dec 20, 2021

Hi, great script, it works pretty well ! I still got issues: It renders bezier curves angularity and transform a lot the font :

text-01

Here is the rendered output :

<?xml version="1.0" ?> <svg baseProfile="full" height="600px" version="1.1" viewBox="-18.345500000000015 -149.8455 1424.691 1790.691" width="478px" xmlns="http://www.w3.org/2000/svg" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xlink="http://www.w3.org/1999/xlink"> <defs/> <path d="M 955.0,1137.0 L 846.0,1260.0 L 725.0,1322.0 L 589.0,1322.0 L 505.0,1322.0 L 436.0,1299.0 L 382.0,1254.0 L 328.0,1209.0 L 301.0,1149.0 L 301.0,1076.0 L 301.0,979.0 L 351.0,904.0 L 451.0,848.0 L 551.0,793.0 L 719.0,752.0 L 955.0,726.0 L 955.0,1137.0 M 967.0,1279.0 L 990.0,1424.0 L 1092.0,1491.0 L 1274.0,1478.0 L 1287.0,1322.0 L 1229.0,1324.0 L 1192.0,1314.0 L 1173.0,1294.0 L 1155.0,1273.0 L 1145.0,1236.0 L 1145.0,1180.0 L 1145.0,504.0 L 1148.0,168.0 L 980.0,0.0 L 642.0,0.0 L 554.0,0.0 L 461.0,17.0 L 365.0,50.0 L 269.0,82.0 L 187.0,122.0 L 119.0,169.0 L 162.0,348.0 L 226.0,301.0 L 300.0,261.0 L 385.0,228.0 L 470.0,195.0 L 549.0,179.0 L 623.0,179.0 L 844.0,179.0 L 955.0,290.0 L 955.0,514.0 L 955.0,596.0 L 386.0,629.0 L 101.0,796.0 L 101.0,1097.0 L 101.0,1210.0 L 143.0,1303.0 L 227.0,1377.0 L 311.0,1451.0 L 417.0,1487.0 L 546.0,1487.0 L 708.0,1487.0 L 845.0,1418.0 L 958.0,1279.0 L 967.0,1279.0" fill="none" stroke="#000000" stroke-width="1.491"/> </svg>
Do you have an idea why it gives so strange result ?

Thanks in advance

@CatherineH
Copy link
Author

CatherineH commented Dec 20, 2021 via email

@Fqlox
Copy link

Fqlox commented Dec 20, 2021

I don't remember (I implemented it on a loop that goes through fonts of specific dir and subdir), but for instance I get this result with the At Apoc

apoc

@CatherineH
Copy link
Author

@Fqlox It looks like the font you're using is not free, so I can't reproduce this exactly. I've updated the script to print out when it encounters segments it can't render, can you run it on this font and tell me if it's unable to handle some of the curves due to the bezier curves having too many control points?

@Fqlox
Copy link

Fqlox commented Dec 21, 2021

Thanks for your answer: I got outputs :

incompatible segment length: 6
incompatible segment length: 5
incompatible segment length: 6
incompatible segment length: 6
incompatible segment length: 5
incompatible segment length: 5

I tried to implement runxel's condition, it gives also some incompatible segments.
Here I tried with the syne font
and It got me that:

syne-italic

@CatherineH
Copy link
Author

@runxel do you understand the algorithm in the paper you posted? The coefficients don't make sense to me and the original algorithm they're improving on is published in Chinese

@CatherineH
Copy link
Author

I've been doing this wrong for a long time, the freetype-py library has a binding to FT_Outline_Decompose function that converts fonts to svg-compatible bezier curves, I'm going to try to get that working.

@Fqlox
Copy link

Fqlox commented Dec 22, 2021

Thanks @CatherineH for looking thought finding a solution. I did a workaround using inkscape cli : inkscape text.svg --export-text-to-path --export-plain-svg=svg_path.svg , But I guess that a solution using freetype would have been way faster and better and can be great for someone looking to replicate this .

@CatherineH
Copy link
Author

FT_Outline_Decompose essentially does what this script does; it skips over higher order bezier curves. Inkscape also uses this function in their codebase, but only if the glyph is of type "outline", and all of these fonts report that they are type "bitmap". I'm not sure how Inkscape is able to convert non-glyph fonts. I need to understand the inkscape libnrtype library better.

@runxel
Copy link

runxel commented Dec 27, 2021

@CatherineH The link was just an example. Also I'm quite bad in understanding heavily math-ed papers :D

Does that mean that FT_Outline_Decompose isn't as helpful as you first thought?

@xmarduel
Copy link

xmarduel commented Jan 8, 2022

as test with char 'B' and
face = Face('C:\Windows\Fonts\arial.ttf')
there are some segments of length 5 and 6

this seems to be Ok (as TrueType infact has only quads):

...
elif len(segment) == 5:
C12 = segment[1]
C23 = segment[2]
C34 = segment[3]

            P1 = segment[0]
            P2 = ((segment[1][0] + segment[2][0]) / 2.0,
                 (segment[1][1] + segment[2][1]) / 2.0)
            P3 = ((segment[2][0] + segment[3][0]) / 2.0,
                 (segment[2][1] + segment[3][1]) / 2.0)
            P4 = segment[4]

            paths.append(QuadraticBezier(start=tuple_to_imag(P1),
                                         control=tuple_to_imag(C12),
                                         end=tuple_to_imag(P2)))
            paths.append(QuadraticBezier(start=tuple_to_imag(P2),
                                         control=tuple_to_imag(C23),
                                         end=tuple_to_imag(P3)))
            paths.append(QuadraticBezier(start=tuple_to_imag(P3),
                                         control=tuple_to_imag(C34),
                                         end=tuple_to_imag(P4)))

        elif len(segment) == 6:
            C12 = segment[1]
            C23 = segment[2]
            C34 = segment[3]
            C45 = segment[4]

            P1 = segment[0]
            P2 = ((segment[1][0] + segment[2][0]) / 2.0,
                 (segment[1][1] + segment[2][1]) / 2.0)
            P3 = ((segment[2][0] + segment[3][0]) / 2.0,
                 (segment[2][1] + segment[3][1]) / 2.0)
            P4 = ((segment[3][0] + segment[4][0]) / 2.0,
                 (segment[3][1] + segment[4][1]) / 2.0)
            P5 = segment[5]

            paths.append(QuadraticBezier(start=tuple_to_imag(P1),
                                         control=tuple_to_imag(C12),
                                         end=tuple_to_imag(P2)))
            paths.append(QuadraticBezier(start=tuple_to_imag(P2),
                                         control=tuple_to_imag(C23),
                                         end=tuple_to_imag(P3)))
            paths.append(QuadraticBezier(start=tuple_to_imag(P3),
                                         control=tuple_to_imag(C34),
                                         end=tuple_to_imag(P4)))
            paths.append(QuadraticBezier(start=tuple_to_imag(P4),
                                         control=tuple_to_imag(C45),
                                         end=tuple_to_imag(P5)))

By the way:

def move_to(a, ctx):
ctx.append("M {},{}".format(a.x, a.y))

def line_to(a, ctx):
ctx.append("L {},{}".format(a.x, a.y))

def conic_to(a, b, ctx):
ctx.append("Q {},{} {},{}".format(a.x, a.y, b.x, b.y))

def cubic_to(a, b, c, ctx):
ctx.append("C {},{} {},{} {},{}".format(a.x, a.y, b.x, b.y, c.x, c.y))

ctx = []
outline.decompose(ctx, move_to=move_to, line_to=line_to, conic_to=conic_to, cubic_to=cubic_to)

works, but gives svg "y" in the opposite direction

https://github.com/rougier/freetype-py/releases

https://github.com/rougier/freetype-py/blob/master/examples/glyph-vector-decompose.py

Has someone here understood the face.set_char_size() and how to get a 'reasonable' svg font size ?
how to get the right scaling back to for example to style="font-size=11" , when working with svg ?

Thanks in advance,
XM

@xmarduel
Copy link

xmarduel commented Jan 8, 2022

... and with ctx:

svg = """
<svg xmlns="http://www.w3.org/2000/svg"
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"

<path
  transform="scale(0.00338) scale(10)"
  style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0"
  d="{}"
/>
""".format(" ".join(ctx))

print(svg)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment