Parse a Font

Parsing TrueType (TTF) Font Files — A Reference

A practical, implementation-agnostic guide to reading the TTF binary format. It is organized around the act of parsing: the spine of the document is 5. How to parse, which walks the file in order and links each step to that table’s byte layout in 6. Table reference. Read the parse flow top to bottom; click into a table’s layout only when you need the exact fields.

Contents

  1. Introduction
  2. Mental model
  3. Conventions
  4. File structure
  5. How to parse
  6. Table reference
  7. Sources

↑ Contents

1. Introduction

The goal is to take a .ttf file as a flat array of bytes and turn it into structured data — the font header, the glyph outlines, the metrics, the character map. This document covers how to do that: the file’s shape, the order to read things in, how to move the read cursor through the bytes, and the byte-level layout of every core table. Field layouts are given as plain Type | Name | Notes tables so they map directly to a struct in any language.

It does not contain program code — it is the format reference you build code from.


↑ Contents

2. Mental model

A TTF file is not a document you read front to back. It is a small archive (the format is called “sfnt” — scalable font). Its bytes are organized as:

  1. A 12-byte offset table (the sfnt header): what kind of font this is and how many tables it holds.
  2. A table directory: one fixed-size record per table, each saying “the table with this tag lives at this byte offset and is this long.”
  3. The table data, located only by the offsets in the directory.

The crucial consequence: you never assume where anything is. You read a table’s offset and length from the directory, then jump there.

It helps to know what the tables are for before parsing them. This is how a character becomes a drawn glyph:

character code ──(cmap)──► glyph index ──(loca)──► glyf  (the outline)
                                        └─(hmtx)──► advance width (the spacing)

head and maxp are the configuration that makes loca interpretable. This chain is also why the parse order in section 5 is what it is.


↑ Contents

3. Conventions

Endianness

Everything is big-endian (most significant byte first). The two bytes 0x00 0x09 mean 9, not 2304. There are no little-endian fields anywhere.

Data types

The spec uses named types. These are the ones you need:

TypeBytesMeaning
uint81unsigned byte
int81signed byte
uint162unsigned short
int162signed short
uint243unsigned 24-bit
uint324unsigned long
int324signed long
Fixed416.16 signed fixed-point
FWORD2int16 in font design units
UFWORD2uint16 in font design units
F2Dot1422.14 signed fixed-point (scales/variations)
LONGDATETIME8signed int64, seconds since 1904-01-01 00:00 UTC
Tag4four uint8 ASCII characters, e.g. glyf
Offset162uint16 offset
Offset324uint32 offset
Version16Dot164packed major/minor version

Signedness matters. Most binary helpers decode only unsigned integers. For signed fields (int16, int64, …), decode the unsigned value of the same width and reinterpret the bits as two’s complement. Get this wrong and a small negative (e.g. a descender of -200) becomes a huge positive — and bounding boxes routinely go negative, so it bites.


↑ Contents

4. File structure

The top of every TTF is a fixed header followed by the directory. These two are the only parts you read linearly; everything else is reached by offset.

4.1 Offset table (sfnt header) — 12 bytes, at byte 0

TypeNameNotes
uint32sfntVersion0x00010000 = TrueType outlines; 0x4F54544F (OTTO) = CFF/PostScript; 0x74727565 (true) on Apple. Your “is this a TTF?” check.
uint16numTablesnumber of table records that follow
uint16searchRange(largest power of 2 ≤ numTables) × 16
uint16entrySelectorlog2(largest power of 2 ≤ numTables)
uint16rangeShiftnumTables × 16 − searchRange

searchRange, entrySelector, and rangeShift are a legacy binary-search optimization. You can ignore their meaning, but for a byte-identical round-trip you must read and re-emit them verbatim (or recompute with the formulas above).

4.2 Table directory — numTables records of 16 bytes each, at byte 12

TypeNameNotes
TagtableTag4 ASCII bytes, e.g. head, glyf
uint32checksumtable checksum
Offset32offsetfrom the beginning of the file
uint32lengththe table’s actual length, excluding pad bytes

