You are viewing this page with JavaSCript disabled; the work on this page was done in order to integrate it into the Processing.js javascript library, so if you want to see the final result, you'll have to enable javascript.

Let's make a small font

Originally posted July 28th, 2011, last updated on June 13th, 2026

A story about creating a 520 byte spec-compliant font, encoded as a 298 character javascript function that generates its Base 64 equivalence string for use on a webpage.

Let's get started

Chances are you already know what a font is. It's something you select in a word processor, or text editor if you're hard core, and write your text in. They're the things that make letters look different in documents, and in today's world, the web. For the longest time the web's been a bit of a limited font game, but in recent years "web fonts" have become more and more popular. The ability to load the font YOU want to use, rather than that "Times" font, or just "serif", has won a lot of people over (fun fact: IE's been able to do this since version 4. I know, who would have guessed, eh?)

What you probably don't know is that fonts are complicated>. However you think a font works: no, it's way more complicated than that. No really, it's so much more complicated than what you're thinking of.

There are a number of common formats, and none of them are what you'd describe as "easy to read". In order to make fonts small, the data inside a font has been encoded in most spectacularly space-saving ways, after which a million and one features were tacked on top because different groups needed additional functionality from fonts. This includes things like right to left writing, subroutines/substitutions so that compositional characters (such as nearly all CJK characters) take up less space, vertical metrics for Asian scripts, substitution pairs for letter combinations, which is the backbone for written Arabian, the list goes on and on. If you are thinking about going into fonts, from a programming perspective, rather than a design perspective, I tip my hat to you; you are in for a rough ride.

However, should you preservere, you might end up where I am today (2026 edit: or, where I was over a decade ago of course =): a head full of knowledge about things most people stay away from, and a sudden realisation that a thing I was trying to do is something that might actually be possible... I need the smallest possible OpenType font avaible so I can embed it into a web page in order to do font load detection... can I just make one?

(Both Fore and aftshadowing: that "just" is doing some seriously heavy lifting)

Some back story

I've written my own OpenType font parsers, with TrueType and Type2 support, and while writing those I've learned many things about font technologies. I used them for playing around with CJK character composition, text composition on web canvas, and a bunch of other things like path extraction and optimization that eventually ended up (in a very round-about way) becoming my Primer on Bézier Curves.

Then, after a while, fonts slowly sank back into the background until one day, while talking about log visualisations, someone mentioned using the Processing programming language. Having never heard of it, I looked it up, discovered it was about the best programming language in the world for quick and easy visualisations, and then dicovered it had a javascript port. I was hooked.

I started helping out with Processing.js (which I ended up playing a significant role in, and ended up retiring in December 2018. RIP Processing.js, you still occupy a huge part of my heart) and after a while I realised I could exploit the web's various technologies to mix visualisation in browsers with my font parsers in the backend for a much more playful font interaction. And, of course (as these things go) I became a dev for Pjs and started looking more and more into its font implementations for every font bug report we received. After we released v1.2.3, font handling desperately needed a full rewrite and I found myself back in a "fixing font things" position. And that brings us to today. (2026 edit: well, over a decade ago now since writing this, but it's still a worthwhile investigative exercise in the current browser landscape)

Today (so, read: "over a decade ago" =), I find myself in the position that we need to wait with starting a Processing "sketch" (the Processing name for a program) on a web page until all fonts that should be preloaded, have finished preloading. This means we can't just wait for the browser to finished downloading them, but we also have to wait for the browser to finish loading them into memory for styling text on a page. If you think that waiting for the download should be enough: fonts are relatively big, complicated things. It can take a few hundred milliseconds between a font being done downloading and being fully loaded in memory if it's a few hundred kb, but bigger, professional fonts in four different styles means it can actually take more than a second between 'download complete' and 'font available for styling'.

I had already written a font detection tester page based on a tiny font, to see whether I could detect client-side font availability by referencing two divs, one with font-family "tiny", and one with font-family "YourFontHere, tiny". A simply timeout loop made sure that as long as the widths were the same, "YourFontHere" wasn't found, so it wasn't installed. I could port that idea for detecting font-load-completion for @font-face fonts, but the reference font I used was big. Over 50kb. And that's way too big to bundle with a javascript library.

What if we just use some font creation software?

