# Why satori throws "ltagTable is not defined" on a woff font

satori throws "ltagTable is not defined" when a font contains an Apple ltag table: its pinned parser, a 2022 fork of opentype.js, assigns that variable without declaring it. The woff format is not the cause, because two other woff files render. All 12 Commit Mono woffs from fontsource carry the table. I vendored the 161 KB upstream OTF for build-time OG images, and the web still loads the 48 KB woff2.

Published: 2026-10-21
Canonical: https://umar.codes/satori-ltagtable-not-defined

## Revisions

- 2026-10-21 — Created from this site's OG renderer (src/lib/og.ts), the vendored Commit Mono OTF, the 2026-07-27 decision entry, and a reproduction run that day against satori 0.29.0 and @shuding/opentype.js 1.4.0-beta.0 under Node 24.13.0.
- 2026-10-21 — Published on its Oct 21 slot; auto-drafted by the publishing run because no draft existed for the row.

---

satori throws `ltagTable is not defined` when a font contains an `ltag` table.
Its font parser, a 2022 fork of opentype.js, assigns that variable without
declaring it, and strict mode turns the assignment into a `ReferenceError`. The
woff format is not the cause. I shipped a vendored OTF; stripping the table or
patching one line also works.

## The build error satori gives for one font

This site renders an Open Graph card for every page at build time, with satori
and resvg; the current build writes 62 of them. Each card uses three typefaces.
Newsreader and Instrument Sans load from fontsource woff files and parse fine.
Commit Mono, the face for the `umar.codes` line at the bottom of the card,
stopped the first build of the renderer on 2026-07-27:

```
ReferenceError: ltagTable is not defined
    at Object.parseBuffer [as parse] (…/@shuding/opentype.js/dist/opentype.js:11505:30)
    at Wt.addFonts (…/satori/dist/index.js:6:50501)
```

The message names a variable, not a font, so the obvious guess is the file
format. That was my guess in July. The comment I wrote in `og.ts` that day still
says the fontsource woff "trips an opentype.js bug", which is true but points at
the wrong thing.

## The woff format is not the cause

I reproduced it today against satori 0.29.0 under Node 24.13.0: one line of
26-pixel text, one font per run.

| Font file | Signature | Bytes | Result |
|---|---|---|---|
| Commit Mono 400 woff | `wOFF` | 57,580 | `ReferenceError: ltagTable is not defined` |
| Commit Mono 400 woff2 | `wOF2` | 48,128 | `Error: Unsupported OpenType signature wOF2` |
| Commit Mono 400 OTF, upstream | `OTTO` | 161,124 | renders in 17 ms |
| Newsreader 600 woff | `wOFF` | 29,784 | renders in 4 ms |
| Instrument Sans 600 woff | `wOFF` | 21,676 | renders in 3 ms |

Two woff files render, so the container is fine. The woff2 failure is a
different limit: satori's parser does not read woff2 at all, and the error says
so. The real difference is inside the file. The Commit Mono woff lists 11
tables, and one of them is `ltag`. Neither of the other woffs has one, and the
upstream OTF has 13 tables and no `ltag`. All 12 Commit Mono `.woff` files in
`@fontsource/commit-mono` 5.3.0 carry it.

`ltag` is an Apple table: a list of language tags that other tables refer to by
index. Nothing on an OG card needs it.

## One missing declaration in satori's pinned parser

satori does not use opentype.js from npm. It pins `@shuding/opentype.js`
1.4.0-beta.0, a fork last published on 2022-08-26. In upstream opentype.js
1.3.4, `parseBuffer` declares `var ltagTable;` before it loops over the font's
tables. The fork's `parseBuffer` lost that line, but its `case 'ltag':` branch
still assigns `ltagTable = ltag.parse(…)`. In strict-mode code, an assignment to
an undeclared name throws. So the bug does nothing for every font without the
table, and stops the build for every font with it.

I checked that this one line is the whole bug. In a scratch copy of the fork
with `var ltagTable;` added, the Commit Mono woff parses to 1,932 glyphs, the
same count as the OTF. Upgrading satori does not help: the latest release on
npm, 0.63.2, still pins the same parser version. A search of satori's issue
tracker for the error text returned no results on 2026-10-21.

## Three fixes for a font with an ltag table

- **Use a font file without the table.** This is what I did on 2026-07-27: the
  upstream OFL-licensed Commit Mono OTF, committed at `src/assets/og-fonts/` and
  read only by `og.ts`. It costs 161,124 bytes in the repository, 2.8 times the
  woff, and nothing in the browser. The build output contains no `.otf`, and
  pages still load the 48,128-byte woff2.
- **Strip the `ltag` entry from the woff.** Removing that one entry from the
  table directory, 11 tables down to 10, makes satori render the same SVG as
  the OTF. It is a binary rewrite inside the build, for one font.
- **Patch the parser.** A one-line `pnpm patch` fixes every font. It is also a
  patch on a dependency of a dependency, and every satori upgrade has to be
  checked against it.

I would make the same call today, for a different reason. In July the OTF was
the first thing that worked. Now the argument is scope: one file, one consumer,
and a failure that stops the build instead of rendering a wrong card. Its real
cost is drift. The OG card and the web page draw Commit Mono from two files that
can change separately, and no check compares them. For one 26-pixel line, I
accept that.

## What the OG renderer costs per card

With the vendored OTF in place, `renderOgImage` took 1,297 ms for the first
card in a fresh process and a median of 93 ms per card after that, including
the PNG rasterisation in resvg. At 62 cards, that is about seven seconds of a
full build. Loading the module and reading the three fonts took 79 ms.

## List the tables before you blame the format

When a font parser throws a `ReferenceError`, the error is about the parser's
code, so the useful question is which code path the font took. A woff's table
directory is 20 bytes per table at a fixed offset, and a dozen lines of Node
print it. The answer here was one of 11 four-letter tags. It took me three
months and a scheduled post to read them.

The renderer is part of the [Astro build of this site](/next-to-astro-conversion);
it runs once per page and never in the browser.
