Repository navigation
fix(text): a mark whose anchor the face leaves out is not attached by that subtable - #435
Merged
Merged
Conversation
sidorares
force-pushed
the
fix/null-anchors
branch
from
September 28, 2026 22:23
e982711 to
adc8f00
Compare
… that subtable A mark attachment subtable may hold a NULL anchor for a glyph and mark class, which the OpenType spec means as "this subtable attaches no such mark to it". fontkit read the NULL's coordinates and threw out of the layout. Noto Sans Bold, DejaVu Sans Mono, Amiri, Noto Naskh Arabic and FreeSerif all ship them, so Arabic in an italic chain, or a Lithuanian letter in the default monospace, threw out of a render. A NULL is taken as HarfBuzz takes it: the subtable did not apply, and the lookup's next one is tried. That needs a wrapper around a call fontkit makes for every glyph at every lookup, which costs 2% of uncached shaping, so a face pays it only from the first NULL its text reaches. Until then only a mark's attachment is watched, and the run that meets the NULL is shaped again. On twelve faces, every base-and-mark pair that reaches a NULL and has the same glyphs as HarfBuzz's puts the mark where HarfBuzz puts it: 4,687 pairs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sidorares
force-pushed
the
fix/null-anchors
branch
from
September 28, 2026 22:27
adc8f00 to
e188f60
Compare
sidorares
pushed a commit
that referenced
this pull request
Sep 28, 2026
🤖 I have created a release *beep* *boop* --- ## [8.14.1](v8.14.0...v8.14.1) (2026-09-28) ### Bug Fixes * **text:** a face no glyph can be made from covers nothing ([#434](#434)) ([7dda32d](7dda32d)) * **text:** a feature a 0 turns off is off in a face's kern table too ([#431](#431)) ([b49a383](b49a383)) * **text:** a letter the first family lacks is set in the families the style names next ([#433](#433)) ([5fecee8](5fecee8)) * **text:** a mark whose anchor the face leaves out is not attached by that subtable ([#435](#435)) ([2c73f20](2c73f20)) --- 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>
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.
Arabic in the sans-serif italic chain takes a react-x11 app down on this machine:
app.fonts.layout('ٱللَّهِ', { family: 'sans-serif', style: 'italic' })throwsCannot 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 fromapplyAnchorinstead, 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 onlyapplyAnchoris 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:
Both are within noise.
Tests (
test/text-anchors.test.js, on the system's DejaVu, and skipped where fontconfig answers another face, astext-marks.test.jsdoes):À+ U+0300 in DejaVu Sans Mono throws in plain fontkit. ThroughFontit comes out exactly as with mark attachment off, andĮ̃ ų̃ ė̃shapes.eis put ahead of the real one: the answer is unchanged. The NULLed copy alone is the same as having no such subtable.Mutation-checked:
docs/text.mdhas 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