I need something really small, with "never used" tiny metrics, I own various font editing applications, so clearly the best place to start is to just see if I can make a small font using FontForge. Create a font with some empty glyphs, compact it (yay for FontForge!), save it as TTF (remember, this is early 2011, WOFF won't even exist until many months from now), and see what we get. As I used the same glyph for 97 characters in the lower ascii region, it's bigger than strictly necessary, and comes out at a filesize of 66kb.

Is that small enough?

Obviously: no. That's bigger than the 50kb font I was using before, so the immediate question is: can I make things smaller? For instance, do we really need all those letters? And obviously, we don't. So let's fire up TTX and kill everything except the "A". That'll do, really. So: strip everything not ".notdef" and "A", then convert the .ttx file back to ttf.

Now we have a 1568 byte file

Is that small enough?

I mean... it's pretty good? And it'll load in Chrome, Firefox, Opera (which when I originally wrote this was still a real, independent browser), Internet Explorer (which at the time still existed!) and Safari if you try to use it in an @font-face rule, but... I know fonts, and even though 1568 bytes sounds tiny for a font, I have read the "45 byte ELF executable" article; I know I can at the very least halve its size with "legal" changes to the file, and probably even more with some creative choices.

Pushing past 1568 bytes

So, back to the TTX xml. There's several CMAP entries, all for the same letter. Do we need those? No, we don't. They're there for compatibility reasons, but we're only targeting a single platform. So: let's rewrite that to only having a single cmap record for platform id 0, meaning we're targetig the generic "Unicode" platform rather than Windows or Mac etc, with platform-specific encoding id 0, meaning we're targeting "unicode 1.0" (which is deprecated, but perfectly legal) and then point to a format 4 subtable, that should work just fine.

And now our font's 1092 bytes.

Next, let's look at some of the mystery tables in our XML? "cvt", "gasp" and "FFTM" are all optional tables according to the spec, and nothing our font will do needs them, so... Let's just remove them!

Now things are starting to get interesting, because the new size is 1004 bytes, meaning we're already sub 1kb. That may sound incredible, but that still seems rather big given what we want this font to do (effectively "actively do nothing"), so let's go prune the glyph definitions and see if we can fix some things there.

  <glyf>
    <TTGlyph name=".notdef" xMin="1" yMin="0" xMax="9" yMax="15">
      <contour>
        <pt x="1" y="0" on="1"/>
        <pt x="1" y="15" on="1"/>
        <pt x="9" y="15" on="1"/>
        <pt x="9" y="0" on="1"/>
      </contour>
      <contour>
        <pt x="2" y="1" on="1"/>
        <pt x="8" y="1" on="1"/>
        <pt x="8" y="14" on="1"/>
        <pt x="2" y="14" on="1"/>
      </contour>
      <instructions><assembly>
        </assembly></instructions>
    </TTGlyph>

    <TTGlyph name="A" xMin="0" yMin="0" xMax="1" yMax="1">
      <contour>
        <pt x="0" y="0" on="1"/>
        <pt x="0" y="1" on="1"/>
        <pt x="1" y="1" on="1"/>
        <pt x="1" y="0" on="1"/>
      </contour>
      <instructions><assembly>
        </assembly></instructions>
    </TTGlyph>
  </glyf>

What? I didn't define a .notdef outline, what's it doing there? And not only does it have some default outline, it's two compounds! Pruning time:

  <glyf>
    <TTGlyph name=".notdef" xMin="0" yMin="0" xMax="0" yMax="0">
      <contour>
        <pt x="0" y="0" on="1"/>
      </contour>
      <instructions><assembly>
        </assembly></instructions>
    </TTGlyph>

    <TTGlyph name="A" xMin="0" yMin="0" xMax="1" yMax="1">
      <contour>
        <pt x="0" y="0" on="1"/>
        <pt x="1" y="1" on="1"/>
      </contour>
      <instructions><assembly>
        </assembly></instructions>
    </TTGlyph>
  </glyf>

Much better: our font is now 980 bytes. But we can do even better: the OpenType spec allows for empty glyphs, and while TTX won't let us write the XML necessary for that, we can optimize the rest of the XML first to see how small we can go with just XML changes, and then start editing the actual byte code.

So what else can we do?

Wait a minute, what's this NAME table?

  <name>
    <namerecord nameID="0" platformID="1" platEncID="0" langID="0x0">
      There is no copyright on this font.
    </namerecord>
    <namerecord nameID="1" platformID="1" platEncID="0" langID="0x0">
      Empty30
    </namerecord>
    <namerecord nameID="2" platformID="1" platEncID="0" langID="0x0">
      Medium
    </namerecord>
    <namerecord nameID="3" platformID="1" platEncID="0" langID="0x0">
      FontForge 2.0 : Empty30 : 5-10-2010
    </namerecord>
    <namerecord nameID="4" platformID="1" platEncID="0" langID="0x0">
      Empty30
    </namerecord>
    <namerecord nameID="5" platformID="1" platEncID="0" langID="0x0">
      Version 001.000 
    </namerecord>
    <namerecord nameID="6" platformID="1" platEncID="0" langID="0x0">
      Empty30
    </namerecord>
    <namerecord nameID="0" platformID="3" platEncID="1" langID="0x409">
      There is no copyright on this font.
    </namerecord>
    <namerecord nameID="1" platformID="3" platEncID="1" langID="0x409">
      Empty30
    </namerecord>
    <namerecord nameID="2" platformID="3" platEncID="1" langID="0x409">
      Medium
    </namerecord>
    <namerecord nameID="3" platformID="3" platEncID="1" langID="0x409">
      FontForge 2.0 : Empty30 : 5-10-2010
    </namerecord>
    <namerecord nameID="4" platformID="3" platEncID="1" langID="0x409">
      Empty30
    </namerecord>
    <namerecord nameID="5" platformID="3" platEncID="1" langID="0x409">
      Version 001.000 
    </namerecord>
    <namerecord nameID="6" platformID="3" platEncID="1" langID="0x409">
      Empty30
    </namerecord>
  </name>

That's a LOT of plain text information! ...which we'll never use because no one's going to install it at the system level, or inspect font properties, etc. Let's just empty all those strings and see what happens:

  <name>
    <namerecord nameID="0" platformID="1" platEncID="0" langID="0x0">
    </namerecord>
    <namerecord nameID="1" platformID="1" platEncID="0" langID="0x0">
    </namerecord>
    <namerecord nameID="2" platformID="1" platEncID="0" langID="0x0">
    </namerecord>
    <namerecord nameID="3" platformID="1" platEncID="0" langID="0x0">
    </namerecord>
    <namerecord nameID="4" platformID="1" platEncID="0" langID="0x0">
    </namerecord>
    <namerecord nameID="5" platformID="1" platEncID="0" langID="0x0">
    </namerecord>
    <namerecord nameID="6" platformID="1" platEncID="0" langID="0x0">
    </namerecord>
    <namerecord nameID="0" platformID="3" platEncID="1" langID="0x409">
    </namerecord>
    <namerecord nameID="1" platformID="3" platEncID="1" langID="0x409">
    </namerecord>
    <namerecord nameID="2" platformID="3" platEncID="1" langID="0x409">
    </namerecord>
    <namerecord nameID="3" platformID="3" platEncID="1" langID="0x409">
    </namerecord>
    <namerecord nameID="4" platformID="3" platEncID="1" langID="0x409">
    </namerecord>
    <namerecord nameID="5" platformID="3" platEncID="1" langID="0x409">
    </namerecord>
    <namerecord nameID="6" platformID="3" platEncID="1" langID="0x409">
    </namerecord>
  </name>

Apparently, that's fine? And we're down to 688 bytes!

Pushing past 688 bytes

While we might have run out of obvious things we can change by messing with the XML representation of our font, reading through the spec shows that almost every> value that's still relevcant to our font can be set to zero. If you ever had to wade through the OpenType spec, this is fantastically fascinating: it's the kind of "it's not wrong, it just makes no sense". Some values have to stay real values for parsers to accent the font as legal, but we're about to get as technically correct as we can by obeying the letter of the spec and ignoring the spirit completely.

Let's update the header table first. Zero all th things (well, nearly all the things):

  <head>
    <tableVersion value="1.0"/>
    <fontRevision value="1.0"/>
    <checkSumAdjustment value="0x908bda5e"/>
    <magicNumber value="0x5f0f3cf5"/>
    <flags value="00000000 00001011"/>
    <unitsPerEm value="32"/>
    <created value="Mon Jan 00 00:00:00 0000"/>
    <modified value="Mon Jan 00 00:00:00 0000"/>
    <xMin value="0"/>
    <yMin value="0"/>
    <xMax value="0"/>
    <yMax value="0"/>
    <macStyle value="00000000 00000000"/>
    <lowestRecPPEM value="0"/>
    <fontDirectionHint value="0"/>
    <indexToLocFormat value="0"/>
    <glyphDataFormat value="0"/>
  </head>

Note that we're basically lying here. the xMax and yMax values are 1, but an intelligent font engine will get those values from the glyph it's actually rendering, so we set them to 0. Zero bytes are nice. We leave the first six values as they are, because they're important, but the rest? Zeroes, all the way down.

Next up, MAXP.

  <maxp>
    <!-- Most of this table will be recalculated by the compiler -->
    <tableVersion value="0x00000"/>
    <numGlyphs value="0"/>
    <maxPoints value="0"/>
    <maxContours value="0"/>
    <maxCompositePoints value="0"/>
    <maxCompositeContours value="0"/>
    <maxZones value="0"/>
    <maxTwilightPoints value="0"/>
    <maxStorage value="0"/>
    <maxFunctionDefs value="0"/>
    <maxInstructionDefs value="0"/>
    <maxStackElements value="0"/>
    <maxSizeOfInstructions value="0"/>
    <maxComponentElements value="0"/>
    <maxComponentDepth value="0"/>
  </maxp>

While TTX says that most of this table will be recalculated, it doesn't hurt to make sure that the values it doesn't recalculate are zero. Because that's still a font that is accepted by all browsers.

Then, the OS/2 table. If you are old enough to remember the OS/2 Warp operating system, this name might be deceiving. Yes, that's what it was originally for, but the data in it is so damn useful that it would have been really silly to get rid of it. Downside: this is a required table and we can't just remove it. Don't worry, I tried. It doesn't work.

But we can> set almost every damn value in it to zero, and it'll still count as a usable font. For a very specific purpose, but legal in all browsers, and that's what matters. In fact, except for the following values, everything else is 0. Including the entire "panose" structure:

  <OS_2>
    <version value="1"/>
    <xAvgCharWidth value="512"/>
    <usWeightClass value="512"/>
    <usWidthClass value="1"/>
    <achVendID value="noop"/>
    <fsSelection value="00000000 01000000"/>
    <fsFirstCharIndex value="35"/>
    <fsLastCharIndex value="35"/>
    <sTypoAscender value="1"/>
    <usWinAscent value="1"/>
  </OS_2>

Wait, did I just set the ulUnicodeRange and ulCodePageRange values to zero? And that's legal? ... wow. But, sure enough none of the browsers complain, and they apply the font just fine.

That basically leaves HMTX and POST. The first is really simple, and has two entries, one for ".notdef" and one for "A". Just to keep with the "lots of zeroes" idea, we set the advance width and left side breaing values to zero, and that's "still data, but it's all zeroes" job's done.

Now for POST...

POST is an annoying table. It provides the a bunch of 32 bit values that help truly ancient printers do their job. Obviously we are not interested in printing text with this font (we're literally trying to achieve text that takes up zero space), but you can't omit it: every font parser in existence will flag the font as corrupt if you do, So: zeroes everywhere.

  <post>
    <formatType value="1.0"/>
    <italicAngle value="0.0"/>
    <underlinePosition value="0"/>
    <underlineThickness value="0"/>
    <isFixedPitch value="0"/>
    <minMemType42 value="0"/>
    <maxMemType42 value="0"/>
    <minMemType1 value="0"/>
    <maxMemType1 value="0"/>
  </post>

Aaaaaand that's going to have to do. We now have a font that is so small, I can just show you the byte layout in a code block:

00 01 00 00 00 0A 00 80 | 00 03 00 20 4F 53 2F 32 | 71 95 70 D4 00 00 01 28 | 00 00 00 56 63 6D 61 70 | 
00 0C 00 74 00 00 01 88 | 00 00 00 2C 67 6C 79 66 | 01 04 62 39 00 00 01 BC | 00 00 00 24 68 65 61 64 | 
DE 06 54 5C 00 00 00 AC | 00 00 00 36 68 68 65 61 | 00 04 00 00 00 00 00 E4 | 00 00 00 24 68 6D 74 78 | 
00 00 00 00 00 00 01 80 | 00 00 00 06 6C 6F 63 61 | 00 12 00 08 00 00 01 B4 | 00 00 00 06 6D 61 78 70 | 
00 04 00 02 00 00 01 08 | 00 00 00 20 6E 61 6D 65 | 00 DF 1C AB 00 00 01 E0 | 00 00 00 B0 70 6F 73 74 | 
00 03 00 00 00 00 02 90 | 00 00 00 20 00 01 00 00 | 00 01 00 00 02 48 13 63 | 5F 0F 3C F5 00 0B 00 20 | 
00 00 00 00 B4 91 A2 80 | 00 00 00 00 CA 57 74 C6 | 00 00 00 00 00 01 00 01 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 01 00 00 | 00 01 00 00 00 00 00 00 | 00 00 FF FF 00 01 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 01 | 00 01 00 00 00 02 00 02 | 00 01 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 01 02 00 02 00 00 01 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 6E 6F 6F 70 00 40 | 00 23 00 23 00 01 00 00 | 00 00 00 01 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 00 01 00 03 00 01 | 00 00 00 0C 00 04 00 20 | 00 00 00 04 00 04 00 01 | 
00 00 00 41 FF FF 00 00 | 00 41 FF FF FF C0 00 01 | 00 00 00 00 00 00 00 08 | 00 12 00 00 00 01 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 31 00 00 01 00 00 | 00 00 00 01 00 01 00 01 | 00 00 31 37 01 01 00 00 | 
00 00 00 0E 00 AE 00 01 | 00 00 00 00 00 00 00 00 | 00 00 00 01 00 00 00 00 | 00 01 00 00 00 00 00 01 | 
00 00 00 00 00 02 00 00 | 00 00 00 01 00 00 00 00 | 00 03 00 00 00 00 00 01 | 00 00 00 00 00 04 00 00 | 
00 00 00 01 00 00 00 00 | 00 05 00 00 00 00 00 01 | 00 00 00 00 00 06 00 00 | 00 00 00 03 00 01 04 09 | 
00 00 00 00 00 00 00 03 | 00 01 04 09 00 01 00 00 | 00 00 00 03 00 01 04 09 | 00 02 00 02 00 00 00 03 | 
00 01 04 09 00 03 00 00 | 00 00 00 03 00 01 04 09 | 00 04 00 00 00 00 00 03 | 00 01 04 09 00 05 00 00 | 
00 00 00 03 00 01 04 09 | 00 06 00 00 00 00 00 40 | 00 03 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 |

That's it. That's an entire OpenType with TrueType outline font, it's legal, TTX can round-trip convert it, and any font parser should accept it. It might not look like anything, but in terms of an OpenType spec-compliant byte sequence, this is fully legal.

However, there are still parts of that byte sequence that sure would look nicer if we could change some non-zero bytes to zero bytes instead, because the longer we can make stretches of zeroes, the more compressible the data will be once we truly run out of things to change.

Let's whip out a hex editor and get cracking.

First, let's have a look at the organization of this font. The file starts with a 12 byte "SFNT" header, which gives us the font version, and four numbers for locating byte sequences for specific tables:

version:  00 01 00 00
number of tables: 10,
search range: 128, 
entry selector: 3,
range shift: 32

This 12 byte header is followed by the "table directory", which is a sortd array of table records, where each record tells us the name of the table, which byte offset we can that table at, and how many bytes the table takes up (and because tables must be long-aligned in a font file, the true table length may be less than the number of bytes between two table offsets!).

As per the header, there are 10 table records, and each records takes up 16 bytes, so bytes 13 through 172 are:

name: head, checkSum: -570010532, offset: 172, length: 54
name: hhea, checkSum:     262144, offset: 228, length: 36
name: maxp, checkSum:     262146, offset: 264, length: 32
name: OS/2, checkSum: 1905619156, offset: 296, length: 86
name: hmtx, checkSum:          0, offset: 384, length: 6
name: cmap, checkSum:     786548, offset: 392, length: 44
name: loca, checkSum:    1179656, offset: 436, length: 6
name: glyf, checkSum:   17064505, offset: 444, length: 36
name: name, checkSum:   14621867, offset: 480, length: 176
name: post, checkSum:     196608, offset: 656, length: 32

Let's look at what that means by colouring our byte sequence in alternative colours, starting at the SFNT header, then the table directory, and then each table in order:

00 01 00 00 00 0A 00 80 | 00 03 00 20 4F 53 2F 32 | 71 95 70 D4 00 00 01 28 | 00 00 00 56 63 6D 61 70 | 
00 0C 00 74 00 00 01 88 | 00 00 00 2C 67 6C 79 66 | 01 04 62 39 00 00 01 BC | 00 00 00 24 68 65 61 64 | 
DE 06 54 5C 00 00 00 AC | 00 00 00 36 68 68 65 61 | 00 04 00 00 00 00 00 E4 | 00 00 00 24 68 6D 74 78 | 
00 00 00 00 00 00 01 80 | 00 00 00 06 6C 6F 63 61 | 00 12 00 08 00 00 01 B4 | 00 00 00 06 6D 61 78 70 | 
00 04 00 02 00 00 01 08 | 00 00 00 20 6E 61 6D 65 | 00 DF 1C AB 00 00 01 E0 | 00 00 00 B0 70 6F 73 74 | 
00 03 00 00 00 00 02 90 | 00 00 00 20 00 01 00 00 | 00 01 00 00 02 48 13 63 | 5F 0F 3C F5 00 0B 00 20 | 
00 00 00 00 B4 91 A2 80 | 00 00 00 00 CA 57 74 C6 | 00 00 00 00 00 01 00 01 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 01 00 00 | 00 01 00 00 00 00 00 00 | 00 00 FF FF 00 01 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 01 | 00 01 00 00 00 02 00 02 | 00 01 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 01 02 00 02 00 00 01 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 6E 6F 6F 70 00 40 | 00 23 00 23 00 01 00 00 | 00 00 00 01 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 00 01 00 03 00 01 | 00 00 00 0C 00 04 00 20 | 00 00 00 04 00 04 00 01 | 
00 00 00 41 FF FF 00 00 | 00 41 FF FF FF C0 00 01 | 00 00 00 00 00 00 00 08 | 00 12 00 00 00 01 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 31 00 00 01 00 00 | 00 00 00 01 00 01 00 01 | 00 00 31 37 01 01 00 00 | 
00 00 00 0E 00 AE 00 01 | 00 00 00 00 00 00 00 00 | 00 00 00 01 00 00 00 00 | 00 01 00 00 00 00 00 01 | 
00 00 00 00 00 02 00 00 | 00 00 00 01 00 00 00 00 | 00 03 00 00 00 00 00 01 | 00 00 00 00 00 04 00 00 | 
00 00 00 01 00 00 00 00 | 00 05 00 00 00 00 00 01 | 00 00 00 00 00 06 00 00 | 00 00 00 03 00 01 04 09 | 
00 00 00 00 00 00 00 03 | 00 01 04 09 00 01 00 00 | 00 00 00 03 00 01 04 09 | 00 02 00 02 00 00 00 03 | 
00 01 04 09 00 03 00 00 | 00 00 00 03 00 01 04 09 | 00 04 00 00 00 00 00 03 | 00 01 04 09 00 05 00 00 | 
00 00 00 03 00 01 04 09 | 00 06 00 00 00 00 00 40 | 00 03 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 
00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 |

Fancy. Now that we have some basic visualization set up, let's get byte-whacking. First, let's deal with that HEAD table. There are lots of bytes that represent strings. It would be nice if we could zero those, but sadly, we can't. The HEAD table is pretty much invulnerable when it comes to byte sniping. It's like the byte-level spec for OpenType was well-thought-out or something...

Moving on, let's try the HHEA table instead. This is its layout:

00 01 00 00   version number "01.00" - we can't touch this
      00 01   Ascender... this can be made 0
      00 00
      00 00
      00 00
      00 00
      FF FF   minRightSideBearing... this can be made 0
      00 01   xMaxExtent... we only have zero-width glyphs, so we can make this 0
      00 00
      00 00
      00 00
      00 00
      00 00
      00 00
      00 00
      00 00
      00 01   numberOfHMetrics - we can't touch this either: our font will indeed contain one horizontal metrics record. 

As long as we don't touch the version number and the number of metrics, we can create a nice strech of zeroes. On to the MAXP table.

00 01 00 00   version number "01.00" - we don't want to touch this
      00 02   numGlyphs, we have two so this'll have to stay 2
      00 02   maxPoints can be set to 0 because our glyphs have no outlines
      00 01   maxContours can also be set to 0, because again: empty glyphs
      00 00
      .. ..
      00 00  

Here, too, there are some improvements possible!

Onward. And by onward, I mean let's skip OS/2, because it's super fragile and too important. Despite the name, it's somehow the most important table in the entire font. And while we're skipping tables, we can skip HMTX too, because it's already all-zereoes. Nothing to optimize! Next up, we can't really touch CMAP because it's already minimally complete, so we're left with the earlier idea of messing with the GLYF table's bytes.

If we're willing to give up TTX parsing (and we already committed to that) we can fit the entire table in exactly 10 bytes. We can follow the spec to the letter and declare a single glyph, with zero contours or points, and zeroes for min x/y and max x/y. If we do that, we can point both our .notdef and "real" character to that glyph, and because it has no outlines, we don't need to specify any additional data like contour instructions or bytemask flags for points, so: let's go, ten zero bytes coming up:

      00 00   "this is a simple glyph"
00 00 00 00   with min x and min y both zero
00 00 00 00   and the same for max x and max y

That's... well that's about as efficient as we can make it!

Then for the LOCA table, which is the "Location to Index" table. It tells font engines which byte offset, relative to the beginning of the GLYF table, to use for which glyph. It currently looks like this:

00 00   Offset to the .notdef glyph
00 08   Offset to our custom character
00 12   Byte length of the GLYF table, divided by two (for very technical reasons)

So let's update that so that it matches what we just did to the GLYF table:

00 00   .notdef still has offset zero
00 00   But we only have one glyph, so we just point to it a second time.
00 05   And our new GLYF table is 10 bytes long, so we have to set this to 5 to keep things legal

No space savings, but we get a few more consecutive zeroes.

That leaves the NAME and POST tables. Last one first: There is an annoying "03" element that we'd like to set to "00", so we do!

Which means we're left with the NAME table. It's huge. Normally, it's tiny compared to the rest of the font, but the rest of our font is about the size of a paragraph of text, so even a minimal name table will be gargantuan compared to the rest of the font. Right now it takes up almost as many bytes as we'll end up with once we're done. So let's see what we can do there.

The first 6 bytes are pretty simple:

00 00   table format 0. we don't touch his.
00 0E   number of entries in this table - there are 14. We don't want that many!
00 AE   start of the "strings" section for this table: (174 - 6) bytes from here

This is then followed by a list of "name records", which consists of six 16 bit values: {platformID, encodingID, languageID, nameID, Length, offset}. So: 168 bytes for 14 records:

 1) 00 01 | 00 00 | 00 00 | 00 00 | 00 00 | 00 00
 2) 00 01 | 00 00 | 00 00 | 00 01 | 00 00 | 00 00
 3) 00 01 | 00 00 | 00 00 | 00 02 | 00 00 | 00 00
 4) 00 01 | 00 00 | 00 00 | 00 03 | 00 00 | 00 00
 5) 00 01 | 00 00 | 00 00 | 00 04 | 00 00 | 00 00
 6) 00 01 | 00 00 | 00 00 | 00 05 | 00 00 | 00 00
 7) 00 01 | 00 00 | 00 00 | 00 06 | 00 00 | 00 00
 8) 00 03 | 00 01 | 04 09 | 00 00 | 00 00 | 00 00
 9) 00 03 | 00 01 | 04 09 | 00 01 | 00 00 | 00 00