Record i begins at byte 12 + i × 16.

  • In well-formed fonts the records are sorted ascending by tag, but the table data they point at can appear in any physical order.
  • Each table’s data is padded with zero bytes to a 4-byte boundary. length is the real (unpadded) length; the next table’s offset accounts for padding.

↑ Contents

5. How to parse

This is the core of the document. The diagram above shows the full parse flow and table dependencies. Below is the exact sequence to follow and the strategy for decoding each table.

Font parsing order and table dependencies

5.1 The parse sequence

Where a table sits (physical) is independent of when you decode it (logical). Decode order is forced by data dependencies:

Follow this sequence. Each step links to that table’s byte layout in section 7; the note is only what the step needs and yields, not the full definition.

  1. Offset tablebyte layout →. Read the first 12 bytes; capture numTables.
  2. Table directorybyte layout →. Read numTables × 16 bytes into a tag → (offset, length) map. After this, switch from linear reading to seeking by offset.
  3. headbyte layout →. Yields unitsPerEm, indexToLocFormat, the font bounding box.
  4. maxpbyte layout →. Yields numGlyphs.
  5. hheabyte layout →. Yields numberOfHMetrics.
  6. hmtxbyte layout →. Needs numberOfHMetrics (hhea) and numGlyphs (maxp).
  7. locabyte layout →. Needs indexToLocFormat (head) and numGlyphs (maxp).
  8. glyfbyte layout →. Needs loca to bracket each glyph.
  9. cmapbyte layout →. Independent; the character → glyph map.
  10. namelayout →, postlayout →, OS/2layout →, kernlayout →. Independent / optional; parse as needed.

Validate as you go. sfntVersion is a known value; head.magicNumber == 0x5F0F3CF5; every offset + length stays within the file; the last loca entry equals the glyf table length. These checks turn corrupt input into clear errors instead of out-of-range reads.

5.2 Fixed- vs variable-layout tables — the decode strategy

How you decode a table depends on its shape:

  • Fixed-layout tables have a fixed set of fixed-width fields in a fixed order, no embedded arrays or strings. Size is known in advance; you can decode one in a single positional pass — provided your struct’s field order matches the on-disk order exactly.
  • Variable-layout tables contain a count, version, or offset that determines how much follows — arrays sized by another field, strings, sub-tables. Size is not known until you read; these need hand-written, conditional parsing.
TableClassWhy
Offset tablefixed12 bytes, fixed fields
Table recordfixed16 bytes, fixed fields
headfixed54 bytes, all fixed-width
maxpfixed*fixed per version (0.5 vs 1.0)
hheafixed36 bytes
OS/2fixed*fixed per version (0–5)
hmtxvariablearray sized by hhea.numberOfHMetrics + trailing array
locavariablenumGlyphs + 1 entries; entry width set by head
glyfvariableper-glyph variable length, two glyph formats
cmapvariablesub-tables in several formats, offset-linked
namevariablerecord array + string storage block
postvariable*header fixed; v2.0 adds a glyph-name array
kernvariableoptional; multiple sub-table formats

* = fixed once you know the version; treat the version word as a branch.


↑ Contents

6. Table reference

The byte layouts the parse sequence in 5.1 links into. Skim only what you need.

↑ Contents

6.1 head — font header (54 bytes, fixed)

The global font header. It contains the design grid size (unitsPerEm), the bounding box that encloses every glyph in the font, creation and modification timestamps, and style flags. The most critical field for parsing is indexToLocFormat: it controls whether the loca table uses 2-byte or 4-byte offsets, so head must be decoded before loca can be read.

TypeNameNotes
uint16majorVersion1
uint16minorVersion0
FixedfontRevisiondesigner’s version; store raw if not interpreting
uint32checksumAdjustmentwhole-file checksum; see spec
uint32magicNumberalways 0x5F0F3CF5 (validation hook)
uint16flagsbit field
uint16unitsPerEmdesign grid; 16–16384, power of 2 for TrueType
LONGDATETIMEcreatedint64, seconds since 1904
LONGDATETIMEmodifiedint64
int16xMinfont bounding box (signed!)
int16yMin
int16xMax
int16yMax
uint16macStylebit field
uint16lowestRecPPEMsmallest readable size in pixels
int16fontDirectionHint
int16indexToLocFormat0 = short loca, 1 = long loca
int16glyphDataFormat0

