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:

StageQuestion AnsweredCanonical Linux LibraryKey Output
Unicode processingWhich Unicode code points are in this string?ICUScalar values
ShapingWhich glyph IDs and contextual forms do I need?HarfBuzzGlyph IDs + deltas
LayoutWhere do those glyphs sit in a line/paragraph?PangoLine boxes, caret maps
RasterisationConvert outlines to pixels at size × DPI.FreeTypeAntialiased bitmap
CompositionBlend glyph bitmaps with the scene.CairoRGBA image
PresentationShow the final frame.Wayland/X11On‑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

1.png That one‑liner already exercises four layers:

LayerWho does the work
Unicode → glyphsHarfBuzz via Pango
Line breaking & metricsPango
RasterisationFreeType
PNG encodingCairo

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")

2.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")

3.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 .ttf supports 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 FontToolsttx:

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")

4.pngThis 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")

5.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)

6.png With liga=1 (default), the string ffi in EB Garamond becomes a single glyph ID f_f_i. 2.png

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:

7.png 8.png

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 0069 means “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.


  1. For a glimpse of complexity I recommend reading Text Rendering Hates You - Faultlore and State of Text Rendering 2024 ↩︎

  2. 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 ↩︎