10) 00 03 | 00 01 | 04 09 | 00 02 | 00 02 | 00 00
11) 00 03 | 00 01 | 04 09 | 00 03 | 00 00 | 00 00
12) 00 03 | 00 01 | 04 09 | 00 04 | 00 00 | 00 00
13) 00 03 | 00 01 | 04 09 | 00 05 | 00 00 | 00 00
14) 00 03 | 00 01 | 04 09 | 00 06 | 00 00 | 00 00
    00 40

And that "00 40" at the end is the actual string heap, containig the only string our font actually uses: the string name for our font version. Normally this would be something like "Regular" or "Bold Italic" but we went with "@", which is the value 0x40. So if you were following along and wondering where that actually got stored: we finally found it.

But... if we're directly working on the bytes, why would we keep all this data that we'll never use? The only name records we need for the font to be considered legal in a browser is the windows font family and subfamily, and if we're never showing names anywhere, then we can even set our only real string to just zeroes. Nothing relies on it, there is no "it has to be printable" requirement, so... here's our new name table:

                              00 00   still table format 0
                              00 02   but only two entries!
                              00 1E   which means a new offset for the string heap
00 03 00 01 04 09 00 01 00 00 00 00   windows font family name entry: use string 0
00 03 00 01 04 09 00 02 00 02 00 00   windows font subfamily name entry: also use string 0
                              00 00   string 0: the most beautiful string

