Skip to content

Merge upstream's master, 2026-10-28 - #8

Merged
sidorares merged 57 commits into
masterfrom
merge-upstream
Oct 29, 2026
Merged

sidorares merged 57 commits into
masterfrom
merge-upstream

Conversation

@sidorares

Copy link
Copy Markdown

This merges foliojs/fontkit's master as of 2026-10-28 (f4c2243). Upstream has released nothing since 2.0.4 in 2024. Its master is a large step past that release: a rolldown build, ESM only, Node 22 and newer, and many HarfBuzz-parity fixes, with breaking changes listed under "Unreleased" in its CHANGELOG. It has also taken three of this fork's patches, in its own form:

Don't tag this yet. ntk, which vendors the fork, needs two things first. Both are below.

How the conflicts were resolved

Every conflict goes to upstream's side. On top of that, the fork keeps only what upstream does not have:

  • Decoded structs with private fields (Decode structs without Object.defineProperties #4). Every file that declares structs imports restructure from src/restructure.js again. That is a single import line in each of 61 files. The patch still pays: SourceSansPro's first shaping takes 17.5 ms against upstream's 23.3, the median of 15 fresh processes.
  • setBrotliDecompressor and the size check (Decompress WOFF2 with the runtime's own Brotli in the builds for Node #5, Export setBrotliDecompressor, so a browser build can be handed a native Brotli #6). Both now sit on upstream's #compression and are exported from src/base.js. Upstream's test/entrypoints.js checks each entry's exact exports, so it gains one expected key.
  • New: dist/browser-module.mjs. This is the browser build in one file, a third output in rolldown.config.ts. Upstream's browser build is now split into chunks, and ntk's vendor script carries a single file. test/brotli.js checks that the file has no chunks and imports no builtins.
  • Release workflow. It now runs on Node 22, the oldest upstream targets, and checks the new dist/ files. It runs the whole suite: the setDefaultLanguage block that the parcel build broke passes now.
  • WINDOWKIT.md. The table lists only what the fork still carries. A list below it records what came back from upstream. README.md documents setBrotliDecompressor.

The Chrome 91 target went away with parcel. Upstream builds for Chrome 120 and Node 22, where private fields are native.

Checked

  • npm test: 722 passing, 7 pending, on Node 22, 24 and 26. npm run format:check passes too. Upstream's lint-test.yml runs both on this PR.
  • ntk, with a local pack of this branch vendored: 1801 of 1817 pass after a one-line fix in ntk. ntk's stand-in for a face's mark lookups, in lib/text/marks.js, had flags: {}. Upstream Honour useMarkFilteringSet lookup flag during glyph iteration foliojs/fontkit#373 reads lookup.flags.flags.useMarkFilteringSet, so every shaping with mark lookups threw. The stand-in now has the shape of a decoded lookup's flags.
  • What still fails in ntk:
    • The vendored-header check, which is expected for a local pack.
    • test/brotli-dictionary.test.js. Upstream now needs brotli ^2.1.0, which is ESM and initialises its 756 KB static dictionary when it is imported, about 11 ms. ntk#427's require.cache stand-in cannot reach an ESM module. ntk's startup goes from 33.7 ms to 36.2: the rest of upstream's build saves about 8 ms. The fix belongs in brotli.js, a lazy dictionary, and is being prepared separately.
  • react-x11-components, on that ntk: 3801 of 3805 pass and 2 are skipped. The one failure is a WebSocket timing test that passes alone, three runs out of three, on both the old fontkit and this one. Upstream's metric changes, typo metrics under useTypoMetrics among them, move nothing those tests measure.

dajiaohuang and others added 30 commits October 2, 2026 18:33
* Fix glyph code point cache invalidation

* fix: isolate glyph cache by code points

* fix(glyphs): refresh empty metadata without changing cache identity
getCharacterSet returned every code in each format 4 segment, including
the required 0xFFFF closing segment and codes mapped to glyph 0, so it
disagreed with hasGlyphForCodePoint. Keep only codes whose lookup is
not .notdef, for every cmap format.

Co-authored-by: youdie006 <[email protected]>
The format 10 struct referenced a numChars field that does not exist,
so any font with a format 10 subtable threw "Not a fixed size" on
lookup.

Co-authored-by: youdie006 <[email protected]>
codePointsForGlyph handled formats 0, 4, 12 and 13 only, so
stringsForGlyph threw "Unknown cmap format 10" for a font whose best
cmap is format 6 or 10, although lookup and characterSet support both.

Co-authored-by: youdie006 <[email protected]>
foliojs#393)

* Keep a glyph's variation metrics stable when several composites use it

_getPhantomPoints built the phantom points from this._metrics, which
_getContours replaces with the varied metrics. A glyph is decoded again
for every composite that uses it, so the gvar delta was applied once per
composite: in Mada-VF at wght 100, dotless i advanced 229, 202, 175, 148
after laying out its accented composites. Build the phantom points from
the default metrics.

* Use !this._defaultMetrics and test a common Latin case

---------

Co-authored-by: youdie006 <[email protected]>
…oliojs#394)

* Map space and missing code points correctly with a legacy Mac cmap

The hand-written Mac encoding tables had a plain space at 0xCA where the
no-break space belongs, so U+0020 looked up the NBSP glyph and U+00A0
fell back to byte 0xA0. lookup also passed through any code point the
encoding lacks, so U+00B9 and U+0080 hit unrelated glyphs. Fix the
tables and return glyph 0 for non-ASCII code points the encoding cannot
represent.

* Check the re-encoded code point with !codepoint

---------

Co-authored-by: youdie006 <[email protected]>
The USE state machine only matches syllables, so a ZWNJ, ZWJ, CGJ or
another character that is not part of one kept a null shaperInfo, and
reorder() threw "Cannot read properties of null (reading 'syllable')".
Give these glyphs their own syllable, as the Indic shaper does with
non_indic_cluster and HarfBuzz does with non_cluster.
TTFGlyphEncoder still passed a byte count to EncodeStream, which since
restructure 3 takes a Uint8Array, so createSubset().encode() threw a
TypeError for any font with set variations (foliojs#308). The existing test for
this only runs when /Library/Fonts/Skia.ttf exists, so add one with a
bundled variable font.
* Continue after the glyphs a lookup matched or inserted

After a lookup applied, fontkit always moved on to the glyph after the
one it started at. A multiple substitution was then applied to its own
output, and a contextual lookup to glyphs inside its input sequence or
inserted by its nested lookups, so fonts like Libre Barcode EAN13 Text
ran out of memory (foliojs#383). After a deletion, the following glyph was
skipped.

Like HarfBuzz, continue after the inserted glyphs, at the glyph that
followed a deleted one, and after the input sequence of a contextual
lookup, adjusted for the glyphs its nested lookups inserted or deleted.

* Use decoded filed values

---------

Co-authored-by: Luiz Americo <[email protected]>
…#400)

* Fix repha, pre-base vowel and dotted circle placement in USE

reorder() in the Universal Shaping Engine differed from HarfBuzz in
three places:

- The dotted circle of a broken cluster was inserted after the first
  non-repha glyph instead of before it, and shared that glyph's
  shaperInfo, so it was never treated as a base.
- Moving a repha read glyphs[i] after splicing the array, which
  duplicated a glyph or inserted undefined, so a repha followed by a
  base (e.g. RA + virama + KA in Modi or Tirhuta) crashed or gave
  extra glyphs. Move it before the first post-base glyph, as HarfBuzz
  does.
- Every part of a pre-base vowel split by a multiple substitution was
  moved before the base. Only move the first, like HarfBuzz and the
  Indic shaper.

* Small optimization

---------

Co-authored-by: Luiz Americo <[email protected]>
- Decompose SARA AM (U+0E33) into NIKHAHIT + SARA AA and reorder NIKHAHIT past above-base marks, matching `preprocess_text_thai`
- Apply PUA tone/vowel shift fallback for legacy fonts without Thai GSUB, mirroring `do_thai_pua_shaping` (above/below state machines + Windows/Mac PUA mapping tables)
- Fall back to the buffer's Unicode script in OTLayoutEngine when neither GSUB nor GPOS picks an OT script — lets script-specific shapers run on fonts without matching GSUB/GPOS so the Thai PUA fallback is actually reachable
- Fix typo in `UnicodeLayoutEngine` Thai mark classification: `0x0E3D` (unassigned) → `0x0E4D` (NIKHAHIT)
- Register `thai` and `'lao '` (the 4-char OT tag with trailing space), add Noto Sans Thai + Noto Sans Lao (OFL) as test fixtures with 8 shaping tests
- Compose Hebrew presentation forms (FBxx range) for fonts without GPOS marks, matching `compose_hebrew`; chain compositions step by step (e.g. SHIN + SHIN_DOT + DAGESH → FB2C) instead of bailing after the first match
- Reorder patah/qamats + sheva/hiriq + meteg/below mark triplet by codepoint identity, matching `reorder_marks_hebrew`'s end state for both canonical NFC input and HarfBuzz's modified-CCC pre-reorder order
- Scope the `hasGposMark` gate to the Hebrew script's GPOS features instead of scanning the global featureList, matching HarfBuzz's per-script `has_gpos_mark`
- Add Taamey Frank CLM fixture (GPL v2 + font exception, also used by HB) with 6 shaping tests; reorder tests now exercise real code (each fails with the function disabled)
…er (foliojs#403)

* Compose canonical decompositions (NFC) in the default shaper

- Compose each base + combining-mark cluster into the font's precomposed glyph via font-aware NFC before GSUB, matching HarfBuzz: decomposed input (i + U+0300, or Arabic alef + fathatan + hamza-above) shapes to the precomposed glyph instead of separate marks
- Only apply the result when it changes the glyph count (composition or its decompose fallback); leave pure canonical reordering alone so it can't disturb downstream GSUB (e.g. Arabic shadda + vowel calt)
- Decompose back any composed codepoint the font has no glyph for, so its marks stay available for GPOS mark positioning
- Scope to the default shaper and the Arabic/Hebrew/Thai shapers that inherit it; Indic/Hangul/Universal keep their own composition
- Add 6 canonical-composition shaping tests (FiraSans Latin, Amiri Arabic incl. the reorder regression)

* Fix canonical composition fallback and mark positioning

---------

Co-authored-by: Jürg Lehni <[email protected]>
Co-authored-by: Luiz Americo <[email protected]>
…iojs#410)

- Place below marks under the base instead of above it, and never shift them up
- Add the gap for unattached marks instead of attached ones, and limit how far above marks shift down
- Center marks on the base advance with integer rounding, and stack marks per combining class
- Add tests with an OFL subset of Noticia Text Italic, checked against hb-shape
- Attached marks were offset by the advances of the marks between them and their base, which shapers zero only after GPOS, misplacing stacked marks in fonts whose marks have an advance (Roboto Serif, and all Hebrew vowels in Akkurat Hebrew)
- Resolve cursive and mark attachments once, after the advances are final, as HarfBuzz does, instead of after every GPOS stage
- Add a test with a Roboto Serif subset (OFL), matching hb-shape

Co-authored-by: Luiz Américo <[email protected]>
lehni and others added 27 commits October 21, 2026 18:53
- A feature requested by the user was ignored if the shaper had already planned it locally, e.g. frac, which is only applied around U+2044 by default
- Such features now apply to the whole run while keeping their stage, matching HarfBuzz's feature merging
- Replaces the previous approach of always applying frac globally, which turned all digits into numerators
- Add a test for user-enabled frac, matching HarfBuzz output
* Support variations of fonts in WOFF and WOFF2 containers

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.

* Address the review of foliojs#389

- Decode the points and deltas of glyph variations in functions at module scope
- Move the components of composite glyphs only for composite glyphs, as TTFGlyph does

---------

Co-authored-by: Andrey Sidorov <[email protected]>
…itution) (foliojs#370)

- Fix the GSUB Type 8 struct to include the missing `backtrackGlyphCount` field; without it the binary reader couldn't decode the table at all
- Implement Type 8 application in GSUBProcessor: check coverage at the current glyph, verify backtrack + lookahead context, replace the glyph with `substitutes[coverageIndex]` on match
- Iterate Type 8 lookups right-to-left in OTProcessor's applyLookups (per spec) so lookahead always sees post-substitution glyphs and backtrack sees the pre-substitution ones
- Add Noto Coptic as a test fixture (its ccmp feature uses Type 8; without this fix any Coptic layout throws "GSUB lookupType 8 is not supported")
- A soft hyphen replaced by its case form was still swapped for an invisible space, as the substituted glyph keeps the soft hyphen's code point
- Hide only default ignorables that still show their nominal glyph, as HarfBuzz does
- Add tests for hiding a soft hyphen and keeping its case form, with Fira Sans
- Type 3 hardcoded USER_INDEX = 0; `nalt: 2` silently rendered as alt #1
- Thread user features through ShapingPlan so GSUB reads the 1-based index
- Don't mutate caller's features in OTLayoutEngine.setup; overlay instead
- A TrueType variable font with no explicit variation set skipped FeatureVariations entirely, unlike HarfBuzz, which always evaluates them at the current location
- Evaluate them at the default location for such fonts, so default-location substitutions (e.g. bracket-layer alternates) apply
- Add a test with a subset of Archivo (OFL), whose rvrn alternates for $ and ¢ cover the default weight
- Backtrack arrays are stored in reverse buffer order per OT spec
- Reverse before matching so backtrack[0] aligns with position before input
- Sort every mark run by combining class instead of keeping the typed order of marks that don't compose (q + dot above + dot below now orders the dot below first)
- Use HarfBuzz's modified combining classes, so shadda sorts before Arabic vowels even when the vowel is typed first, and Hebrew, Thai, Telugu and Tibetan marks follow the order fonts expect
- Move Arabic modifier combining marks (hamza above or below, small high letters) to the start of their run, as in UTR foliojs#53
- Add tests for Latin reordering, Arabic modifier marks and Allah typed with fatha before shadda in Amiri, matching hb-shape
…s#373)

- Skip marks not in the lookup's filtering set per OT spec
- Overrides ignoreMarks and markAttachmentType for marks
Name records with platform 3 and encoding 0 (Symbol) are UTF-16BE like
the other Windows encodings, but were mapped to an encoding the string
decoder doesn't know, so their names came back as Buffers.

Co-authored-by: Wleeaf <[email protected]>
…t ones (foliojs#413)

* Use non-default variation sequences when the selector also has default ones

getVariationSelector only searched the non-default UVS table when the
code point was found in the default UVS table, so a selector with both
tables never returned its variant glyphs.

* Use a subset of Noto Sans CJK JP for the variation sequence test

---------

Co-authored-by: Wleeaf <[email protected]>
…oliojs#419)

* Respect useTypoMetrics, and fix vhea and empty glyph metrics

- Fix the vhea table structure
- Give empty glyphs zero coordinates instead of infinite ones
- Read the maxp, post and vhea versions as 16.16 version numbers
- Respect the useTypoMetrics flag for the font's ascent, descent and line gap, and add lineHeight
- Fix an undefined control box in the glyph metrics

* Follow HarfBuzz for the font metrics

- Use the typo metrics only when the font sets useTypoMetrics and the hhea metrics otherwise, with a positive ascent and a negative descent, as HarfBuzz does, instead of FreeType's fallbacks
- Drop lineHeight, which HarfBuzz doesn't have and clients can derive
- Expect HarfBuzz's vertical advance for Open Sans's space, which has no vmtx and doesn't set useTypoMetrics
- Add tests for the typo metrics, the vertical advance without vmtx, the vhea and maxp versions, and empty CFF glyph boxes, with HarfBuzz's values

---------

Co-authored-by: Vsevolod Volkov <[email protected]>
- Round the varied advances from HVAR to whole units, as HarfBuzz does
- Subsets of fonts with set variations no longer truncate advances like 851.9999 to 851
- Make the two HVAR tests check exact advances
…js#39) (foliojs#415)

* Reverse right-to-left text in fonts without GSUB, GPOS or morx (foliojs#39)

* Test order and marks with the existing mark fallback font

---------

Co-authored-by: Zahraa Sadeq <[email protected]>
…js#431)

U+1A60 TAI THAM SIGN SAKOT was classified as a halant, which the USE
grammar only allows before vowel signs. In Tai Tham the sakot and its
consonant can also follow vowel signs and vowel modifiers, so words
like ᨩᩢ᩠ᨿ became broken clusters and got a dotted circle.

Split the sakot off of H into its own Sk category and allow (Sk B)*
after the vowel modifiers and a sakot-terminated cluster, as HarfBuzz
does.
Upstream has not released since 2.0.4, and its master has taken three of
the fork's patches in its own form, so those are upstream's now:
variations of WOFF and WOFF2 fonts (the fork's #1, upstream's foliojs#411), a
feature the caller asks for applying to every glyph (the fork's #7,
upstream's foliojs#294 and foliojs#371), and the Node build decompressing with the
runtime's zlib (half of the fork's #5).

Every conflict is resolved to upstream's side, and on top of it the fork
keeps what upstream does not have:

- decoded structs with their hidden fields in private fields of a class
  (#4): every file that declares structs imports restructure from
  src/restructure.js again. The first shaping of SourceSansPro still takes
  17.5 ms against upstream's 23.3.
- setBrotliDecompressor and the size check (#5, #6), now over upstream's
  #compression, exported from base.js. test/entrypoints.js expects the
  extra export.
- the browser build in one file, dist/browser-module.mjs, which ntk
  vendors, as a third output of rolldown.config.ts.

The parcel targets for Chrome 91 go with parcel: upstream builds for
Chrome 120 and Node 22. The release workflow runs on Node 22, checks the
new dist files, and runs the whole suite: the setDefaultLanguage block the
parcel build broke passes now.
@sidorares
sidorares merged commit 3e780dc into master Oct 29, 2026
3 checks passed
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.