Skip to content

Read a GSUB subtable's record offsets unsigned, so a subtable over 32 KB still resolves - #107

Merged
jakejackson1 merged 1 commit into
gravitypdffrom
fix/100-expansion-shaping
Sep 14, 2026
Merged

jakejackson1 merged 1 commit into
gravitypdffrom
fix/100-expansion-shaping

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 14, 2026

Copy link
Copy Markdown
Member

Closes #100.

The expansion is not multiplying. A GSUB subtable's record offsets were read signed, so a subtable over 32 KB resolved a quarter of its records to the wrong place — and a font that computes on the glyphs it gets back then expanded what it was given, again.

The read

Multiple, Alternate and Ligature Substitution each hold one record per covered glyph, reached through an array of Offset16 from the start of the subtable. Offset16 is unsigned, and the counts beside those arrays are uint16. Eight fields in the shaper were read with the signed reader, and the same twelve in OtlDump, which keeps its own copy of them:

$Sequences = $subtable_offset + $this->reader->readInt16();

A subtable larger than 32,767 bytes has entries at or past 0x8000, and read signed those come back negative. The shaper then seeked 65,536 bytes short of the record it wanted and decoded whatever the table holds there — another glyph's replacement, with a glyph count belonging to neither.

Noto Sans Duployan

Its dist Multiple Substitutions are about 40 KB each, so roughly a quarter of the 1,185 sequences in each sits past the boundary.

run before after hb-shape
U+1BC44 U+1BC45 175 glyphs, 0.08s 175 glyphs, 0.08s 175
U+1BC44..U+1BC46 102,025 glyphs, 50s 259 glyphs, 0.11s 259
U+1BC44..U+1BC47 102,109 glyphs, 52s 343 glyphs, 0.13s 359
U+1BC00..U+1BC03 666 glyphs, 0.27s 666 glyphs, 0.24s 751

Two characters were already right, because every sequence that run reached lay inside the first 32 KB — which is why the issue's first row looked like a plateau rather than a symptom. The third character reaches one that does not, and from there the glyphs it was handed are matched and expanded by the rules that follow. The log the issue asked for points at it: one Multiple Substitution turned 24 glyphs into 10,157 in a single call, having read a glyph count of 10,119 out of the middle of another sequence.

Rendering the whole Duployan block four characters at a time exhausted 2 GB, and does not now.

Two things the issue expected that are not so

The markers are meant to reach the page. hb-shape with the same font and the same features leaves the same markers: 175 glyphs for two characters, not 5. They are zero-advance glyphs that the chaining rules compute on, and a renderer draws nothing for them. There is no missing collapsing pass.

The residual difference is two further defects rather than one. This PR closes the count gap for runs up to three characters; at four the run is still 16 glyphs short, and those 16 are carry markers emitted by lookups reachable only as nested lookups — which is #102. With that fix applied as well, every sample here matches hb-shape's glyph count exactly: 91 / 175 / 259 / 359 / 751.

The glyphs themselves still do not all agree. The font computes its spacing with a counter built out of marker glyphs, and 2 to 58 of its digits come out wrong depending on run length — traced to one chained context that matches where HarfBuzz matches nothing, and filed as #108. Measured against hb-shape glyph for glyph:

run digits wrong, this PR alone with #102 too
U+1BC44 3 none - identical
U+1BC44 U+1BC45 20 2
U+1BC44..U+1BC46 28 6

What does not move

Of the 104 fonts in packages/, tests/data/ttf and the current google/fonts releases of five Noto families, ten carry an offset past 0x8000:

family affected
Aboriginal Sans and Serif, 8 faces 132–159 of ~270 ligature sets
Noto Emoji, 2 faces 60 of 227 — the keycap ligatures

None of the ten carries a GDEF table, and mPDF refuses useOTL on a font without one, so none reaches a GSUB lookup as shipped and no page in the corpus moves. The suite passes unchanged. The fix matters for fonts a user brings, which is how #100 was found.

The test font

NotoSans-GSUB2-BigSubtable-Synthetic, built for this the way the two GPOS synthetics already in tests/data/ttf were. 1,000 covered glyphs, each replaced by fifteen markers with the glyph itself in the middle — the shape of a font that shapes by expansion. Its Sequence array comes to 36,072 bytes, so the last 95 entries sit past the boundary; only the four glyphs a test types carry an outline, and the markers draw nothing.

Against the code before the fix, a character from the far end of the subtable is replaced by eight glyphs belonging to another one and its row of the snapshot is blank.

🤖 Generated with Claude Code

… KB still resolves (#100)

Multiple, Alternate and Ligature Substitution each hold one record per covered
glyph, reached through an array of Offset16 from the start of the subtable.
Offset16 is unsigned and the counts beside those arrays are uint16, but eight
fields in the shaper and the same twelve in the dump were read signed. A subtable
larger than 32,767 bytes has entries at or past 0x8000, which came back negative:
the reader then seeked 65,536 bytes short of the record it wanted and decoded
whatever the table holds there - another glyph's replacement, with a glyph count
belonging to neither.

Noto Sans Duployan's 'dist' Multiple Substitutions are about 40 KB each, so
roughly a quarter of the 1,185 sequences in each sits past the boundary. A font
that shapes by expansion then matches and expands what it was handed, so a wrong
glyph count is not one wrong glyph: U+1BC44..U+1BC47 came out as 102,109 glyphs
and 52 seconds of shaping, and is now 343 glyphs and 0.13s. Rendering the whole
Duployan block four characters at a time exhausted 2 GB, and does not now.

The residual gap to HarfBuzz's 359 glyphs on that run is #102, whose carry
markers are emitted by lookups reachable only as nested lookups.

Test font: NotoSans-GSUB2-BigSubtable-Synthetic, built for this - 1,000 covered
glyphs each replaced by a run of fifteen markers, whose Sequence array is 36,072
bytes so the last 95 entries sit past the boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 force-pushed the fix/100-expansion-shaping branch from 0c653b0 to 8fe905a Compare September 14, 2026 22:47
@jakejackson1 jakejackson1 added bug Something isn't working create-upstream-pr labels Sep 14, 2026
@jakejackson1
jakejackson1 merged commit 5cc2ed9 into gravitypdf Sep 14, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working create-upstream-pr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A font that shapes by expansion leaves its marker glyphs on the page

1 participant