Note that if any of the previous changes still allowed TTX to at least try to do something with the font, we're now thoroughly off the beaten track: TTX won't even accept this as legal input anymore. Even though we're still obeying the OpenType spec!

But hang on, why are we targeting Windows? There's also the Unicode platform id, which is zero, and the name of the game right now is "more zeroes" so let's switch our font to platform-agnostic Unicode, targeting the deprecated, but perfectly legal, Unicode 1.0 standard:

                              00 00
                              00 02
                              00 1E
00 00 00 00 00 00 00 01 00 00 00 00   Unicode 1.0 font family name entry
00 00 00 00 00 00 00 02 00 02 00 00   Unicode 1.0 font subfamily name entry
                              00 00

Much better.

Of course, this change also moves the POST table up, so we need to make sure that we correct its offset value in the table directory at the top of the font, and we'll need to recompute the NAME table checksum to make sure it's right:

  NAME data: 00 00 00 02 | 00 1E 00 03 | 00 01 04 09 | 00 01 00 00 | 00 00 00 03 | 00 01 04 09 | 00 02 00 02 | 00 00 00 00
  offset: 480
  length: 32
  checksum: ...

How do we calculate the checksum? Thankfully the OpenType specification is really clear on this:

  Table checksums are the unsigned sum of the longs of a given table.
  In C, the following function can be used to determine a checksum:

  ULONG CalcTableChecksum(ULONG *Table, ULONG Length)
  {
    ULONG Sum = 0L;
    ULONG *Endptr = Table+((Length+3) & ~3) / sizeof(ULONG);
    while (Table < EndPtr)
      Sum += *Table++;
    return Sum;
  }

