Skip to content

Read four lookup keys for what the font states, not for what the read assumed (#113) - #139

Merged
jakejackson1 merged 3 commits into
gravitypdffrom
fix/113-unguarded-reads
Sep 16, 2026
Merged

jakejackson1 merged 3 commits into
gravitypdffrom
fix/113-unguarded-reads

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 16, 2026

Copy link
Copy Markdown
Member

Fixes #113.

The issue calls all four reads "the same shape as #104... The behaviour was always 'treat
missing as absent'; only the log changed." Two were doing more than that, one is already
fixed, and the first has a fifth site the issue does not name - in the half of mPDF that
shapes the user's text.

1. The Single Substitution delta is added modulo 65536

A Format 1 subtable states its replacement as a number to add to the glyph id, and the spec
takes the sum modulo 65536 - which is the whole of how a font names a glyph below the one it
covers, or above the end of the range. The five families that reach it all wrap deliberately:

font glyphs the rule sum modulo 65536 feature
Cactus Classical Serif 58,570 glyph 504 + (−7504) −7,000 58536 uni2014.extended none
Noto Sans SignWriting 38,218 glyphs 8–27 + (−28287) −28,279 … 37257 F2.alt calt
Chiron Sung HK 51,055 glyph 50768 + 16458 67,226 1690 cid01690 hist
Chiron Sung HK 51,055 glyph 1 + (−17002) −17,001 48535 cid63015 none
Chiron Hei HK 52,080 glyph 51900 + 15324 67,224 1688 cid01688 hist
Chiron Hei HK 52,080 glyph 1 + (−15871) −15,870 49666 cid63121 none
Chiron GoRound TC 50,706 glyph 50526 + 16698 67,224 1688 cid01688 hist
Chiron GoRound TC 50,706 glyph 1 + (−17245) −17,244 48292 cid63121 none

The parser (TTFontFile::readGSUBrules()) added without the modulo, so the sum fell
outside glyphToChar, unicode_hex() made 00000 of the null that came back, and the rule
was recorded as substituting U+0000. That is the read the issue names, at TTFontFile.php:34
one frame down.

The shaper has its own copy of the same sum, and the issue does not name it.
Otl::_applyGSUBsingleSubst() hands the unmasked id to Otl::glyphToChar(), which indexes
glyphIDtoUni at $gid * 3. That string is exactly 65,536 glyphs long, so a negative id is
read from the end and lands on the right glyph by accident; one past 65,535 reads off the end
and comes back U+0000. The three Chiron families put the historical form of U+3127 above
65,536, and all three lose the character:

before after hb-shape --features=hist
Chiron Sung HK, font-variant-alternates: historical-forms nothing drawn, 6 diagnostics E201 (cid01690) cid01690
Chiron Hei HK nothing drawn, 6 diagnostics E210 (cid01688) cid01688
Chiron GoRound TC nothing drawn, 6 diagnostics E1B9 (cid01688) cid01688

The diagnostics are Uninitialized string offset 201678 and ord(): Providing an empty string is deprecated, both at Otl.php:4953. That is the same bug as the issue's first read
and is fixed the same way.

A sum still outside the font's glyphs after the modulo is a glyph the font has not got and
records nothing, which is what alternateSubstitutes() beside it already did - so the two
share substituteGlyph(), and so do the Ligature and Reverse Chaining branches, which spelt
the same guard out by hand.

2. A mark attachment class GDEF never defined

A lookup flag naming a mark attachment class says to skip every mark outside that class.
Which marks those are is GDEF's MarkAttachClassDef to say, and a font may set the flag
without defining that table at all - Carlito and NATS both do. No mark is then in the class,
so the lookup skips all of them. $this->MarkAttachmentType[$flag >> 8] gave null, which is
no marks skipped.

This is the one place a page moves. Carlito's ccmp replaces i and j with their
dotless forms before a mark above, under a flag naming class 1, and that rule was firing:

mPDF before mPDF after hb-shape
U+0069 U+0313 0131 dotless i 0069 gid98 i
U+012F U+0313 PUA, dotless iogonek 012F gid99 iogonek
U+0249 U+0313 PUA 0249 gid2100 uni0249

NATS does not move. Its flags name classes 1 to 10 and its GDEF defines none of them, but the
58 glyphs in its mark class are the subscript Ottu consonant forms; every Telugu matra and
the halant that its abvs, psts and haln ligatures stand on is GDEF class 1.

3. Context format 2's class 0

Class 0 is every glyph the other classes do not name, so it has no list of glyphs to match
against and mPDF matches nothing at such a position. Every input position above the first
already said so, with the comment explaining why; the first read InputClasses by the class
and got null.

Molengo is the one family, and its rule set cannot fire either way: the subtable's Coverage
holds i, j and U+0268, and the font's own Class Definition puts all three in class 1, so
nothing the Coverage admits is in class 0.

4. The empty LookupListIndex is already guarded

#117 added isset($feature['LookupListIndex'][0]) while splitting the readers, so Sedan SC
raises nothing on gravitypdf today - measured, 0 diagnostics. The comment there says "no
font in the 183 installed carries one"; Sedan SC's smcp lists no lookups at all, so that now
says fonts in the wild do, and there is a fixture.

Dropping the feature is the right answer rather than only the quiet one. A feature that runs
no lookups has nothing to do, and the key the row is ordered by is the lookup it has not got:
keying it by that put it under '', which ksort() ordered ahead of every real lookup index.
Dropping it leaves the real features in the order the Lookup table lists them, which is what
that ksort() is for.

What was measured

Every family the issue names, rendered at useOTL 0xFF with E_ALL through a handler,
against sample text derived per font from its own GSUB and GPOS coverage. The document is
dated and left uncompressed the way the snapshot tests are, so the bytes are comparable.

family warnings before after page bytes
Cactus Classical Serif 3 0 identical
Noto Sans SignWriting 60 0 identical
Chiron Sung HK 6 0 identical
Chiron Hei HK 6 0 identical
Chiron GoRound TC 6 0 identical
Carlito 57 0 moves
NATS 2,765 0 identical
Molengo 32 0 identical
Sedan SC 0 0 identical

The three Chiron families are their variable upstream (ChironSungHK[wght].ttf and the other
two) rather than statics: the GSUB is the same table either way, and it is the GSUB these
reads are in.

Every artefact the parser writes to the font cache is byte-identical for all nine: 77
files across the nine builds, 0 moved. That is where the surprise is. For a Latin, CJK or
SignWriting font the substitution rules readGSUBrules() builds are handed to
_getGSUBarray() and then thrown away - useGSUBlookups() interrogates them only for
arab syrc hebr thaa nko samr and deva beng guru gujr orya taml telu knda mlym. So the
U+0000 rule and the null class were computed and discarded, and reads 1 and 3 have no
consequence on any of the nine through the parser. They are still wrong, and for an Arabic
or Indic font the same reads feed rtlSUB and the Indic tables, which is what the new
synthetic shows.

What reaches the page is read 2, which Otl makes the same way while shaping, and the
shaper's own copy of read 1 - which is why the hist table above is measured through
font-variant-alternates rather than through the sample text.

Whole suite: 1,769 tests on gravitypdf, 1,776 here. Every existing fixture unchanged - all 60
snapshots, and the four golden masters (fontcache, otldump, shaping, subset) regenerated
across all 27 fonts already in tests/data/ttf with nothing left to commit. phpcs clean,
phpstan at its 33-error baseline.

Everything above was measured again after merging gravitypdf at 2362b56, which brought in
#134's char-width header clamp and #135's finals change - both in TTFontFile, neither in a
method this branch touches. The nine families come out at the same nine page hashes and the same
77-files-0-moved either side of the merge, and Blank-WideCmap-Synthetic,
NotoSansArabic-MultipleFinal-Subset and NotoSans-GSUBClassZero-Synthetic - the three fonts
added to the corpus since - all keep every fixture they came with.

One fixture of this branch's own did move, and it is worth saying why rather than only that it
did. NotoSansArabic-GSUB11Wrap-Synthetic's shaping master recorded a kashida of 2 on the alef,
which no run from an empty font cache reproduces. The synthetic was rebuilt part way through this
branch - its one lookup moved from fina to ccmp, so that the shaper applies it directly rather
than only through the joining table the parser derives - and mPDF's font cache keys the derived
GSUB.<script>.<lang>.json by font name and does not notice the file behind it has changed, so
composer shaping:update read the fina build's rtlSUB and finals. With ccmp as its only
feature the font states no joining forms at all and writes no GSUB.arab.DFLT.json, so the
priority-2 rule has nothing to match. Not #135: that trims a final form of several glyphs to its
first, and the stale entry was the single glyph 0E000, where explode() returns the whole of
it - reverting #135's hunk and regenerating gives the corrected file byte for byte.

Tests

TTFontFileTest gains four on the diagnosticsWhileParsing() seam #104 added.
MarkAttachmentTypeTest and DeltaGlyphIdTest are new, and each asserts on drawn glyphs -
the two reads that change what a page shows.

Four fixtures, three of them subsets:

  • Carlito-MarkAttachmentType-Subset.ttf (5,792 bytes) - Carlito 1.104 cut to U+0069,
    U+006A and U+0313. Before this branch it draws E000 0313; after, 0069 0313.
  • Molengo-GSUB52Class0-Subset.ttf (9,364 bytes) - Molengo 0.11, the same 32 diagnostics
    as the whole font. Subset with --retain-gids and GSUB left alone, because the subsetter
    prunes a rule set for class 0 - which is the thing the fixture is for.
  • SedanSC-EmptyFeature-Subset.ttf (8,232 bytes) - Sedan SC 1.100. The subsetter drops a
    feature with no lookups, so smcp is put back. aalt and c2sc are left in so the test can
    say the real features still come out in lookup order.
  • NotoSansArabic-GSUB11Wrap-Synthetic.ttf (132,540 bytes) - the wrap, which no subset of
    the five real families can carry. It covers glyph 32770 and adds 32767, so the sum is 65537
    and the substitute is glyph 1. The glyph count is the point: an int16 delta cannot carry a sum
    past either end of the range from any glyph closer in than 32,769, so a font demonstrating a
    wrap needs tens of thousands of glyphs. All but four are empty and share one advance width, so
    loca and hmtx are 65,544 bytes each and everything else is 1,452. Its letters are Noto Sans
    Arabic 2.012, from the joining subset already here, and its one lookup is a ccmp so that both
    halves read it: the parser through rtlPUAstr, the shaper as a lookup of its own. Before this
    branch the parse raises three diagnostics and rtlPUAstr is empty, and the letter draws as
    U+0000 or not at all; after, the parse is silent, rtlPUAstr is \x{0E000}, and the letter
    draws as E000.

Left alone

multipleSubstitutes() reads glyphToChar[$sub][0] unguarded for the same reason Type 1 did,
and a Sequence naming a glyph the font has not got would reach it. No font in the 1,942-family
catalogue does, and guarding it would be a change nothing measured here covers.

The four flag-decoding members that TTFontFile and Otl each keep a copy of -
_checkGSUBignore/_checkGCOMignore, _getGSUBignoreString/buildGCOMignoreList,
marksOutsideFilteringSet, markGlyphSet - are now five, because this fix had to be written
twice. That is the existing shape of the two classes rather than anything this branch chose,
but it is the reason the mark-attachment rule has two tests, one per copy.

🤖 Generated with Claude Code

… assumed (#113)

Four keys read without checking they are there, each the same shape as #104: a key the
font never set, which PHP 7 answered with a notice and PHP 8 answers with a warning. Two
of them were doing more than warning, and the fourth is already guarded.

A Single Substitution Format 1 states its replacement as a number to add to the glyph id,
and the spec adds it modulo 65536 - which is how a font names a glyph below the one it
covers, or above the end of the range. Cactus Classical Serif reaches its extended em dash
with -7504 from glyph 504, Noto Sans SignWriting its alternate forms with -28287 from
glyphs 8 to 27, and Chiron Sung HK wraps both ways, glyph 50768 up past 65536 and glyph 1
down under zero. Both halves of mPDF added without the modulo: the parser then read
glyphToChar outside its keys, and unicode_hex() made U+0000 of the null that came back, so
the rule named the null character. A sum that is still outside the font after the modulo is
a glyph the font has not got and records nothing, which is what the Alternate Substitution
beside it already did, so the two share substituteGlyph() - and so, now, do the Ligature and
Reverse Chaining branches, which spelt the same guard out by hand.

The shaper has its own copy of that sum, and the issue does not name it. Otl's Format 1
path indexes glyphIDtoUni by the unmasked id, where a negative one is read from the end of
the string and happens to come out right and one past 65535 reads off the end and comes out
U+0000. Chiron Sung HK, Chiron Hei HK and Chiron GoRound TC all hold the historical form of
U+3127 above 65536, so `font-variant-alternates: historical-forms` over that character drew
nothing at all in any of the three. It draws the glyph `hb-shape` draws now.

A lookup flag naming a mark attachment class says to skip every mark outside that class.
Which marks those are is GDEF's MarkAttachClassDef to say, and a font may set the flag
without defining that table at all - Carlito and NATS both do. No mark is then in the class,
so the lookup skips all of them; reading MarkAttachmentType by the class gave null, which is
no marks skipped. This is the one place a page moves. Carlito's 'ccmp' replaces i and j with
their dotless forms before a mark above, and that rule was firing where HarfBuzz, reading
the same flag, leaves the dot on. NATS does not move: its 58 mark glyphs are the subscript
consonant forms, and every Telugu matra and the halant its ligatures stand on is GDEF class
1.

Class 0 of a Class Definition is every glyph the other classes do not name, so it has no
list of glyphs to match against. Every input position of a context rule above the first
already said so, with the comment explaining why; the first read InputClasses by the class
and got null. Molengo is the one font here, and its rule set for class 0 cannot fire either
way - the subtable's Coverage holds nothing but glyphs the font puts in class 1.

The fourth, a feature whose LookupListIndex is empty, is already guarded: #117 added it
while splitting the readers. Sedan SC's 'smcp' lists no lookups at all, which is the font
the comment there said no one carried, so it says so now and there is a fixture for it.

Nothing else moves. Over the nine families the issue names, every artefact the parser writes
to the font cache is byte-identical - 77 files across the nine - and eight of the nine render
byte-identical pages.

Test fonts: Carlito-MarkAttachmentType-Subset (Carlito 1.104, OFL 1.1),
Molengo-GSUB52Class0-Subset (Molengo 0.11, OFL 1.1), SedanSC-EmptyFeature-Subset (Sedan SC
1.100, OFL 1.1) and NotoSansArabic-GSUB11Wrap-Synthetic, whose letters are Noto Sans Arabic
2.012 (OFL 1.1) and whose 32,771 glyphs are what it takes for an int16 delta to carry a sum
past either end of the range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1 jakejackson1 added bug Something isn't working create-upstream-pr labels Sep 16, 2026
jakejackson1 and others added 2 commits September 16, 2026 11:15
…ed-reads

# Conflicts:
#	tests/Mpdf/TTFontFileTest.php
The fixture was written from a stale one. The synthetic was rebuilt part way through this
branch - its one lookup moved from 'fina' to 'ccmp', so that the shaper applies it directly
rather than only through the joining table the parser derives - and mPDF's font cache keys
the derived GSUB.<script>.<lang>.json by font name and does not notice the file behind it
has changed. composer shaping:update read the 'fina' build's rtlSUB, which had a final form
for U+0627 and so a `finals` string, and recorded a kashida of 2 on it.

With 'ccmp' as its only feature the font states no joining forms at all, writes no
GSUB.arab.DFLT.json, and the priority-2 rule has nothing to match. Every run from an empty
cache says so, which is why CI said so on all 24 jobs and this machine did not.

Not #135: that trims a final form of several glyphs to its first, and the stale entry was
the single glyph 0E000, where explode() returns the whole of it. Reverting #135's hunk and
regenerating gives this same file, byte for byte.

Nothing else in the corpus moves. All four masters regenerated from an empty cache over the
31 fonts in tests/data/ttf leave one file changed, this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 2982df6 into gravitypdf Sep 16, 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.

Four more unguarded reads warn under E_ALL, on nine families mPDF otherwise shapes correctly

1 participant