diff --git a/src/Otl.php b/src/Otl.php index 0237092b0..80cae4122 100644 --- a/src/Otl.php +++ b/src/Otl.php @@ -2592,6 +2592,9 @@ function GSUBsubstitute($pos, $substitute, $Type, $GlyphPos = null) } elseif ($this->shaper == 'M') { $newOTLdata[$i]['myanmar_category'] = $this->OTLdata[$pos]['myanmar_category']; $newOTLdata[$i]['myanmar_position'] = $this->OTLdata[$pos]['myanmar_position']; + } elseif ($this->shaper == 'E') { + $newOTLdata[$i]['sea_category'] = $this->OTLdata[$pos]['sea_category']; + $newOTLdata[$i]['sea_position'] = $this->OTLdata[$pos]['sea_position']; } if (isset($this->OTLdata[$pos]['mask'])) { $newOTLdata[$i]['mask'] = $this->OTLdata[$pos]['mask']; @@ -2784,6 +2787,9 @@ function GSUBsubstitute($pos, $substitute, $Type, $GlyphPos = null) } elseif ($this->shaper == 'M') { $newOTLdata[0]['myanmar_category'] = $this->OTLdata[$pos]['myanmar_category']; $newOTLdata[0]['myanmar_position'] = $this->OTLdata[$pos]['myanmar_position']; + } elseif ($this->shaper == 'E') { + $newOTLdata[0]['sea_category'] = $this->OTLdata[$pos]['sea_category']; + $newOTLdata[0]['sea_position'] = $this->OTLdata[$pos]['sea_position']; } if (isset($this->OTLdata[$pos]['mask'])) { $newOTLdata[0]['mask'] = $this->OTLdata[$pos]['mask']; diff --git a/src/TTFontFile.php b/src/TTFontFile.php index d8b579280..083cc51fe 100644 --- a/src/TTFontFile.php +++ b/src/TTFontFile.php @@ -1312,6 +1312,27 @@ function _getClassDefinitionTable($offset = 0) return $GlyphByClass; } + /** + * Add the Private Use Area glyphs a replacement names to the list magic_reverse_dir reads. + * + * A replacement is one glyph code where the substitution replaces one glyph with one, and a + * space-separated list where it replaces one with several - the dotless form of a letter and the + * dots to draw under it, say. + * + * @param string[] $rtlpua the list being built + * @param string $replace one glyph code, or several separated by spaces + */ + private function addPuaGlyphs(array &$rtlpua, $replace) + { + foreach (explode(' ', $replace) as $glyph) { + // Unanchored: a flattened contextual rule carries its backreferences in the same token as + // the glyph code, so '0FB93\2' names 0FB93 + if (preg_match('/(0[EF][A-F0-9]{3})/', $glyph, $matched)) { + $rtlpua[] = $matched[1]; + } + } + } + /** * GSUB - Glyph Substitution */ @@ -2122,7 +2143,9 @@ function _getGSUBtables() foreach ($volt as $v) { // isol fina fin2 fin3 medi med2 for Syriac // ISOLATED FORM :: FINAL :: INITIAL :: MEDIAL :: MED2 :: FIN2 :: FIN3 - if (strpos('isol fina init medi fin2 fin3 med2', $v['tag']) !== false) { + // A contextual entry carries the feature's own tag but keeps its replacements in + // ['rules'], so it has no ['replace'] for this branch to read + if (strpos('isol fina init medi fin2 fin3 med2', $v['tag']) !== false && !isset($v['context'])) { $key = $v['match']; $key = preg_replace('/[\(\)]*/', '', $key); @@ -2153,7 +2176,7 @@ function _getGSUBtables() if (isset($v['ignore']) && $v['ignore']) { $rtl[$key]['ignore'][$kk] = $v['ignore']; } - $rtlpua[] = $sub; + $this->addPuaGlyphs($rtlpua, $sub); } else { // Add any other glyphs which are in PUA if (isset($v['context']) && $v['context']) { @@ -2161,23 +2184,12 @@ function _getGSUBtables() $matchCount = count($vs['match']); for ($i = 0; $i < $matchCount; $i++) { if (isset($vs['replace'][$i]) && preg_match('/^0[A-F0-9]{4}$/', $vs['match'][$i])) { - if (preg_match('/^0[EF][A-F0-9]{3}$/', $vs['replace'][$i])) { - $rtlpua[] = $vs['replace'][$i]; - } + $this->addPuaGlyphs($rtlpua, $vs['replace'][$i]); } } } } else { - preg_match_all('/\((0[A-F0-9]{4})\)/', $v['match'], $m); - $matchCount = count($m[0]); - for ($i = 0; $i < $matchCount; $i++) { - $sb = explode(' ', $v['replace']); - foreach ($sb as $sbg) { - if (preg_match('/(0[EF][A-F0-9]{3})/', $sbg, $mr)) { - $rtlpua[] = $mr[1]; - } - } - } + $this->addPuaGlyphs($rtlpua, $v['replace']); } } } @@ -2213,9 +2225,11 @@ function _getGSUBtables() } // First get 'locl' substitutions (reversed!) + // Contextual entries are skipped here as they are above: their replacements are in + // ['rules'], not ['replace'] $loclsubs = []; foreach ($volt as $v) { - if (strpos('locl', $v['tag']) !== false) { + if (strpos('locl', $v['tag']) !== false && !isset($v['context'])) { $key = $v['match']; $key = preg_replace('/[\(\)]*/', '', $key); $sub = $v['replace']; diff --git a/tests/Mpdf/Shaper/SeaTest.php b/tests/Mpdf/Shaper/SeaTest.php new file mode 100644 index 000000000..535ba7415 --- /dev/null +++ b/tests/Mpdf/Shaper/SeaTest.php @@ -0,0 +1,103 @@ +drawn([self::SAKOT, self::HIGH_KA]); + + restore_error_handler(); + + $this->assertSame([], $raised); + $this->assertSame([self::DOTTED_CIRCLE, self::SAKOT_HIGH_KA], $drawn); + } + + /** + * The same Sakot inside a well-formed cluster, which is what the rule is for: the consonant in + * front of it is the base, no dotted circle is needed, and the pair after it is subscripted under + * it. + */ + public function testASakotInsideAClusterSubscriptsTheConsonantAfterIt() + { + $this->assertSame( + [self::HIGH_KA, self::SAKOT_HIGH_KA], + $this->drawn([self::HIGH_KA, self::SAKOT, self::HIGH_KA]) + ); + } + + /** + * A Sakot on its own ligates with nothing, so no glyph the categorising pass missed ever reaches + * the reorderer and the character is drawn as it was written. + */ + public function testASakotWithNothingAfterItIsDrawnAsItself() + { + $this->assertSame([self::SAKOT], $this->drawn([self::SAKOT])); + } + + /** + * @param int[] $codepoints + * + * @return int[] the codepoints of the line as it is handed to the drawing code, in visual order + */ + private function drawn($codepoints) + { + $html = ''; + foreach ($codepoints as $codepoint) { + $html .= sprintf('&#x%04X;', $codepoint); + } + + $mpdf = new TextRecordingMpdf(); + $mpdf->WriteHTML('

' . $html . '

'); + + return array_values(unpack('N*', mb_convert_encoding($mpdf->drawnText[0], 'UTF-32BE', 'UTF-8'))); + } + +} diff --git a/tests/Mpdf/TTFontFileTest.php b/tests/Mpdf/TTFontFileTest.php index 829c8ad7d..715c525fb 100644 --- a/tests/Mpdf/TTFontFileTest.php +++ b/tests/Mpdf/TTFontFileTest.php @@ -83,6 +83,68 @@ public function testGetMetricsReadsMarkGlyphSetsCoverage() $this->assertSame([' 00DCA| 00DD2| 00DD3', ' 0E00A'], $this->ttf->MarkGlyphSets); } + /** + * The Indic pre-pass that indexes 'locl' substitutions by their replacement read ['replace'] on + * every entry tagged 'locl'. A contextual entry keeps its replacements in ['rules'] and has no + * such key, so it read null once per font load. + * + * The subset is the contextual 'locl' of Noto Sans Devanagari 2.005 (OFL 1.1): a chained context + * under the Santali language system whose backtrack is U+0905 or U+0906 and whose nested lookup + * substitutes a Santali nukta for U+093C. Nothing is indexed from it either way, so the + * diagnostic is all there was to fix. + */ + public function testAContextualLoclEntryIsNotReadForATopLevelReplacement() + { + $raised = $this->diagnosticsWhileParsing('NotoSansDevanagari-ContextualLocl-Subset.ttf'); + + $this->assertSame([], $raised); + $this->assertSame('NotoSansDevanagari-Regular', $this->ttf->fullName); + } + + /** + * A joining form can be a Multiple Substitution, so the replacement recorded for one can name more + * than one glyph. The whole string was pushed into the Private Use Area list as if it named one, + * and hexdec() raised PHP 8's invalid-characters deprecation on it and returned a number far + * outside the PUA, so every glyph in it was dropped. + * + * The subset is Noto Sans Arabic 2.012 (OFL 1.1) cut down to U+0628 and U+06CC. The Farsi Yeh's + * initial and medial forms are Multiple Substitutions replacing it with the dotless form and the + * pair of dots to draw under it - E007 here, which has no codepoint of its own and is the glyph + * the whole-string push lost. + */ + public function testAJoiningFormOfMoreThanOneGlyphContributesEachOfThem() + { + $raised = $this->diagnosticsWhileParsing('NotoSansArabic-MultipleForm-Subset.ttf'); + + $this->assertSame([], $raised); + $this->assertSame('\x{0E000}-\x{0E003}\x{0E005}-\x{0E007}', $this->ttf->rtlPUAstr); + } + + /** + * Parse a font, collecting every diagnostic PHP raised doing it. Deprecations are not converted to + * exceptions, so a handler is what sees them. + * + * @return string[] + */ + private function diagnosticsWhileParsing($file) + { + $raised = []; + + set_error_handler(function ($number, $message, $path, $line) use (&$raised) { + $raised[] = sprintf('%s in %s:%d', $message, basename($path), $line); + + return true; + }); + + try { + $this->ttf->getMetrics(__DIR__ . '/../data/ttf/' . $file, uniqid('', true), 0, false, false, 0xFF); + } finally { + restore_error_handler(); + } + + return $raised; + } + /** * debugfont mode validates each table's version as it goes, so it walks the byte offsets by a * different route than normal mode and has its own chance to lose its place diff --git a/tests/Snapshots/TaiThamSakotSnapshotTest.php b/tests/Snapshots/TaiThamSakotSnapshotTest.php new file mode 100644 index 000000000..4bc222f52 --- /dev/null +++ b/tests/Snapshots/TaiThamSakotSnapshotTest.php @@ -0,0 +1,143 @@ +mpdf and + * loading it with content + * + * @return void + * @internal Don't call any $this->mpdf->Output*() method + */ + public function generatePdf() + { + $this->mpdf = $this->createMpdf(['mode' => 'utf-8']); + $this->mpdf->WriteHTML($this->style() . $this->samples()); + } + + private function style() + { + ob_start(); + ?> + + +

mPDF

+

Tai Tham clusters joined by a Sakot

+

A Sakot subscripts the consonant after it, and the two are drawn as one glyph + the font substitutes for the pair. Written with no consonant in front of them they are a + broken cluster, and a dotted circle is inserted to stand in for the consonant that is + missing. Read the subscript form under each: the broken cluster draws the same one the + well-formed cluster does.

+ sample('Sakot, High Ka - no base, so a dotted circle carries the pair', self::SAKOT . self::HIGH_KA) + . $this->sample('High Ka, Sakot, High Ka - the same pair under a base, the form to match', self::HIGH_KA . self::SAKOT . self::HIGH_KA) + . $this->sample('High Ka alone - the base by itself', self::HIGH_KA) + . $this->sample('Sakot alone - nothing to subscript, drawn as it was written', self::SAKOT) + . $this->sample('Sakot, High Xa - the same rule one consonant over', self::SAKOT . self::HIGH_XA) + . $this->sample('Sakot, High Ka, Vowel Sign Aa - a vowel after the broken cluster', self::SAKOT . self::HIGH_KA . self::VOWEL_AA) + . $this->sample('High Ka, Sakot, High Ka, Vowel Sign Aa - the same vowel after the base', self::HIGH_KA . self::SAKOT . self::HIGH_KA . self::VOWEL_AA); + } + + private function sample($label, $text) + { + return '

' . $label . '

' + . '

' . $text . '

'; + } + + /** + * The document only shows anything if the broken cluster draws the subscript form the well-formed + * cluster draws, rather than the two characters it was written as. The glyph has no codepoint of + * its own, so the samples are compared with each other rather than by naming it. + * + * @return int[] the glyphs the sample was drawn with, left to right + */ + private function glyphsOf($text) + { + $mpdf = new TextRecordingMpdf(['mode' => 'utf-8']); + $mpdf->WriteHTML($this->style() . '

' . $text . '

'); + + return array_values(unpack('N*', mb_convert_encoding(end($mpdf->drawnText), 'UTF-32BE', 'UTF-8'))); + } + + /** + * The Sakot and the consonant after it become one glyph either way. In the broken cluster a dotted + * circle stands where the base would be, and the subscript form is the same one the base takes. + */ + public function testTheBrokenClusterDrawsTheSubscriptFormTheWellFormedClusterDraws() + { + $broken = $this->glyphsOf(self::SAKOT . self::HIGH_KA); + $wellFormed = $this->glyphsOf(self::HIGH_KA . self::SAKOT . self::HIGH_KA); + + $this->assertCount(2, $broken, 'the broken cluster draws something other than a stand-in and one subscript form'); + $this->assertSame(end($wellFormed), end($broken), 'the broken cluster draws a subscript form the well-formed one does not'); + $this->assertNotSame($this->glyphsOf(self::HIGH_KA)[0], $broken[0], 'the stand-in is the consonant rather than a dotted circle'); + } + + /** + * The Sakot is genuinely replaced rather than drawn alongside the form that replaces it. + */ + public function testTheSakotIsNotDrawnBesideTheFormThatReplacesIt() + { + $alone = $this->glyphsOf(self::SAKOT); + $subscripted = $this->glyphsOf(self::SAKOT . self::HIGH_KA); + + $this->assertNotContains($alone[0], $subscripted, 'the pair draws the Sakot rather than the form that replaces it'); + } +} diff --git a/tests/data/fontcache/NotoSansArabic-MultipleForm-Subset.json b/tests/data/fontcache/NotoSansArabic-MultipleForm-Subset.json new file mode 100644 index 000000000..a37c16ca2 --- /dev/null +++ b/tests/data/fontcache/NotoSansArabic-MultipleForm-Subset.json @@ -0,0 +1,181 @@ +{ + "_": "Generated by composer fontcache:update. See tests/Mpdf/Fonts/ParserGoldenMaster.php.", + "fullName": "NotoSansArabic-Regular", + "mtx": { + "GSUBScriptLang": { + "arab": "DFLT " + }, + "GSUBFeatures": { + "arab": { + "DFLT": { + "ccmp": [ + 0 + ], + "init": [ + 1 + ], + "medi": [ + 2 + ], + "fina": [ + 3 + ] + } + } + }, + "GSUBLookups": [ + { + "Type": 2, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 104 + ], + "MarkFilteringSet": "" + }, + { + "Type": 2, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 132 + ], + "MarkFilteringSet": "" + }, + { + "Type": 2, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 160 + ], + "MarkFilteringSet": "" + }, + { + "Type": 2, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 188 + ], + "MarkFilteringSet": "" + } + ], + "GPOSScriptLang": { + "arab": "DFLT " + }, + "GPOSFeatures": { + "arab": { + "DFLT": { + "mark": [ + 0 + ], + "mkmk": [ + 1 + ] + } + } + }, + "GPOSLookups": [ + { + "Type": 4, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 72 + ], + "MarkFilteringSet": "" + }, + { + "Type": 6, + "Flag": 16, + "SubtableCount": 1, + "Subtables": [ + 154 + ], + "MarkFilteringSet": 1 + } + ], + "MarkGlyphSets": [ + " 0E007", + " 0E006| 0E007" + ], + "rtlPUAstr": "\\x{0E000}-\\x{0E003}\\x{0E005}-\\x{0E007}", + "haskernGPOS": false, + "hassmallcapsGSUB": false + }, + "cache": { + "GDEFdata.json": { + "GlyphClassBases": " 00020| 0E000| 0E001| 0E002| 0E003| 0E005", + "GlyphClassMarks": " 0E006| 0E007", + "GlyphClassLigatures": "", + "GlyphClassComponents": "", + "MarkGlyphSets": [ + " 0E007", + " 0E006| 0E007" + ], + "MarkAttachmentType": [] + }, + "GPOS.dat": "214 bytes, sha256 6f1081afffd2d5c5a30b17e71f251c17a584618bfbc40346bc042504df84b694", + "GPOSdata.json": [ + [ + { + "57350": 0, + "57351": 1 + } + ], + [ + { + "57350": 0, + "57351": 1 + } + ] + ], + "GSUB.arab.DFLT.json": { + "rtlSUB": { + "006CC": { + "2": "0E003 0E007", + "3": "0E002 0E007", + "1": "0E005" + }, + "0E000": { + "2": "0E003", + "3": "0E002", + "1": "0E001" + } + }, + "finals": "0E001 0E005 ", + "rphf": [], + "half": [], + "pref": [], + "blwf": [], + "pstf": [] + }, + "GSUB.dat": "214 bytes, sha256 2c5dc6ca7b1b0726e8838189ee37155b1d356e15ca530ccf02d949c091ae1637", + "GSUBdata.json": [ + [ + { + "1576": 0 + } + ], + [ + { + "57344": 0, + "1740": 1 + } + ], + [ + { + "57344": 0, + "1740": 1 + } + ], + [ + { + "57344": 0, + "1740": 1 + } + ] + ] + } +} diff --git a/tests/data/fontcache/NotoSansDevanagari-ContextualLocl-Subset.json b/tests/data/fontcache/NotoSansDevanagari-ContextualLocl-Subset.json new file mode 100644 index 000000000..58500e0d1 --- /dev/null +++ b/tests/data/fontcache/NotoSansDevanagari-ContextualLocl-Subset.json @@ -0,0 +1,70 @@ +{ + "_": "Generated by composer fontcache:update. See tests/Mpdf/Fonts/ParserGoldenMaster.php.", + "fullName": "NotoSansDevanagari-Regular", + "mtx": { + "GSUBScriptLang": { + "dev2": "SAT " + }, + "GSUBFeatures": { + "dev2": { + "SAT ": { + "locl": [ + 0 + ] + } + } + }, + "GSUBLookups": [ + { + "Type": 6, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 64 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 98 + ], + "MarkFilteringSet": "" + } + ], + "GPOSScriptLang": [], + "GPOSFeatures": [], + "GPOSLookups": [], + "MarkGlyphSets": [], + "rtlPUAstr": "", + "haskernGPOS": false, + "hassmallcapsGSUB": false + }, + "cache": { + "GDEFdata.json": { + "GlyphClassBases": " 00905| 00906", + "GlyphClassMarks": " 0093C| 0E001", + "GlyphClassLigatures": "", + "GlyphClassComponents": "", + "MarkGlyphSets": [], + "MarkAttachmentType": [] + }, + "GPOS.dat": "16 bytes, sha256 db40218e56c29eac86a5130fe0abcb62caa3d8bba870ca1d05c9060ad63dd29f", + "GPOSdata.json": [], + "GSUB.dat": "110 bytes, sha256 7d8abc87c74095b51303e243a3d897e90358ea673a2f40725938cb9ddf569e05", + "GSUBdata.json": [ + [ + { + "2364": 0 + } + ], + [ + { + "2364": 0 + } + ] + ] + } +} diff --git a/tests/data/otldump/NotoSansArabic-MultipleForm-Subset.txt b/tests/data/otldump/NotoSansArabic-MultipleForm-Subset.txt new file mode 100644 index 000000000..5b6b5b688 --- /dev/null +++ b/tests/data/otldump/NotoSansArabic-MultipleForm-Subset.txt @@ -0,0 +1,28 @@ +

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

+
◌
+

Mark Glyph Set class: 1

+
◌ ◌
+

GSUB Tables

+

GSUB Scripts & Languages

+
+
arab
DFLT: ccmp init medi fina
+
+

GPOS Tables

+

GPOS Scripts & Languages

+
arab
DFLT: mark mkmk
+ + +=== detail: script arab language DFLT === +

GSUB Tables

+
Lookup #0 [tag: ccmp]
Subtable #0
LookupType 2: Multiple Substitution Subtable
U+0628   ب  » »    ◌  M+E000, M+E006
Lookup #1 [tag: init]
Subtable #0
LookupType 2: Multiple Substitution Subtable
M+E000     » »     M+E003
U+06CC   ی  » »    ◌  M+E003, M+E007
Lookup #2 [tag: medi]
Subtable #0
LookupType 2: Multiple Substitution Subtable
M+E000     » »     M+E002
U+06CC   ی  » »    ◌  M+E002, M+E007
Lookup #3 [tag: fina]
Subtable #0
LookupType 2: Multiple Substitution Subtable
M+E000     » »     M+E001
U+06CC   ی  » »     M+E005
+

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[1]
Subtable #0
LookupType 6: MarkToMark attachment
Marks: ◌ ◌
Bases: ◌ ◌
Example(s): ◌   ◌  
diff --git a/tests/data/otldump/NotoSansDevanagari-ContextualLocl-Subset.txt b/tests/data/otldump/NotoSansDevanagari-ContextualLocl-Subset.txt new file mode 100644 index 000000000..2d16e9485 --- /dev/null +++ b/tests/data/otldump/NotoSansDevanagari-ContextualLocl-Subset.txt @@ -0,0 +1,24 @@ +

GDEF table

+

Glyph classes

+

Glyph class 1

+
Base glyph (single character, spacing glyph)
+
अ आ
+

Glyph class 3

+
Mark glyph (non-spacing combining glyph)
+
◌़ ◌
+

GSUB Tables

+

GSUB Scripts & Languages

+
+
dev2
SAT : locl
+
+

GPOS Tables

+

GPOS Scripts & Languages

+
No entries in GPOS table.
+ + +=== detail: script dev2 language SAT === +

GSUB Tables

+
Lookup #0 [tag: locl]
Subtable #0
LookupType 6: Chaining Contextual Substitution Subtable
Format 3: Coverage-based Chaining Context Glyph Substitution
CONTEXT:
Backtrack #0: U+0905, U+0906
Input #0:  ◌़ 
Substitution Position: 0
Lookup #1 [tag: locl]
Subtable #0
LookupType 1: Single Substitution Subtable
U+093C   ◌़  » »   ◌  M+E001
+

GPOS Tables

+
This font's GPOS table offers no script "dev2". It has: none
+ diff --git a/tests/data/snapshots/tai-tham-sakot.pdf b/tests/data/snapshots/tai-tham-sakot.pdf new file mode 100644 index 000000000..a192b6a87 Binary files /dev/null and b/tests/data/snapshots/tai-tham-sakot.pdf differ diff --git a/tests/data/ttf/NotoSansArabic-MultipleForm-Subset.ttf b/tests/data/ttf/NotoSansArabic-MultipleForm-Subset.ttf new file mode 100644 index 000000000..abf437765 Binary files /dev/null and b/tests/data/ttf/NotoSansArabic-MultipleForm-Subset.ttf differ diff --git a/tests/data/ttf/NotoSansDevanagari-ContextualLocl-Subset.ttf b/tests/data/ttf/NotoSansDevanagari-ContextualLocl-Subset.ttf new file mode 100644 index 000000000..240383e51 Binary files /dev/null and b/tests/data/ttf/NotoSansDevanagari-ContextualLocl-Subset.ttf differ