Skip to content

fix(text): a mark whose anchor the face leaves out is not attached by that subtable - #435

Merged
sidorares merged 1 commit into
masterfrom
fix/null-anchors
Sep 28, 2026
Merged

sidorares merged 1 commit into
masterfrom
fix/null-anchors

Conversation

@sidorares

Copy link
Copy Markdown
Owner

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:

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

… 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
sidorares merged commit 2c73f20 into master Sep 28, 2026
6 checks passed
@sidorares
sidorares deleted the fix/null-anchors branch September 28, 2026 22:28
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>
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.

1 participant