Skip to content

Support variations of fonts in WOFF and WOFF2 containers - #1

Merged
sidorares merged 1 commit into
masterfrom
woff-variations
Sep 30, 2026
Merged

sidorares merged 1 commit into
masterfrom
woff-variations

Conversation

@sidorares

Copy link
Copy Markdown

font.getVariation() works on a plain sfnt and throws, or returns a font with no tables, on the same font in a WOFF or a WOFF2. Nearly every variable web font is served as WOFF2, so this is the case a web page hits: Geist, Inter, anything from next/font or Google Fonts.

fontkit.openSync('Geist-Variable.woff2').getVariation({ wght: 700 });
// 2.0.4: a TTFFont whose directory was parsed out of Brotli-compressed
// bytes. No cmap; "Cannot read properties of undefined (reading 'tables')"
// at the first glyph. On a small file, "Offset is outside the bounds of the
// DataView" from getVariation itself.

Cause

getVariation rebuilds the font as new TTFFont(stream, coords), from this.stream.buffer at the directory's offset.

  • WOFF: that reads a WOFF directory as an sfnt directory.
  • WOFF2: the stream is the compressed file, or, once anything was decoded, the decompressed tables with no directory in front of them.

Two more things were in the way once the font was built by the right class:

  • GlyphVariationProcessor read the glyph variation data from font.stream at offsets that are positions in the stream the gvar table was decoded from. For a WOFF that is the inflated table, not the file.
  • WOFF2Glyph._decode returns the glyph decoded in advance from the transformed glyf table, and the variation is applied in TTFGlyph._decodeSimple / _decodeComposite, which a WOFF2 glyph never goes through.

Fix

  • TTFFont#_createVariation(coords) makes the font getVariation returns, as new this.constructor(...). A container reads its directory and its tables the way it holds them.
  • WOFF: the processor reads gvar through font._getTableStream('gvar'). WOFFFont inflates a table once and keeps it (shared with its variations) — it inflated the whole table again for every glyph that asked, glyf included, which is a speed-up for a WOFF font with no variation at all.
  • WOFF2: a variation reads the directory from the file again and shares the font's decompressed tables and its decoded glyphs, so Brotli runs once per font however many variations are made. The transformed glyf table is decoded without a variation applied; WOFF2Glyph moves a copy of a glyph's points, or of its components' offsets, through the same code TTFGlyph uses, now _applyVariation. The phantom points need the bounding box a glyf header carries: the transformed table stores it for some glyphs and leaves it to be computed from the points for the rest (WOFF2, 5.1), so the bounding box stream is read, where it was skipped.

Nothing changes for an sfnt: new this.constructor is new TTFFont, and _getTableStream('gvar') is the font's own stream.

Tests

test/variations.js, "variations of a font in a container": every glyph of a variation — path, bounding box, advance width and height — is compared with the same variation of the sfnt the container was made from.

font containers what it covers
TestGVAROne WOFF, WOFF2, WOFF2 with an untransformed glyf simple glyphs, advances from the phantom points
TestGVARFour WOFF, WOFF2 two axes, advances from HVAR
Mada-VF WOFF2 292 composite glyphs of 529
AdobeVFPrototype-Subset WOFF, WOFF2 CFF2 outlines

Plus: the font and its other variations are unchanged after a variation is read (the decoded glyphs are shared), a variation of a variation, a named variation, and GPOS mark anchors in a layout.

The containers were made with fontTools from the fonts already in test/data (TTFont(path); font.flavor = 'woff2'; font.save(...)), 87 KB in all.

556 passing, 7 pending, 1 failing — the failure is i18n › can set global default language to "ar", which fails the same way on master. The suite needs Node ≤ 24 to start: mocha 10's yargs does not load on Node 26.

Checked outside the suite: the 975 glyphs of Geist's variable WOFF2 at wght 600 and 900 have the same paths as fontTools' instancer makes from it, and advances within a unit.

Geist, 975 glyphs open first variation every glyph path next variation
.ttf 1.7 ms 1.5 ms 46 ms 0.2 ms
.woff2 0.9 ms 27 ms 30 ms 0.2 ms

The first variation of a WOFF2 is where it is decompressed and its glyf table decoded.

getVariation() rebuilt the font as a TTFFont, from the stream at the
directory's offset. For a WOFF that read a WOFF directory as an sfnt's, and
for a WOFF2 the stream is the compressed file, or once anything was decoded
the decompressed tables with no directory at all: the variation had no
tables, and threw from wherever its first table was asked for.

A variation is now made by the font's own class (_createVariation), so a
container reads its directory and its tables the way it holds them.

WOFF: the glyph variation data was read from the font's stream at offsets
that are positions in the inflated gvar table. GlyphVariationProcessor reads
it from the stream the table was decoded from. A table is inflated once and
kept, where it was inflated again for every glyph that asked for it.

