Skip to content

fix(text): a variable font is instantiated out of a WOFF or a WOFF2, where the first weight off the file's default threw - #479

Merged
sidorares merged 1 commit into
masterfrom
fix/variable-woff2
Sep 30, 2026
Merged

sidorares merged 1 commit into
masterfrom
fix/variable-woff2

Conversation

@sidorares

Copy link
Copy Markdown
Owner

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.

…where the first weight off the file's default threw
@sidorares
sidorares merged commit b009cdd into master Sep 30, 2026
6 checks passed
@sidorares
sidorares deleted the fix/variable-woff2 branch September 30, 2026 10:53
sidorares pushed a commit that referenced this pull request Sep 30, 2026
🤖 I have created a release *beep* *boop*
---


## [8.17.2](v8.17.1...v8.17.2)
(2026-09-30)


### Bug Fixes

* **svg:** a gradient takes its stops, and what it does not set, from
the gradient its href names
([#481](#481))
([9db8108](9db8108))
* **text:** a font weight between two of the hundreds goes to fontconfig
on its own scale, so 450 in Arial is the regular and not the bold
([#480](#480))
([3973b42](3973b42))
* **text:** a variable font is instantiated out of a WOFF or a WOFF2,
where the first weight off the file's default threw
([#479](#479))
([b009cdd](b009cdd))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
sidorares added a commit that referenced this pull request Sep 30, 2026
…F or a WOFF2 is read as the sfnt inside it (#485)

8.17.2 never reached npm, and nothing merged since 8.17.1 is published.
#479 named fontkit by the URL of a fork's release tarball, and npm 12
fetches no URL or git dependency unless told to: `allow-remote` and
`allow-git` default to `none`. The publish job upgrades npm and failed
in `npm ci` with `EALLOWREMOTE`. The test jobs install with the npm
their Node ships, so CI was green. An application on npm 12 could not
have installed that version either, which I reproduced with npm 12.1.0
against a packed master.

That was my recommendation in #479 and it was wrong: I tried the tarball
with npm 11 and Bun, not with the npm the publish job uses.

## What this does

**`fontkit` is `^2.0.4` from npm again**, and the lockfile is 8.17.1's
for it.

**What the fork was for is done here instead.** fontkit cuts an instance
of a variable font by reading the file again as a plain sfnt, which a
WOFF or a WOFF2 is not. So a variable face that arrives in a container
is handed to fontkit as the sfnt inside it, built by the new
`lib/text/sfnt.js`: a WOFF's tables as fontkit inflates them, a WOFF2's
as it decompresses them, with `glyf` and `loca` written back from the
transformed streams. It is done once, when the face is opened, 10 ms for
Geist's 70 KB WOFF2. A static face stays in its container, and a
container that cannot be taken apart stays too and refuses at
`variation`, as before #479.

**CI has a `publishable` job** that does what the publish job does up to
the upload, with the newest npm, and then installs the packed tarball
into an empty project.

The `loca` guard from #479 stays: it is about static WOFF2 faces and
does not depend on which fontkit is installed.

## Verification

- The tests #479 added pass unchanged but for one assertion, against
fontkit 2.0.4: every glyph of an instance out of the `.woff` and the
`.woff2` fixture equals the sfnt's, at three weights.
- `test/sfnt.test.js`: the sfnt made of a WOFF is the original, table
for table. The one made of a WOFF2 has every table but `glyf`, `loca`
and `head` byte for byte, and every glyph reads the same. The two faces
used have composite, hinted and empty glyphs between them, and a test
says so.
- Outside the suite, 16 containers were rebuilt and compared with
fontTools' own reading of the same file, glyph by glyph: coordinates,
on-curve flags, contour ends, instructions, component records, boxes,
the overlap flag, and every other table's bytes. Geist with 975 glyphs,
Mada with 292 composites of 529, Source Sans Pro with 1974 hinted
glyphs, CFF and CFF2 flavours, and a WOFF2 with an untransformed `glyf`.
- Through react-x11-components' `<Html>` with this `lib/` and fontkit
2.0.4: all 728 characters of Geist's variable WOFF2 at eight weights,
each matched to an instance of the rebuilt face.
- With npm 12.1.0: `npm ci`, `npm publish --dry-run`, and an install of
the packed tarball into an empty project all pass.

`npm test`: 1449 pass, 10 skipped, none fail.

## After this

8.17.2 cannot be published from its tag, whose commit has the URL.
Merging this and then the release PR, #484, publishes 8.17.3 with
everything since 8.17.1.
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