Skip to content

Apply a nested lookup only to a glyph the subtable it names covers - #106

Open
jakejackson1 wants to merge 3 commits into
gravitypdffrom
fix/102-nested-lookup-coverage
Open

Apply a nested lookup only to a glyph the subtable it names covers#106
jakejackson1 wants to merge 3 commits into
gravitypdffrom
fix/102-nested-lookup-coverage

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Closes #102.

A contextual or chaining lookup matches a sequence of glyphs on its own Coverages, then names other lookups to run at positions within that match. The glyph handed to one of those is whatever the context matched at that position; the subtables of the lookup it names each carry a Coverage of their own, and nothing compared the two.

Two commits:

  1. the fix, with a subset carrying the positioning half and a test covering both
  2. a snapshot of the marks the substitution half was replacing

Where the check goes

The three loops that apply a lookup from the top of the list test the glyph against each subtable's Coverage before entering it. _applyGSUBlookupRecords() and _applyGPOSlookupRecords() walked every subtable of a nested lookup unconditionally, and every handler then indexes that Coverage with no check of its own, because until now it could not miss.

The index is not merely undefined. It is null, which is 0, so the handler reads whatever the subtable holds for the first glyph it covers and applies that to a glyph it was never meant to touch. It then reports having applied, so the loop breaks — and the subtable of the same lookup that does cover the glyph is never reached.

The issue asks where the check belongs. It is in _applyGSUBsubtable() and _applyGPOSsubtable(), the one seam both callers pass through, so the invariant every handler below rests on is stated once where they all come past it. The three top-level loops keep theirs as a fast path: a glyph no subtable covers then costs no seek(), which is what #83 was about.

What it was costing

DejaVu Sans, which is shipped. It substitutes a shorter, higher form of a combining mark for the one written when it follows a capital, through a chained context naming a single substitution that covers the twelve marks it has such a form for. The context presents forty-four. The other thirty-two were all given Coverage Index 0 — the capital grave — so a macron, an overline, a hook above or a comma above over a capital was drawn as a grave accent. Every one of them the same accent.

written drawn before drawn now
A + U+0300 grave capital grave capital grave
A + U+0304 macron capital grave macron
A + U+0305 overline capital grave overline
A + U+0309 hook above capital grave hook above
a + U+0304 macron macron macron

Noto Sans Gurmukhi, the shape the issue was reported from. A dist chained context names a single adjustment at a Devanagari stress sign; that lookup's first subtable covers the bindi and its second covers the stress signs. The udatta and anudatta were positioned by the bindi's record — on the baseline and 150 units to the left — instead of above and below the letter, and reading the Coverage raised a warning per glyph per run under E_ALL.

How reachable

Of the 104 fonts in packages/, tests/data/ttf and the current google/fonts releases of the five families #104 names, 23 carry a nested lookup subtable a context can hand a glyph it does not cover: Padauk, Taamey David, sixteen faces of the DejaVu families, Free Serif, Noto Sans, and the Bengali, Devanagari and Gurmukhi Noto families.

The issue reports none. The check behind that figure was per lookup; this one is per subtable, and a lookup can cover a glyph in its second subtable and still be entered at its first.

What moves

sweep runs result
Gurmukhi, Devanagari, Bengali, Myanmar, Hebrew, Odia and the combining marks, in the ten fonts the scan flagged 8,067 141 lines move

Every one of them is in DejaVu Sans, DejaVu Serif or Noto Sans Gurmukhi, and every one is a glyph or a position belonging to another character becoming the one the font asks for. Nothing else in the corpus moves, and the suite passes unchanged.

The test font

NotoSansGurmukhi-NestedCoverage-Subset — 5 glyphs of Noto Sans Gurmukhi 2.005 (OFL 1.1): the KA, the bindi, the two stress signs, and the conjunct that keeps the nested lookup's first subtable at the two entries which make it a format 2. Format 2 is the one that indexes its Coverage, so it is the one that reads a glyph it has not got rather than applying a single record to everything; a subsetter that drops the conjunct collapses it to format 1 and the diagnostic goes with it.

Nothing shipped reaches the positioning half. The substitution half needs no font of its own — DejaVu Sans carries it.

🤖 Generated with Claude Code

jakejackson1 and others added 3 commits September 14, 2026 16:52
)

A contextual or chaining lookup matches a sequence of glyphs on its own Coverages, then names other
lookups to run at positions within that match. The glyph handed to one of those is whatever the
context matched at that position; the subtables of the lookup it names each carry a Coverage of
their own, and nothing compared the two.

The three loops that apply a lookup from the top of the list do compare them - isset() on the
subtable's Coverage before entering it. _applyGSUBlookupRecords() and _applyGPOSlookupRecords(), which
apply what a matched context names, walked every subtable of the nested lookup unconditionally. Every
handler then indexes that Coverage with no check of its own, because until now it could not miss:

    $GlyphPos = $LuCoverage[$currGID];

The index is not merely undefined. It is null, which is 0, so the handler reads whatever the subtable
holds for the first glyph it covers and applies that to a glyph it was never meant to touch. It then
reports having applied, so the loop breaks - and the subtable of the same lookup that does cover the
glyph is never reached.

The check goes in _applyGSUBsubtable() and _applyGPOSsubtable(), the one seam both callers pass
through, rather than in the two *lookupRecords() methods, so that the invariant every handler below
rests on is stated once where they all come past it. The three top-level loops keep theirs: a glyph no
subtable covers then costs no seek, which is what #83 was about.