Fair enough. It's only 32 bytes, or 8 ULONG values, so the chucksem is the sum of these eight values. Let's just do this by hand: 0xSUM = [2 + 1E0003 + 10409 + 10000 + 3 + 10409 + 20002] == 0x23081C (= 2295836 decimal).

  NAME: 00 00 00 02 00 1E 00 03 00 01 04 09 00 01 00 00 00 00 00 03 00 01 04 09 00 02 00 02 00 00 00 00
  checksum: 2295836 (= [00 23 08 1C])
  offset: 480
  length: 32 (= 0x20)
  
  POST: content is the same
  checksum: the same as before
  offset: 656 - (176-32) = 512 (=0x200)
  length: the same as before

Does this still work? You bet. And it kills off loads of data. We now have a 544 byte OpenType font with truetype outlines that is accepted by all five browsers!

Did we actually need to do that?

But wait... is that checksum actually necessary? What if I just set all of them to zero? That would create some more repetetive data, right? Sure enough, it turns out the checksums are like traffic lights - they're more of a guideline that you can complete ignore when it's safe to do so. Since fonts can be huge, and verifying that every table in a font matches its checksum up front can take seconds for fonts (average CJK font size on my computer: 12MB), browsers are (rightly) not going to bother with verifying this value. All font software generates valid checksums. If things don't work, you probably did something weird like trying to byte-snipe your font into something more compressible.

And let's be reasonable: who'd even do that?

Extreme compression: let's just overlap tables?

I've read the minimal ELF article, you've read the minimal ELF article: as long as the bytes are the same, execution ranges are just byte offsets, so if the bytes are identical who cares, right?

...right...?

Here's where things get annoying. Yes, the spec allows for this. We could shave off SO MANY BYTES by just ordering our tables cleverly and then overlapping some of them on top of, or even completely inside of, other tables. But apparently that's where font parsers draw the line. Even though there is literally nothing in the OpenType spec that says this is illegal (the same is not true for WOFF and WOFF2, overlapping tables are explicitly illegal for those flavours of font due to per-table compression requirements) every browser font engine will go "this is too creative" and treats the font as corrupt.

So much for super hacks, that's at least 32 bytes just left on the table =_=

Getting "creative" with our bytes

Alright so what's left for us to mess with? If you'll remember, we skipped over the OS/2 table because it's kind of "the master table" but like... screw it, we're byte sniping, I want more zeroes. I didn't want to do this earlier because it's a pretty fragile table, any changes to it can trivially invalidate a font, but at this point I've run out of things I can do to the font by reading the spec, so it's "try and see what happens" time.

First, we set the xAvgCharWidth to zero. This is legal, if very strange for normal fonts (how many fonts literally have no printable characters?), and improves compressibility.

Then we set the usWeightClass to one, which is the lowest non-zero value we can legally assign it. Way to finally decide that numbers start at 1 instead of 0, why are we like this?

We also blank the Font Vendor Identification string. Leaving it blank in TTX results in the string "noop" in the byte code, but since it's never referenced by anything there's no reason to leave it as anything other than four zeroes, thus further improving compressibility.

We then unset all the fsSelection bits, because they are irrelevant. This font will never be used in any classification system or font catalog, so the content of the category bits are arbitrary. That's two more zero bytes.

Trudging on, we set the typographic ascender to zero. Its main use is to detemine a correct default line height, and as this font will never be used for mult-line content. This isn't just technically possible, it's the only correct value for this field now!

Finally, there are five versions of the OS/2, because the OpenType spec has had quite a few revisions so some folks on the OpenType mailing list even back in 2011 suggested experimenting with changing the table version. Unfortunately, while version 0 is a real version, and it's much smaller than versions 1 through 4, it's also so old that most font parsers consider it a bug if they see an OS/2 table marked as version 0. The lowest we can set it to without upsetting browsers is 1. So... I guess that's what we'll do.

And... I mean short of quickly filing a few PRs that allow things like FreeType, Harfbuzz, or OTS to accept things like overlapping tables, that's kind of it. We've done all we can. At least, I think we have, if you know of another trick to try, scroll down to the bottom and let me know! O_O

Putting it on a web page - Base 64 and beyond

We now have a 520 byte TTF that will compress quite ridiculously well. Stand-alone gzipping using -9 turns it into a 242 byte file, so let's see how small we can get it if we want it compressed in a way that we can just inline on a web page!

First off, let's look at our current byte code, because we can:

00 01 00 00 00 0a 00 80 | 00 03 00 20 4f 53 2f 32 | 00 00 00 00 00 00 00 ac | 00 00 00 56 63 6d 61 70
00 00 00 00 00 00 01 04 | 00 00 00 2c 67 6c 79 66 | 00 00 00 00 00 00 01 30 | 00 00 00 0a 68 65 61 64
00 00 00 00 00 00 01 3c | 00 00 00 36 68 68 65 61 | 00 00 00 00 00 00 01 74 | 00 00 00 24 68 6d 74 78
00 00 00 00 00 00 01 98 | 00 00 00 06 6c 6f 63 61 | 00 00 00 00 00 00 01 a0 | 00 00 00 06 6d 61 78 70
00 00 00 00 00 00 01 a8 | 00 00 00 20 6e 61 6d 65 | 00 00 00 00 00 00 01 c8 | 00 00 00 20 70 6f 73 74
00 00 00 00 00 00 01 e8 | 00 00 00 20 00 01 00 00 | 00 01 00 01 00 00 00 00 | 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 01 | 00 03 00 01 00 00 00 0c | 00 04 00 20 00 00 00 04 | 00 04 00 01 00 00 00 41
ff ff 00 00 00 41 ff ff | ff c0 00 01 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 01 00 00
00 00 00 00 00 00 00 00 | 5f 0f 3c f5 00 00 10 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00
00 00 00 00 01 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 01 00 00 | 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 01 | 00 00 00 00 00 00 00 00
00 00 00 00 00 05 00 00 | 00 01 00 00 00 02 00 00 | 00 00 00 00 00 00 00 01 | 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 | 00 00 00 02 00 1e 00 03 | 00 01 00 00 00 01 00 00 | 00 00 00 03 00 01 00 00
00 02 00 02 00 00 00 00 | 00 01 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00 | 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

Again: that's a complete, spec-compliant TTF file. Crazy.

But more importantly: look at all those beautifully long stretches of zeroes. Time to compress the heck out of those. First, if we want to embed this on a webpage, we'll need the Base 64 representation of this font. That part's easy: one binary-to-ascii call later, and we have this:

AAEAAAAKAIAAAwAgT1MvMgAAAAAAAACsAAAAVmNtYXAAAAAAAAABBAAAACxnbHlmAAAAAAAAATAAAAAKaGVhZAAAAAAAAAE8AAAAN
mhoZWEAAAAAAAABdAAAACRobXR4AAAAAAAAAZgAAAAGbG9jYQAAAAAAAAGgAAAABm1heHAAAAAAAAABqAAAACBuYW1lAAAAAAAAAc
gAAAAgcG9zdAAAAAAAAAHoAAAAIAABAAAAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAwABAAgAAAABAAEAAEAAABB//8AAABB////wAABAAAA
AAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAF8PPPUAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAEAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAUAAAABAAAAAgAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAA
AAAAAAAgAeAAAAAAAAAAEAAAAAAAAAAAAAAAIAAgAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==

That's a 696 byte string, which seems way more than the TTF file we started with... but it's hard to miss all the repetition in the string. You can see why making as many values 00 as possible was a good idea: there are some long stretches of "A" in there, and that means we can save a lot of space by reversibly replacing them with shorter strings.

For example: there are very, very few numbers in the Base 64 string, and not a single digit follows the letter "A", so we could simply collapse all those stretches of "AAA..." with a single "A" and then the number of repetitions with a simple base64String.replace(/A(A+)/g,(_,a) => `A${a.length+1}`), which gives us the following result:

