Repository navigation
Support variations of fonts in WOFF and WOFF2 containers - #1
Merged
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 fromnext/fontor Google Fonts.Cause
getVariationrebuilds the font asnew TTFFont(stream, coords), fromthis.stream.bufferat the directory's offset.Two more things were in the way once the font was built by the right class:
GlyphVariationProcessorread the glyph variation data fromfont.streamat offsets that are positions in the stream thegvartable was decoded from. For a WOFF that is the inflated table, not the file.WOFF2Glyph._decodereturns the glyph decoded in advance from the transformedglyftable, and the variation is applied inTTFGlyph._decodeSimple/_decodeComposite, which a WOFF2 glyph never goes through.Fix
TTFFont#_createVariation(coords)makes the fontgetVariationreturns, asnew this.constructor(...). A container reads its directory and its tables the way it holds them.gvarthroughfont._getTableStream('gvar').WOFFFontinflates a table once and keeps it (shared with its variations) — it inflated the whole table again for every glyph that asked,glyfincluded, which is a speed-up for a WOFF font with no variation at all.glyftable is decoded without a variation applied;WOFF2Glyphmoves a copy of a glyph's points, or of its components' offsets, through the same codeTTFGlyphuses, now_applyVariation. The phantom points need the bounding box aglyfheader 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.constructorisnew 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.TestGVAROneglyfTestGVARFourHVARMada-VFAdobeVFPrototype-SubsetPlus: 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 isi18n › can set global default language to "ar", which fails the same way onmaster. 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
wght600 and 900 have the same paths as fontTools'instancermakes from it, and advances within a unit..ttf.woff2The first variation of a WOFF2 is where it is decompressed and its
glyftable decoded.