## What it was costing

DejaVu Sans, which is shipped. It substitutes a shorter, higher form of a combining mark for the one
written when it follows a capital, through a chained context naming a single substitution that covers
the twelve marks it has such a form for. The context presents forty-four. The other thirty-two were
all given Coverage Index 0 - the capital grave - so a macron, an overline, a hook above or a comma
above written over a capital was drawn as a grave accent. Every one of them the same accent.

Noto Sans Gurmukhi, the shape the issue was reported from. A `dist` chained context names a single
adjustment at a Devanagari stress sign; that lookup's first subtable covers the bindi and its second
covers the stress signs. The udatta and anudatta were positioned by the bindi's value record - on the
baseline and 150 units to the left - instead of above and below the letter, and reading the Coverage
raised a warning per glyph per run under E_ALL.

Of the 104 fonts in packages/, tests/data/ttf and the current google/fonts releases of the families
#104 names, 23 carry a nested lookup subtable a context can hand a glyph it does not
cover: Padauk, Taamey David, sixteen faces of the DejaVu families, Free Serif, Noto Sans, and the
Bengali, Devanagari and Gurmukhi Noto families. The issue reports none, because the check behind it
was per lookup rather than per subtable - and a lookup can cover a glyph in its second subtable and
still be entered at its first.

Over the blocks those fonts shape - 8,067 runs across Gurmukhi, Devanagari, Bengali, Myanmar, Hebrew,
Odia and the combining marks - 141 lines move, all of them in DejaVu Sans, DejaVu Serif and Noto Sans
Gurmukhi, and all of them from a glyph or a position belonging to another character to the one the
font asks for. Nothing else in the corpus moves, and the suite passes unchanged.

## The test font

NotoSansGurmukhi-NestedCoverage-Subset, 5 glyphs of Noto Sans Gurmukhi 2.005 (OFL 1.1): the KA, the
bindi, the two stress signs, and the conjunct that keeps the nested lookup's first subtable at the two
entries which make it a format 2 - the format that indexes its Coverage, and so the one that reads a
glyph it has not got rather than applying one record to everything. Nothing shipped reaches the
positioning half; the substitution half is covered through DejaVu Sans, which is.

Refs #102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n a snapshot

Both halves of this move a page, so the document is what a reader can check and the fixture is what
stops either coming back.

DejaVu Sans is the half that needs no font of its own. Ten combining marks are drawn over a capital A
and again over a lowercase a, five the font has a capital form of and five it has not. The lowercase
column is the mark as written and the capital column is what the font asks for, so a row shows both
what should happen and what did: before the fix every mark in the second table was drawn as the
capital grave of the first row above it, and the page was five À in a column labelled macron,
overline, hook above, comma above and double overline.

Noto Sans Gurmukhi is the half the issue was reported from, and the subset carries it. The udatta and
anudatta are drawn on a KA, with the KA alone beside each so the mark can be seen against where the
letter is, and the bindi under them - the glyph whose value record the two were being given.

Two tests beside the snapshot read the glyphs off the page rather than naming them, since the capital
forms have no codepoints of their own and are mapped into the Private Use Area as the subset is built.
Each mark with no capital form has to be drawn as the mark that was written and not as the grave;
each mark with one has to be drawn as something else, and as something different from the others, so
that turning the rule off entirely would not pass. Both fail against the code before the fix, and so
does the fixture.

Refs #102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…check is worth

A review pass over the fix, none of which changes a page: 8,067 runs across the ten fonts that carry
the shape and 4,096 across six Indic fonts are identical either way, and the suite passes unchanged.

_applyGSUBsubtableSpecial() is the third public way into a subtable and was left indexing its Coverage
unguarded, in exactly the caller-holds-the-invariant arrangement the fix rejected for the other two.
It is indexed by the consonant after the Halant rather than by the glyph the cursor is on, so the
guard is on $nextGID. Its one caller still checks, as the other loops do.

_applyGPOSpairAdjustmentFormat1() was the one handler that defended itself. The isset() half of that
is now dead, and leaving it in implies the invariant is not trusted - which is an invitation for the
next handler to re-add its own.

The two *lookupRecords() methods assigned $shift per record with no accumulation, so a record that
applied nothing overwrote the shift of one that had. That was unreachable before: a nested subtable
read Coverage Index 0 and reported having applied, whatever glyph it was handed. Returning 0 is the
normal case now, so the path is live, and the docblock's "from the last nested lookup that shifted
anything" describes what the code does rather than what it did.

The docblock claimed the callers' check saves a seek. It does not any more - the guard stands ahead of
the seek - so what it saves is the call. That is still worth keeping, and now measured: the callers
turn away 88,700 of 90,000 glyphs on an Arabic run, leaning on the guard alone is 15% slower end to
end, and keeping both costs 0.03 ms a page. _applyGSUBrulesIndic's copy could not have been removed
anyway, its elseif being the Halant-Consonant case.

The tests lose three assertions that restated their neighbours - the two-marks comparison is implied
by the exact-array assertion above it, and the overline and the lowercase macron each repeat the line
before - and the snapshot's glyphsOf() stops prepending the headings it then had to skip with end().
The breadth stays in the snapshot, which walks ten marks rather than three.

Refs #102

Co-Authored-By: Claude Opus 5 (1M context) <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

Development

Successfully merging this pull request may close these issues.

A nested lookup is applied to a glyph it does not cover, and reads another glyph's record for it

1 participant