Draw Me a Word
The power (and beauty) of software comes from abstractions. Working with low‑level primitives helps us appreciate all the complexities we usually take for granted.
Rendering a sentence sounds trivial, until you zoom in on the layers that make it happen. This post walks those layers one by one, starting with a single shell command and finishing at the Bézier contours hidden in a .ttf. This is a vast topic1 and I won’t pretend to know enough to teach it, but this is my attempt to learn and improve.
Every character you now see on your screen is the product of a pipeline, with roughly these stages:
| Stage | Question Answered | Canonical Linux Library | Key Output |
|---|---|---|---|
| Unicode processing | Which Unicode code points are in this string? | ICU | Scalar values |
| Shaping | Which glyph IDs and contextual forms do I need? | HarfBuzz | Glyph IDs + deltas |
| Layout | Where do those glyphs sit in a line/paragraph? | Pango | Line boxes, caret maps |
| Rasterisation | Convert outlines to pixels at size × DPI. | FreeType | Antialiased bitmap |
| Composition | Blend glyph bitmaps with the scene. | Cairo | RGBA image |
| Presentation | Show the final frame. | Wayland/X11 | On‑screen pixels |
This will be a journey of trying to solve the following problem:
Given a font file and a sentence, render the sentence with the provided font into a PNG file.
One Liner
Pango couples a shaping engine (HarfBuzz) with a layout engine and lets you bolt on any drawing API you like. The fastest way to see it in action is the bundled CLI tool, pango-view:
pango-view \
--font="Arial" \
--text="Sphinx of black quartz, judge my vow" \
--dpi=250 \
--output=out.png
That one‑liner already exercises four layers:
| Layer | Who does the work |
|---|---|
| Unicode → glyphs | HarfBuzz via Pango |
| Line breaking & metrics | Pango |
| Rasterisation | FreeType |
| PNG encoding | Cairo |
But what if we want to write code ourselves? With Python bindings for Pango and Cairo, we can do:
import gi, cairo
gi.require_version('Pango', '1.0')
gi.require_version('PangoCairo', '1.0')
from gi.repository import Pango, PangoCairo
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 4096, 1024)
ctx = cairo.Context(surface)
ctx.set_source_rgb(1, 1, 1); ctx.paint() # white bg
ctx.set_source_rgb(0, 0, 0) # black fg
layout = PangoCairo.create_layout(ctx)
PangoCairo.context_set_resolution(layout.get_context(), 144)
desc = Pango.FontDescription()
desc.set_family("EB Garamond")
desc.set_size(int(60 * Pango.SCALE))
layout.set_font_description(desc)
layout.set_text("Office officials efficiently shuffle fluffy waffles.", -1)
PangoCairo.update_layout(ctx, layout)
ctx.move_to(0, 0)
PangoCairo.show_layout(ctx, layout)
ink_rect, _ = layout.get_pixel_extents()
crop = surface.create_for_rectangle(0, 0, ink_rect.width, ink_rect.height)
cr = cairo.Context(crop)
cr.set_source_surface(surface, -ink_rect.x, -ink_rect.y)
cr.paint()
crop.write_to_png("output.png")
Here, Pango handles the text layout (shaping and positioning glyphs), while Cairo draws the vector shapes and writes the PNG.
Dropping Down to FreeType
Under the hood, Pango on Linux and other Unix‑like systems2 uses FreeType for font discovery, metrics, and rasterization. Let’s bypass Pango and render a single character directly with FreeType:
from freetype import Face, FT_LOAD_RENDER, FT_LOAD_TARGET_NORMAL
from PIL import Image
SIZE_PT = 64
DPI = 96
face = Face("/System/Library/Fonts/Supplemental/Georgia.ttf")
face.set_char_size(SIZE_PT * 64, 0, DPI, DPI)
face.load_char("A", FT_LOAD_RENDER | FT_LOAD_TARGET_NORMAL)
bmp = face.glyph.bitmap
w, h = bmp.width, bmp.rows
buf = bytes(bmp.buffer)
img = Image.frombytes("L", (w, h), buf, "raw", "L", bmp.pitch)
img.save(f"out.png")
FreeType’s job ends at pixels. Give it a character code, size, and resolution; it returns a grayscale bitmap plus metrics. The outline itself lives in the font file, indexed through two tables we’ll explore next:
- cmap – maps Unicode code points → glyph IDs
- glyf – stores the Bézier contours for each glyph ID
Everything else in a
.ttfsupports those two tables or adds typographic niceties (kerning, variations, color).
Anatomy of a TTF File
A TrueType or OpenType font is a binary file composed of multiple tables. A directory at the file’s start lists each table’s tag, offset, length and a checksum. We can list them with FontTools’ ttx:
ttx -l FiraCode-Regular.ttf
Listing table info for "FiraCode-Regular.ttf":
tag checksum length offset
---- ---------- -------- --------
DSIG 0x00000001 8 289616
GDEF 0x16E11C94 500 300
GPOS 0x35FB7947 8102 800
GSUB 0x8267ECF4 25286 8904
OS/2 0x80288F8C 96 34192
cmap 0xDCE45825 17272 34288
cvt 0x4692195E 168 285624
fpgm 0x9E3615D2 3605 285792
gasp 0x00000010 8 285616
glyf 0xA5299FFC 193028 51560
head 0x16316E9B 54 244588
hhea 0x02780564 36 244644
hmtx 0xB1FBEF36 7986 244680
loca 0x0A919278 8124 252668
maxp 0x0FD21410 32 260792
name 0x75269DF9 1252 260824
post 0x41B18B81 23539 262076
prep 0x8ACD9C1E 214 289400
The cmap table maps character codes to glyph names:
ttx -t cmap <font_file>
<map code="0x41" name="A"/><!-- LATIN CAPITAL LETTER A -->
The glyf table holds glyph outlines:
ttx -t glyf <font_file>
Looking at glyph for “A” we see :
<TTGlyph name="A">
<contours>
<contour> <pt x="…" y="…"/> … </contour>
… more contours …
</contours>
<instructions> … hinting bytecode … </instructions>
</TTGlyph>
Manual Glyph Drawing with FontTools
Armed with FontTools, we can parse the glyf table and draw the raw contours:
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw
# ── 1. load the font & grab the glyph ───────────────────────────────────────
FONT_PATH = "Arial.ttf"
tt = TTFont(FONT_PATH)
glyph_name= tt.getBestCmap()[ord("B")]
glyph = tt["glyf"][glyph_name]
# ── 2. pull its bounding‑box & compute scale────────────────────────────────
x_min, y_min, x_max, y_max = glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax
glyph_w = x_max - x_min
glyph_h = y_max - y_min
target_px = 128
scale = target_px / glyph_h
# ── 3. transform each point into pixel coords (and flip Y) ────────────────
raw = glyph.coordinates # list of (x, y) tuples in font‑units
coords = [((x - x_min)*scale, (y_max - y)*scale) for x,y in raw]
# ── 4. split into contours ─────────────────────────────────────────────────
contours = []
start = 0
for end in glyph.endPtsOfContours:
contours.append(coords[start:end+1])
start = end + 1
# ── 5. draw & save ─────────────────────────────────────────────────────────
pad = 50
img_w = int(glyph_w*scale + 2*pad)
img_h = int(glyph_h*scale + 2*pad)
img = Image.new("L", (img_w, img_h), 0)
draw = ImageDraw.Draw(img)
for contour in contours:
pts = [(x+pad, y+pad) for x,y in contour]
draw.polygon(pts, fill=255)
img.save("manual.png")
This crude approach fills polygons but ignores Bezier curves and inner holes, so it only resembles a “B.”
To do it properly, we can record the drawing commands, flatten curves to line segments, and then distinguish outer loops from holes:
from fontTools.ttLib import TTFont
from fontTools.pens.recordingPen import RecordingPen
from fontPens.flattenPen import FlattenPen
from PIL import Image, ImageDraw
# ─────────────────────────────────────────────────────────────────────────────
# 1. Load the font and pick the “B” glyph
# ─────────────────────────────────────────────────────────────────────────────
FONT_PATH = "Arial.ttf" # adjust to your Arial path
tt = TTFont(FONT_PATH)
cmap = tt.getBestCmap()
glyph_name = cmap[ord("B")]
glyphSet = tt.getGlyphSet()
# ─────────────────────────────────────────────────────────────────────────────
# 2. Set up a RecordingPen + fontPens’ FlattenPen
# ─────────────────────────────────────────────────────────────────────────────
recorder = RecordingPen()
flatten_pen = FlattenPen(
otherPen=recorder,
approximateSegmentLength=5.0,
segmentLines=True
)
# ─────────────────────────────────────────────────────────────────────────────
# 3. Draw the glyph into the flatten‑wrapper
# ─────────────────────────────────────────────────────────────────────────────
glyphSet[glyph_name].draw(flatten_pen)
# ─────────────────────────────────────────────────────────────────────────────
# 4. Extract the flattened commands
# ─────────────────────────────────────────────────────────────────────────────
commands = recorder.value
# e.g. [("moveTo", [(x0,y0)]), ("lineTo", [(x1,y1)]), …, ("closePath", [])]
# ─────────────────────────────────────────────────────────────────────────────
# 5. Split commands into separate contours
# ─────────────────────────────────────────────────────────────────────────────
contours = []
current = []
for op, pts in commands:
if op == "moveTo":
if current:
contours.append(current)
current = [pts[0]]
elif op == "lineTo":
current.append(pts[0])
elif op == "closePath":
if current:
contours.append(current)
current = []
# catch any dangling contour
if current:
contours.append(current)
# ─────────────────────────────────────────────────────────────────────────────
# 6. Compute glyph bounding box & scaling
# ─────────────────────────────────────────────────────────────────────────────
g = tt["glyf"][glyph_name]
x_min = g.xMin; y_min = g.yMin
x_max = g.xMax; y_max = g.yMax
glyph_w = x_max - x_min
glyph_h = y_max - y_min
target_h = 128 # desired glyph height in pixels
scale = target_h / glyph_h
pad = 50
img_w = int(glyph_w * scale + pad*2)
img_h = int(glyph_h * scale + pad*2)
def to_pixel(pt):
x, y = pt
px = (x - x_min) * scale + pad
py = (y_max - y) * scale + pad # flip Y
return (px, py)
# ─────────────────────────────────────────────────────────────────────────────
# 7. Signed‐area to distinguish outer contours vs holes
# ─────────────────────────────────────────────────────────────────────────────
def signed_area(pts):
area = 0
for i in range(len(pts)):
x1, y1 = pts[i]
x2, y2 = pts[(i+1) % len(pts)]
area += x1 * y2 - x2 * y1
return area / 2
# ─────────────────────────────────────────────────────────────────────────────
# 8. Render to a PIL image and save
# ─────────────────────────────────────────────────────────────────────────────
img = Image.new("L", (img_w, img_h), 0) # black background
draw = ImageDraw.Draw(img)
for contour in contours:
area = signed_area(contour)
# TrueType: outer loops are clockwise ⇒ signed_area < 0
fill = 255 if area < 0 else 0 # white for outer, black for holes
poly = [to_pixel(pt) for pt in contour]
draw.polygon(poly, fill=fill)
img.save("manual-2.png")
This looks somewhat better and we can say that at this point we understand a bit how this works. It is, of course, more complex, but at this point we should move on.
Ligatures
Most fonts carry far more glyphs than there are Unicode code points. Shaping is the act of turning a stream of code points into the right sequence of glyph IDs, applying script rules and designer‑defined substitutions on the way.
Have you noticed, in our example from the start ("Office officials shuffle waffles"), how ff, ffi, and ffl appear as single connected shapes? Those are ligatures. We can turn them off by adding this in the code:
attrs = Pango.AttrList()
attrs.insert(Pango.attr_font_features_new("liga=0, clig=0, calt=0, dlig=0"))
layout.set_attributes(attrs)
With liga=1 (default), the string ffi in EB Garamond becomes a single glyph ID f_f_i.