↑ Contents

6.2 maxp — maximum profile

Establishes the memory requirements for the font. It records the worst-case counts of points, contours, and interpreter stack depth across all glyphs so the rasterizer can pre-allocate exactly what it needs. The only field you strictly need for parsing is numGlyphs — it sizes the loca and hmtx arrays. Version 0.5 (CFF fonts) carries only that field; version 1.0 (TrueType) carries the full set.

Version 0.5 (0x00005000, CFF fonts — 6 bytes):

TypeNameNotes
uint32version0x00005000
uint16numGlyphsthe only field you need

Version 1.0 adds (0x00010000, TrueType — 32 bytes total):

TypeNameNotes
uint16maxPointsmax points in a non-composite glyph
uint16maxContoursmax contours in a non-composite glyph
uint16maxCompositePointsmax points in a composite glyph
uint16maxCompositeContoursmax contours in a composite glyph
uint16maxZones1 = no twilight zone; 2 = twilight zone used
uint16maxTwilightPointsmax points in the twilight zone
uint16maxStoragemax storage area locations
uint16maxFunctionDefsmax function definitions
uint16maxInstructionDefsmax instruction definitions
uint16maxStackElementsmax stack depth
uint16maxSizeOfInstructionsmax byte count for glyph instructions
uint16maxComponentElementsmax number of components in a composite glyph
uint16maxComponentDepthmax nesting depth of composites

↑ Contents

6.3 hhea — horizontal header (36 bytes, fixed)

Contains the metrics needed to lay out glyphs on a horizontal baseline: the ascender, descender, and line gap that renderers use for line spacing, and the maximum advance width. All values are in font design units (FUnits). The key field for parsing is numberOfHMetrics, which tells the hmtx parser how many full (advance width + LSB) pairs the table contains.

TypeNameNotes
uint16majorVersion1
uint16minorVersion0
FWORDascender(int16)
FWORDdescender(int16, usually negative)
FWORDlineGap(int16)
UFWORDadvanceWidthMax(uint16)
FWORDminLeftSideBearing(int16)
FWORDminRightSideBearing(int16)
FWORDxMaxExtent(int16)
int16caretSlopeRiseslope of the cursor (rise/run); 1 for vertical
int16caretSlopeRun0 for vertical
int16caretOffsetshift for slanted highlight; 0 for non-slanted
[4]int16(reserved)set to 0
int16metricDataFormat0
uint16numberOfHMetricssizes the hmtx table

↑ Contents

6.4 hmtx — horizontal metrics (variable)

Stores the advance width and left side bearing (LSB) for every glyph. The advance width is how far the cursor moves after drawing the glyph; the LSB is the distance from the glyph’s origin to the left edge of its bounding box. The table has no header — it is two back-to-back arrays whose sizes come from hhea.numberOfHMetrics and maxp.numGlyphs.

Two back-to-back arrays:

  1. hMetrics: numberOfHMetrics entries (from hhea), each:

    TypeName
    uint16advanceWidth
    int16lsb (left side bearing)
  2. leftSideBearings: numGlyphs − numberOfHMetrics entries of int16.

The trailing array lets monospaced runs share one advance width: glyphs past numberOfHMetrics reuse the last advance width and store only their side bearing.

↑ Contents

6.5 loca — index to location (variable)

Maps each glyph index to the byte offset of its outline data inside the glyf table. Without loca you cannot find where any glyph starts. It stores numGlyphs + 1 offsets — the extra entry marks the end of the last glyph, so each glyph’s length is simply loca[i+1] − loca[i]. The entry width (2 or 4 bytes) is set by head.indexToLocFormat.

numGlyphs + 1 offsets into glyf. The + 1 exists so each glyph’s length is loca[i+1] − loca[i]; the final entry marks the end of the last glyph.

  • Short (indexToLocFormat == 0): numGlyphs+1 × Offset16 (uint16). The stored value is the real offset ÷ 2 — multiply by 2 when reading. This works because glyph data is 2-byte aligned (every real offset is even) and it doubles a uint16’s reach to ~128 KB.
  • Long (indexToLocFormat == 1): numGlyphs+1 × Offset32 (uint32), the real offset directly.

