Skip to content

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] and loca[gid+1] are byte offsets that bracket the exact raw outline bytes of one glyph inside the glyf table.
  • 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) and points (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.

Glyph contour points and quadratic Bézier curves On-curve points sit on the outline, off-curve points are Bézier control handles, and two consecutive off-curve points imply an on-curve midpoint. one quadratic segment on-curve on-curve off-curve control two off-curve in a row ⇒ implied on-curve midpoint (M) on-curve point — on the outline off-curve — quadratic control handle implied on-curve midpoint control handle Coordinates are stored as deltas: x += dx ; y += dy glyphBudget caps total points: maxGlyphPoints = 1<<20


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

cd stb-truetype-go && go test -v -run TestCompositeBudgetAborts ./...

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:

cd stb-truetype-go && go test -race ./...

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:

  1. flattenContour converts quadratic Bezier arcs to straight-line polygons (10 steps per arc via flattenQuad) — 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.
  2. buildEdges transforms 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.
  3. fillCoverage scan-fills with the nonzero winding rule, accumulating a coverage count per output pixel (a pixel being one single dot of the final image).
  4. 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] and loca[gid+1] bracket the exact bytes of a glyph in glyf. An empty glyph (like space) has loca[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. withImpliedPoints inserts it before flattening.
  • Composite glyphs must be budget-capped. Recursive expansion without limits is a billion-laughs attack vector. glyphBudget caps both total component invocations (4096) and total points (1M), and is checked at every level of the recursion.