DEV Community

Cover image for How text on a circle is laid out, rebuilt in Python with fontTools
Hanako H.
Hanako H.

Posted on

How text on a circle is laid out, rebuilt in Python with fontTools

Text on a circle looks like a drawing trick, but it is ordinary text layout with one change: instead of moving along a straight baseline, each letter moves along an arc and turns to face outward. The Type on a Path Tool in the Vector Editor of the osFoundry desktop app does this, and so does SVG's <textPath>. Below, the same layout is written in Python with fontTools, numpy and Pillow (about 150 lines including the drawing), on the stamp from the app (KUMO COFFEE ROASTERS over the top of a 450.6 px circle, EST. 2026 along the bottom, 40 px bold). Three things are measured: how the spacing compares with rotating letters by equal angles, why centred text lands opposite the point you clicked, and where the bottom line ends up after Flip and Ascender.

The app's text is in Source Sans 3. The script uses Arial Bold from macOS, because it was the bold font at hand, so the letter widths differ from the app's; the geometry does not.

Advance widths, not angles

A font gives every glyph an advance width, the distance the pen moves after drawing it. Straight text is laid out by adding those up. On a circle you do exactly the same, and treat the running total as a distance along the arc.

from fontTools.ttLib import TTFont

font = TTFont('/System/Library/Fonts/Supplemental/Arial Bold.ttf')
cmap, hmtx, glyf = font.getBestCmap(), font['hmtx'], font['glyf']
k = 40.0 / font['head'].unitsPerEm      # 40 px text
R = 225.3                               # the app's circle: 450.6 px wide

def layout(text):
    s, out = 0.0, []
    for ch in text:
        name = cmap[ord(ch)]
        adv = hmtx[name][0] * k
        g = glyf[name]
        box = (g.xMin * k, g.xMax * k) if g.numberOfContours else None   # ink, None for a space
        out.append((ch, s, adv, box))
        s += adv
    return out, s
Enter fullscreen mode Exit fullscreen mode

The glyph whose middle is at distance s along the path is drawn at angle s / R radians, rotated by that same angle. For KUMO COFFEE ROASTERS the total is 528.9 px of path, which is 134.5 degrees of a 225.3 px circle.

The way people usually do it by hand is different: rotate each character by the same step. To compare the two fairly, I gave the equal-angle version the same 134.5 degrees, split into 20 equal slots, one per character (spaces included), and measured the gap between the ink of neighbouring letters along the baseline:

letters by advance width by equal angle
K U 2.9 px 0.5 px
M O 4.6 px -1.2 px
O C (word space) 14.6 px 26.4 px
F F 4.8 px 6.8 px
E R (word space) 16.0 px 30.0 px
R O 2.0 px -1.6 px
O A 1.6 px -2.0 px

Within words, the advance-width layout gives gaps of 1.6 to 6.0 px, which is simply the spacing the type designer built into Arial. Equal angles give -2.0 to 6.8 px: a negative gap means the letters touch or overlap, and it happens next to the wide letters M, O and A. The word spaces almost double, from 14.6 and 16.0 px to 26.4 and 30.0 px, because a space is narrow but gets a full slot. That is the uneven look of hand-rotated lettering, and it comes from the rule itself, not from any one letter.

Why centred text jumps to the other side

On an open path, centred text sits in the middle of the path. A circle is a closed path, so "the middle" depends on where it starts. In the app the path starts where you click, so its middle is half the circumference away:

C = 2 * math.pi * R                    # 1415.6 px
click = 180.0                          # the bottom of the circle, degrees clockwise from the top
mid = (click + math.degrees((C / 2) / R)) % 360   # 0: the top
Enter fullscreen mode Exit fullscreen mode

The text is then placed from mid - width / 2 to mid + width / 2. I checked this in the app by measuring the dark pixels of its screenshot around the circle's centre (the screenshot is 2880 px wide at 66.57 % zoom on a 2x screen, so one document pixel is 1.33 screenshot pixels). The name, typed after clicking the bottom, spans -55.8 to +56.0 degrees from the top, so its middle is 0.1 degrees off straight up. The second line, typed after clicking the top, is centred 0.1 degrees from straight down. That is why the tutorial steps click the bottom for the top line and the top for the bottom line.

Flip, and the gap that Ascender leaves

Going clockwise, the tangent at the bottom of a circle points left, so text that follows the path reads right to left there, upside down. Flip reverses the direction and moves the glyphs to the other side of the path. In code, that is a negative direction and an inward normal:

phi = phi0 + direction * (s + adv / 2) / radius      # glyph centre angle
rot = phi if direction > 0 else phi - math.pi        # flipped glyphs turn 180 degrees
tx, ty = math.cos(rot), math.sin(rot)                # along the text
ox, oy = math.sin(phi), -math.cos(phi)               # outward from the centre (y points down)
# each outline point (gx, gy) in glyph space, centred on the glyph:
px = cx + radius * ox + gx * tx + n * gy * ox        # n = +1 outward, -1 inward
py = cy + radius * oy + gx * ty + n * gy * oy
Enter fullscreen mode Exit fullscreen mode

With the baseline still on the circle, the flipped letters grow toward the centre, inside the circle. Align to Path: Ascender moves the baseline out by the font's ascender, so the top of the ascender touches the circle and the letters sit outside it. In the script, that is radius = R + ascender.

The catch is that the ascender is not the cap height. Arial Bold at 40 px has an ascender of 36.2 px and capitals 28.6 px tall. The top line's capitals occupy radius 225.3 to 253.9; the bottom line's occupy 232.9 to 261.5. They are offset by 7.6 px, the difference between the two metrics.

The app's screenshot shows the same thing with its own font. The top line's ink runs from radius 225.8 to 251.7 px and the bottom line's from 239.2 to 265.1 px, an offset of about 13 px. Both lines fit comfortably between the two rings of the finished stamp, so it reads as one band, but if the rings are tight, the bottom line is the one that touches the outer ring. For an exact match, the bottom baseline belongs at R + cap_height (253.9 px with Arial), not at R + ascender.

The stamp drawn by the script, with the path circle in red and the two rings

The curve stretches one side of every letter

A letter's baseline sits on the circle, but its top is at a different radius, so the same angle covers a different length there. For the top line, the tops of the capitals are on a circle 28.6 px larger, and the spacing between letters at cap height is 1.127 times the spacing at the baseline. For the flipped bottom line, the capitals point toward the centre, and their tops get 0.891 times the baseline spacing. So at the same size and tracking, the bottom line looks tighter than the top line, by an amount you can compute before drawing anything.

print((R + CAP) / R)           # 1.127  top line, tops of the capitals
print((R + ASC - CAP) / (R + ASC))   # 0.891  bottom line after Flip + Ascender
Enter fullscreen mode Exit fullscreen mode

Running it

The full script, which also draws the image above, is circle_text.py. It runs with uv run --with fonttools --with numpy --with pillow python circle_text.py; the drawing part flattens each glyph outline into polygons with a small fontTools pen and fills them with an even-odd rule so that holes in O and A stay open. For a one-off logo, the same layout is a click and a dialog in the Vector Editor in osFoundry, which is free from the download page.

Top comments (0)