If loca[i] == loca[i+1], glyph i has no outline (e.g. the space). This is common — handle it.

↑ Contents

6.6 glyf — glyph data (variable, the hard one)

Contains the actual outline data for every glyph — the contours and control points that the rasterizer turns into pixels. It has no table header; it is just glyph blocks packed back to back, located entirely by loca. Each block starts with a common 10-byte header that tells you whether the glyph is simple (contours) or composite (references to other glyphs), followed by format-specific data. This is the most complex table to parse.

No table header. Just glyph blocks back to back, located by loca. Each block starts with a common header:

TypeNameNotes
int16numberOfContours≥ 0 → simple glyph; < 0 (use −1) → composite glyph
int16xMinglyph bounding box
int16yMin
int16xMax
int16yMax

Simple glyph (numberOfContours ≥ 0)

TypeNameNotes
uint16endPtsOfContours[numberOfContours]last value + 1 = total point count
uint16instructionLengthbytes of hinting that follow
uint8instructions[instructionLength]TrueType hinting bytecode (can pass through opaquely)
uint8flags[…]one logical flag per point, compressed (below)
xCoordinates[…]delta-encoded, width per flags
yCoordinates[…]delta-encoded, width per flags

Total point count = endPtsOfContours[last] + 1. You need it to know how many flags and coordinates to read.

Flag bits (one uint8 per point, logically):

BitNameMeaning
0x01ON_CURVE_POINTpoint is on the curve (vs an off-curve Bézier control point)
0x02X_SHORT_VECTORx delta is 1 byte (else 2 bytes or 0)
0x04Y_SHORT_VECTORy delta is 1 byte
0x08REPEAT_FLAGthe next byte is a repeat count; repeat this flag that many additional times
0x10X_IS_SAME_OR_POSITIVE_X_SHORT_VECTORdual meaning (below)
0x20Y_IS_SAME_OR_POSITIVE_Y_SHORT_VECTORdual meaning
0x40OVERLAP_SIMPLEcontours may overlap
0x80reservedset to 0

The flag array is compressed: when REPEAT_FLAG (0x08) is set, the following byte gives how many extra points share that flag. Read flags in a loop until you have accumulated pointCount of them, expanding repeats as you go.

Coordinate decoding (x shown; y identical with 0x04/0x20). Coordinates are stored as deltas from the previous point (the first point’s delta is from 0), and all x deltas come first, then all y deltas:

  • X_SHORT_VECTOR (0x02) set → x delta is 1 byte (uint8); its sign is given by 0x10: set ⇒ positive, clear ⇒ negative.
  • X_SHORT_VECTOR clear:
    • 0x10 set ⇒ delta is 0 (this x equals the previous x).
    • 0x10 clear ⇒ x delta is a 2-byte int16.

Accumulate deltas to get absolute coordinates.

Composite glyph (numberOfContours < 0)

A loop of components, each referencing another glyph:

TypeNameNotes
uint16flagscomponent flags (below)
uint16glyphIndexthe component glyph’s id
arg1, arg2int8/uint8 or int16/uint16 per ARG_1_AND_2_ARE_WORDS; if ARGS_ARE_XY_VALUES, signed placement offsets, else point-matching indices
transform0, 1, 2, or 4 × F2Dot14 depending on the scale flags

Component flag bits:

BitNameMeaning
0x0001ARG_1_AND_2_ARE_WORDSargs are 16-bit (else 8-bit)
0x0002ARGS_ARE_XY_VALUESargs are offsets (else point indices)
0x0004ROUND_XY_TO_GRID
0x0008WE_HAVE_A_SCALEone F2Dot14 uniform scale follows
0x0020MORE_COMPONENTSanother component follows; loop
0x0040WE_HAVE_AN_X_AND_Y_SCALEtwo F2Dot14 follow
0x0080WE_HAVE_A_TWO_BY_TWOfour F2Dot14 (2×2 matrix) follow
0x0100WE_HAVE_INSTRUCTIONSafter the last component: uint16 length + instruction bytes
0x0200USE_MY_METRICS
0x0400OVERLAP_COMPOUND

Loop while MORE_COMPONENTS is set.

↑ Contents

6.7 cmap — character-to-glyph mapping (variable)

Maps Unicode character codes (or other encodings) to glyph indices. It is the bridge between text and outlines — without it you cannot know which glyph to draw for a given character. The table contains multiple subtables for different platform/encoding combinations; you pick the best one for your use case (typically the Windows Unicode BMP subtable, format 4) and use only that.

Header:

TypeName
uint16version (0)
uint16numTables

Then numTables encoding records:

TypeNameNotes
uint16platformID0=Unicode, 1=Mac, 3=Windows
uint16encodingIDplatform-specific
Offset32subtableOffsetfrom the start of the cmap table

Common pairings: (3,1) Windows BMP Unicode → usually format 4; (3,10) Windows full Unicode → format 12; (0,*) Unicode; (1,0) Mac Roman → format 0. Pick the best subtable, then parse by its format word:

  • Format 0 — byte encoding: format, length, language, then uint8 glyphIdArray[256].
  • Format 4 — segment mapping (BMP, the workhorse): format, length, language, segCountX2, searchRange, entrySelector, rangeShift, then parallel arrays endCode[segCount], a reserved pad, startCode[segCount], idDelta[segCount], idRangeOffset[segCount], and a trailing glyphIdArray.
  • Format 6 — trimmed table: a contiguous range of codes.
  • Format 12 — segmented coverage (full Unicode): format(12), reserved, uint32 length, uint32 language, uint32 numGroups, then groups of { uint32 startCharCode; uint32 endCharCode; uint32 startGlyphID }.

↑ Contents

6.8 name — human-readable strings (variable)

Stores all the font’s human-readable text strings: family name, subfamily, full name, version string, copyright notice, trademark, and more. Each string is stored once in a raw byte pool at the end of the table, and a list of records points into that pool with platform, encoding, language, and name ID metadata. For most use cases you want name ID 1 (family), 2 (subfamily), and 4 (full name), platform 3 (Windows), decoded as UTF-16BE.

TypeNameNotes
uint16version0 or 1
uint16countnumber of name records
Offset16storageOffsetstart of string storage, from the table start

Then count name records:

TypeNameNotes
uint16platformID
uint16encodingID
uint16languageID
uint16nameIDwhat the string is (1=family, 2=subfamily, 4=full name, 5=version, …)
uint16lengthstring length in bytes
Offset16stringOffsetfrom storageOffset

(Version 1 adds a langTagCount + language-tag records before the storage.) Then the raw string bytes, encoded per platform — usually UTF-16BE for platform 3.

↑ Contents

6.9 post — PostScript data (variable, version-dependent)

Contains PostScript-specific metadata: the italic angle, whether the font is monospaced (isFixedPitch), and optionally a mapping from glyph index to PostScript glyph name. The 32-byte header is always present; the glyph name array only appears in version 2.0. For most transformation work you only need isFixedPitch and italicAngle.

Fixed 32-byte header:

TypeName
Version16Dot16version
FixeditalicAngle
FWORDunderlinePosition
FWORDunderlineThickness
uint32isFixedPitch
uint32minMemType42
uint32maxMemType42
uint32minMemType1
uint32maxMemType1
  • 1.0 (0x00010000): header only (standard Mac glyph names assumed).
  • 2.0 (0x00020000): header + uint16 numGlyphs + uint16 glyphNameIndex[numGlyphs] + Pascal-string names for indices ≥ 258.
  • 2.5: deprecated. 3.0 (0x00030000): header only, no names.

↑ Contents

6.10 OS/2 — OS/2 and Windows metrics (fixed per version)

The primary source of font classification metadata for Windows and cross-platform renderers. It holds the weight class (thin → black), width class (condensed → expanded), typographic ascender/descender/line gap, Windows-specific ascender/descender, Unicode and code-page coverage bitmaps, the PANOSE classification, and embedding permission flags. This is the table that bold and width transformations modify. The version word determines how many trailing fields exist. Zero-pad to the full struct size when reading older versions so missing fields default to 0.

Version 0 (78 bytes, all versions):

