Skip to content

Skip mark-positioning lookups with null anchors instead of crashing - #374

Open
lehni wants to merge 1 commit into
foliojs:masterfrom
lineto:fix/null-gpos-anchor
Open

lehni wants to merge 1 commit into
foliojs:masterfrom
lineto:fix/null-gpos-anchor

Conversation

@lehni

@lehni lehni commented May 20, 2026 •

Copy link
Copy Markdown

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.applyAnchor currently dereferences both baseAnchor and markRecord.markAnchor without guarding against null, so any font that ships a NULL anchor crashes with TypeError: Cannot read properties of null (reading 'xCoordinate') during shaping.

Add an early-return null check in applyAnchor, matching HarfBuzz's behaviour (MarkBasePosFormat1.hh and 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.

@lehni
lehni force-pushed the fix/null-gpos-anchor branch from 8e6fe5c to 00519d6 Compare May 20, 2026 17:21
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant