From 64bbb2f947a1cf9d05df47ba7fef30078d67ed38 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 08:04:05 +1000 Subject: [PATCH 1/2] Match each class-based chained rule against its own backtrack and lookahead 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) --- src/OtlDump.php | 8 - src/TTFontFile.php | 24 +- tests/Mpdf/ChainedClassRulePositionsTest.php | 81 +++++++ tests/Mpdf/Fonts/GsubArrayTest.php | 17 +- ...oSansArabic-GSUB62Positions-Synthetic.json | 226 ++++++++++++++++++ ...toSansArabic-GSUB62Positions-Synthetic.txt | 27 +++ ...toSansArabic-GSUB62Positions-Synthetic.txt | 96 ++++++++ ...oSansArabic-GSUB62Positions-Synthetic.json | 129 ++++++++++ ...toSansArabic-GSUB62Positions-Synthetic.ttf | Bin 0 -> 2548 bytes 9 files changed, 571 insertions(+), 37 deletions(-) create mode 100644 tests/Mpdf/ChainedClassRulePositionsTest.php create mode 100644 tests/data/fontcache/NotoSansArabic-GSUB62Positions-Synthetic.json create mode 100644 tests/data/otldump/NotoSansArabic-GSUB62Positions-Synthetic.txt create mode 100644 tests/data/shaping/NotoSansArabic-GSUB62Positions-Synthetic.txt create mode 100644 tests/data/subset/NotoSansArabic-GSUB62Positions-Synthetic.json create mode 100644 tests/data/ttf/NotoSansArabic-GSUB62Positions-Synthetic.ttf diff --git a/src/OtlDump.php b/src/OtlDump.php index 177cb9a88..f5246ce6a 100644 --- a/src/OtlDump.php +++ b/src/OtlDump.php @@ -433,14 +433,6 @@ protected function gsubReverseChainRule(array $Lookup, $i, $c, $tag, $scripttag, return []; } - /** - * The dump reads each class-based rule's positions fresh, where the parser does not (#170). - */ - protected function keepsEarlierRulePositions() - { - return false; - } - /** * One substitution, as a row of the report. * diff --git a/src/TTFontFile.php b/src/TTFontFile.php index 86ff29b43..a922ff38f 100644 --- a/src/TTFontFile.php +++ b/src/TTFontFile.php @@ -2412,8 +2412,8 @@ function _getGSUBarray(array $Lookup, $lul, $scripttag) { $volt = []; - // Kept across rules and lookups rather than read fresh for each: see keepsEarlierRulePositions(). - // A Type 5 Format 1 or 2 rule hands whatever these last held to the Arabic shaper (#189). + // A Type 5 Format 1 or 2 rule reads these without setting them, so hands whatever the last + // chained rule left in them to the Arabic shaper (#189). $backtrackGlyphs = []; $lookaheadGlyphs = []; @@ -2544,16 +2544,12 @@ function _getGSUBarray(array $Lookup, $lul, $scripttag) for ($cscrule = 0; $cscrule < $cscs['ChainSubClassRuleCnt']; $cscrule++) { $rule = $cscs['ChainSubClassRule'][$cscrule]; - if (!$rule['BacktrackGlyphCount'] || !$this->keepsEarlierRulePositions()) { - $backtrackGlyphs = []; - } + $backtrackGlyphs = []; for ($gcl = 0; $gcl < $rule['BacktrackGlyphCount']; $gcl++) { $backtrackGlyphs[$gcl] = $this->classGlyphs($subtable['BacktrackClasses'], $rule['Backtrack'][$gcl]); } - if (!$rule['LookaheadGlyphCount'] || !$this->keepsEarlierRulePositions()) { - $lookaheadGlyphs = []; - } + $lookaheadGlyphs = []; for ($gcl = 0; $gcl < $rule['LookaheadGlyphCount']; $gcl++) { $lookaheadGlyphs[$gcl] = $this->classGlyphs($subtable['LookaheadClasses'], $rule['Lookahead'][$gcl]); } @@ -2679,18 +2675,6 @@ private function addTo(array &$volt, array $entries) } } - /** - * Whether a class-based chained rule (Type 6 Format 2) that names fewer backtrack or lookahead - * positions than the rule read before it keeps that rule's extra positions. - * - * The parser does, which is #170: the extra positions end up in the rule's matchback and counts, - * and in what an Arabic joining form hands the shaper. Fixing it is returning false here. - */ - protected function keepsEarlierRulePositions() - { - return true; - } - /** * The rules of a Single, Multiple, Alternate or Ligature substitution subtable (Types 1 to 4). * diff --git a/tests/Mpdf/ChainedClassRulePositionsTest.php b/tests/Mpdf/ChainedClassRulePositionsTest.php new file mode 100644 index 000000000..afbad63ce --- /dev/null +++ b/tests/Mpdf/ChainedClassRulePositionsTest.php @@ -0,0 +1,81 @@ + [__DIR__ . '/../data/ttf'], + 'fontdata' => ['notosansarabicgsub62positionssynthetic' => [ + 'R' => 'NotoSansArabic-GSUB62Positions-Synthetic.ttf', + 'useOTL' => 0xFF, + ]], + 'default_font' => 'notosansarabicgsub62positionssynthetic', + ]); + $mpdf->WriteHTML('

' . $html . '

'); + + return array_values(unpack('N*', mb_convert_encoding($mpdf->drawnText[0], 'UTF-32BE', 'UTF-8'))); + } + + public function dataRuns() + { + return [ + 'the first rule, with two positions either side' => [ + [self::ALEF, self::BEH, self::ALEF, self::LOW_ALEF, self::LOW_ALEF], + [self::LOW_ALEF, self::LOW_ALEF, self::ALEF_FINAL, self::BEH, self::ALEF], + ], + 'the second rule, where the first rule\'s second backtrack position does not hold' => [ + [self::BEH, self::BEH, self::LOW_ALEF, self::LOW_ALEF], + [self::LOW_ALEF, self::LOW_ALEF, self::DOTLESS_BEH_FINAL, self::BEH], + ], + 'the second rule, where the first rule\'s second lookahead position does not hold' => [ + [self::ALEF, self::BEH, self::BEH, self::LOW_ALEF], + [self::LOW_ALEF, self::DOTLESS_BEH_FINAL, self::BEH, self::ALEF], + ], + ]; + } + + /** + * @dataProvider dataRuns + */ + public function testEachRuleMatchesItsOwnPositions($codepoints, $expected) + { + $this->assertSame($expected, $this->drawn($codepoints)); + } + +} diff --git a/tests/Mpdf/Fonts/GsubArrayTest.php b/tests/Mpdf/Fonts/GsubArrayTest.php index 860d1b06d..e036284f0 100644 --- a/tests/Mpdf/Fonts/GsubArrayTest.php +++ b/tests/Mpdf/Fonts/GsubArrayTest.php @@ -9,8 +9,7 @@ use Mpdf\TTFontFile; /** - * The GSUB walk the parser and the dump share, where the two part ways on purpose and no font in - * tests/data/ttf shows it. + * The GSUB walk the parser and the dump share, over rule sets no font in tests/data/ttf has. * * The lookups are built by hand, in the shape readGSUBrules() leaves them: lookup 0 is a class-based * chained context (Type 6 Format 2) whose two rules name first two backtrack positions, then one, and @@ -20,21 +19,21 @@ class GsubArrayTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase { /** - * The parser keeps the first rule's second backtrack position into the second rule (#170). + * The second rule matches the one backtrack position it names, not the first rule's second as + * well (#170). */ - public function testTheParserKeepsAnEarlierClassRulesExtraPositions() + public function testTheParserReadsEachClassRuleWithItsOwnPositions() { $parser = $this->withGdef(new TTFontFile($this->cache(), 'win')); $volt = $parser->_getGSUBarray($this->chainedClassRules(), [0 => 'ccmp'], 'latn'); - $this->assertSame([2, 2], [$volt[0]['nBacktrack'], $volt[1]['nBacktrack']]); - $this->assertSame('(00043)() (00043)() ', $volt[1]['matchback']); + $this->assertSame([2, 1], [$volt[0]['nBacktrack'], $volt[1]['nBacktrack']]); + $this->assertSame('(00043)() ', $volt[1]['matchback']); } /** - * The dump reads each rule's positions fresh, so it reports the second rule with the one - * backtrack position it names. + * So does the dump, which reports the second rule with the one backtrack position it names. */ public function testTheDumpReportsEachClassRuleWithItsOwnPositions() { @@ -80,7 +79,7 @@ public function testAPlainContextRuleOfAnArabicFormCarriesTheLastChainedRulesSeq $volt = $parser->_getGSUBarray($Lookup, [0 => 'init', 2 => 'init'], 'arab'); - $this->assertSame(['match' => '00041', 'replace' => '00044', 'tag' => 'init', 'prel' => ['00043', '00043'], 'postl' => [], 'ignore' => '()'], end($volt)); + $this->assertSame(['match' => '00041', 'replace' => '00044', 'tag' => 'init', 'prel' => ['00043'], 'postl' => [], 'ignore' => '()'], end($volt)); } private function chainedClassRules() diff --git a/tests/data/fontcache/NotoSansArabic-GSUB62Positions-Synthetic.json b/tests/data/fontcache/NotoSansArabic-GSUB62Positions-Synthetic.json new file mode 100644 index 000000000..a3bac07e4 --- /dev/null +++ b/tests/data/fontcache/NotoSansArabic-GSUB62Positions-Synthetic.json @@ -0,0 +1,226 @@ +{ + "_": "Generated by composer fontcache:update. See tests/Mpdf/Fonts/ParserGoldenMaster.php.", + "fullName": "NotoSansArabicGSUB62PositionsSynthetic-Regular", + "mtx": { + "GSUBScriptLang": { + "DFLT": "DFLT ", + "arab": "DFLT " + }, + "GSUBFeatures": { + "DFLT": { + "DFLT": { + "fina": [ + 0 + ] + } + }, + "arab": { + "DFLT": { + "fina": [ + 0 + ] + } + } + }, + "GSUBLookups": [ + { + "Type": 6, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 64 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 174 + ], + "MarkFilteringSet": "" + } + ], + "GPOSScriptLang": { + "DFLT": "DFLT ", + "arab": "DFLT ", + "cyrl": "DFLT ", + "dev2": "DFLT ", + "grek": "DFLT ", + "latn": "DFLT " + }, + "GPOSFeatures": { + "DFLT": { + "DFLT": { + "mark": [ + 0 + ], + "mkmk": [ + 1 + ] + } + }, + "arab": { + "DFLT": { + "mark": [ + 0 + ], + "mkmk": [ + 1 + ] + } + }, + "cyrl": { + "DFLT": { + "mark": [ + 0 + ], + "mkmk": [ + 1 + ] + } + }, + "dev2": { + "DFLT": { + "mark": [ + 0 + ], + "mkmk": [ + 1 + ] + } + }, + "grek": { + "DFLT": { + "mark": [ + 0 + ], + "mkmk": [ + 1 + ] + } + }, + "latn": { + "DFLT": { + "mark": [ + 0 + ], + "mkmk": [ + 1 + ] + } + } + }, + "GPOSLookups": [ + { + "Type": 4, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 102 + ], + "MarkFilteringSet": "" + }, + { + "Type": 6, + "Flag": 16, + "SubtableCount": 1, + "Subtables": [ + 194 + ], + "MarkFilteringSet": 0 + } + ], + "MarkGlyphSets": [ + " 0E005" + ], + "rtlPUAstr": "\\x{0E000}\\x{0E002}", + "haskernGPOS": false, + "hassmallcapsGSUB": false + }, + "cache": { + "GDEFdata.json": { + "GlyphClassBases": " 00020| 00627| 008AD| 0E000| 0E001| 0E002| 0E003| 0E004", + "GlyphClassMarks": " 0E005", + "GlyphClassLigatures": "", + "GlyphClassComponents": "", + "MarkGlyphSets": [ + " 0E005" + ], + "MarkAttachmentType": [] + }, + "GPOS.dat": "234 bytes, sha256 0f363351c5ea276d72ca75127977045024304d1f2b19b04e8cfded715c737a13", + "GPOSdata.json": [ + [ + { + "57349": 0 + } + ], + [ + { + "57349": 0 + } + ] + ], + "GSUB.arab.DFLT.json": { + "rtlSUB": { + "00627": { + "1": "0E000", + "prel": { + "1": [ + "00628", + "00627" + ] + }, + "postl": { + "1": [ + "008AD", + "008AD" + ] + }, + "ignore": { + "1": "()" + } + }, + "00628": { + "1": "0E002", + "prel": { + "1": [ + "00628" + ] + }, + "postl": { + "1": [ + "008AD" + ] + }, + "ignore": { + "1": "()" + } + } + }, + "finals": "0E000 0E002 ", + "rphf": [], + "half": [], + "pref": [], + "blwf": [], + "pstf": [] + }, + "GSUB.dat": "192 bytes, sha256 7da285b0e28dc3dc7c919987056d9b0e39edcf78f337ef45ec3b10f9d8fc07ac", + "GSUBdata.json": [ + [ + { + "1575": 0, + "1576": 1 + } + ], + [ + { + "1575": 0, + "1576": 1 + } + ] + ] + } +} diff --git a/tests/data/otldump/NotoSansArabic-GSUB62Positions-Synthetic.txt b/tests/data/otldump/NotoSansArabic-GSUB62Positions-Synthetic.txt new file mode 100644 index 000000000..cd09e743a --- /dev/null +++ b/tests/data/otldump/NotoSansArabic-GSUB62Positions-Synthetic.txt @@ -0,0 +1,27 @@ +

GDEF table

+

Glyph classes

+

Glyph class 1

+
Base glyph (single character, spacing glyph)
+
ا ࢭ     
+

Glyph class 3

+
Mark glyph (non-spacing combining glyph)
+
◌
+

Mark Glyph Sets

+

Mark Glyph Set class: 0

+
◌
+

GSUB Tables

+

GSUB Scripts & Languages

+
+
DFLT
DFLT: fina
arab
DFLT: fina
+
+

GPOS Tables

+

GPOS Scripts & Languages

+
+
DFLT
DFLT: mark mkmk
arab
DFLT: mark mkmk
cyrl
DFLT: mark mkmk
dev2
DFLT: mark mkmk
grek
DFLT: mark mkmk
latn
DFLT: mark mkmk
+
+ +=== detail: script arab language DFLT === +

GSUB Tables

+
Lookup #0 [tag: fina]
Subtable #0
LookupType 6: Chaining Contextual Substitution Subtable
Format 2: Class-based Chaining Context Glyph Substitution
Input Class: 1
Rule: 0
CONTEXT:
Backtrack #1: U+0627
Backtrack #0: U+0628
Input #0:  ا 
Lookahead #0: U+08AD
Lookahead #1: U+08AD
Substitution Position: 0
Lookup #1 [tag: fina]
Subtable #0
LookupType 1: Single Substitution Subtable
U+0627  ا‍ب اࢭ‍ࢭ  » »  ا‍ب ࢭ‍ࢭ  M+E000
Input Class: 2
Rule: 0
CONTEXT:
Backtrack #0: U+0628
Input #0:  ب 
Lookahead #0: U+08AD
Substitution Position: 0
Lookup #1 [tag: fina]
Subtable #0
LookupType 1: Single Substitution Subtable
U+0628  ب ب  » »  ب   M+E002
+

GPOS Tables

+
Lookup #0 [tag: mark]
Subtable #0
LookupType 4: MarkToBase attachment
Marks: ◌
Bases: ا      ࢭ
Example(s):    ا                  ࢭ  
Lookup #1 [tag: mkmk]
Ignoring: Marks outside Mark Glyph Set[0]
Subtable #0
LookupType 6: MarkToMark attachment
Marks: ◌
Bases: ◌
Example(s): ◌  
diff --git a/tests/data/shaping/NotoSansArabic-GSUB62Positions-Synthetic.txt b/tests/data/shaping/NotoSansArabic-GSUB62Positions-Synthetic.txt new file mode 100644 index 000000000..3320dea0b --- /dev/null +++ b/tests/data/shaping/NotoSansArabic-GSUB62Positions-Synthetic.txt @@ -0,0 +1,96 @@ +=== latin === +0xFF 0041 0056 0041 0054 0061 0072 => 0041 0056 0041 0054 0061 0072 group=CCCCCC +0x80 0041 0056 0041 0054 0061 0072 => 0041 0056 0041 0054 0061 0072 group=CCCCCC +0xFF +kern -liga 0041 0056 0041 0054 0061 0072 => 0041 0056 0041 0054 0061 0072 group=CCCCCC +=== cyrillic === +0xFF 0416 0430 0439 => 0416 0430 0439 group=CCC +0x80 0416 0430 0439 => 0416 0430 0439 group=CCC +0xFF +kern -liga 0416 0430 0439 => 0416 0430 0439 group=CCC +=== greek === +0xFF 03B1 03B2 03C2 => 03B1 03B2 03C2 group=CCC +0x80 03B1 03B2 03C2 => 03B1 03B2 03C2 group=CCC +0xFF +kern -liga 03B1 03B2 03C2 => 03B1 03B2 03C2 group=CCC +=== hiragana === +0xFF 3042 3043 3044 => 3042 3043 3044 group=CCC +0x80 3042 3043 3044 => 3042 3043 3044 group=CCC +0xFF +kern -liga 3042 3043 3044 => 3042 3043 3044 group=CCC +=== arabic === +0xFF 0628 0640 0645 0644 0627 => 0628 0640 0645 0644 0627 group=CCCCC gpos={"1":{"kashida":8}} +0x80 0628 0640 0645 0644 0627 => 0628 0640 0645 0644 0627 group=CCCCC gpos={"1":{"kashida":8}} +0xFF +kern -liga 0628 0640 0645 0644 0627 => 0628 0640 0645 0644 0627 group=CCCCC gpos={"1":{"kashida":8}} +=== syriac === +0xFF 0710 0712 0713 0715 => 0710 0712 0713 0715 group=CCCC +0x80 0710 0712 0713 0715 => 0710 0712 0713 0715 group=CCCC +0xFF +kern -liga 0710 0712 0713 0715 => 0710 0712 0713 0715 group=CCCC +=== nko === +0xFF 07CA 07CB 07CC => 07CA 07CB 07CC group=CCC +0x80 07CA 07CB 07CC => 07CA 07CB 07CC group=CCC +0xFF +kern -liga 07CA 07CB 07CC => 07CA 07CB 07CC group=CCC +=== devanagari === +0xFF 0915 094D 0937 093F => 0915 094D 093F 0937 group=CCCC +0x80 0915 094D 0937 093F => 0915 094D 093F 0937 group=CCCC +0xFF +kern -liga 0915 094D 0937 093F => 0915 094D 093F 0937 group=CCCC +=== bengali === +0xFF 0995 09CD 09B7 09BF => 0995 09CD 09BF 09B7 group=CCCC +0x80 0995 09CD 09B7 09BF => 0995 09CD 09BF 09B7 group=CCCC +0xFF +kern -liga 0995 09CD 09B7 09BF => 0995 09CD 09BF 09B7 group=CCCC +=== gurmukhi === +0xFF 0A15 0A4D 0A38 0A3F => 0A15 0A4D 0A3F 0A38 group=CCCC +0x80 0A15 0A4D 0A38 0A3F => 0A15 0A4D 0A3F 0A38 group=CCCC +0xFF +kern -liga 0A15 0A4D 0A38 0A3F => 0A15 0A4D 0A3F 0A38 group=CCCC +=== tamil === +0xFF 0B95 0BCD 0BB7 0BBF => 0B95 0BCD 0BB7 0BBF group=CCCC +0x80 0B95 0BCD 0BB7 0BBF => 0B95 0BCD 0BB7 0BBF group=CCCC +0xFF +kern -liga 0B95 0BCD 0BB7 0BBF => 0B95 0BCD 0BB7 0BBF group=CCCC +=== malayalam === +0xFF 0D15 0D4D 0D37 0D3F => 0D15 0D4D 0D37 0D3F group=CCCC +0x80 0D15 0D4D 0D37 0D3F => 0D15 0D4D 0D37 0D3F group=CCCC +0xFF +kern -liga 0D15 0D4D 0D37 0D3F => 0D15 0D4D 0D37 0D3F group=CCCC +=== sinhala === +0xFF 0D9A 0DCA 0DBB 0DBB => 0D9A 0DCA 0DBB 0DBB group=CCCC +0x80 0D9A 0DCA 0DBB 0DBB => 0D9A 0DCA 0DBB 0DBB group=CCCC +0xFF +kern -liga 0D9A 0DCA 0DBB 0DBB => 0D9A 0DCA 0DBB 0DBB group=CCCC +=== khmer === +0xFF 1780 17D2 1781 17C1 => 17C1 1780 17D2 1781 group=CCCC +0x80 1780 17D2 1781 17C1 => 17C1 1780 17D2 1781 group=CCCC +0xFF +kern -liga 1780 17D2 1781 17C1 => 17C1 1780 17D2 1781 group=CCCC +=== thai === +0xFF 0E01 0E34 0E48 0E23 => 0E01 0E34 0E48 0E23 group=CCCC +0x80 0E01 0E34 0E48 0E23 => 0E01 0E34 0E48 0E23 group=CCCC +0xFF +kern -liga 0E01 0E34 0E48 0E23 => 0E01 0E34 0E48 0E23 group=CCCC +=== lao === +0xFF 0E81 0EB4 0E8D => 0E81 0EB4 0E8D group=CCC +0x80 0E81 0EB4 0E8D => 0E81 0EB4 0E8D group=CCC +0xFF +kern -liga 0E81 0EB4 0E8D => 0E81 0EB4 0E8D group=CCC +=== myanmar === +0xFF 1000 103A 1039 1001 => 1000 103A 1039 1001 group=CCCC +0x80 1000 103A 1039 1001 => 1000 103A 1039 1001 group=CCCC +0xFF +kern -liga 1000 103A 1039 1001 => 1000 103A 1039 1001 group=CCCC +=== new tai lue === +0xFF 1980 19B0 1981 => 1980 19B0 1981 group=CCC +0x80 1980 19B0 1981 => 1980 19B0 1981 group=CCC +0xFF +kern -liga 1980 19B0 1981 => 1980 19B0 1981 group=CCC +=== cham === +0xFF AA00 AA33 AA01 => AA00 AA33 AA01 group=CCC +0x80 AA00 AA33 AA01 => AA00 AA33 AA01 group=CCC +0xFF +kern -liga AA00 AA33 AA01 => AA00 AA33 AA01 group=CCC +=== tai tham === +0xFF 1A20 1A60 1A21 => 1A20 1A60 1A21 group=CCC +0x80 1A20 1A60 1A21 => 1A20 1A60 1A21 group=CCC +0xFF +kern -liga 1A20 1A60 1A21 => 1A20 1A60 1A21 group=CCC +=== mixed scripts === +0xFF 0041 0628 0915 0042 => 0041 0628 0915 0042 group=CCCC +0x80 0041 0628 0915 0042 => 0041 0628 0915 0042 group=CCCC +0xFF +kern -liga 0041 0628 0915 0042 => 0041 0628 0915 0042 group=CCCC +=== spaced === +0xFF 0041 0020 0628 0020 0042 => 0041 0020 0628 0020 0042 group=CSCSC +0x80 0041 0020 0628 0020 0042 => 0041 0020 0628 0020 0042 group=CSCSC +0xFF +kern -liga 0041 0020 0628 0020 0042 => 0041 0020 0628 0020 0042 group=CSCSC +=== font characters 0 === +0xFF 0020 0627 0628 08AD E000 E001 E002 E003 => 0020 0627 0628 08AD E000 E001 E002 E003 group=SCCCCCCC gpos={"4":{"kashida":2},"6":{"kashida":2}} +0x80 0020 0627 0628 08AD E000 E001 E002 E003 => 0020 0627 0628 08AD E000 E001 E002 E003 group=SCCCCCCC gpos={"4":{"kashida":2},"6":{"kashida":2}} +0xFF +kern -liga 0020 0627 0628 08AD E000 E001 E002 E003 => 0020 0627 0628 08AD E000 E001 E002 E003 group=SCCCCCCC gpos={"4":{"kashida":2},"6":{"kashida":2}} +=== font characters 1 === +0xFF E004 E005 => E004 E005 group=CM gpos={"1":{"BaseWidth":269,"XPlacement":31,"YPlacement":-3}} +0x80 E004 E005 => E004 E005 group=CM gpos={"1":{"BaseWidth":269,"XPlacement":31,"YPlacement":-3}} +0xFF +kern -liga E004 E005 => E004 E005 group=CM gpos={"1":{"BaseWidth":269,"XPlacement":31,"YPlacement":-3}} diff --git a/tests/data/subset/NotoSansArabic-GSUB62Positions-Synthetic.json b/tests/data/subset/NotoSansArabic-GSUB62Positions-Synthetic.json new file mode 100644 index 000000000..ac95dfa96 --- /dev/null +++ b/tests/data/subset/NotoSansArabic-GSUB62Positions-Synthetic.json @@ -0,0 +1,129 @@ +{ + "_": "Generated by composer subset:update. See tests/Mpdf/Fonts/SubsetGoldenMaster.php.", + "useOTL=0x00": { + "characters": 5, + "makeSubset": { + "bytes": 1540, + "sha256": "e77f3591d38195a8ca52bb36cf48ec32528a34cfb10492a8506e9804d7151ae3", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "92 bytes, checksum 0x0EEF06F5, sha256 ae6fb5dc97aecd1fc550113941bb8dd17c1e315a921037dd742091775397192d", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "304 bytes, checksum 0x7C5B4089, sha256 dda0aee857654d74b832394e016fc1e1e7710819c3dd061b666da107b0c39414", + "head": "54 bytes, checksum 0x46EA9EA2, sha256 dbb762ff579e225ee9b32a99c331cd90fff0f542dd6fe48ad92283744ad83d65", + "hhea": "36 bytes, checksum 0x29871F54, sha256 b6790f08d7b49267a846ddaa6976fa1ae4f2c482adfd5ca104166fe4ec48ca93", + "hmtx": "28 bytes, checksum 0x0CF90144, sha256 5a0c3e3815967f5179a662960145cd59d1377b1065498bb99d512c035752a67d", + "loca": "16 bytes, checksum 0x00D00114, sha256 82987de0caadd775301bb7f92dc6663a42f1351074a37aaa9e8f43ddd2629de3", + "maxp": "32 bytes, checksum 0x0058052C, sha256 712fd6ee38e7ed3e4f3271a00a1ea02f9588b526df499378ee6c2db8aeed859e", + "name": "626 bytes, checksum 0x36A04F5C, sha256 4ac8e50cdaf871921a459fd22a3d72cd1e297ec807b51608557d7d1e2145d1ba", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 65535, + "defaultWidth": 600, + "codeToGlyph": "5 entries, sha256 e330c81f30e51088fd0070ff21260ad702c20dd4487cd3b50ac48068aee467ca" + }, + "makeSubsetSIP": { + "bytes": 1528, + "sha256": "61015d0b42002421bfd8ab49d509ce9eec1f338b500b701f440dd4cd479fd00a", + "tables": { + "OS/2": "96 bytes, checksum 0x69A2D688, sha256 69a32b780b72e95ba9dfa002a1b8a2d0019efbe42ba74209c65095bc15e06732", + "cmap": "80 bytes, checksum 0x002C0083, sha256 3d0e58f1892397e1e94f3d0f75c6038f1212ed4d46a99eef47bc722bc366ee41", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "304 bytes, checksum 0x7C5B4089, sha256 184dd6f6301fa823051edcb9ea0c09851b5c5fd96ef911f898c134000990f13b", + "head": "54 bytes, checksum 0x46EA9EA2, sha256 16d82b9b235e72a1e7f02b2948e351696a40866e8ce9bbc4ec0681bdd5dad2fc", + "hhea": "36 bytes, checksum 0x29871F54, sha256 b6790f08d7b49267a846ddaa6976fa1ae4f2c482adfd5ca104166fe4ec48ca93", + "hmtx": "28 bytes, checksum 0x0CF90144, sha256 cd581a635bc9a3a20a5f498d3842038b3b5acfb25d1187221fab16a8cf9430ee", + "loca": "16 bytes, checksum 0x00C40114, sha256 b012200316381b41dc293cfa1f58f92eb79c195dbc958907b7309a3fcab6d76e", + "maxp": "32 bytes, checksum 0x0058052C, sha256 712fd6ee38e7ed3e4f3271a00a1ea02f9588b526df499378ee6c2db8aeed859e", + "name": "626 bytes, checksum 0x36994F5C, sha256 154baee591bcc5b123fb15fe83ce360d42974c8abfb09816a6f085e524001800", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUniChar": 65535, + "defaultWidth": 600 + }, + "repackageTTF": { + "bytes": 2028, + "sha256": "258c43c914b092e79f1b834433f86e189324544bab685b8b48760f6d33626cb7", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "72 bytes, checksum 0x06420F70, sha256 e29b8cf3f1e636faa27f210c98884c21505fdd6b6b8acf965ef0b86eafa0c480", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "668 bytes, checksum 0xD2CBF85C, sha256 64df708dd07ef438ab78c2fa6ec037cb9d2ec39a026eeecb018460dbe43ab527", + "head": "54 bytes, checksum 0x46EA9EA2, sha256 680287d4e4cd4f6c7b3231566d790ba1780f4c1ea85a3dc8950599725a24a9cf", + "hhea": "36 bytes, checksum 0x29871F58, sha256 d5c8aa00ac210ae41f8cae1f3201846d77c0ac04d04e11627ee19851bdfdcd08", + "hmtx": "44 bytes, checksum 0x14C501AA, sha256 af3326b0ee31557a2ee01c72ee59dac76903c6a84f8a077b74b99271f2ba42f5", + "loca": "24 bytes, checksum 0x036E041D, sha256 0a4cc922874d893e9926bbbf0eece0202d5cab7ec6724963cafca63946eec457", + "maxp": "32 bytes, checksum 0x005C052C, sha256 26689100d5138bb752af4293a4bbb556b534aa9d94134a46edd960ec269019ec", + "name": "626 bytes, checksum 0x36A04F5C, sha256 4ac8e50cdaf871921a459fd22a3d72cd1e297ec807b51608557d7d1e2145d1ba", + "post": "151 bytes, checksum 0xB924F86C, sha256 4baf6477b5a1ef33145e61b8c7bb9c828d6b17629e10af34eaf47d20e2978446", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 0 + } + }, + "useOTL=0xFF": { + "characters": 11, + "makeSubset": { + "bytes": 1956, + "sha256": "8ad5f665839d42f00390e52aab3caa0bb3bdc35647e94d6f53a5a67d8c80a602", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "108 bytes, checksum 0xEF02E71C, sha256 78a92931bee3fd46f5a9c5415eedaa0f295efa03625ca4afa41c4480a898243b", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "680 bytes, checksum 0x41A9897B, sha256 56ca9bb7742204cbdc96a05b2385d3fa81b1179034b69c093b9aa272a3e64d52", + "head": "54 bytes, checksum 0x46EA9EA2, sha256 f6d6b03dc841e641f44185e8da3ef71a41da8c233c6f6193aebd1e38023cb360", + "hhea": "36 bytes, checksum 0x29871F58, sha256 d5c8aa00ac210ae41f8cae1f3201846d77c0ac04d04e11627ee19851bdfdcd08", + "hmtx": "44 bytes, checksum 0x14C501AA, sha256 af3326b0ee31557a2ee01c72ee59dac76903c6a84f8a077b74b99271f2ba42f5", + "loca": "24 bytes, checksum 0x03800432, sha256 2cb2965a2d09fea2eb65e980cd64ac413ab3d2cfcd850e65997ad5fef232b95b", + "maxp": "32 bytes, checksum 0x005C052C, sha256 26689100d5138bb752af4293a4bbb556b534aa9d94134a46edd960ec269019ec", + "name": "626 bytes, checksum 0x36A04F5C, sha256 4ac8e50cdaf871921a459fd22a3d72cd1e297ec807b51608557d7d1e2145d1ba", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 65535, + "defaultWidth": 600, + "codeToGlyph": "11 entries, sha256 4976a91e70c6d83b58a35c5bf0afec8d29c446258274744a3317930df09fa365" + }, + "makeSubsetSIP": { + "bytes": 1940, + "sha256": "19079b8cc29a85b04b96564eb2180ec12bbbdfeb126a45b9b22a3a481635d059", + "tables": { + "OS/2": "96 bytes, checksum 0x69A2D68E, sha256 d3e6c0234f8db691dbb73991a5141f8184691d245eda464e73fcfd0645daf3a7", + "cmap": "92 bytes, checksum 0x005600B0, sha256 5febc775230d1d4c2ff1203cc3ba03258d79c52651c9eb23a521082d323ea414", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "680 bytes, checksum 0x41AB897C, sha256 78d00159157d7c704efacfa95bba99beebb11d2b1c4313852a6f55f06936c1af", + "head": "54 bytes, checksum 0x46EA9EA2, sha256 dccea7ea3b78b86d002a614d3117eb3b51ccbb49378cf37a18622638cc09cebc", + "hhea": "36 bytes, checksum 0x29871F58, sha256 d5c8aa00ac210ae41f8cae1f3201846d77c0ac04d04e11627ee19851bdfdcd08", + "hmtx": "44 bytes, checksum 0x14C501AA, sha256 1bcad69d16f9d4b7cdb1a2e30f1e1b5172902317c43c2383364b125bc9287e90", + "loca": "24 bytes, checksum 0x02D2038E, sha256 276b53cc6f655d4a16fd4168880b6fba91e36f6b119e3f1b5905dd0725dba35c", + "maxp": "32 bytes, checksum 0x005C052C, sha256 26689100d5138bb752af4293a4bbb556b534aa9d94134a46edd960ec269019ec", + "name": "626 bytes, checksum 0x36994F5C, sha256 154baee591bcc5b123fb15fe83ce360d42974c8abfb09816a6f085e524001800", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUniChar": 65535, + "defaultWidth": 600 + }, + "repackageTTF": { + "bytes": 2056, + "sha256": "9ce11cff4be42fecbcbe0ff2bbba52059205ccb6957c9406e42ae78adba255a7", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "100 bytes, checksum 0xF793DE7F, sha256 0dbe9edb138b44d6281c2fb3bff43096c4966deba0e380e4bed0d7e8887d0cba", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "668 bytes, checksum 0xD2CBF85C, sha256 64df708dd07ef438ab78c2fa6ec037cb9d2ec39a026eeecb018460dbe43ab527", + "head": "54 bytes, checksum 0x46EA9EA2, sha256 0b8c3ca999a2b17fbf2b3d563eb50321fe89783a5c2ca8f58cb91d6d0db34387", + "hhea": "36 bytes, checksum 0x29871F58, sha256 d5c8aa00ac210ae41f8cae1f3201846d77c0ac04d04e11627ee19851bdfdcd08", + "hmtx": "44 bytes, checksum 0x14C501AA, sha256 af3326b0ee31557a2ee01c72ee59dac76903c6a84f8a077b74b99271f2ba42f5", + "loca": "24 bytes, checksum 0x036E041D, sha256 0a4cc922874d893e9926bbbf0eece0202d5cab7ec6724963cafca63946eec457", + "maxp": "32 bytes, checksum 0x005C052C, sha256 26689100d5138bb752af4293a4bbb556b534aa9d94134a46edd960ec269019ec", + "name": "626 bytes, checksum 0x36A04F5C, sha256 4ac8e50cdaf871921a459fd22a3d72cd1e297ec807b51608557d7d1e2145d1ba", + "post": "151 bytes, checksum 0xB924F86C, sha256 4baf6477b5a1ef33145e61b8c7bb9c828d6b17629e10af34eaf47d20e2978446", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 0 + } + } +} diff --git a/tests/data/ttf/NotoSansArabic-GSUB62Positions-Synthetic.ttf b/tests/data/ttf/NotoSansArabic-GSUB62Positions-Synthetic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7638d11e412ce854ea8b380f2101e8ad8b5cf3f6 GIT binary patch literal 2548 zcmb_eTTGNk6h8C+yZO`p1xnK|do zneTjOX8xHaK}2?Xl~kGxHMg|VJ}Mxxu7kBQ)YaMD@Kt>Y^fu^sLfyShIbo-XD5Vzq zrOs}j|Mjo?tBJ%6^dke&$fVX}pCnS?Lv6#6Dae$G`8s;*@c8VpAFkf(!}vJr*^!~h zVC$Wa&tvR9YH$RG4Rh-c0gv%ZN1}-t{abMX`Zeg4;}ZiBHD+>QZV&XlXk=!R`plIW z#~kv+BGIAXxlZ7lt?-FYPD~}fEWL+VmX|PodNMvVIikIN5@YSqEyTGis^h0?_ifMG zxBW?~`U@;{ zKVwk(D0#{i$R5&w*)Mn(?oyj5p*Hmq^sAX=wV&42cF2uUS}?C-=>r-E^Z0vxtW=T%+a0vP5n%~ zM-K90ew}}Z5~%f-*SVD(Jwqueu7Vm2*5zhEcRFiw#Ty0FpGE`gocq#qGlQ@EcO&k3g$`ii}SPZWCggxn3?wRySK`|9!o!TfCdhK*UZJ6-)X8=oxJ z+?i#S_2#gBQ@F|QsN9sPdt|g+U$Nq;aN@(0=yV;coGsCVL9Fr0BCED7^#z#)_WfAU ztl~hcIhAC$g>u{1 zx-;anRqxro#|Qs{WHY^-{6zW}d$EbeiD=JpxpH)u>!M57-ELiX5zeqXxtY&^=EWK3 zTOdW8N&YIO!+iQpDUi>g8nU1sXojrdOTN1-tJH*B%dnehQ0!Qi%~T-HFUuPBiHo$G zCTNmoDNds_Oe2&a5BaGY)eor$mLc>V@JgV1s0*GG^c<`M(zlVO(T~6urzwnANQ?w9 zr>LHMD8oQzk7+teRhTtFQCMT>8Sxlu4AK;A5nx2%H43i{Yhrf88k6XaYdky@qHgL% zra^G+0?w3-G%699OE>ajY|bGeQNxgi!DL*<@`Fz`PQ9NRfYpf-g0zzBO4PM6H`Bin zR(v_u|HaYsP>ciM%xi1p#k;e@dL{Zw?6pk)du<+FnTInoZe1L-~+z+RpRG|n} zn1oqq!Xm69Mc6FUvC--uSs8E3oj}m4bqkkHRkm_bF1g(GEI9T61fA4|yZ; z$WihR%*MybJ2>>bpS;8Ip)vA~M-nmIzWnX+*T}C6;zT3yG0KRJMaS@R$U8)~%s`_f z(%XnVLTT`ikyEl^8*?Ds=y|x!N@*j`8^04L-15rMp1S zgl?1GX%gE3oCi5+klT|>XiZXTmY#$erPwt^=9(dM%~;Ke*G2HANM@{Y770|&j9=6l z@GZuCesk+_8hLGvVgoW~Z4_o^gKXr^tV~j43s)|TX|OZ&2ey@HjG*GbhJkDQ2i`H9 AfB*mh literal 0 HcmV?d00001 From 46622c62cde54623ed3a131bd7463de5faef2dbb Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 08:09:49 +1000 Subject: [PATCH 2/2] Build a class-based chained rule's backtrack and lookahead in one place (#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) --- src/TTFontFile.php | 29 ++++++++++++++------ tests/Mpdf/ChainedClassRulePositionsTest.php | 1 + tests/Mpdf/Fonts/GsubArrayTest.php | 3 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/TTFontFile.php b/src/TTFontFile.php index a922ff38f..9b9444b78 100644 --- a/src/TTFontFile.php +++ b/src/TTFontFile.php @@ -2544,15 +2544,7 @@ function _getGSUBarray(array $Lookup, $lul, $scripttag) for ($cscrule = 0; $cscrule < $cscs['ChainSubClassRuleCnt']; $cscrule++) { $rule = $cscs['ChainSubClassRule'][$cscrule]; - $backtrackGlyphs = []; - for ($gcl = 0; $gcl < $rule['BacktrackGlyphCount']; $gcl++) { - $backtrackGlyphs[$gcl] = $this->classGlyphs($subtable['BacktrackClasses'], $rule['Backtrack'][$gcl]); - } - - $lookaheadGlyphs = []; - for ($gcl = 0; $gcl < $rule['LookaheadGlyphCount']; $gcl++) { - $lookaheadGlyphs[$gcl] = $this->classGlyphs($subtable['LookaheadClasses'], $rule['Lookahead'][$gcl]); - } + list($backtrackGlyphs, $lookaheadGlyphs) = $this->classSequences($subtable, $rule); $this->addTo($volt, $this->gsubContextRule($Lookup, $i, $c, $tag, $scripttag, $ignore, $this->contextRule( $cscrule, @@ -2634,6 +2626,25 @@ private function coverageSequences(array $subtable) ]; } + /** + * @return array [backtrack, lookahead] of one class-based chained rule (Type 6 Format 2), one + * "|"-joined glyph string per position + */ + private function classSequences(array $subtable, array $rule) + { + $backtrack = []; + for ($gcl = 0; $gcl < $rule['BacktrackGlyphCount']; $gcl++) { + $backtrack[$gcl] = $this->classGlyphs($subtable['BacktrackClasses'], $rule['Backtrack'][$gcl]); + } + + $lookahead = []; + for ($gcl = 0; $gcl < $rule['LookaheadGlyphCount']; $gcl++) { + $lookahead[$gcl] = $this->classGlyphs($subtable['LookaheadClasses'], $rule['Lookahead'][$gcl]); + } + + return [$backtrack, $lookahead]; + } + /** * The input sequence of a class-based rule: the class its rule set is for, then the class of each * position after the first. diff --git a/tests/Mpdf/ChainedClassRulePositionsTest.php b/tests/Mpdf/ChainedClassRulePositionsTest.php index afbad63ce..888e14f27 100644 --- a/tests/Mpdf/ChainedClassRulePositionsTest.php +++ b/tests/Mpdf/ChainedClassRulePositionsTest.php @@ -40,6 +40,7 @@ private function drawn($codepoints) } $mpdf = new TextRecordingMpdf([ + 'mode' => 'utf-8', 'fontDir' => [__DIR__ . '/../data/ttf'], 'fontdata' => ['notosansarabicgsub62positionssynthetic' => [ 'R' => 'NotoSansArabic-GSUB62Positions-Synthetic.ttf', diff --git a/tests/Mpdf/Fonts/GsubArrayTest.php b/tests/Mpdf/Fonts/GsubArrayTest.php index e036284f0..8fbaaf8d2 100644 --- a/tests/Mpdf/Fonts/GsubArrayTest.php +++ b/tests/Mpdf/Fonts/GsubArrayTest.php @@ -55,8 +55,7 @@ public function testTheDumpReportsEachClassRuleWithItsOwnPositions() /** * A Type 5 rule has no backtrack or lookahead, yet where it belongs to an Arabic joining form the - * parser hands the shaper the sequences of the chained rule it read last. Kept as it was by the - * walk the two share; see #189. + * parser hands the shaper the sequences of the chained rule it read last (#189). */ public function testAPlainContextRuleOfAnArabicFormCarriesTheLastChainedRulesSequences() {