fonts
How Font Files Actually Work: TTF, OTF, WOFF & WOFF2 Explained
A developer-friendly look at what is really inside a font file — sfnt tables, TrueType vs CFF outlines, and why WOFF/WOFF2 are just compressed containers. Plus when converting between formats is lossless and when it is not.
You drop a .ttf into a converter, get a .woff2 back, and it just works. But what actually changed? Did the font get “re-drawn”? Is anything lost? And why can a converter happily turn a TTF into a WOFF2 but refuse to turn it into an OTF?
The answers are simpler than they look once you know what a font file really is: a small database of tables wrapped in a container. Let’s open one up.
A font file is a table directory
Every TTF and OTF is an sfnt — a binary format that starts with a tiny header and a directory of named 4-byte tables. The first four bytes tell you the flavor:
| Magic bytes | Meaning |
|---|---|
0x00010000 or true | TrueType outlines (usually .ttf) |
OTTO | CFF / PostScript outlines (usually .otf) |
wOFF | A WOFF container |
wOF2 | A WOFF2 container |
ttcf | A TrueType Collection (multiple fonts in one file) |
After the header comes the table directory: a list of (tag, checksum, offset, length) records pointing at each table’s bytes. A typical font has 10–20 tables, each with a single job:
cmap— maps Unicode code points to glyph IDs (the “which picture for this character” lookup).glyf+loca— the actual outline data for TrueType fonts, and an index into it.CFF— the outline data for OpenType/PostScript fonts (instead ofglyf/loca).head,hhea,maxp— global metadata: units per em, number of glyphs, ascent/descent.hmtx— per-glyph advance widths (how far the cursor moves after each glyph).name— human-readable strings: family name, style, version, license.OS/2,post— weight/width class, embedding permissions, and more.GSUB,GPOS— OpenType Layout: ligatures, kerning, contextual substitutions.
When a tool shows you a font’s family name and glyph count, it’s just reading the name and maxp tables. Nothing magic — it’s parsing a directory.
TTF vs OTF is about the outlines, not the wrapper
Here’s the part that trips people up. TTF and OTF use the same sfnt container. The difference is how the glyph shapes are stored:
- TrueType (
glyf) describes outlines with quadratic Bézier curves — on-curve and off-curve points. This is what.ttffiles use. - CFF / PostScript (
CFF) describes outlines with cubic Bézier curves, via Type 2 charstrings. This is what.otffiles (magicOTTO) use.
Both render to identical-looking glyphs on screen. But the math describing the curves is fundamentally different — quadratic vs cubic. A point that’s exactly on a cubic curve generally has no exact quadratic equivalent; you have to approximate.
That single fact explains a lot of converter behavior, which we’ll come back to.
From a character to pixels
Rendering text is a pipeline, and the font supplies the data for each stage:
- Character → glyph. Your text is Unicode code points. The
cmaptable maps each to a glyph ID. (One character can map to several glyphs, andGSUBcan swap them — that’s how ligatures and script shaping happen.) - Glyph → outline. The glyph ID indexes into
glyforCFFto get the vector outline in font units. - Outline → grid. At small sizes, outlines don’t land cleanly on pixel boundaries. Hinting nudges them: TrueType ships a stack-based bytecode in the
glyf/fpgm/preptables; CFF uses lighter declarative hints. Most modern renderers lean on grayscale anti-aliasing and do less aggressive hinting than they used to. - Rasterization. The renderer fills the adjusted outline and anti-aliases the edges into the pixels you actually see.
The key takeaway for converting formats: none of these tables care which container they arrive in. A cmap is a cmap whether it’s inside a raw TTF, a WOFF, or a WOFF2.
WOFF and WOFF2 are just compressed sfnt
Now the web formats. People often think of WOFF and WOFF2 as separate “font types.” They’re not — they’re the same sfnt tables, repackaged with compression so they download faster.
- WOFF (1.0) wraps an sfnt and compresses each table individually with zlib (the same DEFLATE you get from gzip). It adds a small header and an optional metadata block. Decompress every table, lay them back out, and you have the original sfnt back — byte for byte.
- WOFF2 is more aggressive. It compresses the whole table stream with Brotli (better ratios than zlib), and crucially it applies a glyph transform: it rewrites the
glyf/locatables into a more compact, more compressible representation. WOFF2 files are typically ~30% smaller than WOFF and roughly half the size of the raw TTF.
Because WOFF/WOFF2 wrap any sfnt, they work the same for TrueType and CFF fonts. The container doesn’t know or care whether the outlines inside are quadratic or cubic.
So when is conversion lossless?
This is where the format map pays off. There are two very different operations hiding under the word “convert”:
1. Container repackaging — lossless.
Going TTF/OTF ⇄ WOFF ⇄ WOFF2 only wraps, unwraps, and (de)compresses. The glyph outlines, cmap, metrics, and layout tables pass through untouched. Compression is fully reversible. Your converted font renders identically because it contains the same outline data.
There’s one footnote: a WOFF2 round-trip isn’t guaranteed to be byte-for-byte identical to the original sfnt, because the glyph transform normalizes padding and table ordering when it reconstructs the glyf table. The result is functionally identical — same glyphs, same metrics, renders the same — it just might differ by a few bytes of housekeeping. WOFF 1.0 round-trips, by contrast, are exactly reversible.
2. Outline conversion (TTF ↔ OTF) — not repackaging.
Turning a real TrueType font into a real OTF (or vice versa) means converting quadratic outlines to cubic (or back), rewriting glyf into CFF (or back), and rebuilding several tables. Because quadratic and cubic curves don’t have exact mutual equivalents, this is an approximation — genuine font-engineering work, not a container swap. Do it carelessly and you get subtle shape drift.
That’s why a well-behaved converter will happily produce WOFF2 from your TTF but won’t pretend to make an OTF out of it. Refusing the cross-flavor case is the honest behavior — it’s the difference between “repackage these exact outlines” and “redraw every glyph and hope it’s close.”
Making a font web-ready
For the web, you almost always want WOFF2, with a WOFF fallback for older browsers, referenced from @font-face:
@font-face {
font-family: 'My Font';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('myfont.woff2') format('woff2'),
url('myfont.woff') format('woff');
}
The browser picks the first format it supports, so list WOFF2 first. font-display: swap shows fallback text immediately and swaps in your font when it loads, avoiding invisible text.
Our free, browser-based Font Converter does exactly the lossless repackaging described above: drop a TTF/OTF/WOFF/WOFF2, get the other formats back, and it generates the @font-face rule for you — inferring the family, weight, and style straight from the font’s name table. Everything runs locally in your browser; the font bytes are never uploaded, which matters because fonts are often licensed binaries. It even renders a live preview from the converted file, so you can see for yourself that the output is a valid, identical-looking font.
TL;DR
A font file is an sfnt: a directory of tables. TTF and OTF share that container and differ only in how outlines are stored — quadratic glyf vs cubic CFF . WOFF and WOFF2 are the same tables with zlib or Brotli compression bolted on. So converting between containers is lossless repackaging, while converting between outline flavors is a real redraw. Once you can tell those two apart, font conversion stops being mysterious — it’s just reading and rewriting a well-documented binary directory.