Skip to content

Instantly share code, notes, and snippets.

@CatherineH
Last active December 5, 2023 13:41
Show Gist options
  • 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")
@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