Match each class-based chained rule against its own backtrack and lookahead positions (#170) - #195
Merged
Merged
Conversation
jakejackson1
force-pushed
the
refactor/160-otldump-gsub-array
branch
from
September 16, 2026 22:15
d820375 to
e5db3e5
Compare
jakejackson1
force-pushed
the
fix/170-class-rule-positions
branch
from
September 16, 2026 22:21
6680d8b to
7168e2e
Compare
jakejackson1
force-pushed
the
refactor/160-otldump-gsub-array
branch
2 times, most recently
from
September 16, 2026 23:42
052c539 to
e8a2bce
Compare
…kahead positions (#170) The parser built a Type 6 Format 2 rule's backtrack and lookahead sequences into arrays it only emptied when a rule named no positions at all. A rule naming fewer positions than the rule read before it kept that rule's extra ones: a matchback and counts too long, and, for an Arabic joining form, a prel and postl the shaper then required of the text. The form substitution only fired where the text happened to hold the earlier rule's context as well. Both arrays are now built fresh for every rule, which is what the dump already did. That leaves nothing for keepsEarlierRulePositions() to decide, so the hook and the dump's override of it go. No font in tests/data/ttf reaches this, and regenerating every golden master from cold moves none of them. NotoSansArabic-GSUB62Positions-Synthetic makes it visible: it is NotoSansArabic-Joining-Subset (Noto Sans Arabic 2.012, OFL 1.1) with its GSUB replaced, in fontTools 4.59.2, by one 'fina' Type 6 Format 2 lookup and the Single Substitution it nests, and name IDs 1, 4 and 6 renamed. Alef's rule gives it the final form after beh, alef and before two low alefs; beh's rule, read after it, gives beh the dotless final form after one beh and before one low alef. Before, beh's rule asked for alef two back and a second low alef, so beh beh low-alef low-alef and alef beh beh low-alef both drew a plain beh. hb-shape 14.3.1 draws the dotless final form in both, as mPDF now does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce (#170) classSequences() returns both sequences fresh for each rule, as coverageSequences() does for a Coverage-based one, so a rule cannot pick up positions from the one before by how the walk is written rather than by a reset at the top of the loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1
force-pushed
the
fix/170-class-rule-positions
branch
from
September 17, 2026 01:46
7168e2e to
46622c6
Compare
jakejackson1
changed the base branch from
refactor/160-otldump-gsub-array
to
gravitypdf
September 17, 2026 01:47
Member
Author
|
Rebased: #190 was squash-merged into |
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.
Closes #170
What
TTFontFile::_getGSUBarray()built a class-based chained context rule's (GSUB Type 6 Format 2) backtrack and lookahead sequences into$backtrackGlyphsand$lookaheadGlyphs, and only emptied them when a rule named no positions at all. A rule with fewer positions than the rule read before it kept that rule's extra ones. It got amatchbackandnBacktrack/nLookaheadthat were too long. For an Arabic joining form (isol/fina/fin2/fin3/medi/med2/init, scriptarab), the extra positions also went into theprel/postlthatShaper\Arabicrequires the text to hold. So the form substitution only fired where the text happened to match the earlier rule's context as well.Each rule now gets both sequences from a new
classSequences()helper, which builds them fresh the waycoverageSequences()already does for Format 3 and Type 8, and the wayOtlDumpalready read them:After that,
keepsEarlierRulePositions()has nothing left to decide. Nothing else called it, so the hook #190 added andOtlDump's override are both deleted.The comment above the two variables' initialisation now only mentions #189. Type 5 Formats 1 and 2 still read them without setting them, and the stacked PR for #189 fixes that.
Test font and shaping test
No font in the corpus reaches this (see below), so
NotoSansArabic-GSUB62Positions-Synthetic.ttfmakes it visible. It isNotoSansArabic-Joining-Subset.ttf(Noto Sans Arabic 2.012, OFL 1.1), changed in fontTools 4.59.2 in two ways. Its GSUB is replaced by onefinaType 6 Format 2 lookup (forDFLTandarab) plus the Single Substitution it nests, and name IDs 1, 4 and 6 are renamed.uni066E.fina).U+08AD LOW ALEF doesn't join, so a beh before it is final.
ChainedClassRulePositionsTestrecords the drawn glyphs withTextRecordingMpdf. The output is in visual order, and PUA E000 =uni0627.fina, E002 =uni066E.fina:hb-shape14.3.108AD 08AD E000 0628 0627uni08AD uni08AD uni0627.fina uni0628 uni0627✓08AD 08AD 0628 062808AD 08AD E002 0628uni08AD uni08AD uni066E.fina uni0628✓08AD 0628 0628 062708AD E002 0628 0627uni08AD uni066E.fina uni0628 uni0627✓HarfBuzz agrees with mPDF after the fix on all three runs. The new font's
fontcachefixture has the fixed table:rtlSUB→00628→prel{"1": ["00628"]},postl{"1": ["008AD"]}.GsubArrayTestnow expects the fixed behaviour.testTheParserKeepsAnEarlierClassRulesExtraPositionsis renamed totestTheParserReadsEachClassRuleWithItsOwnPositionsand expectsnBacktrack[2, 1]and a one-positionmatchback. The #189 test keeps describing that bug, but the chained rule it inherits from now has one backtrack position instead of two, so its expectedprelis['00043'].Fixtures
Regenerated from cold: I deleted
tests/Mpdf/tmp/mpdf,tmp/mpdfandtmp/ttfontdata, then ranfontcache:update,otldump:update,shaping:updateandsubset:update all.git status -- tests/datashowed only the new font's five files (ttf, fontcache, otldump, shaping, subset). No existing fixture moved. That matches the issue's measurement: the corpus has no 6.2 rule followed by a shorter one that reaches the output.The new font's shaping master has no visible form: the golden master's Arabic run uses letters this subset doesn't have. The shaper is covered by
ChainedClassRulePositionsTestinstead.Mutation check
With
keepsEarlierRulePositions()put back (returningtrue, with the dump'sfalseoverride) and caches cleared, 5 tests fail:ChainedClassRulePositionsTestdata sets "the second rule, where the first rule's second backtrack position does not hold" and "... second lookahead position does not hold"GsubArrayTest::testTheParserReadsEachClassRuleWithItsOwnPositionsGsubArrayTest::testAPlainContextRuleOfAnArabicFormCarriesTheLastChainedRulesSequences(the carriedprelgoes back to two positions)ParserGoldenMasterTestforNotoSansArabic-GSUB62Positions-SyntheticMirroring upstream
Upstream has no hook. In
mpdf/mpdfdevelopment(744f75a),TTFontFile::_getGSUBarray()'s LookupType 6 Format 2 loop (foreach ($Lookup[$i]['Subtable'][$c]['ChainSubClassSet'] as $inputClass => $cscs), around lines 2780–2810) has:and the same
if/elsefor$lookaheadGlyphs. The mirror sets$backtrackGlyphs = [];and$lookaheadGlyphs = [];before eachfor, and drops theelsebranches. Upstream'sOtlDump::_getGSUBarray()still has its own copy of the loop with the sameif/else(around line 2481), so the mirror should reset there too. The fork fixed its copy in 271b425. The upstream PR also needs aCHANGELOG.mdentry. The test font andChainedClassRulePositionsTestcan be taken over as they are.Base
Stacked on #190 (
refactor/160-otldump-gsub-array) → #188 → #186 →gravitypdf. #177 and #178 were squash-merged intogravitypdf. The stack was then rebased ontogravitypdf, and #190's head moved fromd820375toe5db3e5. I rebased this branch withgit rebase --onto refactor/160-otldump-gsub-array d820375and force-pushed it with a lease. There were no conflicts. #186, #188 and #190 are still open, so the base staysrefactor/160-otldump-gsub-array./simplify
Applied:
classSequences(), so a rule can't inherit positions however the loop is edited later'mode' => 'utf-8', as the sibling shaping tests passSkipped:
drawn()helper, which this test shares with 7 existing shaping tests, into a trait. That would mean editing those tests, which is outside this diff.OtlDump::classNames()intoTTFontFile, which is outside the files this change touches.Verification
vendor/bin/phpunit(after the rebase): 1999 tests, 5781 assertions, 1 skipped. The basee5db3e5has 1989 / 5761 / 1. The 10 extra tests are the new font's golden-master data sets plus the three shaping runs.git status -- tests/datais clean, so nothing moved and the new font's committed fixtures reproduce exactly.vendor/bin/phpunit --group=snapshot: OK, 79 tests, 110 assertions.composer cs: clean.🤖 Generated with Claude Code