Repository navigation
Conversation
lehni
force-pushed
the
fix/null-gpos-anchor
branch
from
May 20, 2026 17:21
8e6fe5c to
00519d6
Compare
sidorares
added a commit
to sidorares/ntk
that referenced
this pull request
Sep 28, 2026
… that subtable (#435) Arabic in the sans-serif italic chain takes a react-x11 app down on this machine: `app.fonts.layout('ٱللَّهِ', { family: 'sans-serif', style: 'italic' })` throws `Cannot read properties of null (reading 'xCoordinate')`. So does a Lithuanian `Į̃` in DejaVu Sans Mono, the default monospace here, and so does `À` followed by a combining grave. It happens on 8.13.1 and 8.14.0 alike, so it is not a regression. **Why:** a mark-to-base, mark-to-ligature or mark-to-mark subtable holds an anchor for each base and mark class, and the OpenType spec lets one be NULL, meaning this subtable attaches no mark of that class to that glyph. fontkit reads the NULL's coordinates anyway. Faces ship NULLs: Noto Sans Bold has 2,822, DejaVu Sans Mono 198, and Amiri, Noto Naskh Arabic and FreeSerif thousands each. The italic chain reaches Amiri Italic for Arabic. Upstream this is foliojs/fontkit#367, and fontkit has not released since 2.0.4 in August 2024. **The fix** (`lib/text/anchors.js`, `Font#_layout`): a NULL is taken as HarfBuzz takes it. The subtable did not apply, and the lookup's next subtable gets its turn. foliojs/fontkit#374 returns early from `applyAnchor` instead, which ends the crash but still counts the subtable as applied, so a later subtable never gets the mark. Reporting "not applied" means wrapping fontkit's `applyLookup`, which runs for every glyph at every lookup. Always on, that wrapper costs 2% of uncached shaping, about 0.6 µs a word. So a face pays for it only from the first NULL its text reaches, the same shape as #428's mark stand-ins. Until then only `applyAnchor` is watched, which runs only when a mark attaches, and a NULL there abandons the layout. The run is then shaped again with the wrapper in. Abandoning loses nothing, because fontkit keeps no state from a layout apart from tables it has decoded. **Checked against HarfBuzz** (harfbuzzjs, in a scratch directory, not a dependency). I took every base-and-mark pair that reaches a NULL in twelve faces: DejaVu Sans Mono and Serif, Noto Sans Bold, FreeSerif, FreeSans, Liberation Mono, Amiri Regular and Italic, Noto Naskh Arabic, Noto Sans Arabic, Adlam and Bhaiksuki. That is 6,729 pairs, and fontkit's substitution gives the same glyphs as HarfBuzz for 4,687 of them. In all 4,687 the mark sits where HarfBuzz puts it. The rest differ from HarfBuzz before any anchor is read. They are mostly Arabic, where fontkit's joining and ligatures are not HarfBuzz's, and that is a separate matter. **Cost, interleaved against master:** | Case | master | this change | |---|---|---| | 30,000 uncached Latin words, no mark attaches | 865–876 ms | 858–878 ms | | 20,000 uncached words with combining marks, DejaVu Sans (no NULLs) | 541–543 ms | 528–548 ms | Both are within noise. **Tests** (`test/text-anchors.test.js`, on the system's DejaVu, and skipped where fontconfig answers another face, as `text-marks.test.js` does): 1. `À` + U+0300 in DejaVu Sans Mono throws in plain fontkit. Through `Font` it comes out exactly as with mark attachment off, and `Į̃ ų̃ ė̃` shapes. 2. A face whose text reaches no NULL is never wrapped. It is wrapped from the first NULL on, and still gives fontkit's answer. 3. In DejaVu Sans, a NULLed copy of the subtable that attaches U+0301 to `e` is put ahead of the real one: the answer is unchanged. The NULLed copy alone is the same as having no such subtable. Mutation-checked: - With no watch, all three tests fail. - With #374's early return, two fail: tests 2 and 3. - With the wrapper on from the start, test 2 fails. `docs/text.md` has a section on it. Full suite: 1,351 tests. The only failures are the two live-X tests that flake on this desktop, hinted motion and XI2 selection. #434 fixes the other shaping crash from the same survey: emoji that fall back to a bitmap-only face. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.
GPOS subtables (Lookup Types 4, 5, 6) may legally contain NULL anchor offsets per the OT spec — they mean "no anchor for this base/ligature/mark class in this lookup", which the layout engine should treat as a no-op rather than an error.
GPOSProcessor.applyAnchorcurrently dereferences bothbaseAnchorandmarkRecord.markAnchorwithout guarding againstnull, so any font that ships a NULL anchor crashes withTypeError: Cannot read properties of null (reading 'xCoordinate')during shaping.Add an early-return null check in
applyAnchor, matching HarfBuzz's behaviour (MarkBasePosFormat1.hhand friends silently skip the application when the anchor is null).Test
Added the hinted Noto Sans Malayalam (
NotoSansMalayalam-Hinted.ttf, SIL OFL) which ships 189 NULL anchors. Layout ofപുസ്തകംreaches the null path and used to throw; the assertion now matches HarfBuzz's output exactly.Resolves #367.