TypeNameNotes
uint16version0–5
int16xAvgCharWidthweighted average advance width of lower-case chars
uint16usWeightClass100–900 (matches CSS font-weight)
uint16usWidthClass1–9, condensed → expanded
uint16fsTypeembedding permission flags
int16ySubscriptXSize
int16ySubscriptYSize
int16ySubscriptXOffset
int16ySubscriptYOffset
int16ySuperscriptXSize
int16ySuperscriptYSize
int16ySuperscriptXOffset
int16ySuperscriptYOffset
int16yStrikeoutSize
int16yStrikeoutPosition
int16sFamilyClassIBM font-family classification
uint8[10]panose10-byte PANOSE classification
uint32ulUnicodeRange1Unicode block coverage bits 0–31
uint32ulUnicodeRange2bits 32–63
uint32ulUnicodeRange3bits 64–95
uint32ulUnicodeRange4bits 96–127
TagachVendID4-char vendor identifier
uint16fsSelectionstyle flags (italic, bold, regular, …)
uint16usFirstCharIndexlowest Unicode codepoint in the font
uint16usLastCharIndexhighest Unicode codepoint in the font
int16sTypoAscendertypographic ascender (FUnits)
int16sTypoDescendertypographic descender (FUnits, usually negative)
int16sTypoLineGaptypographic line gap (FUnits)
uint16usWinAscentWindows ascender metric
uint16usWinDescentWindows descender metric (positive value)

Version 1 adds (86 bytes total):

TypeNameNotes
uint32ulCodePageRange1code-page coverage bits 0–31
uint32ulCodePageRange2bits 32–63

Version 2 / 3 / 4 add (96 bytes total):

TypeNameNotes
int16sxHeightheight of lowercase ‘x’ (FUnits)
int16sCapHeightheight of uppercase ‘H’ (FUnits)
uint16usDefaultCharglyph index for the default character
uint16usBreakCharglyph index for the word-break char
uint16usMaxContextmax length of target glyph context

Version 5 adds (100 bytes total):

TypeNameNotes
uint16usLowerOpticalPointSizelower optical size, ×20
uint16usUpperOpticalPointSizeupper optical size, ×20

↑ Contents

6.11 kern — kerning (optional, variable)

Stores spacing adjustments between specific pairs of glyphs. Where hmtx gives every glyph a single advance width, kern lets you say “when an ‘A’ is followed by a ‘V’, pull them 40 units closer together.” It is optional — many modern fonts omit it and use GPOS instead for more powerful contextual kerning. When present, it is a sequence of subtables each covering a set of pairs.

Optional and historically messy — Apple and Microsoft define incompatible headers. Many fonts omit kern entirely (modern kerning lives in GPOS). The Microsoft format (the one to target for OpenType-era fonts):

Table header:

TypeNameNotes
uint16version0
uint16nTablesnumber of subtables that follow

Per subtable header:

TypeNameNotes
uint16versionsubtable version (0)
uint16lengthtotal subtable length in bytes (including this header)
uint16coveragehigh byte = format (0 or 2); low byte = flags (see below)

Coverage flags (low byte): bit 0 = horizontal, bit 1 = minimum, bit 2 = cross-stream, bit 3 = override.

Format 0 (sorted kern pairs — the common case):

TypeNameNotes
uint16nPairsnumber of kern pairs
uint16searchRange(largest power of 2 ≤ nPairs) × 6
uint16entrySelectorlog2(largest power of 2 ≤ nPairs)
uint16rangeShiftnPairs × 6 − searchRange

Then nPairs entries, each 6 bytes:

TypeNameNotes
uint16leftleft glyph index
uint16rightright glyph index
int16valuekern adjustment in FUnits

Pairs are sorted by (left << 16) | right for binary search. Loop over subtables while there are bytes remaining.


↑ Contents

7. Sources

When the two disagree on edge cases, treat the Microsoft spec as canonical for OpenType-era fonts.

Per-table pages, all under https://learn.microsoft.com/en-us/typography/opentype/spec/:

TablePageTablePage
structureotffcmapcmap
headheadnamename
maxpmaxppostpost
hheahheaOS/2os2
hmtxhmtxkernkern
localocaglyfglyf