Latin scripts typically use only a small set of ligatures beyond what we’ve seen here, but many writing systems, such as Arabic or various Indic scripts, rely on contextual shaping for correct rendering. A popular use of ligatures today is in programming fonts like Fira Code where sequences like >= or != become single symbols:

How the font encodes that decision
Under the hood the lookup lives in the GSUB (Glyph Substitution) table.
ttx -t GSUB -o gsub.ttx EBGaramond-Regular.ttf
In the GSUB XML, we find:
<FeatureRecord index="8">
<FeatureTag value="liga"/>
<Feature>
<!-- LookupCount=1 -->
<LookupListIndex index="0" value="29"/>
</Feature>
</FeatureRecord>
...
<Lookup index="29">
<LookupType value="4"/>
<LookupFlag value="0"/>
<!-- SubTableCount=1 -->
<LigatureSubst index="0">
<LigatureSet glyph="f">
<Ligature components="f,i" glyph="f_f_i"/>
<Ligature components="f,l" glyph="f_f_l"/>
<Ligature components="f" glyph="f_f"/>
<Ligature components="i" glyph="f_i"/>
<Ligature components="l" glyph="f_l"/>
</LigatureSet>
</LigatureSubst>
</Lookup>
In the code above, LookupType="4" means ligature substitution. Similar lookups enable contextual alternates (calt), half‑forms in Indic scripts, and even emoji family skin‑tone combinations.
We can confirm that the glyph f_i exists by inspecting glyf table:
ttx -t glyf -o glyf.ttx EBGaramond-Regular.ttf
<TTGlyph name="f_i" xMin="24" yMin="-3" xMax="499" yMax="706">
<component glyphName="uniFB01" x="0" y="0" flags="0x604"/>
</TTGlyph>
Unicode !== Glyphs! Unicode promises that
0066 0069means “fi”, but it says nothing about how the text should look. Shaping converts code points -> glyph IDs based on font data and language tags; only then can layout measure anything.
Software is Fascinating
We started with pango‑view and ended inside the glyf table, drawing contours by hand. Along the way we met Unicode, HarfBuzz, FreeType, and a stack of decades‑old standards that still miraculously agree on what a letter is.
Software is fascinating: an organic complexity shaped by history, evolving standards, and cross‑cutting optimizations. Our detour through text rendering shows how many layers of abstraction come together to draw simple shapes on your screen.
For a glimpse of complexity I recommend reading Text Rendering Hates You - Faultlore and State of Text Rendering 2024 ↩︎
Only when Pango is built with its FT2 backend, which targets the Fontconfig + FreeType stack used on Linux and other Unix‑like systems (FreeBSD, OpenBSD, Solaris, embedded/handheld Linux, etc.). The macOS build ships with the CoreText (Quartz) backend, and the Windows build uses the Win32/DirectWrite backend—neither of those call FreeType unless you re‑compile Pango yourself with
--with-ft2↩︎