A2EA4KAIA3wAgT1MvMgA8CsA4VmNtYXA9BBA4CxnbHlmA9TA5KaGVhZA9E8A4NmhoZWEA8BdA4CRobXR4A9ZgA4GbG9jYQA8GgA4B
m1heHA9BqA4CBuYW1lA9cgA4gcG9zdA9HoA4IA2BA5QABA111BA10wABA2gA4BA2EA2EA3BB//8A3BB////wA2BA23BA13F8PPPUA
2BA29BA22EA44BA18UA4BA5gA12EA26gAeA10EA15IA2gA7QA40==

And now we have a 255 byte string (better than 2:1 compression using the simplest possible compression technique!) that only needs a tiny bit of extra JS to get the original Base 64 string back:

"A2EA...A40==".replace(/A(\d+)/g,(_,a)=>`A`.repeat(+a))

So with 43 bytes for the reconstitution code, we get a final 298 bytes that we'll need to add to a webpage in order to form our Base 64 font string, way doesn from even the already ridiculously tiny 520 bytes of binary code. That's amazing!

The proof is in the pudding.

But you want proof that it works. I anticipated this, and made sure that this very page uses this font. Of course, it's tiny, so you didn't see it, but just for fun, scroll to the top of this page... do you see the text "Let's make a small font"? Copy-paste that text. Anywhere will do, a text editor is the most useful.

Yes, that's a hundred A's in there, which you didn't see because they're actually typeset using this tiny, tiny font, which was loaded through an @font-face declaration in a dynamically created <style> element.

This stuff is amazing.

So... what did you do this for?

In a nutshell: @font-face custom font loading detecting.

Loading custom fonts on a webpage takes time, because the font has to be downloaded, and even when it has been downloaded, it has to be loaded into memory. So, between "showing the page" and "showing the page with the right font", there's a period where the text has been typeset, but with the wrong font. Best case, this period is imperceivably short. But the bigger the fonts, the bigger the problem. Even a 100Kb font can cause a website to "flip" between the initial fallback font, and the intended font. If that's the page's main font, the user-experience is ruined. For web animation using the canvas, the problem is even bigger, because you might see an animation that's started before all the fonts are done downloading, and the text will suddenly change typeface a few, or maybe even 100 frames into the animation.

You may know this as the "FOUT" or "Flash Of Unstyled Text" problem, and a lot of work went into addressing it between when I originally wrote this, and the latest update in 2026, with things like font-display: swap and preloading, but the same period saw the rise of client side frameworks with their "lol there is no content here until JS runs" (please. Be better. SRR was always an option, even back in the React 0.x days - if your page shows nothing without JS, you are bad at the web and you should strive to do better), and in that context it's still useful to have something that runs immediately and can be used to detect font loads because a reflow suddenly turned a stretch of text from zero-width to not-zero-width.

My personal motive was to work this into processing.js, which is the JavaScript interpreter for Processing source code, used to write visual programs such as graphics and animations. You can imagine how shitty an animation would be if it used "sans-serif" for the first 10 or 20 frames, before using that beautiful font you bought specifically for your animation =)

-- Pomax


Some questions


Why don't you just gzip it?

True, 298 bytes to get a Base 64 string is larger than a 242 byte gzipped payload. It's a good idea, right? However, that only works if we could direcetly embed a gzipped payload as a data uri, and unfortunately we'd still need a Base 64 version for that to work. As Base 64, by definition, turns every 3 bytes into 4 bytes, we'd end up with 323 bytes which is actually more than our 298 byte generator!

What about embedding it as gzipped image data in a PNG? Those get unpacked by the browser.

That's actually a really cool idea, and some people have indeed experimented with that even before HTML5 was "HTML5". The long and short of it is that you can indeed embed arbitrary data as a gzipped block inside a PNG, using pixel values to encode binary data (they're both bytes, it's perfectly legal), and then extracting the data again by grabbing the image's pixel data by drawing it on a canvas and walking through the pixel array.

Seriously, this is really cool. The problem with this is that it's only really cool for big payloads, where the act of gzipping the data saves you 500 bytes or more. For small payloads it actually bloats things tremendously: The PNG header adds a little over 70 bytes to the gzipped payload, so that's not so bad, but in order to load the image, make a canvas, get its 2D context, draw the image using this context, get the pixel data, then run through the data, decode and buffer it in a string, then load that string as javascript, you need about 400 bytes worth of javascript. And that's without data validation.

It doesn't work!

That's not a question, but: you need javascript enabled. Don't worry, this page doesn't use google analytics or some other nonsense. And feel free to check the source code if you don't believe me. This page doesn't use any imported scripts. If you're in IE, depending on your security settings, you might get a warning because of the data-URI. You can safely allow this (but again, you are responsible for making sure I'm not lying. I know I'm not, but you only have my word for it, and you don't know me).

Did you change something in 2026? This used to be broken in Safari!

I did! It only took fifteen years!

When I first wrote this article in 2011, Safari/WebKit had a weird bug that showed the title as "sma ll", but at the time I didn't own anything made by Apple so I had no way to really figure out what was going on.

However, at the time of the current rewrite in mid-2026 I have multiple macbooks lying around, and it turns out that whatever as wrong in 2011 was still wrong, so I threw out a request of help over on social media and someone immediately jumped in to help get the problem fixed. Maybe by the time you read this WebKit bug 316951 is already fixed, and your version of Safari won't even know it ever had this bug!

It doesn't work in [some browser] on [some OS] still, though!?

Send me an email, to pomax at nihongoresources dot com or message me on mastodon over on https://mastodon.social/@TheRealPomax, and I'll see what I can do. The more current browsers this works on, the better. However, I'm only trying to get this to work for browsers that support HTML5 (if your browser doesn't support the <canvas> element, for instance, I'm going to tell you to get a better browser instead).