diff --git a/src/Otl.php b/src/Otl.php index 0237092b0..166144c99 100644 --- a/src/Otl.php +++ b/src/Otl.php @@ -1624,6 +1624,11 @@ private function _getIndicFeatureMask($usetag) function _applyGSUBsubtableSpecial($lookupID, $subtable, $ptr, $currGlyph, $currGID, $nextGlyph, $nextGID, $subtable_offset, $Type, $LuCoverage) { + // The same guard the other two entry points make, on the glyph this one is indexed by: the + // lookup is on the consonant after the Halant, not on the glyph the cursor is on + if (!isset($LuCoverage[$nextGID])) { + return 0; + } // Special case for Indic // Check to substitute Halant-Consonant in PREF, BLWF or PSTF @@ -1702,12 +1707,25 @@ function _applyGSUBsubtableSpecial($lookupID, $subtable, $ptr, $currGlyph, $curr * Lookup type 7, Extension, never arrives here: _getGSUBtables() resolves it at font-build time * into the type and offset it points at. * + * A subtable applies only to a glyph in its own Coverage, and the structures below index that + * Coverage by the glyph with no check of their own. A glyph arriving from a matched context has + * been tested against the context's Coverages and not against this subtable's, so the test belongs + * here rather than only at the callers. + * + * The loops that apply a lookup from the top of the list keep their own copy of it. That is not + * redundant: theirs stands ahead of the call, and on a run through Arabic turns away 88,700 of + * 90,000 glyphs - leaning on this guard alone measured 15% slower end to end. + * * @see https://learn.microsoft.com/en-us/typography/opentype/spec/gsub * * @return int Glyphs to advance by, 0 if the subtable did not apply */ function _applyGSUBsubtable($lookupID, $subtable, $ptr, $currGlyph, $currGID, $subtable_offset, $Type, $Flag, $MarkFilteringSet, $LuCoverage, $level, $currentTag, $is_old_spec, $tagInt) { + if (!isset($LuCoverage[$currGID])) { + return 0; + } + $ignore = $this->getGCOMignoreSet($Flag, $MarkFilteringSet); $this->reader->seek($subtable_offset); @@ -3019,12 +3037,19 @@ private function _getXAdvancePos($pos) * Lookup type 9, Extension, never arrives here: _getGPOStables() resolves it at font-build time * into the type and offset it points at. * + * The Coverage test is the same one _applyGSUBsubtable makes, and is there for the same reason - + * read it there. + * * @see https://learn.microsoft.com/en-us/typography/opentype/spec/gpos * * @return int Glyphs to advance by, 0 if the subtable did not apply */ private function _applyGPOSsubtable($lookupID, $subtable, $ptr, $currGlyph, $currGID, $subtable_offset, $Type, $Flag, $MarkFilteringSet, $LuCoverage, $tag, $level, $is_old_spec) { + if (!isset($LuCoverage[$currGID])) { + return 0; + } + // RIGHT_TO_LEFT. Only cursive attachment reads it. $dir = ($Flag & 0x0001) == 1 ? 'RTL' : 'LTR'; @@ -3165,7 +3190,7 @@ private function _applyGPOSpairAdjustmentFormat1($lookupID, $subtable, $ptr, $cu $PairSetOffset[] = $subtable_offset + $this->reader->readUInt16(); } for ($p = 0; $p < $PairSetCount; $p++) { - if (isset($LuCoverage[$currGID]) && $LuCoverage[$currGID] == $p) { + if ($LuCoverage[$currGID] == $p) { $this->reader->seek($PairSetOffset[$p]); //PairSet table $PairValueCount = $this->reader->readUInt16(); @@ -4129,8 +4154,9 @@ private function _applyGSUBlookupRecords($SubstCount, $InputGlyphCount, $matched $lucurrGID = $this->OTLdata[$luptr]['uni']; foreach ($this->GSUBLookups[$lu]['Subtables'] as $luc => $lusubtable_offset) { - $shift = $this->_applyGSUBsubtable($lu, $luc, $luptr, $lucurrGlyph, $lucurrGID, $lusubtable_offset, $luType, $luFlag, $luMarkFilteringSet, $this->GSLuCoverage[$lu][$luc], 1, $currentTag, $is_old_spec, $tagInt); - if ($shift) { + $applied = $this->_applyGSUBsubtable($lu, $luc, $luptr, $lucurrGlyph, $lucurrGID, $lusubtable_offset, $luType, $luFlag, $luMarkFilteringSet, $this->GSLuCoverage[$lu][$luc], 1, $currentTag, $is_old_spec, $tagInt); + if ($applied) { + $shift = $applied; break; } } @@ -4189,8 +4215,9 @@ private function _applyGPOSlookupRecords($PosCount, $InputGlyphCount, $matched, $lucurrGID = $this->OTLdata[$luptr]['uni']; foreach ($this->GPOSLookups[$lu]['Subtables'] as $luc => $lusubtable_offset) { - $shift = $this->_applyGPOSsubtable($lu, $luc, $luptr, $lucurrGlyph, $lucurrGID, $lusubtable_offset, $luType, $luFlag, $luMarkFilteringSet, $this->LuCoverage[$lu][$luc], $tag, 1, $is_old_spec); - if ($shift) { + $applied = $this->_applyGPOSsubtable($lu, $luc, $luptr, $lucurrGlyph, $lucurrGID, $lusubtable_offset, $luType, $luFlag, $luMarkFilteringSet, $this->LuCoverage[$lu][$luc], $tag, 1, $is_old_spec); + if ($applied) { + $shift = $applied; break; } } diff --git a/tests/Mpdf/NestedLookupCoverageTest.php b/tests/Mpdf/NestedLookupCoverageTest.php new file mode 100644 index 000000000..8d94df07e --- /dev/null +++ b/tests/Mpdf/NestedLookupCoverageTest.php @@ -0,0 +1,192 @@ + 'utf-8']); + $mpdf->WriteHTML('

' . $html . '

'); + + return array_values(unpack('N*', mb_convert_encoding($mpdf->drawnText[0], 'UTF-32BE', 'UTF-8'))); + } + + /** + * The mark the nested subtable does cover is still substituted, which is what the rule is for. The + * capital form has no codepoint of its own and is mapped into the Private Use Area as the subset + * is built, so it is read as "not the character written" rather than named. + */ + public function testAMarkTheNestedSubtableCoversIsStillSubstituted() + { + $drawn = $this->drawn([self::CAPITAL_A, self::GRAVE]); + + $this->assertCount(2, $drawn); + $this->assertSame(self::CAPITAL_A, $drawn[0]); + $this->assertNotSame(self::GRAVE, $drawn[1], 'the capital form of the grave was not substituted'); + } + + /** + * A mark it does not cover is left as itself. It was being given the record at Coverage Index 0 - + * the capital grave - so a macron written over a capital A was drawn as a grave accent. + */ + public function testAMarkTheNestedSubtableDoesNotCoverIsLeftAlone() + { + $this->assertSame([self::CAPITAL_A, self::MACRON], $this->drawn([self::CAPITAL_A, self::MACRON])); + } + + /** + * With a lowercase letter in front of it the context does not match at all, so even the mark the + * nested subtable does cover stays as written. + */ + public function testAMarkAfterALowercaseLetterIsLeftAlone() + { + $this->assertSame([self::SMALL_A, self::GRAVE], $this->drawn([self::SMALL_A, self::GRAVE])); + } + + /** + * The positioning half, and the shape GravityPDF/mpdf#102 was reported from. + * + * NotoSansGurmukhi-NestedCoverage-Subset is 5 glyphs of Noto Sans Gurmukhi 2.005 (OFL 1.1) whose + * `dist` chained context names a single adjustment at a Devanagari stress sign. That lookup has + * two subtables - the first covers the bindi and a conjunct, the second the two stress signs - and + * the bindi's value record was what the stress signs were positioned by. + * + * @return array the adjustment the mark of a two-character run was given + */ + private function markPosition($mark) + { + $mpdf = new PositionRecordingMpdf([ + 'mode' => 'utf-8', + 'fontDir' => [__DIR__ . '/../data/ttf'], + 'fontdata' => ['gurmukhinested' => [ + 'R' => 'NotoSansGurmukhi-NestedCoverage-Subset.ttf', + 'useOTL' => 0xFF, + ]], + 'default_font' => 'gurmukhinested', + ]); + $mpdf->WriteHTML('

' . self::KA . $mark . '

'); + + return $mpdf->drawnPositions[0][1]; + } + + /** + * The udatta is raised above the letter by its own record in the second subtable, where the first + * subtable's record left it on the baseline and 150 units to the left. + */ + public function testAMarkIsPositionedByTheSubtableThatCoversIt() + { + $this->assertSame( + ['BaseWidth' => 622, 'XPlacement' => 602, 'YPlacement' => 275, 'XAdvanceL' => 602, 'XAdvanceR' => 602], + $this->markPosition(self::UDATTA) + ); + } + + /** + * The anudatta is the same the other way: its own record drops it below the letter, where the + * first subtable's put it on the baseline. + */ + public function testTheSecondMarkOfTheSameSubtableIsPositionedByItToo() + { + $this->assertSame( + ['BaseWidth' => 622, 'XPlacement' => 255, 'YPlacement' => -355, 'XAdvanceL' => 255, 'XAdvanceR' => 255], + $this->markPosition(self::ANUDATTA) + ); + } + + /** + * The bindi is the glyph the first subtable does cover, and the one whose record the other two + * were being given. It is positioned exactly as it was, so the guard turns away the glyphs the + * subtable does not cover without turning away the one it does. + */ + public function testTheGlyphTheFirstSubtableCoversIsPositionedAsItWas() + { + $this->assertSame( + ['BaseWidth' => 622, 'XPlacement' => 603, 'YPlacement' => 0, 'XAdvanceL' => 603, 'XAdvanceR' => 603], + $this->markPosition(self::BINDI) + ); + } + + /** + * Reading a Coverage at a glyph it does not hold is a warning per glyph per subtable under E_ALL, + * and the value it yields is an offset, so what follows it can be a deprecation as well. Asserted + * beside the positions, since an install running at production's error_reporting sees none of it - + * only a mark in the wrong place. + */ + public function testANestedLookupReadsNoCoverageItHasNot() + { + $raised = []; + + set_error_handler(function ($number, $message, $file, $line) use (&$raised) { + // Filtered to the shaper so a notice raised elsewhere in the render cannot fail this + if (false !== strpos($file, 'Otl.php')) { + $raised[] = sprintf('%s in %s:%d', $message, basename($file), $line); + } + + return true; + }); + + try { + foreach ([self::UDATTA, self::ANUDATTA, self::BINDI] as $mark) { + $this->markPosition($mark); + } + } finally { + restore_error_handler(); + } + + $this->assertSame([], $raised); + } + +} diff --git a/tests/Snapshots/NestedLookupCoverageSnapshotTest.php b/tests/Snapshots/NestedLookupCoverageSnapshotTest.php new file mode 100644 index 000000000..8fe19424a --- /dev/null +++ b/tests/Snapshots/NestedLookupCoverageSnapshotTest.php @@ -0,0 +1,223 @@ + 0x0300, + 'acute' => 0x0301, + 'circumflex' => 0x0302, + 'tilde' => 0x0303, + 'dieresis' => 0x0308, + ]; + + /** + * Marks it has no capital form of. Every one of these was drawn as the capital grave, because the + * subtable holds that at Coverage Index 0 and a missing index reads as 0. + */ + const UNCOVERED = [ + 'macron' => 0x0304, + 'overline' => 0x0305, + 'hook above' => 0x0309, + 'comma above' => 0x0313, + 'double overline' => 0x033F, + ]; + + /** U+0A15 GURMUKHI LETTER KA */ + const KA = 'ਕ'; + + /** U+0A02 GURMUKHI SIGN BINDI, the glyph the nested subtable does cover */ + const BINDI = 'ਂ'; + + /** U+0951 DEVANAGARI STRESS SIGN UDATTA, drawn above the letter */ + const UDATTA = '॑'; + + /** U+0952 DEVANAGARI STRESS SIGN ANUDATTA, drawn below it */ + const ANUDATTA = '॒'; + + /** + * @return string A unique identifier / name for the snapshot + */ + public function getId() + { + return 'nested-lookup-coverage'; + } + + /** + * Generate a PDF document by initializing the Mpdf object on $this->mpdf and + * loading it with content + * + * @return void + * @internal Don't call any $this->mpdf->Output*() method + */ + public function generatePdf() + { + $this->mpdf = $this->createMpdf($this->config()); + $this->mpdf->WriteHTML($this->style() . $this->markSamples() . $this->gurmukhiSamples()); + } + + /** + * DejaVu Sans is shipped and needs no registering. The Gurmukhi subset is 5 glyphs of Noto Sans + * Gurmukhi 2.005 (OFL 1.1) - the KA, the bindi, the two stress signs and the conjunct that keeps + * the nested lookup's first subtable at the two entries that make it index its Coverage. + */ + private function config() + { + return [ + 'mode' => 'utf-8', + 'fontDir' => [__DIR__ . '/../data/ttf'], + 'fontdata' => [ + 'gurmukhinested' => [ + 'R' => 'NotoSansGurmukhi-NestedCoverage-Subset.ttf', + 'useOTL' => 0xFF, + ], + ], + ]; + } + + private function style() + { + ob_start(); + ?> + + +

mPDF

+ Combining marks after a capital' + . '

DejaVu Sans has a capital form of some combining marks and not of others.' + . ' The lowercase column is the mark as written; the capital column is the form drawn over a' + . ' capital letter. Read down the second table: each mark should be its own, not the grave of' + . ' the first row above.

'; + + foreach (['a capital form' => self::COVERED, 'no capital form' => self::UNCOVERED] as $heading => $marks) { + $html .= '

Marks with ' . $heading . '

'; + foreach ($marks as $label => $mark) { + $html .= '' + . '' + . ''; + } + $html .= '
' . $label . ' U+' . sprintf('%04X', $mark) . '' . sprintf('A&#x%04X;', $mark) . '' . sprintf('a&#x%04X;', $mark) . '
'; + } + + return $html; + } + + private function gurmukhiSamples() + { + return '

Gurmukhi stress signs

' + . '

The udatta sits above the letter and the anudatta below it. Both were given' + . ' the bindi\'s adjustment instead - on the baseline, and to the left of where the bindi' + . ' itself is drawn.

' + . '' + . $this->gurmukhiRow('udatta U+0951 - above', self::UDATTA) + . $this->gurmukhiRow('anudatta U+0952 - below', self::ANUDATTA) + . $this->gurmukhiRow('bindi U+0A02 - the record they were given', self::BINDI) + . '
'; + } + + private function gurmukhiRow($label, $mark) + { + return '' . $label . '' + . '' . self::KA . $mark . '' + . '' . self::KA . ''; + } + + /** + * The font comes from the paragraph's own inline style rather than from the stylesheet, so the + * sample stands on its own and the run is the first thing drawn. + * + * @return string[] the glyphs the sample was drawn with, left to right + */ + private function glyphsOf($text) + { + $mpdf = new TextRecordingMpdf($this->config()); + $mpdf->WriteHTML('

' . $text . '

'); + + return preg_split('//u', $mpdf->drawnText[0], -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * The document only shows anything if the marks the nested substitution does not cover are drawn + * as themselves rather than as the capital grave. The capital forms have no codepoints of their + * own - they are mapped into the Private Use Area as the subset is built - so the samples are + * compared with each other rather than named. + */ + public function testTheMarksWithNoCapitalFormAreNotDrawnAsTheGrave() + { + $grave = $this->glyphsOf('À'); + + foreach (self::UNCOVERED as $label => $mark) { + $sample = $this->glyphsOf(sprintf('A&#x%04X;', $mark)); + $written = $this->glyphsOf(sprintf('a&#x%04X;', $mark)); + + $this->assertNotSame($grave[1], $sample[1], $label . ' is drawn as the capital grave'); + $this->assertSame($written[1], $sample[1], $label . ' over a capital is not the mark written'); + } + } + + /** + * The marks that do have a capital form still get it, which is what tells the guard apart from + * turning the rule off. Each is drawn as something other than the character written, and each as + * something other than the others. + */ + public function testTheMarksWithACapitalFormStillGetIt() + { + $drawn = []; + foreach (self::COVERED as $label => $mark) { + $sample = $this->glyphsOf(sprintf('A&#x%04X;', $mark)); + $written = $this->glyphsOf(sprintf('a&#x%04X;', $mark)); + + $this->assertNotSame($written[1], $sample[1], $label . ' was not given its capital form'); + $drawn[] = $sample[1]; + } + + $this->assertSame($drawn, array_unique($drawn), 'two capital forms are drawn as the same glyph'); + } +} diff --git a/tests/data/fontcache/NotoSansGurmukhi-NestedCoverage-Subset.json b/tests/data/fontcache/NotoSansGurmukhi-NestedCoverage-Subset.json new file mode 100644 index 000000000..969fd3591 --- /dev/null +++ b/tests/data/fontcache/NotoSansGurmukhi-NestedCoverage-Subset.json @@ -0,0 +1,100 @@ +{ + "_": "Generated by composer fontcache:update. See tests/Mpdf/Fonts/ParserGoldenMaster.php.", + "fullName": "NotoSansGurmukhi-Regular", + "mtx": { + "GSUBScriptLang": [], + "GSUBFeatures": [], + "GSUBLookups": [], + "GPOSScriptLang": { + "gur2": "DFLT " + }, + "GPOSFeatures": { + "gur2": { + "DFLT": { + "abvm": [ + 0 + ], + "dist": [ + 1 + ] + } + } + }, + "GPOSLookups": [ + { + "Type": 4, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 74 + ], + "MarkFilteringSet": "" + }, + { + "Type": 8, + "Flag": 0, + "SubtableCount": 2, + "Subtables": [ + 162, + 186 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 2, + "Subtables": [ + 230, + 250 + ], + "MarkFilteringSet": "" + } + ], + "MarkGlyphSets": [], + "rtlPUAstr": "", + "haskernGPOS": false, + "hassmallcapsGSUB": false + }, + "cache": { + "GDEFdata.json": { + "GlyphClassBases": " 00A15", + "GlyphClassMarks": " 00951| 00952| 00A02| 0E000", + "GlyphClassLigatures": "", + "GlyphClassComponents": "", + "MarkGlyphSets": [], + "MarkAttachmentType": [] + }, + "GPOS.dat": "270 bytes, sha256 141fe74bac6028f61694f29b20070b0c62950a8ad4392743aa464aaacff0f0b6", + "GPOSdata.json": [ + [ + { + "2562": 0, + "57344": 1, + "2385": 2, + "2386": 3 + } + ], + [ + { + "2385": 0 + }, + { + "2386": 0 + } + ], + [ + { + "2562": 0, + "57344": 1 + }, + { + "2385": 0, + "2386": 1 + } + ] + ], + "GSUB.dat": "26 bytes, sha256 aa7ce2b45ba3a2c52647924a58315c984a0acb7958ba1d7b9aaab161ec333b84", + "GSUBdata.json": [] + } +} diff --git a/tests/data/otldump/NotoSansGurmukhi-NestedCoverage-Subset.txt b/tests/data/otldump/NotoSansGurmukhi-NestedCoverage-Subset.txt new file mode 100644 index 000000000..eff8c7183 --- /dev/null +++ b/tests/data/otldump/NotoSansGurmukhi-NestedCoverage-Subset.txt @@ -0,0 +1,17 @@ +

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

+
+
No entries in GSUB table.
+
+

GPOS Tables

+

GPOS Scripts & Languages

+
gur2
DFLT: abvm dist
+ diff --git a/tests/data/snapshots/nested-lookup-coverage.pdf b/tests/data/snapshots/nested-lookup-coverage.pdf new file mode 100644 index 000000000..5e266e21f Binary files /dev/null and b/tests/data/snapshots/nested-lookup-coverage.pdf differ diff --git a/tests/data/ttf/NotoSansGurmukhi-NestedCoverage-Subset.ttf b/tests/data/ttf/NotoSansGurmukhi-NestedCoverage-Subset.ttf new file mode 100644 index 000000000..8fb35febb Binary files /dev/null and b/tests/data/ttf/NotoSansGurmukhi-NestedCoverage-Subset.ttf differ