WOFF2: a variation reads the directory from the file again and shares the
font's decompressed tables and decoded glyphs, so nothing is decompressed or
decoded twice. The transformed glyf table is decoded without a variation
applied, and WOFF2Glyph moves a copy of a glyph's points, or of its
components' offsets, where TTFGlyph moves what it decoded. The phantom
points need the bounding box a glyf header has, which the transformed table
stores for some glyphs and leaves to be computed from the points for the
rest, so the bounding box stream is read.

The tests compare every glyph of a variation (path, bounding box, advances)
with the same variation of the sfnt the container was made from: TrueType
outlines with and without HVAR, composite glyphs, CFF2, and a WOFF2 whose
glyf table is not transformed. The containers were made with fontTools from
the fonts already in test/data.
@sidorares
sidorares merged commit 64ba0c4 into master Sep 30, 2026
sidorares added a commit to sidorares/react-x11-components that referenced this pull request Sep 30, 2026
… from is passed over, where it left the document blank (#459)

ntk sets a variable face at the weight and the size a style asks for by
cutting an instance out of it, inside match, and fontkit cuts none out of a
WOFF or a WOFF2. The throw came out of the first text layout in the family
at any weight but the file's default, and the document was left blank:
nextjs.org's blog, whose sheet declares Geist as a variable WOFF2.

A face is now asked for an instance once, before it is registered, at the
far end of the wght and opsz axes, the two a style moves. One that refuses
is a source that did not load: the next source is tried, and with none left
the family stays out of the list and the text is set in the next family, as
a browser sets it when it cannot use a font. It is said once a connection,
in development.

Only an engine that draws through the face is asked. CoreText and
DirectWrite move an axis themselves, and the face react-x11 opens there is
fontkit's, for reading.

The check asks rather than knows, so it passes once fontkit can cut the
instance, which is windowkit/fontkit#1.
sidorares added a commit to sidorares/ntk that referenced this pull request Sep 30, 2026
…where the first weight off the file's default threw (#479)

A variable font registered from a `.woff2`, which is how a web font
nearly always arrives, threw from `match` at any weight but the file's
default: `ntk: cannot instantiate a variation of Geist-Regular (WOFF2)`.
In react-x11-components' `<Html>` that was a whole document left blank,
found on nextjs.org/blog. A `.woff` failed the same way.

## Cause

fontkit 2.0.4's `getVariation` rebuilds the face as a plain sfnt from
the container's bytes. For a WOFF that reads the wrong directory, and
for a WOFF2 the bytes are still Brotli-compressed.

## Fix

**fontkit is now windowkit's fork, as a release tarball.** The fix is
windowkit/fontkit#1, offered upstream as foliojs/fontkit#389. Upstream's
last release is from 2024, so `dependencies` names the URL of the
tarball attached to the fork's `v2.0.4-windowkit.1` release, which is
what `npm publish` would have uploaded, and the lockfile holds its
integrity. Not a `github:` spec: a git dependency has no `dist/`, npm
builds one on every install with parcel and the devDependencies, 18 s
measured, and Bun installs it unbuilt. The tarball was installed with
npm and with Bun. `AGENTS.md` records the decision and when to go back
to a range on npm.

**One change in ntk itself.** `mendEmptyGlyphBoxes` asked the `loca`
table which glyphs are empty. A WOFF2 whose `glyf` table is transformed
has no `loca` in the file, and fontkit reads the table after it in its
place: it is wrong about 7 of the fixture's 76 glyphs, 24 of Geist's
975, 17 of KaTeX Main's 286. Those glyphs had their box read from a
header that is not there, in static faces too, and in an instance of a
variable face, whose box is needed to make the outline, the read went
round until the stack ran out. A transformed `glyf` is now left alone.

The `cannot instantiate` error stays for an application that resolves
`fontkit` to npm's over ntk's, and says that.

## Tests

`test/variable-fonts.test.js`, with the fixture as a `.woff` and a
`.woff2`, made from the `.ttf` with fontTools:

- an instance out of either container has every glyph of the sfnt's
instance, by outline, advance and box, at three weights
- bytes registered with `load` and matched at 700 lay text out as wide
as the sfnt does
- a WOFF2 glyph's box is its outline's, which fails on glyph 9 without
the `loca` change

All three fail on master, and the first two fail with a stack overflow
when only the fontkit is changed.

`npm test`: 1399 pass, 46 skipped, and one failure in
`present-clock.test.js`, a frame-timing test that passed three times out
of three alone, on a machine at a load average of 40.

Every character of Geist's variable WOFF2, 728 of them, was also set at
five weights through `<Html>` with this ntk.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant