Read four lookup keys for what the font states, not for what the read assumed (#113) - #139
Merged
Merged
Conversation
… 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>
…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>
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.
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:
uni2014.extendedF2.alt…caltcid01690histcid63015cid01688histcid63121cid01688histcid63121The parser (
TTFontFile::readGSUBrules()) added without the modulo, so the sum felloutside
glyphToChar,unicode_hex()made00000of the null that came back, and the rulewas recorded as substituting U+0000. That is the read the issue names, at
TTFontFile.php:34one 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 toOtl::glyphToChar(), which indexesglyphIDtoUniat$gid * 3. That string is exactly 65,536 glyphs long, so a negative id isread 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:
hb-shape --features=histfont-variant-alternates: historical-formsE201(cid01690)cid01690E210(cid01688)cid01688E1B9(cid01688)cid01688The diagnostics are
Uninitialized string offset 201678andord(): Providing an empty string is deprecated, both atOtl.php:4953. That is the same bug as the issue's first readand 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 twoshare
substituteGlyph(), and so do the Ligature and Reverse Chaining branches, which speltthe 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
MarkAttachClassDefto say, and a font may set the flagwithout 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 isno marks skipped.
This is the one place a page moves. Carlito's
ccmpreplacesiandjwith theirdotless forms before a mark above, under a flag naming class 1, and that rule was firing:
hb-shape0131dotless i0069i012Fiogonek0249uni0249NATS 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
Ottuconsonant forms; every Telugu matra andthe halant that its
abvs,pstsandhalnligatures 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
InputClassesby the classand got null.
Molengo is the one family, and its rule set cannot fire either way: the subtable's Coverage
holds
i,jand U+0268, and the font's own Class Definition puts all three in class 1, sonothing the Coverage admits is in class 0.
4. The empty
LookupListIndexis already guarded#117 added
isset($feature['LookupListIndex'][0])while splitting the readers, so Sedan SCraises nothing on
gravitypdftoday - measured, 0 diagnostics. The comment there says "nofont in the 183 installed carries one"; Sedan SC's
smcplists no lookups at all, so that nowsays 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
'', whichksort()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
useOTL0xFF withE_ALLthrough 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.
The three Chiron families are their variable upstream (
ChironSungHK[wght].ttfand the othertwo) 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 forarab syrc hebr thaa nko samranddeva beng guru gujr orya taml telu knda mlym. So theU+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
rtlSUBand the Indic tables, which is what the newsynthetic shows.
What reaches the page is read 2, which
Otlmakes the same way while shaping, and theshaper's own copy of read 1 - which is why the
histtable above is measured throughfont-variant-alternatesrather than through the sample text.Whole suite: 1,769 tests on
gravitypdf, 1,776 here. Every existing fixture unchanged - all 60snapshots, and the four golden masters (
fontcache,otldump,shaping,subset) regeneratedacross all 27 fonts already in
tests/data/ttfwith nothing left to commit.phpcsclean,phpstanat its 33-error baseline.Everything above was measured again after merging
gravitypdfat2362b56, which brought in#134's char-width header clamp and #135's
finalschange - both inTTFontFile, neither in amethod 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-SubsetandNotoSans-GSUBClassZero-Synthetic- the three fontsadded 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
finatoccmp, so that the shaper applies it directly ratherthan only through the joining table the parser derives - and mPDF's font cache keys the derived
GSUB.<script>.<lang>.jsonby font name and does not notice the file behind it has changed, socomposer shaping:updateread thefinabuild'srtlSUBandfinals. Withccmpas its onlyfeature the font states no joining forms at all and writes no
GSUB.arab.DFLT.json, so thepriority-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, whereexplode()returns the whole ofit - reverting #135's hunk and regenerating gives the corrected file byte for byte.
Tests
TTFontFileTestgains four on thediagnosticsWhileParsing()seam #104 added.MarkAttachmentTypeTestandDeltaGlyphIdTestare 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 diagnosticsas the whole font. Subset with
--retain-gidsand GSUB left alone, because the subsetterprunes 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 afeature with no lookups, so
smcpis put back.aaltandc2scare left in so the test cansay the real features still come out in lookup order.
NotoSansArabic-GSUB11Wrap-Synthetic.ttf(132,540 bytes) - the wrap, which no subset ofthe 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
locaandhmtxare 65,544 bytes each and everything else is 1,452. Its letters are Noto SansArabic 2.012, from the joining subset already here, and its one lookup is a
ccmpso that bothhalves read it: the parser through
rtlPUAstr, the shaper as a lookup of its own. Before thisbranch the parse raises three diagnostics and
rtlPUAstris empty, and the letter draws asU+0000 or not at all; after, the parse is silent,
rtlPUAstris\x{0E000}, and the letterdraws as
E000.Left alone
multipleSubstitutes()readsglyphToChar[$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
TTFontFileandOtleach keep a copy of -_checkGSUBignore/_checkGCOMignore,_getGSUBignoreString/buildGCOMignoreList,marksOutsideFilteringSet,markGlyphSet- are now five, because this fix had to be writtentwice. 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