09 · TrueType II - decoding glyph outlines¶
Objectives: Understand how a TrueType font maps a character to a glyph ID (cmap), locates the raw outline bytes (loca/glyf), and encodes that outline as contours of on- and off-curve points with delta-compressed coordinates. See how composite glyphs reuse simpler ones, and why an unchecked composite tree is a security hazard. Estimated time: 25 minutes.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- cmap = "a phone book that has four different editions." You hand it a character (say
'A') and it returns a small integer called a glyph ID; the rasterizer scores all available editions (formats 0, 4, 6, 12) and uses the best one. - loca / glyf = "a table of contents paired with the actual chapters."
loca[gid]andloca[gid+1]are byte offsets that bracket the exact raw outline bytes of one glyph inside theglyftable. - contour = "a rubber-band loop stretched over a peg-board." Each loop is a list of points; on-curve points sit on the outline, off-curve points are quadratic Bézier control handles, and two consecutive off-curve points imply a hidden on-curve midpoint exactly halfway between them.
- delta-encoded coordinates = "a road-trip odometer that only records how far you drove each leg, not where you are." Every x and y value is the signed difference from the previous point, so the decoder must accumulate a running sum to recover absolute positions.
- composite glyph = "a cut-and-paste collage of simpler shapes." It lists component glyph IDs with per-component offsets and transforms, letting accented letters reuse base glyphs — but composites can reference other composites, making the call tree grow exponentially if unchecked.
- glyphBudget = "a shared fuel tank for the whole recursive trip." A single struct with two counters —
components(max 4096 invocations) andpoints(max ~1 M) — is decremented at every level of the recursion and returns an error the moment either counter goes negative. See Lesson 19 for the full threat model.
Why it matters: without these bounds, a single maliciously crafted .ttf
file can freeze or crash any program that renders text - including your own.
See it — on-curve, off-curve, and the implied midpoint. A contour is a loop of points. On-curve points sit on the outline; off-curve points are quadratic Bézier control handles. Two off-curve points in a row imply a hidden on-curve midpoint between them. Coordinates arrive as deltas, so the decoder keeps a running sum.
Step 1 - the table directory and tableData¶
Before anything else, parseSFNT (from stb-truetype-go/sfnt.go) reads the
file header and builds a map of every table's byte range. (parseSFNT is a
function — a named, reusable chunk of code that performs a task; you call
or invoke it by writing its name, which tells the computer to run that chunk
right now. A map here is a lookup structure, like an index at the back of a
book: you hand it a name and it hands back the matching information — in this
case, a table's location and size in the file.)
// stb-truetype-go/sfnt.go
func (f *Font) parseSFNT() error {
d := f.rawData
switch binary.BigEndian.Uint32(d) {
case 0x00010000, 0x74727565: // TrueType magic
case 0x4F54544F: // "OTTO" = OpenType/CFF - not supported
return errors.New("truetype: OpenType/CFF fonts are not supported (no glyf table)")
default:
return fmt.Errorf("truetype: unrecognized sfnt version 0x%08x", ...)
}
numTables := int(binary.BigEndian.Uint16(d[4:]))
f.tables = make(map[string]tableRec, numTables)
for i := 0; i < numTables; i++ {
off := 12 + i*16
f.tables[string(d[off:off+4])] = tableRec{
offset: binary.BigEndian.Uint32(d[off+8:]),
length: binary.BigEndian.Uint32(d[off+12:]),
}
}
// then parse head, maxp, hhea, loca, cmap in order
}
In plain terms: this code reads the first few bytes of the font file to check it's really a TrueType font, counts how many tables the file says it has, then walks through that list and records where each table starts and how long it is (so later code can jump straight to the right bytes).
Every subsequent lookup goes through tableData, which always bounds-checks the
slice before returning it. (A slice in Go is a view onto a run of bytes sitting
in memory — think of it as a window that shows you items number 5 through 12 of a
long list, without copying them. "Bounds-checking" means confirming the window
you're asking for is actually inside the list before you look through it.)
// stb-truetype-go/sfnt.go
func (f *Font) tableData(tag string) ([]byte, bool) {
rec, ok := f.tables[tag]
if !ok {
return nil, false
}
start, end := int(rec.offset), int(rec.offset)+int(rec.length)
if start < 0 || end < start || end > len(f.rawData) {
return nil, false
}
return f.rawData[start:end], true
}
In plain terms: this code looks up a table by its short name (like "loca"
or "glyf"); if the name isn't found, or if the recorded start/end position
would fall outside the actual file, it hands back "not found" instead of a
slice — so nothing downstream can be pointed at memory that doesn't belong to
this file. The second value it returns (true/false) is a simple yes/no flag
telling the caller — whoever called this function — whether the lookup
succeeded.
Why bounds-check every table?
Font files are untrusted. A corrupt or adversarial file can store a table
offset that points outside the file. Without this guard, the next line that
slices f.rawData panics (in plain terms: the program crashes immediately
rather than continuing, because it tried to read memory it has no right to
read). The ok return lets every caller fail cleanly.
Step 2 - cmap: four formats, one goal¶
parseCmap scores each subtable and keeps the best one. The priority (from
cmapScore in sfnt.go) is:
| Score | Platform / Encoding |
|---|---|
| 5 | Windows, UCS-4 (encoding 10) or Unicode, full BMP (encodings 4/6) |
| 4 | Windows, BMP (encoding 1) |
| 3 | Unicode platform, any |
| 1 | anything else |
Then glyphIndex dispatches to the right decoder (in plain terms: it looks at
which format the font uses and hands the work off to the matching piece of
code below):
// stb-truetype-go/sfnt.go
func (f *Font) glyphIndex(r rune) uint16 {
switch binary.BigEndian.Uint16(f.cmapData) { // format field at byte 0
case 0: return cmapFormat0(f.cmapData, r) // 256-entry byte array
case 4: return cmapFormat4(f.cmapData, r) // BMP, sorted segment ranges
case 6: return cmapFormat6(f.cmapData, r) // dense range of codepoints
case 12: return cmapFormat12(f.cmapData, r) // full Unicode, 32-bit groups
}
return 0
}
In plain terms: this function reads a 2-byte number at the very start of the
cmap data to learn which of the four formats this font uses, then routes the
character (r) to the matching format-specific function below and returns
whatever glyph ID that function comes back with. (A byte is the basic unit
computers store data in — one small chunk of 8 bits; a bit is a single 0-or-1
switch. Reading "2 bytes" just means reading 16 of those switches at once as one
number.)
Format 0 is trivial - just index a 256-byte array with the rune value.
("Index" here means using a number to jump straight to one entry in a list —
the same way you'd use a page number to flip straight to a page, rather than
reading from the start. A rune is Go's name for one readable character, like
'A' or '€'.)
Format 4 is the most common for Latin fonts. It stores sorted segments:
each segment covers a range [start, end] and either adds a constant delta or
indexes into a glyph-ID array. Here is the core loop, condensed from sfnt.go:
// stb-truetype-go/sfnt.go - cmapFormat4 (condensed)
func cmapFormat4(d []byte, r rune) uint16 {
c := uint16(r)
segX2 := int(binary.BigEndian.Uint16(d[6:])) // 2 * segCount
endOff := 14
startOff := endOff + segX2 + 2 // +2 = reservedPad
deltaOff := startOff + segX2
rangeOff := deltaOff + segX2
for i := 0; i < segX2/2; i++ {
if c > binary.BigEndian.Uint16(d[endOff+i*2:]) {
continue
}
start := binary.BigEndian.Uint16(d[startOff+i*2:])
idDelta := binary.BigEndian.Uint16(d[deltaOff+i*2:])
idRange := binary.BigEndian.Uint16(d[rangeOff+i*2:])
if idRange == 0 {
return c + idDelta // simple arithmetic mapping
}
gidx := rangeOff + i*2 + int(idRange) + int(c-start)*2
g := binary.BigEndian.Uint16(d[gidx:])
if g != 0 {
return g + idDelta
}
return 0
}
return 0
}
In plain terms: the font stores its character coverage as a sorted list of
ranges ("segments"). This code walks the list looking for the segment that
covers the character r; once found, it either adds a fixed number to get the
glyph ID directly, or uses the character's position within the segment to look
up the glyph ID in a small side table.
Format 12 handles full Unicode (including emoji, CJK extensions) using
groups of (startChar, endChar, startGlyphID) triples:
// stb-truetype-go/sfnt.go - cmapFormat12 (condensed)
func cmapFormat12(d []byte, r rune) uint16 {
nGroups := binary.BigEndian.Uint32(d[12:])
for i := uint32(0); i < nGroups; i++ {
g := 16 + int(i)*12
startChar := binary.BigEndian.Uint32(d[g:])
endChar := binary.BigEndian.Uint32(d[g+4:])
if uint32(r) >= startChar && uint32(r) <= endChar {
gid := binary.BigEndian.Uint32(d[g+8:]) + (uint32(r) - startChar)
return uint16(gid)
}
}
return 0
}
In plain terms: this scans a list of character ranges; when it finds the
range containing r, it computes the glyph ID as an offset from that range's
starting glyph ID and returns it.
Step 3 - loca and glyf: finding the outline bytes¶
parseLoca (from sfnt.go) fills f.loca[], a slice of byte offsets into the
glyf table. (An offset is just a distance, in bytes, from the start of some
data — "offset 40" means "start counting 40 bytes in.") There are two
sub-formats:
// stb-truetype-go/sfnt.go
func (f *Font) parseLoca() error {
n := int(f.numGlyphs) + 1
f.loca = make([]uint32, n)
if f.indexToLoc == 0 { // short format: uint16 values, each doubled
for i := 0; i < n; i++ {
f.loca[i] = uint32(binary.BigEndian.Uint16(d[i*2:])) * 2
}
return nil
}
// long format: uint32 values, used as-is
for i := 0; i < n; i++ {
f.loca[i] = binary.BigEndian.Uint32(d[i*4:])
}
return nil
}
In plain terms: this fills in a list of "where does each glyph's data start" markers. Depending on which sub-format the font uses, it either reads each marker as a small 2-byte number and doubles it, or reads it directly as a bigger 4-byte number.
The head table's indexToLocFormat field (stored in f.indexToLoc, a field
being one named slot of data inside a larger structured record — like one
labeled box in a filing cabinet drawer) says which sub-format applies. When it
is 0 the values are halved to fit in a uint16 (a number stored in 2 bytes, so it
can only count up to 65,535), so the parser multiplies by 2.
With those offsets in hand, glyphContours (from sfnt.go) slices out the raw
glyph bytes:
// stb-truetype-go/sfnt.go
start, end := f.loca[gid], f.loca[gid+1]
if start >= end {
return nil, nil // empty glyph (e.g. space)
}
g := glyf[start:end]
numContours := i16(g) // first 2 bytes; negative means composite
if numContours < 0 {
return f.compositeContours(g, depth, b)
}
return parseSimpleGlyph(g, int(numContours))
In plain terms: this grabs the exact byte range for one glyph (or returns nothing, for an empty glyph like the space character), reads a small number at the front to find out how many contours it has, and then either hands off to the composite-glyph decoder or the simple-glyph decoder based on whether that number is negative.
A negative numContours is the TrueType spec's signal for a composite glyph.
Step 4 - simple glyph decoding¶
parseSimpleGlyph (from sfnt.go) decodes a glyph with numContours >= 0.
The layout of the glyph bytes is:
[numContours 2B][bBox 8B][endPtsOfContours numContours×2B]
[instructionLength 2B][instructions ...B]
[flags ...B][xCoordinates ...B][yCoordinates ...B]
Hinting instructions are skipped entirely (the parser just advances past them). The interesting parts are flags and coordinates.
Flags¶
Each point gets one flag byte. readGlyphFlags expands run-length-encoded
repeats (a compression trick where, instead of writing the same value many
times in a row, the file writes the value once plus a count of how many more
times to repeat it):
// stb-truetype-go/sfnt.go
fl := g[*pos]; *pos++
flags[i] = fl; i++
if fl&0x08 != 0 { // REPEAT_FLAG set
rep := int(g[*pos]); *pos++
for j := 0; j < rep && i < numPts; j++ {
flags[i] = fl; i++
}
}
In plain terms: it reads one flag byte and stores it for the current point; if that byte's "repeat" bit is set, it then reads one more byte saying how many additional points share that exact same flag, and copies it that many times — so the file doesn't have to spell out an identical flag byte over and over.
The important bit in each flag byte is bit 0: 1 = on-curve point,
0 = off-curve (Bezier control handle).
Delta-encoded coordinates¶
Both axes use the same scheme, handled by readGlyphCoords:
// stb-truetype-go/sfnt.go
v := 0
for i, fl := range flags {
switch {
case fl&shortMask != 0: // 1-byte magnitude; sign from sameMask
d := int(g[*pos]); *pos++
if fl&sameMask == 0 { d = -d }
v += d
case fl&sameMask == 0: // 2-byte signed delta
v += int(i16(g[*pos:])); *pos += 2
// else: coordinate same as previous (delta == 0)
}
coords[i] = v
}
In plain terms: v is a running total (starting at 0) that accumulates
each point's coordinate. For every point, depending on its flag bits, the code
either adds a small one-byte adjustment, adds a larger two-byte adjustment, or
adds nothing at all (meaning this point sits at the same position as the last
one on this axis) — and stores the running total as that point's absolute
coordinate.
Three cases: 1-byte delta (with a sign bit), 2-byte delta, or repeat the previous value (both flags clear means delta is zero - implicit in the spec).
Implied on-curve midpoints¶
TrueType allows two consecutive off-curve points with no on-curve point between
them. The spec says there is an implied on-curve midpoint at the average of
the two. withImpliedPoints inserts it:
// stb-truetype-go/sfnt.go
if !p.onCurve && !nxt.onCurve {
seq = append(seq, glyphPoint{
x: (p.x + nxt.x) / 2,
y: (p.y + nxt.y) / 2,
onCurve: true,
})
}
In plain terms: whenever two off-curve points appear back to back, this code inserts a new point exactly halfway between them and marks it as on-curve, so the shape has the midpoint the format assumes but never actually stores.
Without this step the Bezier flattening would treat two control handles as a segment, producing garbage output.
Step 5 - composite glyphs and glyphBudget¶
A composite glyph is a list of (flags, componentGID, dx, dy, optional-transform)
records. compositeContours (from sfnt.go) loops over them:
(A record here is just one fixed-shape group of related values bundled
together, the same idea as one row in a table.)
// stb-truetype-go/sfnt.go
for pos+4 <= len(g) {
flags := binary.BigEndian.Uint16(g[pos:])
compGID := binary.BigEndian.Uint16(g[pos+2:])
pos += 4
tf, ok := readComponentTransform(g, &pos, flags)
if !ok { break }
all, err = f.appendComponent(all, compGID, depth, tf, b)
if flags&0x0020 == 0 { break } // no MORE_COMPONENTS
}
In plain terms: this loop reads one component record at a time — which
sub-glyph to reuse and how to place it — fetches that sub-glyph's outline (by
calling itself again indirectly through appendComponent), and keeps going
until the flags say there are no more components to add.
appendComponent calls glyphContours recursively (in plain terms: the
function calls itself again, going one level deeper each time, to fetch the
outline of whatever glyph this component points to — the same way a
Russian nesting doll opens into another doll of the same kind), applies the
2x2 matrix plus translation to every point, and appends the transformed
contours:
// stb-truetype-go/sfnt.go
for _, pt := range ct {
np[i] = glyphPoint{
x: tf.a*pt.x + tf.c*pt.y + tf.dx,
y: tf.b*pt.x + tf.d*pt.y + tf.dy,
onCurve: pt.onCurve,
}
}
In plain terms: for every point in the reused sub-glyph, this computes a
new, repositioned and rescaled point using the component's transform (its
scale/rotation matrix tf.a, tf.b, tf.c, tf.d and its offset tf.dx, tf.dy) —
this is how, say, an accented "é" is built by placing an existing "e" outline
and an existing accent outline at the right spots.
The billion-laughs danger¶
A composite can reference other composites. Without a limit, a font with K children per level and 8 levels of nesting triggers K^8 invocations from a single top-level glyph request - exponential work from a tiny file.
glyphBudget stops it with two counters:
(A struct, defined just below, is a way of grouping several related named
values together under one type, similar to a labeled form with a few fields to
fill in.)
// stb-truetype-go/sfnt.go
type glyphBudget struct {
components int // remaining glyph invocations (call-tree nodes)
points int // remaining total contour points
}
const (
maxGlyphComponents = 4096
maxGlyphPoints = 1 << 20 // ~1 million
)
In plain terms: glyphBudget is a small record holding two remaining
"allowances" — how many more component look-ups are allowed, and how many more
outline points are allowed — set to fixed starting caps (4096 and about
1 million).
Every recursive call to glyphContours decrements b.components first:
// stb-truetype-go/sfnt.go
if b.components--; b.components < 0 {
return nil, errors.New("truetype: composite glyph component budget exceeded")
}
In plain terms: each time this function is entered it subtracts one from the remaining component allowance; if that pushes the count below zero, it immediately returns (in plain terms: the function stops running right here and hands control back to whoever called it) with an error instead of continuing to decode.
And after decoding a simple glyph the point count is charged:
// stb-truetype-go/sfnt.go
for _, c := range contours {
b.points -= len(c)
}
if b.points < 0 {
return nil, errors.New("truetype: composite glyph point budget exceeded")
}
In plain terms: after a glyph's points are decoded, their count is subtracted from the remaining point allowance; if the running total drops below zero, decoding stops with an error rather than letting a file balloon memory usage unchecked.
A fresh budget is created per top-level render call in rasterizeGlyph:
// stb-truetype-go/sfnt.go
budget := glyphBudget{components: maxGlyphComponents, points: maxGlyphPoints}
contours, err := f.glyphContours(gid, 0, &budget)
In plain terms: a brand-new budget (full allowance) is created every time someone asks to render a glyph, so one request's recursive digging can never eat into another request's allowance.
Try it
Run the TrueType tests (including the security-oriented budget tests) from
the repo root. (A test is a small piece of code that runs part of the
program and checks the result matches what's expected — go test is the
command that finds and runs all such tests in the project.)
Expected outcome: TestCompositeBudgetAborts passes, confirming that
composite budget violations are caught and returned as errors rather than
hanging or panicking. go test ./... should also pass cleanly with no
panics on the bundled font fixtures.
Also try the race detector
The GlyphCache in stb_truetype.go uses a sync.Mutex-guarded LRU. (A sync.Mutex is a lock: a mechanism
that lets only one part of the program touch a shared piece of data at a
time, so two simultaneously running pieces of code — see "goroutine" in an
earlier lesson — can't corrupt it by writing at once. An LRU is a cache that
throws away its "Least Recently Used" entry when it needs room.) Run:
No data-race reports should appear. (A data race is when two parts of a
program running at the same time read and write the same piece of memory
without a lock protecting it, producing unpredictable results; -race is a
special test mode that watches for this and reports it.)
Step 6 - from contours to pixels (a brief look ahead)¶
Once glyphContours returns the point lists, rasterizeGlyph (in sfnt.go)
takes over:
flattenContourconverts quadratic Bezier arcs to straight-line polygons (10 steps per arc viaflattenQuad) — in plain terms, it approximates each smooth curve with a short chain of ten straight segments, since straight lines are much simpler to fill in with color than true curves.buildEdgestransforms the polygon to supersampled device space (ssaa = 4, so 4x supersampling per axis) — meaning it works at 4 times the final resolution in each direction, so edges look smoother once shrunk back down.fillCoveragescan-fills with the nonzero winding rule, accumulating a coverage count per output pixel (a pixel being one single dot of the final image).- Coverage is scaled to 0-255 and stored in an
image.Gray(a simple black-and-white/grayscale image type, where 0 is black and 255 is white).
The rasterization pipeline is the subject of Lesson 10.
Key takeaways¶
- cmap is not one format; it is four. The rasterizer scores all available subtables and picks the best. Format 4 covers BMP; format 12 covers full Unicode including supplementary planes.
- loca is an indirection table.
loca[gid]andloca[gid+1]bracket the exact bytes of a glyph inglyf. An empty glyph (like space) hasloca[gid] == loca[gid+1]and produces no contours. - Coordinates are delta-encoded with three sub-cases: 1-byte magnitude (with sign bit), 2-byte signed delta, or implicit zero. You must accumulate a running sum to get absolute coordinates.
- Two consecutive off-curve points imply a hidden midpoint. Missing this
insertion produces incorrect Bezier shapes.
withImpliedPointsinserts it before flattening. - Composite glyphs must be budget-capped. Recursive expansion without limits
is a billion-laughs attack vector.
glyphBudgetcaps both total component invocations (4096) and total points (1M), and is checked at every level of the recursion.