From 65953dad6a1272bf3010ecae5bcb2702c6e4875e Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 09:03:40 +1000 Subject: [PATCH 1/5] Test a glyph's membership of a GDEF class by the glyph, not by its hex turning up in the class (#193) GDEF's classes are kept as " 00300| 00301", and every reader asked whether a glyph was in one with strpos(). GlyphString::of() writes a plane 16 character six digits wide, and a six-digit glyph holds two five-digit ones: U+100300 is 10030 followed by a 0, and a 1 followed by 00300. Where a class named U+100300, U+0300 and U+10030 both read as members. The stored formats do not change. GlyphString gains set(), a list of glyphs keyed by glyph, and inList(), which finds a glyph only where no hex digit touches it on either side: - LookupFlag::skips() looks each class up in a set, built once per class. - Otl builds a set of the marks once per font, and its twenty GlyphClassMarks tests become isMark(). The LookupFlag it built per run is kept per font with it. LineBreaking is handed the set. - The mark to base, ligature and mark attachment subtables found the base in a "|"-joined Coverage string and took its index as the strpos() offset over six; they look both up in a map of hex to Coverage Index. - `finals`, Shaper\Arabic's backtrack, lookahead and ignore strings, the parser's test of a nested lookup's first glyph against a rule position, and OtlDump::positionHolds() go through inList(). No font TTFontFile parses can reach this today. It reads no cmap entry at or past U+30000 and maps a glyph only such an entry reaches into the Private Use Area, so every glyph in a class is five digits wide. Regenerating every golden master from cold moves none of them. NotoSans-PlaneSixteenMark-Synthetic shows it all the same. It is Noto Sans 2.007 (OFL 1.1) cut down in fontTools 4.59.2 to space, A, grave, asciitilde, gravecomb and acutecomb, with u10030 (drawn as A, mapped to U+10030) and u100300 (drawn as gravecomb, mapped to U+100300) added, its layout tables replaced and name IDs 1, 4 and 6 renamed. GDEF classes gravecomb and u10030 as bases, acutecomb and u100300 as marks; a 'ccmp' lookup setting IgnoreMarks substitutes grave for gravecomb, and a 'mark' lookup attaches acutecomb to u10030. Handed the classes as the font states them, the shaper drew A U+0300 unsubstituted and left the mark in U+10030 U+0301 unattached. It now draws `A grave` and attaches the mark at x -339 from the base's end, as hb-shape 14.3.1 does. Co-Authored-By: Claude Opus 5 (1M context) --- src/Fonts/GlyphString.php | 62 +++++++- src/Fonts/Table/LookupFlag.php | 29 +++- src/Otl.php | 118 ++++++++++----- src/OtlDump.php | 4 +- src/Shaper/Arabic.php | 13 +- src/Shaper/LineBreaking.php | 17 +-- src/TTFontFile.php | 4 +- tests/Mpdf/Fonts/GlyphStringTest.php | 42 ++++++ tests/Mpdf/Fonts/Table/LookupFlagTest.php | 23 +++ tests/Mpdf/GdefClassMembershipTest.php | 134 ++++++++++++++++++ tests/Mpdf/Shaper/ArabicTest.php | 37 ++++- tests/Mpdf/Shaper/LineBreakingTest.php | 16 ++- .../NotoSans-PlaneSixteenMark-Synthetic.json | 98 +++++++++++++ .../NotoSans-PlaneSixteenMark-Synthetic.txt | 24 ++++ .../NotoSans-PlaneSixteenMark-Synthetic.txt | 96 +++++++++++++ .../NotoSans-PlaneSixteenMark-Synthetic.json | 123 ++++++++++++++++ .../NotoSans-PlaneSixteenMark-Synthetic.ttf | Bin 0 -> 2608 bytes 17 files changed, 779 insertions(+), 61 deletions(-) create mode 100644 tests/Mpdf/GdefClassMembershipTest.php create mode 100644 tests/data/fontcache/NotoSans-PlaneSixteenMark-Synthetic.json create mode 100644 tests/data/otldump/NotoSans-PlaneSixteenMark-Synthetic.txt create mode 100644 tests/data/shaping/NotoSans-PlaneSixteenMark-Synthetic.txt create mode 100644 tests/data/subset/NotoSans-PlaneSixteenMark-Synthetic.json create mode 100644 tests/data/ttf/NotoSans-PlaneSixteenMark-Synthetic.ttf diff --git a/src/Fonts/GlyphString.php b/src/Fonts/GlyphString.php index ef7847fc5..943d18081 100644 --- a/src/Fonts/GlyphString.php +++ b/src/Fonts/GlyphString.php @@ -5,13 +5,14 @@ /** * The fixed-width hexadecimal strings the OTL code writes characters as. * - * Coverage tables, mark and ligature classes and substitution rules are all carried as text and - * compared as text: GlyphClassMarks is one run of them that membership is tested against with - * strpos(), and a substitution of one character by several is their concatenation. Both only work - * while every character takes up the same number of characters, which is what this is for. + * Coverage tables, mark and ligature classes and substitution rules are all carried as text, and a + * substitution of one character by several is their concatenation, which only works while every + * character takes up the same number of characters. That is what this is for. * * Five digits is that width. It covers everything up to U+FFFFF; a character above that - plane 16, - * the second private use area - comes back six digits wide and lines up with nothing. + * the second private use area - comes back six digits wide and lines up with nothing. A six-digit + * glyph holds two five-digit ones, U+100300 both U+10030 and U+0300, so a list of glyphs is never + * searched for one with strpos(): set() and inList() test it glyph by glyph. */ class GlyphString { @@ -26,4 +27,55 @@ public static function of($codepoint) return str_pad(strtoupper(dechex($codepoint)), 5, '0', STR_PAD_LEFT); } + /** + * A list of glyphs as a set, for a list asked about glyph after glyph. + * + * @param string $glyphs "|"-separated, with or without the space GDEF's classes put before each + * glyph: " 00300| 00301" + * + * @return true[] Glyph, as hex => true + */ + public static function set($glyphs) + { + $set = []; + foreach (explode('|', $glyphs) as $glyph) { + $glyph = trim($glyph); + if ($glyph !== '') { + $set[$glyph] = true; + } + } + + return $set; + } + + /** + * Whether a list of glyphs names this one, for a list asked about too seldom to be worth a set. + * + * The glyph has to stand on its own: the list may separate glyphs with anything but a hex digit, + * so the one test serves GDEF's " 00300| 00301", a Coverage table's "00300|00301", the space-ended + * "0FE8E 0FE94 " of `finals`, and an ignore pattern's "((?:(?: 00300| 00301))*)". + * + * @param string $glyphs + * @param string $glyph As hex + * + * @return bool + */ + public static function inList($glyphs, $glyph) + { + $length = strlen($glyph); + + for ($at = strpos($glyphs, $glyph); $at !== false; $at = strpos($glyphs, $glyph, $at + 1)) { + if (!self::isHexDigitAt($glyphs, $at - 1) && !self::isHexDigitAt($glyphs, $at + $length)) { + return true; + } + } + + return false; + } + + private static function isHexDigitAt($string, $at) + { + return $at >= 0 && isset($string[$at]) && strpos('0123456789ABCDEFabcdef', $string[$at]) !== false; + } + } diff --git a/src/Fonts/Table/LookupFlag.php b/src/Fonts/Table/LookupFlag.php index 9b93d60fc..6c80ae24c 100644 --- a/src/Fonts/Table/LookupFlag.php +++ b/src/Fonts/Table/LookupFlag.php @@ -3,6 +3,7 @@ namespace Mpdf\Fonts\Table; use Mpdf\Exception\FontException; +use Mpdf\Fonts\GlyphString; /** * The glyphs a lookup passes over, from its LookupFlag, its markFilteringSet and GDEF. @@ -48,6 +49,11 @@ class LookupFlag */ private $marksOutsideFilteringSets = []; + /** + * @var true[][] Each class skips() has been asked about, as GlyphString::set() gives it + */ + private $sets = []; + /** * @param string $fontkey * @param array $gdef @@ -132,7 +138,8 @@ public function glyphs($flag, $markFilteringSet) * * The same answer as looking for the glyph in glyphs(), a class at a time, without joining the * classes: the shaper asks once per glyph a subtable is offered, and a font's marks can run to tens - * of kilobytes of text. + * of kilobytes of text. Each class is looked up in a set rather than searched, so a glyph is not + * found inside a longer one's hex. * * @param int $flag The lookup's LookupFlag * @param string $glyph The glyph, as hex @@ -145,7 +152,8 @@ public function skips($flag, $glyph, $markFilteringSet) $this->checkMarkFilteringSet($flag, $markFilteringSet); foreach (self::skipped($flag) as $class) { - if (strpos($this->glyphsOf($class, $flag, $markFilteringSet), $glyph)) { + $set = $this->setOf($class, $flag, $markFilteringSet); + if (isset($set[$glyph])) { return true; } } @@ -183,6 +191,23 @@ private function glyphsOf($class, $flag, $markFilteringSet) } } + private function setOf($class, $flag, $markFilteringSet) + { + if ($class === self::MARKS_OUTSIDE_FILTERING_SET) { + $key = $class . $markFilteringSet; + } elseif ($class === self::MARKS_OUTSIDE_ATTACHMENT_CLASS) { + $key = $class . self::attachmentClass($flag); + } else { + $key = $class; + } + + if (!isset($this->sets[$key])) { + $this->sets[$key] = GlyphString::set($this->glyphsOf($class, $flag, $markFilteringSet)); + } + + return $this->sets[$key]; + } + /** * UseMarkFilteringSet means "skip every mark except those in the given mark glyph set", so the * glyphs to skip are the marks minus that set - not the set itself. diff --git a/src/Otl.php b/src/Otl.php index b93ca3255..80dba45e6 100644 --- a/src/Otl.php +++ b/src/Otl.php @@ -102,6 +102,21 @@ class Otl */ private $lookupFlag; + /** + * GlyphClassMarks for the current font, as GlyphString::set() gives it + * + * @var true[] + */ + private $marks; + + /** + * $lookupFlag and $marks for every font laid out so far, by font key, since both are built from the + * whole of GDEF and a document sets one font for run after run + * + * @var array[] + */ + private $gdefSets = []; + var $Ignores; var $LuCoverage; @@ -290,7 +305,22 @@ private function loadGdefData() $this->GlyphClassLigatures = $gdef['GlyphClassLigatures']; $this->GlyphClassComponents = $gdef['GlyphClassComponents']; $this->GlyphClassBases = $gdef['GlyphClassBases']; - $this->lookupFlag = new LookupFlag($this->fontkey, $gdef); + + if (!isset($this->gdefSets[$this->fontkey])) { + $this->gdefSets[$this->fontkey] = [new LookupFlag($this->fontkey, $gdef), GlyphString::set($gdef['GlyphClassMarks'])]; + } + + list($this->lookupFlag, $this->marks) = $this->gdefSets[$this->fontkey]; + } + + /** + * @param string $hex A glyph, as GlyphString::of() writes it + * + * @return bool Whether GDEF classes it as a mark + */ + private function isMark($hex) + { + return isset($this->marks[$hex]); } /** @@ -349,7 +379,7 @@ private function analyseCharacters($str) $charasstr = GlyphString::of($char); - if (strpos($this->GlyphClassMarks, $charasstr) !== false) { + if ($this->isMark($charasstr)) { $OTLdata[$subchunk][$charctr]['group'] = 'M'; } elseif ($char == 32 || $char == 12288) { $OTLdata[$subchunk][$charctr]['group'] = 'S'; @@ -404,7 +434,7 @@ private function markWordBoundaries($scriptblock) if ($this->usesWordBoundaryDictionary()) { $dict = $this->lineBreakDictionary(); if ($dict !== null) { - LineBreaking::southEastAsian($this->OTLdata, $dict, $this->GlyphClassMarks); + LineBreaking::southEastAsian($this->OTLdata, $dict, $this->marks); } } elseif ($this->usesTibetanWordBoundaries($scriptblock)) { LineBreaking::tibetan($this->OTLdata); @@ -613,7 +643,7 @@ private function shapeArabic($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $scrip // Position: After the character elseif ($this->OTLdata[$i]['uni'] == 0xFEB3 || $this->OTLdata[$i]['uni'] == 0xFEB4 || $this->OTLdata[$i]['uni'] == 0xFEBB || $this->OTLdata[$i]['uni'] == 0xFEBC) { $checkpos = $i + 1; - while (isset($this->OTLdata[$checkpos]) && strpos($this->GlyphClassMarks, $this->OTLdata[$checkpos]['hex']) !== false) { + while (isset($this->OTLdata[$checkpos]) && $this->isMark($this->OTLdata[$checkpos]['hex'])) { $checkpos++; } if (isset($this->OTLdata[$checkpos])) { @@ -639,7 +669,7 @@ private function shapeArabic($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $scrip elseif ($this->OTLdata[$i]['uni'] == 0xFEAE || $this->OTLdata[$i]['uni'] == 0xFEF2 || $this->OTLdata[$i]['uni'] == 0xFEF0 || $this->OTLdata[$i]['uni'] == 0xFEF4 || $this->OTLdata[$i]['uni'] == 0xFBE9 || $this->OTLdata[$i]['uni'] == 0xFBFD || $this->OTLdata[$i]['uni'] == 0xFBFF ) { $checkpos = $i - 1; - while (isset($this->OTLdata[$checkpos]) && strpos($this->GlyphClassMarks, $this->OTLdata[$checkpos]['hex']) !== false) { + while (isset($this->OTLdata[$checkpos]) && $this->isMark($this->OTLdata[$checkpos]['hex'])) { $checkpos--; } if (isset($this->OTLdata[$checkpos]) && $this->OTLdata[$checkpos]['uni'] == 0xFE92) { @@ -664,7 +694,7 @@ private function shapeArabic($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $scrip */ if (!isset($this->OTLdata[$i]['GPOSinfo']['kashida'])) { - if (strpos($this->GSUBdata[$this->GSUBfont]['finals'], $this->OTLdata[$i]['hex']) !== false) { // ANY OTHER FINAL FORM + if (GlyphString::inList($this->GSUBdata[$this->GSUBfont]['finals'], $this->OTLdata[$i]['hex'])) { // ANY OTHER FINAL FORM $this->OTLdata[$i]['GPOSinfo']['kashida'] = 2; } elseif (strpos('0FEAE 0FEF0 0FEF2', $this->OTLdata[$i]['hex']) !== false) { // not already included in 5 above $this->OTLdata[$i]['GPOSinfo']['kashida'] = 1; @@ -720,7 +750,7 @@ private function shapeIndic($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $script $newinfo[$i]['general_category'] = $ucd_record[0]; $newinfo[$i]['bidi_type'] = $ucd_record[2]; $charasstr = GlyphString::of($sub[$i]); - if (strpos($this->GlyphClassMarks, $charasstr) !== false) { + if ($this->isMark($charasstr)) { $newinfo[$i]['group'] = 'M'; } else { $newinfo[$i]['group'] = 'C'; @@ -1018,7 +1048,7 @@ private function shapeGeneric($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $scri $newinfo[0]['general_category'] = $ucd_record[0]; $newinfo[0]['bidi_type'] = $ucd_record[2]; $charasstr = GlyphString::of($sub[0]); - if (strpos($this->GlyphClassMarks, $charasstr) !== false) { + if ($this->isMark($charasstr)) { $newinfo[0]['group'] = 'M'; } else { $newinfo[0]['group'] = 'C'; @@ -1045,7 +1075,7 @@ private function shapeGeneric($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $scri $newinfo[0]['general_category'] = $ucd_record[0]; $newinfo[0]['bidi_type'] = $ucd_record[2]; $charasstr = GlyphString::of($sub[1]); - if (strpos($this->GlyphClassMarks, $charasstr) !== false) { + if ($this->isMark($charasstr)) { $newinfo[0]['group'] = 'M'; } else { $newinfo[0]['group'] = 'C'; @@ -1198,7 +1228,7 @@ private function applyGPOS($GPOSscriptTag, $GPOSlangsys, $GPOSFeatures, $scriptb for ($i = (count($this->OTLdata) - 1); $i >= 0; $i--) { if (isset($this->Entry[$i]) && isset($this->Entry[$i]['Y']) && $this->Entry[$i]['dir'] == 'RTL') { $nextbase = $i - 1; // Set as next base ignoring marks (next base reading RTL in logical oder - while (isset($this->OTLdata[$nextbase]['hex']) && strpos($this->GlyphClassMarks, $this->OTLdata[$nextbase]['hex']) !== false) { + while (isset($this->OTLdata[$nextbase]['hex']) && $this->isMark($this->OTLdata[$nextbase]['hex'])) { $nextbase--; } if (isset($this->Exit[$nextbase]) && isset($this->Exit[$nextbase]['Y'])) { @@ -1228,7 +1258,7 @@ private function applyGPOS($GPOSscriptTag, $GPOSlangsys, $GPOSFeatures, $scriptb } else { $incurs = false; } - } elseif (strpos($this->GlyphClassMarks, $this->OTLdata[$i]['hex']) !== false) { + } elseif ($this->isMark($this->OTLdata[$i]['hex'])) { continue; } // ignore Marks else { @@ -1240,7 +1270,7 @@ private function applyGPOS($GPOSscriptTag, $GPOSlangsys, $GPOSFeatures, $scriptb for ($i = 0; $i < count($this->OTLdata); $i++) { if (isset($this->Exit[$i]) && isset($this->Exit[$i]['Y']) && $this->Exit[$i]['dir'] == 'LTR') { $nextbase = $i + 1; // Set as next base ignoring marks - while (isset($this->OTLdata[$nextbase]['hex']) && strpos($this->GlyphClassMarks, $this->OTLdata[$nextbase]['hex']) !== false) { + while (isset($this->OTLdata[$nextbase]['hex']) && $this->isMark($this->OTLdata[$nextbase]['hex'])) { $nextbase++; } if (isset($this->Entry[$nextbase]) && isset($this->Entry[$nextbase]['Y'])) { @@ -1270,7 +1300,7 @@ private function applyGPOS($GPOSscriptTag, $GPOSlangsys, $GPOSFeatures, $scriptb } else { $incurs = false; } - } elseif (strpos($this->GlyphClassMarks, $this->OTLdata[$i]['hex']) !== false) { + } elseif ($this->isMark($this->OTLdata[$i]['hex'])) { continue; } // ignore Marks else { @@ -2736,7 +2766,7 @@ function GSUBsubstitute($pos, $substitute, $Type, $GlyphPos = null) $bt = $this->OTLdata[$pos]['bidi_type']; // } - if (strpos($this->GlyphClassMarks, $newOTLdata[$i]['hex']) !== false) { + if ($this->isMark($newOTLdata[$i]['hex'])) { $gp = 'M'; } elseif ($uni == 32) { $gp = 'S'; @@ -2806,7 +2836,7 @@ function GSUBsubstitute($pos, $substitute, $Type, $GlyphPos = null) if ($this->restrictToSyllable && isset($this->OTLdata[$GlyphPos[$i]]['syllable']) && $this->OTLdata[$GlyphPos[$i]]['syllable'] != $current_syllable) { return 0; } - if (strpos($this->GlyphClassMarks, $unistr) !== false) { + if ($this->isMark($unistr)) { $contains_marks = true; } else { $contains_nonmarks = true; @@ -2900,7 +2930,7 @@ function GSUBsubstitute($pos, $substitute, $Type, $GlyphPos = null) // While next char to right is a mark (but not the next matched glyph) // ?? + also include a Mark Ligature here $ic = 1; - while ((($i == count($GlyphPos) - 1) || (isset($GlyphPos[$i + 1]) && ($GlyphPos[$i] + $ic) < $GlyphPos[$i + 1])) && isset($this->OTLdata[($GlyphPos[$i] + $ic)]) && strpos($this->GlyphClassMarks, $this->OTLdata[($GlyphPos[$i] + $ic)]['hex']) !== false) { + while ((($i == count($GlyphPos) - 1) || (isset($GlyphPos[$i + 1]) && ($GlyphPos[$i] + $ic) < $GlyphPos[$i + 1])) && isset($this->OTLdata[($GlyphPos[$i] + $ic)]) && $this->isMark($this->OTLdata[($GlyphPos[$i] + $ic)]['hex'])) { $newComp = $currComp; if (isset($this->assocMarks[$GlyphPos[$i] + $ic])) { // One of the inbetween Marks is already associated with a Lig // OK as long as it is associated with the current Lig @@ -2925,7 +2955,7 @@ function GSUBsubstitute($pos, $substitute, $Type, $GlyphPos = null) $bt = $this->OTLdata[$pos]['bidi_type']; // } - if (strpos($this->GlyphClassMarks, GlyphString::of($substitute)) !== false) { + if ($this->isMark(GlyphString::of($substitute))) { $gp = 'M'; } elseif ($substitute == 32) { $gp = 'S'; @@ -3126,7 +3156,7 @@ private function _applyGPOSvaluerecord($basepos, $Value) // If current glyph is a mark with a defined width, any XAdvance is considered to REPLACE the character Advance Width // Test case
င်္က္ကျြွေိ
- if (strpos($this->GlyphClassMarks, $this->OTLdata[$basepos]['hex']) !== false) { + if ($this->isMark($this->OTLdata[$basepos]['hex'])) { $cw = round($this->mpdf->_getCharWidth($this->mpdf->CurrentFont['cw'], $this->OTLdata[$basepos]['uni']) * $this->mpdf->CurrentFont['unitsPerEm'] / 1000); // convert back to font design units } else { $cw = 0; @@ -3189,11 +3219,11 @@ private function _getXAdvancePos($pos) { // NB Not all fonts have all marks specified in GlyphClassMarks // If the current glyph is not a base (but a mark) then ignore this, and apply to the current position - if (strpos($this->GlyphClassMarks, $this->OTLdata[$pos]['hex']) !== false) { + if ($this->isMark($this->OTLdata[$pos]['hex'])) { return $pos; } - while (isset($this->OTLdata[$pos + 1]['hex']) && strpos($this->GlyphClassMarks, $this->OTLdata[$pos + 1]['hex']) !== false) { + while (isset($this->OTLdata[$pos + 1]['hex']) && $this->isMark($this->OTLdata[$pos + 1]['hex'])) { $pos++; } return $pos; @@ -3551,7 +3581,7 @@ private function _applyGPOSmarkToBase($lookupID, $subtable, $ptr, $currGlyph, $c $BaseArray = $subtable_offset + $this->reader->readUInt16(); // Offset to BaseArray table $this->reader->seek($BaseCoverage); - $BaseGlyphs = implode('|', $this->_getCoverage()); + $BaseGlyphs = $this->coverageIndexByHex(); $checkpos = $ptr; $checkpos--; @@ -3563,16 +3593,16 @@ private function _applyGPOSmarkToBase($lookupID, $subtable, $ptr, $currGlyph, $c // This Fix blocks the GPOS rule if the "mark" is not actually classified as a mark in the GlyphClasses of GDEF // but only in Indic old-spec. // Test cases: ನ್ನು and ಕ್ರೌ - if ($this->shaper == 'I' && $is_old_spec && strpos($this->GlyphClassMarks, $this->OTLdata[$ptr]['hex']) === false) { + if ($this->shaper == 'I' && $is_old_spec && !$this->isMark($this->OTLdata[$ptr]['hex'])) { return; } // "To identify the base glyph that combines with a mark, the text-processing client must look backward in the glyph string from the mark to the preceding base glyph." - while (isset($this->OTLdata[$checkpos]) && strpos($this->GlyphClassMarks, $this->OTLdata[$checkpos]['hex']) !== false) { + while (isset($this->OTLdata[$checkpos]) && $this->isMark($this->OTLdata[$checkpos]['hex'])) { $checkpos--; } - if (isset($this->OTLdata[$checkpos]) && strpos($BaseGlyphs, $this->OTLdata[$checkpos]['hex']) !== false) { + if (isset($this->OTLdata[$checkpos]) && isset($BaseGlyphs[$this->OTLdata[$checkpos]['hex']])) { $matchedpos = $checkpos; } else { $matchedpos = false; @@ -3586,7 +3616,7 @@ private function _applyGPOSmarkToBase($lookupID, $subtable, $ptr, $currGlyph, $c // Get the relevant BaseRecord $this->reader->seek($BaseArray); $BaseCount = $this->reader->readUInt16(); - $BasePos = strpos($BaseGlyphs, $this->OTLdata[$matchedpos]['hex']) / 6; + $BasePos = $BaseGlyphs[$this->OTLdata[$matchedpos]['hex']]; // Move to the BaseRecord we want $nSkip = (2 * $BasePos * $ClassCount ); @@ -3643,17 +3673,17 @@ private function _applyGPOSmarkToLigature($lookupID, $subtable, $ptr, $currGlyph $LigatureArray = $subtable_offset + $this->reader->readUInt16(); // Offset to LigatureArray table $this->reader->seek($LigatureCoverage); - $LigatureGlyphs = implode('|', $this->_getCoverage()); + $LigatureGlyphs = $this->coverageIndexByHex(); $checkpos = $ptr; $checkpos--; // "To position a combining mark using a MarkToLigature attachment subtable, the text-processing client must work backward from the mark to the preceding ligature glyph." - while (isset($this->OTLdata[$checkpos]) && strpos($this->GlyphClassMarks, $this->OTLdata[$checkpos]['hex']) !== false) { + while (isset($this->OTLdata[$checkpos]) && $this->isMark($this->OTLdata[$checkpos]['hex'])) { $checkpos--; } - if (isset($this->OTLdata[$checkpos]) && strpos($LigatureGlyphs, $this->OTLdata[$checkpos]['hex']) !== false) { + if (isset($this->OTLdata[$checkpos]) && isset($LigatureGlyphs[$this->OTLdata[$checkpos]['hex']])) { $matchedpos = $checkpos; } else { $matchedpos = false; @@ -3667,7 +3697,7 @@ private function _applyGPOSmarkToLigature($lookupID, $subtable, $ptr, $currGlyph // Get the relevant LigatureRecord $this->reader->seek($LigatureArray); $LigatureCount = $this->reader->readUInt16(); - $LigaturePos = strpos($LigatureGlyphs, $this->OTLdata[$matchedpos]['hex']) / 6; + $LigaturePos = $LigatureGlyphs[$this->OTLdata[$matchedpos]['hex']]; // Move to the LigatureAttach table Record we want $nSkip = (2 * $LigaturePos); @@ -3748,13 +3778,13 @@ private function _applyGPOSmarkToMark($lookupID, $subtable, $ptr, $currGlyph, $c $Mark1Array = $subtable_offset + $this->reader->readUInt16(); // Offset to MarkArray table $Mark2Array = $subtable_offset + $this->reader->readUInt16(); // Offset to Mark2Array table $this->reader->seek($Mark2Coverage); - $Mark2Glyphs = implode('|', $this->_getCoverage()); + $Mark2Glyphs = $this->coverageIndexByHex(); $checkpos = $ptr; $checkpos--; while (isset($this->OTLdata[$checkpos]) && isset($ignore[$this->OTLdata[$checkpos]['uni']])) { $checkpos--; } - if (isset($this->OTLdata[$checkpos]) && strpos($Mark2Glyphs, $this->OTLdata[$checkpos]['hex']) !== false) { + if (isset($this->OTLdata[$checkpos]) && isset($Mark2Glyphs[$this->OTLdata[$checkpos]['hex']])) { $matchedpos = $checkpos; } else { $matchedpos = false; @@ -3768,7 +3798,7 @@ private function _applyGPOSmarkToMark($lookupID, $subtable, $ptr, $currGlyph, $c // Get the relevant Mark2Record $this->reader->seek($Mark2Array); $Mark2Count = $this->reader->readUInt16(); - $Mark2Pos = strpos($Mark2Glyphs, $this->OTLdata[$matchedpos]['hex']) / 6; + $Mark2Pos = $Mark2Glyphs[$this->OTLdata[$matchedpos]['hex']]; // Move to the Mark2Record we want $nSkip = (2 * $Mark2Pos * $ClassCount ); @@ -4610,6 +4640,30 @@ private function _getCoverage() return $this->LuDataCache[$this->otlCacheKey]['coverage'][$offset]; } + /** + * The characters a Coverage table covers, each with its Coverage Index, for the mark attachment + * subtables, which find the glyph a mark attaches to and then index a parallel array by it. + * + * @return int[] hex => Coverage Index, the first where two glyphs stand for one character + */ + private function coverageIndexByHex() + { + $offset = $this->reader->tell(); + + if (!isset($this->LuDataCache[$this->otlCacheKey]['coverageIndex'][$offset])) { + $indexes = []; + foreach ($this->_getCoverage() as $index => $hex) { + if (!isset($indexes[$hex])) { + $indexes[$hex] = $index; + } + } + + $this->LuDataCache[$this->otlCacheKey]['coverageIndex'][$offset] = $indexes; + } + + return $this->LuDataCache[$this->otlCacheKey]['coverageIndex'][$offset]; + } + /** * The characters a Coverage table covers, as a set keyed by codepoint. * diff --git a/src/OtlDump.php b/src/OtlDump.php index 20159d1e7..02a953b4d 100644 --- a/src/OtlDump.php +++ b/src/OtlDump.php @@ -1624,10 +1624,10 @@ private function contextExample(array $exampleB, array $exampleI, array $example private function positionHolds($coverage, $class0excl, $glyph) { if ($coverage === '' && $class0excl !== '') { - return strpos($class0excl, $glyph) === false; + return !GlyphString::inList($class0excl, $glyph); } - return strpos($coverage, $glyph) !== false; + return GlyphString::inList($coverage, $glyph); } /** diff --git a/src/Shaper/Arabic.php b/src/Shaper/Arabic.php index b65ec496e..dad7134af 100644 --- a/src/Shaper/Arabic.php +++ b/src/Shaper/Arabic.php @@ -2,6 +2,7 @@ namespace Mpdf\Shaper; +use Mpdf\Fonts\GlyphString; use Mpdf\Utils\UtfString; /** @@ -306,13 +307,13 @@ private static function glyphs($char, $type, &$chars, $i, $scriptTag, $usetags, foreach ($arabGlyphs[$char]['prel'][$retk] as $k => $v) { // $k starts 0, 1... if (!isset($chars[$i - $ig - $k])) { $match = false; - } elseif (strpos($v, $chars[$i - $ig - $k]) === false) { - while (strpos($arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k]) !== false) { // ignore + } elseif (!GlyphString::inList($v, $chars[$i - $ig - $k])) { + while (GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k])) { // ignore $ig++; } if (!isset($chars[$i - $ig - $k])) { $match = false; - } elseif (strpos($v, $chars[$i - $ig - $k]) === false) { + } elseif (!GlyphString::inList($v, $chars[$i - $ig - $k])) { $match = false; } } @@ -323,13 +324,13 @@ private static function glyphs($char, $type, &$chars, $i, $scriptTag, $usetags, foreach ($arabGlyphs[$char]['postl'][$retk] as $k => $v) { // $k starts 0, 1... if (!isset($chars[$i + $ig + $k])) { $match = false; - } elseif (strpos($v, $chars[$i + $ig + $k]) === false) { - while (strpos($arabGlyphs[$char]['ignore'][$retk], $chars[$i + $ig + $k]) !== false) { // ignore + } elseif (!GlyphString::inList($v, $chars[$i + $ig + $k])) { + while (GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i + $ig + $k])) { // ignore $ig++; } if (!isset($chars[$i + $ig + $k])) { $match = false; - } elseif (strpos($v, $chars[$i + $ig + $k]) === false) { + } elseif (!GlyphString::inList($v, $chars[$i + $ig + $k])) { $match = false; } } diff --git a/src/Shaper/LineBreaking.php b/src/Shaper/LineBreaking.php index 260b6e5e2..02139a901 100644 --- a/src/Shaper/LineBreaking.php +++ b/src/Shaper/LineBreaking.php @@ -46,9 +46,10 @@ public static function tibetan(&$info) * ends and the next begins. That cannot be read off the characters - it needs a dictionary, walked * as a trie over the low byte of each codepoint. * - * @param string $dict The dictionary as loaded from the font package, in the format wordMatch() walks + * @param string $dict The dictionary as loaded from the font package, in the format wordMatch() walks + * @param true[] $marks GDEF's marks, as GlyphString::set() gives them: a word does not end before one */ - public static function southEastAsian(&$info, $dict, $glyphClassMarks) + public static function southEastAsian(&$info, $dict, $marks) { // Find all word boundaries and mark end of word $info[$i]['wordend']=true on last character // If Thai, allow for possible suffixes (not in Lao or Khmer) @@ -65,7 +66,7 @@ public static function southEastAsian(&$info, $dict, $glyphClassMarks) $matches = $rollover; $rollover = []; } else { - $matches = self::wordMatch($dict, $info, $glyphClassMarks, $ptr); + $matches = self::wordMatch($dict, $info, $marks, $ptr); } if (count($matches) == 1) { $matchpos = $matches[0]; @@ -87,7 +88,7 @@ public static function southEastAsian(&$info, $dict, $glyphClassMarks) for ($m = count($matches) - 1; $m >= 0; $m--) { //for ($m=0;$m'; // Do not match if next character in text is a Mark - if (isset($info[$ptr]['uni']) && strpos($glyphClassMarks, $info[$ptr]['hex']) === false) { + if (isset($info[$ptr]['uni']) && !isset($marks[$info[$ptr]['hex']])) { $matches[] = $ptr - 1; } $dictptr++; } elseif ($x == self::FINAL_MATCH) { //echo "DICT_FINAL_MATCH: ".dechex($c).'
'; // Do not match if next character in text is a Mark - if (isset($info[$ptr]['uni']) && strpos($glyphClassMarks, $info[$ptr]['hex']) === false) { + if (isset($info[$ptr]['uni']) && !isset($marks[$info[$ptr]['hex']])) { $matches[] = $ptr - 1; } return $matches; @@ -164,7 +165,7 @@ private static function wordMatch(&$dict, $info, $glyphClassMarks, $ptr) $next = ord($dict[$dictptr + 1]); if ($next == self::INTERMEDIATE_MATCH || $next == self::FINAL_MATCH) { // Do not match if next character in text is a Mark - if (isset($info[$ptr]['uni']) && strpos($glyphClassMarks, $info[$ptr]['hex']) === false) { + if (isset($info[$ptr]['uni']) && !isset($marks[$info[$ptr]['hex']])) { $matches[] = $ptr - 1; } } diff --git a/src/TTFontFile.php b/src/TTFontFile.php index 9b9444b78..2c8c6632a 100644 --- a/src/TTFontFile.php +++ b/src/TTFontFile.php @@ -1359,7 +1359,7 @@ protected function restrictedFont() /** * GDEF's glyph lists as the parser keeps them: space-prefixed, "|"-separated hex, " 00641| 00642", - * which is what LookupFlag and the shaper search. + * which is what LookupFlag and the shaper read. * * @param string[] $glyphs One class, as hex * @@ -2762,7 +2762,7 @@ protected function gsubContextRule(array $Lookup, $i, $c, $tag, $scripttag, $ign $lookupGlyphs = $luss['Replace']; // Only where the nested lookup's (first) glyph is one the rule's position can hold - if (strpos($rule['input'][$seqIndex], $lookupGlyphs[0]) === false) { + if (!GlyphString::inList($rule['input'][$seqIndex], $lookupGlyphs[0])) { continue; } diff --git a/tests/Mpdf/Fonts/GlyphStringTest.php b/tests/Mpdf/Fonts/GlyphStringTest.php index db15d7432..97021a7c5 100644 --- a/tests/Mpdf/Fonts/GlyphStringTest.php +++ b/tests/Mpdf/Fonts/GlyphStringTest.php @@ -33,6 +33,48 @@ public function codepointProvider() ]; } + /** + * A six-digit glyph holds two five-digit ones: U+100300 is 10030 followed by a 0, and a 1 followed + * by 00300. Neither is in a class that names only U+100300. + * + * @dataProvider listProvider + */ + public function testAGlyphIsInAListOnlyWhereTheListNamesIt($glyphs, $glyph, $expected) + { + $set = GlyphString::set($glyphs); + + $this->assertSame($expected, isset($set[$glyph]), 'set()'); + $this->assertSame($expected, GlyphString::inList($glyphs, $glyph), 'inList()'); + } + + public function listProvider() + { + return [ + 'plane 16, itself' => [' 100300', '100300', true], + 'a BMP glyph its hex ends with' => [' 100300', '00300', false], + 'a plane 1 glyph its hex begins with' => [' 100300', '10030', false], + 'a plane 16 glyph whose hex ends with a BMP one in the list' => [' 00300', '100300', false], + 'the first of several' => [' 00300| 100300| 00302', '00300', true], + 'the last of several' => [' 00300| 100300| 00302', '00302', true], + 'between two that hold it' => [' 100300| 00300| 003001', '00300', true], + 'several, none of them it' => [' 00301| 100300| 00302', '00300', false], + 'an empty class' => ['', '00300', false], + 'a Coverage table, without the spaces' => ['00041|100300', '00300', false], + ]; + } + + /** + * inList() reads whatever separates the glyphs, since the lists it is asked about are not all + * GDEF's shape + */ + public function testAListMaySeparateItsGlyphsWithAnythingButAHexDigit() + { + $this->assertTrue(GlyphString::inList('0FE8E 0FE94 ', '0FE94')); + $this->assertFalse(GlyphString::inList('0FE8E 10FE94 ', '0FE94')); + $this->assertTrue(GlyphString::inList('((?:(?: 00300| 00301))*)', '00301')); + $this->assertFalse(GlyphString::inList('((?:(?: 100300| 100301))*)', '00301')); + } + /** * \Mpdf\unicode_hex() has been a public function of the Mpdf namespace since 5.7.1 and upstream * declares it in the same place, so anything outside the library that calls it has to keep diff --git a/tests/Mpdf/Fonts/Table/LookupFlagTest.php b/tests/Mpdf/Fonts/Table/LookupFlagTest.php index 91b8d31b1..41ec1527e 100644 --- a/tests/Mpdf/Fonts/Table/LookupFlagTest.php +++ b/tests/Mpdf/Fonts/Table/LookupFlagTest.php @@ -2,6 +2,8 @@ namespace Mpdf\Fonts\Table; +use Mpdf\Fonts\GlyphString; + class LookupFlagTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase { @@ -109,6 +111,27 @@ public function testAnUndefinedMarkFilteringSetIsRefused() $this->lookupFlag->skips(0x0018, '00041', 3); } + /** + * GlyphString::of() writes a plane 16 glyph six digits wide, and the five-digit glyph its hex ends + * with is not skipped with it + */ + public function testAGlyphWhoseHexEndsAPlaneSixteenMarksIsNotSkippedWithIt() + { + $marks = ' ' . implode('| ', [GlyphString::of(0x100300)]); + $flag = new LookupFlag('demo', [ + 'GlyphClassBases' => '', + 'GlyphClassMarks' => $marks, + 'GlyphClassLigatures' => '', + 'GlyphClassComponents' => '', + 'MarkGlyphSets' => [], + 'MarkAttachmentType' => [], + ]); + + $this->assertFalse($flag->skips(LookupFlag::IGNORE_MARKS, GlyphString::of(0x0300), '')); + $this->assertFalse($flag->skips(LookupFlag::IGNORE_MARKS, GlyphString::of(0x0301), '')); + $this->assertTrue($flag->skips(LookupFlag::IGNORE_MARKS, GlyphString::of(0x100300), '')); + } + /** * An empty class adds no separator of its own, but one it follows still gets its "|" - which is * how the ignore strings in the cached GSUB data have always read diff --git a/tests/Mpdf/GdefClassMembershipTest.php b/tests/Mpdf/GdefClassMembershipTest.php new file mode 100644 index 000000000..be061d391 --- /dev/null +++ b/tests/Mpdf/GdefClassMembershipTest.php @@ -0,0 +1,134 @@ +mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'tempDir' => $tempDir, + 'fontDir' => [__DIR__ . '/../data/ttf'], + 'fontdata' => [self::FONTKEY => [ + 'R' => 'NotoSans-PlaneSixteenMark-Synthetic.ttf', + 'useOTL' => 0xFF, + ]], + 'default_font' => self::FONTKEY, + ]); + + $this->otl = new Otl($this->mpdf, new FontCache(new Cache($tempDir . '/mpdf/ttfontdata'))); + } + + protected function tear_down() + { + $this->mpdf->cleanup(); + + parent::tear_down(); + } + + public function testTheParserClassesThePlaneSixteenMarkAsThePrivateUseCharacterItMapsItTo() + { + $this->shape([0x41]); + + $this->assertSame(' 00301| 0E001', $this->otl->GDEFdata[self::FONTKEY]['GlyphClassMarks']); + } + + public function dataRuns() + { + return [ + 'IgnoreMarks does not skip U+0300, whose hex U+100300 ends with' => [ + [0x41, 0x0300], + [[0x41, 'C', null], [0x60, 'C', null]], + ], + 'a mark attaches to U+10030, whose hex U+100300 begins with' => [ + [0x10030, 0x0301], + [[0x10030, 'C', null], [0x0301, 'M', ['BaseWidth' => 639, 'XPlacement' => 300, 'YPlacement' => 0]]], + ], + ]; + } + + /** + * @dataProvider dataRuns + */ + public function testAGlyphIsNotTakenForTheMarkItsHexIsPartOf($codepoints, $expected) + { + // Before the shaper first reads GDEF, which it then keeps for the font + $this->otl->GDEFdata[self::FONTKEY] = [ + 'GlyphClassBases' => ' 00020| 00041| 00060| 0007E| 00300| 10030', + 'GlyphClassMarks' => ' 00301| 100300', + 'GlyphClassLigatures' => '', + 'GlyphClassComponents' => '', + 'MarkGlyphSets' => [], + 'MarkAttachmentType' => [], + ]; + + $this->assertSame($expected, $this->shape($codepoints)); + } + + /** + * @param int[] $codepoints + * + * @return array[] The codepoint, the group and the positioning of each glyph of the run + */ + private function shape($codepoints) + { + $text = ''; + foreach ($codepoints as $codepoint) { + $text .= UtfString::code2utf($codepoint); + } + $this->otl->applyOTL($text, 0xFF); + + $run = []; + foreach ($this->otl->OTLdata['char_data'] as $i => $character) { + $run[] = [ + $character['uni'], + $this->otl->OTLdata['group'][$i], + isset($this->otl->OTLdata['GPOSinfo'][$i]) ? $this->otl->OTLdata['GPOSinfo'][$i] : null, + ]; + } + + return $run; + } + +} diff --git a/tests/Mpdf/Shaper/ArabicTest.php b/tests/Mpdf/Shaper/ArabicTest.php index 17da48a0d..513cd289a 100644 --- a/tests/Mpdf/Shaper/ArabicTest.php +++ b/tests/Mpdf/Shaper/ArabicTest.php @@ -479,17 +479,50 @@ public function testEveryJoiningTableEntryIsFiledUnderItsOwnCodepoint() } } + /** + * A form a chained rule gives is drawn where the text holds the glyphs the rule names, and a glyph + * whose hex is only part of one of theirs is not one of them: 00628 is inside 100628, and 0064E + * inside 10064E. + * + * @dataProvider dataContextNamingAPlaneSixteenGlyph + */ + public function testAFormsContextIsNotMetByAGlyphWhoseHexIsPartOfOneItNames($prel, $ignore, $hexes, $expected) + { + $glyphs = $this->glyphs(); + $glyphs[self::DAL] = ['D_ISOL', 'D_FINA', 'prel' => [1 => [$prel]], 'ignore' => [1 => $ignore]]; + + $this->assertSame($expected, $this->shape($hexes, self::ALL_FORMS, 'arab', self::FATHA, $glyphs)); + } + + public function dataContextNamingAPlaneSixteenGlyph() + { + return [ + 'the backtrack' => [ + '1' . self::BEH, + '()', + [self::BEH, self::DAL], + [['B_INIT', 2], [self::DAL, 0]], + ], + 'the glyphs the lookup skips' => [ + self::BEH, + '((?:(?: 1' . self::FATHA . '))*)', + [self::BEH, self::FATHA, self::DAL], + [['B_INIT', 2], [self::FATHA, 0], [self::DAL, 0]], + ], + ]; + } + /** * @return array one [hex, form] pair per character, in logical order */ - private function shape($hexes, $usetags = self::ALL_FORMS, $scriptTag = 'arab', $glyphClassMarks = self::FATHA) + private function shape($hexes, $usetags = self::ALL_FORMS, $scriptTag = 'arab', $glyphClassMarks = self::FATHA, $glyphs = null) { $info = []; foreach ($hexes as $hex) { $info[] = ['hex' => $hex, 'uni' => hexdec($hex)]; } - Arabic::shape($info, $this->glyphs(), ' ' . $glyphClassMarks, $usetags, $scriptTag); + Arabic::shape($info, $glyphs === null ? $this->glyphs() : $glyphs, ' ' . $glyphClassMarks, $usetags, $scriptTag); $forms = []; foreach ($info as $char) { diff --git a/tests/Mpdf/Shaper/LineBreakingTest.php b/tests/Mpdf/Shaper/LineBreakingTest.php index 53a365cd3..630f7e9d0 100644 --- a/tests/Mpdf/Shaper/LineBreakingTest.php +++ b/tests/Mpdf/Shaper/LineBreakingTest.php @@ -54,6 +54,18 @@ public function testAWordInTheDictionaryEndsAWord() $this->assertSame([1], $this->southEastAsian($dict, [0x0E17, 0x0E14, 0x0E2A, 0x0E2D])); } + /** + * A word does not end before a mark GDEF classes, and a character whose hex is only part of a + * mark's is not one: 00E2A is inside 100E2A + */ + public function testAWordDoesNotEndBeforeAMark() + { + $dict = $this->linear([0x17, 0x14]) . chr(0x04); + + $this->assertSame([], $this->southEastAsian($dict, [0x0E17, 0x0E14, 0x0E2A, 0x0E2D], GlyphString::set(' 00E2A'))); + $this->assertSame([1], $this->southEastAsian($dict, [0x0E17, 0x0E14, 0x0E2A, 0x0E2D], GlyphString::set(' 100E2A'))); + } + public function testTextThatIsNotInTheDictionaryEndsNoWord() { $dict = $this->linear([0x17, 0x14]) . chr(0x04); @@ -105,10 +117,10 @@ private function tibetan($unicode) /** * @return array the indexes marked as ending a word */ - private function southEastAsian($dict, $unicode) + private function southEastAsian($dict, $unicode, $marks = []) { $info = $this->info($unicode); - LineBreaking::southEastAsian($info, $dict, ''); + LineBreaking::southEastAsian($info, $dict, $marks); return $this->wordEnds($info); } diff --git a/tests/data/fontcache/NotoSans-PlaneSixteenMark-Synthetic.json b/tests/data/fontcache/NotoSans-PlaneSixteenMark-Synthetic.json new file mode 100644 index 000000000..2d48db0c9 --- /dev/null +++ b/tests/data/fontcache/NotoSans-PlaneSixteenMark-Synthetic.json @@ -0,0 +1,98 @@ +{ + "_": "Generated by composer fontcache:update. See tests/Mpdf/Fonts/ParserGoldenMaster.php.", + "fullName": "NotoSans-PlaneSixteenMark-Synthetic", + "mtx": { + "GSUBScriptLang": { + "DFLT": "DFLT ", + "latn": "DFLT " + }, + "GSUBFeatures": { + "DFLT": { + "DFLT": { + "ccmp": [ + 0 + ] + } + }, + "latn": { + "DFLT": { + "ccmp": [ + 0 + ] + } + } + }, + "GSUBLookups": [ + { + "Type": 1, + "Flag": 8, + "SubtableCount": 1, + "Subtables": [ + 62 + ], + "MarkFilteringSet": "" + } + ], + "GPOSScriptLang": { + "DFLT": "DFLT ", + "latn": "DFLT " + }, + "GPOSFeatures": { + "DFLT": { + "DFLT": { + "mark": [ + 0 + ] + } + }, + "latn": { + "DFLT": { + "mark": [ + 0 + ] + } + } + }, + "GPOSLookups": [ + { + "Type": 4, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 62 + ], + "MarkFilteringSet": "" + } + ], + "MarkGlyphSets": [], + "rtlPUAstr": "", + "haskernGPOS": false, + "hassmallcapsGSUB": false + }, + "cache": { + "GDEFdata.json": { + "GlyphClassBases": " 00020| 00041| 00060| 0007E| 00300| 10030", + "GlyphClassMarks": " 00301| 0E001", + "GlyphClassLigatures": "", + "GlyphClassComponents": "", + "MarkGlyphSets": [], + "MarkAttachmentType": [] + }, + "GPOS.dat": "108 bytes, sha256 c1668d8fb37c2532bd19f2435d2f11a6b4bf397ec03e2511fa4b0f2d2616cf87", + "GPOSdata.json": [ + [ + { + "769": 0 + } + ] + ], + "GSUB.dat": "74 bytes, sha256 7cf15c4f04eae9517b326ff0aedf14c27e790d0304901cdde3da390ffb0da577", + "GSUBdata.json": [ + [ + { + "768": 0 + } + ] + ] + } +} diff --git a/tests/data/otldump/NotoSans-PlaneSixteenMark-Synthetic.txt b/tests/data/otldump/NotoSans-PlaneSixteenMark-Synthetic.txt new file mode 100644 index 000000000..a73861ad4 --- /dev/null +++ b/tests/data/otldump/NotoSans-PlaneSixteenMark-Synthetic.txt @@ -0,0 +1,24 @@ +

GDEF table

+

Glyph classes

+

Glyph class 1

+
Base glyph (single character, spacing glyph)
+
A ` ~ ̀ 𐀰
+

Glyph class 3

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

GSUB Tables

+

GSUB Scripts & Languages

+
+
DFLT
DFLT: ccmp
latn
DFLT: ccmp
+
+

GPOS Tables

+

GPOS Scripts & Languages

+
+
DFLT
DFLT: mark
latn
DFLT: mark
+
+ +=== detail: script latn language DFLT === +

GSUB Tables

+
Lookup #0 [tag: ccmp]
Ignoring: Mark Glyphs
Subtable #0
LookupType 1: Single Substitution Subtable
U+0300   ̀  » »   `  U+0060
+

GPOS Tables

+
Lookup #0 [tag: mark]
Subtable #0
LookupType 4: MarkToBase attachment
Marks: ◌́
Bases: 𐀰
Example(s): 𐀰́  
diff --git a/tests/data/shaping/NotoSans-PlaneSixteenMark-Synthetic.txt b/tests/data/shaping/NotoSans-PlaneSixteenMark-Synthetic.txt new file mode 100644 index 000000000..0848d5b42 --- /dev/null +++ b/tests/data/shaping/NotoSans-PlaneSixteenMark-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 0041 0060 007E 0300 0301 E000 E001 => 0020 0041 0060 007E 0060 0301 E000 E001 group=SCCCCMCM +0x80 0020 0041 0060 007E 0300 0301 E000 E001 => 0020 0041 0060 007E 0300 0301 E000 E001 group=SCCCCMCM +0xFF +kern -liga 0020 0041 0060 007E 0300 0301 E000 E001 => 0020 0041 0060 007E 0060 0301 E000 E001 group=SCCCCMCM +=== font characters 1 === +0xFF 10030 => 10030 group=C +0x80 10030 => 10030 group=C +0xFF +kern -liga 10030 => 10030 group=C diff --git a/tests/data/subset/NotoSans-PlaneSixteenMark-Synthetic.json b/tests/data/subset/NotoSans-PlaneSixteenMark-Synthetic.json new file mode 100644 index 000000000..4a6a2b5cd --- /dev/null +++ b/tests/data/subset/NotoSans-PlaneSixteenMark-Synthetic.json @@ -0,0 +1,123 @@ +{ + "_": "Generated by composer subset:update. See tests/Mpdf/Fonts/SubsetGoldenMaster.php.", + "useOTL=0x00": { + "characters": 7, + "makeSubset": { + "bytes": 2164, + "sha256": "9df5bc841e7302bc88da8f6e0c81ea38d8eb42792ba2ccd7126fb027d78e0983", + "tables": { + "OS/2": "96 bytes, checksum 0x69905ECF, sha256 75ed87fcd39de51c848126876b746496217cb77b11eee5924c77b998d965d9ac", + "cmap": "100 bytes, checksum 0xFE1F06FA, sha256 89b421e2370643be66cb089780c9efde21469aa1c60b3ee16a2d081e9189a0cf", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "328 bytes, checksum 0x5592D924, sha256 3b52e8f22833280c9a01178b3f3bcd4f717bbede1d2623e04a92d32825f73f08", + "head": "54 bytes, checksum 0x24A01B35, sha256 95d0254baa52b5498baeea26b2a6490cbe3dc902ae8f4e1befa4853fbb1ea28e", + "hhea": "36 bytes, checksum 0x04C00164, sha256 d823c15753198902d40fae1bdcd1c4ba7a80550c231827159cca494bb717a265", + "hmtx": "32 bytes, checksum 0x0A4AFDAE, sha256 e776e083f28df155a71827b989e5393ef22cd22e13cbdfaa2f0fe11b1b44fe1e", + "loca": "18 bytes, checksum 0x018A0150, sha256 2521a73cf2e8c40ec15313572a9abf19e18bf835ab21348de614103c457fbf5d", + "maxp": "32 bytes, checksum 0x000D0026, sha256 5d94c82e0267eb8bda3ae2583f29931f361ca329e8d02805939b19b3931aafac", + "name": "1236 bytes, checksum 0x738E9F04, sha256 0be8b381ecfa265eb0a24147f3646b5c16593a6c1fd1dc297da6de27a6aab66f", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83" + }, + "maxUni": 65535, + "defaultWidth": 600, + "codeToGlyph": "7 entries, sha256 49cb3701b52574d324fd3d5c45fc67c79d0d4fee83bef1338644e9609e6dee93" + }, + "makeSubsetSIP": { + "bytes": 2148, + "sha256": "4330ef41b9d6fd3c959c5fdfec254c97f8fd7ec4e4d12a0167bc865b91838a54", + "tables": { + "OS/2": "96 bytes, checksum 0x671AD6D3, sha256 dec08803ef4e2ce8a92937ef1ef3376b45e47c14eadf4d9fa09f8e2cff17cd8a", + "cmap": "84 bytes, checksum 0x00380090, sha256 b744253609e2a88d21149d2993bd74c60e813a46a40dbdd5fa6a10f27bd1defe", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "328 bytes, checksum 0x5592D924, sha256 3b52e8f22833280c9a01178b3f3bcd4f717bbede1d2623e04a92d32825f73f08", + "head": "54 bytes, checksum 0x24A01B35, sha256 4a5f7976b0744ab3b65b43814b3a447d73e934d4aa816f4413783aa8ea032088", + "hhea": "36 bytes, checksum 0x04C00164, sha256 d823c15753198902d40fae1bdcd1c4ba7a80550c231827159cca494bb717a265", + "hmtx": "32 bytes, checksum 0x0A4AFDAE, sha256 e776e083f28df155a71827b989e5393ef22cd22e13cbdfaa2f0fe11b1b44fe1e", + "loca": "18 bytes, checksum 0x018A0150, sha256 2521a73cf2e8c40ec15313572a9abf19e18bf835ab21348de614103c457fbf5d", + "maxp": "32 bytes, checksum 0x000D0026, sha256 5d94c82e0267eb8bda3ae2583f29931f361ca329e8d02805939b19b3931aafac", + "name": "1236 bytes, checksum 0x737F9F04, sha256 40411a81b7ac52b71e19743c2e4dcc41232a064ce59c61358d620beaf586cbc4", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83" + }, + "maxUniChar": 65584, + "defaultWidth": 600 + }, + "repackageTTF": { + "bytes": 2340, + "sha256": "9a1a6d3d2d5e1d6b783aea845afbfc6f1313db2ce174ca9ef73ff721ccedd15d", + "tables": { + "OS/2": "96 bytes, checksum 0x69905ECF, sha256 75ed87fcd39de51c848126876b746496217cb77b11eee5924c77b998d965d9ac", + "cmap": "184 bytes, checksum 0x010F13B5, sha256 17340b2a2bbaf28b2e2188a32693e2b812ead19f148da6418ef0fa3113c4357c", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "408 bytes, checksum 0x9F0EEDB7, sha256 e0fd580e9734f5d8592419566f8b725646a2b580c8f66aa9c97951f7b39863c1", + "head": "54 bytes, checksum 0x24A01B35, sha256 79d057003d9ddcd22a6fdc391fb897a087eda6ecb45ccd4796e1e09f87c5ccce", + "hhea": "36 bytes, checksum 0x04C00166, sha256 806d71a0901d3be883cb46e8a1d5dcb02bbd37a507a71b6c6c4482aaffc72c40", + "hmtx": "40 bytes, checksum 0x0CCAFBC1, sha256 f94f691680abb9a22beaad6f14962449f9df4dd8d3a47b3cc939f10cdb66500c", + "loca": "22 bytes, checksum 0x025B021B, sha256 1bbc152cda174dc93d7f9b6ae1edd0a6f44d595706e5974f5809bab59382f519", + "maxp": "32 bytes, checksum 0x000F0026, sha256 42c6c6b6190f27b9917ae0e063a5d610b2880c2dd2f6361c7f6164560bf3e3dd", + "name": "1236 bytes, checksum 0x738E9F04, sha256 0be8b381ecfa265eb0a24147f3646b5c16593a6c1fd1dc297da6de27a6aab66f", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83" + }, + "maxUni": 0 + } + }, + "useOTL=0xFF": { + "characters": 10, + "makeSubset": { + "bytes": 2284, + "sha256": "cd3731c556cb0701a5277b2c641ed4c126942547080992772e40c645f93460d9", + "tables": { + "OS/2": "96 bytes, checksum 0x69905ECF, sha256 75ed87fcd39de51c848126876b746496217cb77b11eee5924c77b998d965d9ac", + "cmap": "116 bytes, checksum 0xDE2CE717, sha256 3610cab263d1bdb4ff2b7722b9b6db0a5db18616f9a25615228c58fb503e2a73", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "420 bytes, checksum 0x15B47714, sha256 414ee1a5d24493556c3a669fc83b61ef2344b6283c7aab0e6ae468aacbe36a53", + "head": "54 bytes, checksum 0x24A01B35, sha256 6cf0b7eac9e40bd3a0ce31947b493a9e0297f053b91f886bda547330aed17bde", + "hhea": "36 bytes, checksum 0x04C00166, sha256 806d71a0901d3be883cb46e8a1d5dcb02bbd37a507a71b6c6c4482aaffc72c40", + "hmtx": "40 bytes, checksum 0x0CCAFBC1, sha256 f94f691680abb9a22beaad6f14962449f9df4dd8d3a47b3cc939f10cdb66500c", + "loca": "22 bytes, checksum 0x026A0226, sha256 c2f366d49bab7c6d720fab9efbfb8ec9b8a50f99377ee2cc992c0bceae701df1", + "maxp": "32 bytes, checksum 0x000F0026, sha256 42c6c6b6190f27b9917ae0e063a5d610b2880c2dd2f6361c7f6164560bf3e3dd", + "name": "1236 bytes, checksum 0x738E9F04, sha256 0be8b381ecfa265eb0a24147f3646b5c16593a6c1fd1dc297da6de27a6aab66f", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83" + }, + "maxUni": 65535, + "defaultWidth": 600, + "codeToGlyph": "10 entries, sha256 11275dbcb4c91f864eeaf65226c51141d2bcdba705fb24dc0fedb967f611039c" + }, + "makeSubsetSIP": { + "bytes": 2188, + "sha256": "52a6a002203d815f5eefeaaf32bc43083671744ce0c0339abe09f8f7c60b185d", + "tables": { + "OS/2": "96 bytes, checksum 0x671AD6D6, sha256 dced49671d63709d875fd91b4b020468a64f1ad76edc59efede3cecb59714ce1", + "cmap": "98 bytes, checksum 0x0074008B, sha256 7069cba5b60ce066b70695505d4d3e25596e7b93baff0fada19904b447b3a2a0", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "348 bytes, checksum 0x5AF3D405, sha256 7e2f05e7a3e5408b7d917864cbbba4182bfd7447cd7e7d90fc42a96d98a005f8", + "head": "54 bytes, checksum 0x24A01B35, sha256 4b2c52a2191b38992a5fbb3d66c629d08cab89f6f7631fbc460bcf557aa9e418", + "hhea": "36 bytes, checksum 0x04C00165, sha256 c14499b68ed8904e1706fce621a52c3f6f4a287e795eec861b3ffe298ae7136a", + "hmtx": "36 bytes, checksum 0x0A4BFBC1, sha256 ddb26c4c0d71f1694fedd70c42748a63ddaf25a5685ef0a59c506d7e61d0b38b", + "loca": "20 bytes, checksum 0x018A01FE, sha256 b27c7908c9b2d0615b2b87b7549b482277e9c93b42815b873045373f84640e35", + "maxp": "32 bytes, checksum 0x000E0026, sha256 a06643819368fbe265136f0fd8b86f771ca43604583385456f3eaf051e5f5f28", + "name": "1236 bytes, checksum 0x737F9F04, sha256 40411a81b7ac52b71e19743c2e4dcc41232a064ce59c61358d620beaf586cbc4", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83" + }, + "maxUniChar": 65584, + "defaultWidth": 600 + }, + "repackageTTF": { + "bytes": 2264, + "sha256": "b434debea8e6a9b4fdb54e401117691f1a66ef0afe5215292aef4f2eb235d0e3", + "tables": { + "OS/2": "96 bytes, checksum 0x69905ECF, sha256 75ed87fcd39de51c848126876b746496217cb77b11eee5924c77b998d965d9ac", + "cmap": "108 bytes, checksum 0xE0EBE44C, sha256 a9eb287f469b4e4d2252f7a4cb7e1e3dd00d3549a4a1319f02c0701641bd31ea", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "408 bytes, checksum 0x9F0EEDB7, sha256 e0fd580e9734f5d8592419566f8b725646a2b580c8f66aa9c97951f7b39863c1", + "head": "54 bytes, checksum 0x24A01B35, sha256 27a5a6281e2a988221d2882335a51feb915e61a6052c51f03aaeb8a7ef9c9cc6", + "hhea": "36 bytes, checksum 0x04C00166, sha256 806d71a0901d3be883cb46e8a1d5dcb02bbd37a507a71b6c6c4482aaffc72c40", + "hmtx": "40 bytes, checksum 0x0CCAFBC1, sha256 f94f691680abb9a22beaad6f14962449f9df4dd8d3a47b3cc939f10cdb66500c", + "loca": "22 bytes, checksum 0x025B021B, sha256 1bbc152cda174dc93d7f9b6ae1edd0a6f44d595706e5974f5809bab59382f519", + "maxp": "32 bytes, checksum 0x000F0026, sha256 42c6c6b6190f27b9917ae0e063a5d610b2880c2dd2f6361c7f6164560bf3e3dd", + "name": "1236 bytes, checksum 0x738E9F04, sha256 0be8b381ecfa265eb0a24147f3646b5c16593a6c1fd1dc297da6de27a6aab66f", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83" + }, + "maxUni": 0 + } + } +} diff --git a/tests/data/ttf/NotoSans-PlaneSixteenMark-Synthetic.ttf b/tests/data/ttf/NotoSans-PlaneSixteenMark-Synthetic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..bfc4e9a052dbad1db022a8a1e955f078ec09887e GIT binary patch literal 2608 zcmb_dUu;ul6hGhB+jf7tmTk}t0lD4En1pq#rJZABIv6Y9VBp4LLQ;0=x z|L1(?oO?eKMC7HX$wA}sfdhlIiK>aJN5Kuoj}4D>zWdNA(6gY^@sShz+Fso}2Kpz^ zhanBmTu6LHB&I=+Co@J~_!{2>{U_)Tri@}9`#R_kVXvA>&rL3R|9GEBUV_fTbjp|r zzOg9^+3$eZG#J_?F$q}^bZ|OTn)O`y=Q8NeLAR%KNkg8Ln_&MoXn)3-&6AJXAU_CN z&l;Ij@ufwLNP7bCO+HsFnTr%g{GBW(KC2X0k1ZTHe9E`?FLF430f&Cr?vC+(^Xo51 z%8gK&mt~MHlFY>eJFTPq9VF)=7jj;CvSik~MsaGW88#l^9I5g!t;t?-ft>L1yhL{N zn6--pg~dK_Yn@VaYEmM5>aUm*#fOLEbcuA+v~qCn7cW4rBFJWW1vpLyp9uk5c^;yT z)GqhJFLok6QTZe0TR1XwDC_muYrq^OjUL7Bp(@BUclHFPm?f z&n~&F=#W~EM{N@}<;JetAh7q_z||bL!GV3=24u~ogLT>SE(MquS_eJ@8VFdA^)L<7 zQ}iNTq(%Caz5rKJ0w?|(b#M?ON9qIh+XD50gm|sIAfn}~^2$;yA}657%JcFXwCafh z+XL=a5fLIJx4T?!cSHup%0HhtDVmN8fBA;PTc^1jT*q%*zb>yWMWnN8M~7!?Z~eE( ztz9PQ7AB;YsPRGBw4oI)qA%A5w)uLS_H=n<+26gp(bLe=)4)>^7F%Vtc$1otWm{Wo zi_0C2MZ*3jzdsU=b#HLBwzP@5V5fg`U-PbmTY5sl&JA1on!Dngd&MK`YFjoQje6Ge zek4}6Cbn0)TcdrlJ95ht?rGlRYi+50Qr_mcp2pqXe$y;B%0&5lWa=iz(r<(gVh$4K zGm7IZzoM%5zf|S!Sa9p35785$aqp%97B1)@C<~YP^Soo@4vNvoHmCs7WtWtdug}hCheyj zslmaz`wVy)3`oT$Ke|*KhsHhYbp;^HE zrf341M`2w8=rlqGWg*q+7%Ul2fj2|5kf*S+bOhW2ol&}Tpi9Urh4&045hsN#&MM0S z@(Ur?UDQpz$Z!}d4s1Era-@zmR%7r{Kn63&CyOW*FLzu1SB@RXeQl1*UUWp@a~gmW zA{wYfiZUyw%h4nx_pa;_l^fS=4*lVpA5xZ6@R>ygn9iaeAykV`v08Hg`h2=6b-Gbd z25eVzxhFcEhHw2I_MBzKp1Z>KJ5|gADDId}W2y&9^d^UXgw*Vmkn0}m#5$)|2su>d zCWL%*@YD$|_p{T^I|qy>?{4c1^jDv);-Lubvp5C!a1_>7r3aNh&+rJaN$8wY)#iLH zjWqV8Is?xqSK%xo@QDk+tx03+Lzr+K^Tf618L4<4y2ArkW$iq5p&AVOs?6?;yPS=I z@56vU{tiy#&3T>!&3RaJGl(zjxWY@QtiDpGqFv`APQ%}B9yUe9{f5)TX!hoK4g6%b}H;XVkMKA S{2j5ZIPBA9E-C*mqJIGd7^YwV literal 0 HcmV?d00001 From e03e6da3c88da454df2e5fa6cb2c6f5cf8a3f8f6 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 09:23:37 +1000 Subject: [PATCH 2/5] Share GDEF's mark set between LookupFlag and the shaper, and index a mark attachment Coverage straight from the table (#193) Otl built the marks into a set of its own beside the LookupFlag that built the same set on the first IgnoreMarks lookup; LookupFlag::marks() hands out that one, and Otl keeps just the LookupFlag per font. marksOutsideFilteringSet() reads its mark glyph set through GlyphString::set(). coverageIndexByHex() was the last reader of _getCoverage(), whose hex list was cached only to be turned into the index, so the index is built from the table and the list goes. Co-Authored-By: Claude Opus 5 (1M context) --- src/Fonts/Table/LookupFlag.php | 13 +++++++---- src/Otl.php | 41 ++++++++++------------------------ 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/Fonts/Table/LookupFlag.php b/src/Fonts/Table/LookupFlag.php index 6c80ae24c..053881898 100644 --- a/src/Fonts/Table/LookupFlag.php +++ b/src/Fonts/Table/LookupFlag.php @@ -191,6 +191,14 @@ private function glyphsOf($class, $flag, $markFilteringSet) } } + /** + * @return true[] GDEF's marks, as GlyphString::set() gives them + */ + public function marks() + { + return $this->setOf(self::MARKS, 0, ''); + } + private function setOf($class, $flag, $markFilteringSet) { if ($class === self::MARKS_OUTSIDE_FILTERING_SET) { @@ -221,10 +229,7 @@ private function marksOutsideFilteringSet($markFilteringSet) } $keep = []; - $inSet = []; - foreach (explode('|', $this->gdef['MarkGlyphSets'][$markFilteringSet]) as $glyph) { - $inSet[trim($glyph)] = true; - } + $inSet = GlyphString::set($this->gdef['MarkGlyphSets'][$markFilteringSet]); foreach (explode('|', $this->gdef['GlyphClassMarks']) as $glyph) { $glyph = trim($glyph); diff --git a/src/Otl.php b/src/Otl.php index 80dba45e6..4f4675829 100644 --- a/src/Otl.php +++ b/src/Otl.php @@ -110,12 +110,12 @@ class Otl private $marks; /** - * $lookupFlag and $marks for every font laid out so far, by font key, since both are built from the - * whole of GDEF and a document sets one font for run after run + * $lookupFlag for every font laid out so far, by font key: the sets it builds from GDEF are kept + * with it, and a document sets one font for run after run * - * @var array[] + * @var LookupFlag[] */ - private $gdefSets = []; + private $lookupFlags = []; var $Ignores; @@ -306,11 +306,12 @@ private function loadGdefData() $this->GlyphClassComponents = $gdef['GlyphClassComponents']; $this->GlyphClassBases = $gdef['GlyphClassBases']; - if (!isset($this->gdefSets[$this->fontkey])) { - $this->gdefSets[$this->fontkey] = [new LookupFlag($this->fontkey, $gdef), GlyphString::set($gdef['GlyphClassMarks'])]; + if (!isset($this->lookupFlags[$this->fontkey])) { + $this->lookupFlags[$this->fontkey] = new LookupFlag($this->fontkey, $gdef); } - list($this->lookupFlag, $this->marks) = $this->gdefSets[$this->fontkey]; + $this->lookupFlag = $this->lookupFlags[$this->fontkey]; + $this->marks = $this->lookupFlag->marks(); } /** @@ -4608,7 +4609,7 @@ private function glyphToChar($gid) * The glyph IDs a Coverage table covers, for a Single Substitution Format 1, which adds a delta * to a glyph ID rather than naming a replacement. * - * Cached apart from _getCoverage below: the same table, projected differently. + * Cached apart from coverageIndexByHex below: the same table, projected differently. */ private function _getCoverageGID() { @@ -4621,25 +4622,6 @@ private function _getCoverageGID() return $this->LuDataCache[$this->otlCacheKey]['coverageGID'][$offset]; } - /** - * The characters a Coverage table covers, as the hex strings the shaper matches against - */ - private function _getCoverage() - { - $offset = $this->reader->tell(); - - if (!isset($this->LuDataCache[$this->otlCacheKey]['coverage'][$offset])) { - $g = []; - foreach (Coverage::glyphs($this->reader) as $glyphID) { - $g[] = GlyphString::of($this->glyphToChar($glyphID)); - } - - $this->LuDataCache[$this->otlCacheKey]['coverage'][$offset] = $g; - } - - return $this->LuDataCache[$this->otlCacheKey]['coverage'][$offset]; - } - /** * The characters a Coverage table covers, each with its Coverage Index, for the mark attachment * subtables, which find the glyph a mark attaches to and then index a parallel array by it. @@ -4652,7 +4634,8 @@ private function coverageIndexByHex() if (!isset($this->LuDataCache[$this->otlCacheKey]['coverageIndex'][$offset])) { $indexes = []; - foreach ($this->_getCoverage() as $index => $hex) { + foreach (Coverage::glyphs($this->reader) as $index => $glyphID) { + $hex = GlyphString::of($this->glyphToChar($glyphID)); if (!isset($indexes[$hex])) { $indexes[$hex] = $index; } @@ -4674,7 +4657,7 @@ private function coverageIndexByHex() * thousands and whose Coverage tables name thousands of glyphs, that scanning was most of the time * spent shaping a word. * - * Cached apart from _getCoverage above: the same table, projected differently. + * Cached apart from coverageIndexByHex above: the same table, projected differently. * * @return array map of unicode => 1 */ From 51ea11f7c1089e11d537470eb33895ea7be3688e Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 12:41:29 +1000 Subject: [PATCH 3/5] Document the two helpers inList() and skips() test a glyph through (#193) Co-Authored-By: Claude Opus 5 (1M context) --- src/Fonts/GlyphString.php | 9 +++++++++ src/Fonts/Table/LookupFlag.php | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/Fonts/GlyphString.php b/src/Fonts/GlyphString.php index 943d18081..10308767d 100644 --- a/src/Fonts/GlyphString.php +++ b/src/Fonts/GlyphString.php @@ -73,6 +73,15 @@ public static function inList($glyphs, $glyph) return false; } + /** + * Whether a match in a list is flanked by another hex digit, which makes it part of a longer glyph + * rather than a glyph of its own. + * + * @param string $string The list being searched + * @param int $at A position either side of a match, which may fall outside the string + * + * @return bool False before the start or past the end, where nothing flanks the match + */ private static function isHexDigitAt($string, $at) { return $at >= 0 && isset($string[$at]) && strpos('0123456789ABCDEFabcdef', $string[$at]) !== false; diff --git a/src/Fonts/Table/LookupFlag.php b/src/Fonts/Table/LookupFlag.php index 053881898..3c59eeea7 100644 --- a/src/Fonts/Table/LookupFlag.php +++ b/src/Fonts/Table/LookupFlag.php @@ -199,6 +199,18 @@ public function marks() return $this->setOf(self::MARKS, 0, ''); } + /** + * A class skips() tests glyphs against, built once and kept, since it is asked glyph after glyph. + * + * The marks outside a filtering set or an attachment class depend on which set or class the flag + * names, so those are kept per set or class; every other class is the same for any flag. + * + * @param string $class One of the class constants, as skipped() gives it + * @param int $flag The lookup's LookupFlag, which names the attachment class + * @param int|string $markFilteringSet The mark glyph set it names, or '' where it names none + * + * @return true[] The class, as GlyphString::set() gives it + */ private function setOf($class, $flag, $markFilteringSet) { if ($class === self::MARKS_OUTSIDE_FILTERING_SET) { From 01a932c4e2e78762f2ea8cef0c9094f7fc300afe Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 09:46:59 +1000 Subject: [PATCH 4/5] Stop an Arabic form's context walk at the edge of the run (#204) Shaper\Arabic::glyphs() matches a joining form's backtrack (prel) and lookahead (postl) by walking over the glyphs the lookup ignores, and nothing stopped that walk at the edge of the run. Where a word starts or ends in ignored glyphs, $chars[...] past the edge is null. On PHP 7 that was a notice per glyph read before the loop gave up; from PHP 8 GlyphString::inList() searches the ignore pattern for "", finds it, and the loop never returns. A word ending in BEH FATHA, where beh's final or isolated form has a lookahead, was enough. Both walks now test isset() before reading the ignore pattern. The check after the walk already refused a position the run does not hold, which is what HarfBuzz's match_backtrack()/match_lookahead() do when skippy_iter runs out of glyphs, so no other result changes. NotoSansArabic-ContextEdge-Synthetic is NotoSansArabic-Joining-Subset (Noto Sans Arabic 2.012, OFL 1.1) rebuilt in fontTools 4.59.2: a FATHA (uni064E, dotbelowar's outline, GDEF mark class, U+064E) is added; GSUB is replaced by 'init' and 'fina' Chaining Context Substitutions (Type 6 Format 3, IgnoreMarks) - beh takes uni066E.init after uni08AD and uni066E.fina before it - and a single 'medi'; 'ccmp' is dropped, since the shaper resolves joining after it and a beh it takes apart joins nothing (#209); name IDs 1, 4 and 6 are renamed. hb-shape 14.3.1 draws what ArabicContextEdgeTest expects for BEH BEH FATHA, FATHA BEH BEH, and the same with LOW ALEF past the mark. Regenerating fontcache, otldump, shaping and subset from cold adds the new font's four fixtures and moves no existing one. Co-Authored-By: Claude Opus 5 (1M context) --- src/Shaper/Arabic.php | 8 +- tests/Mpdf/ArabicContextEdgeTest.php | 90 +++++ tests/Mpdf/Fixtures/arabic-shape.php | 24 ++ tests/Mpdf/Shaper/ArabicTest.php | 87 +++++ .../NotoSansArabic-ContextEdge-Synthetic.json | 319 ++++++++++++++++++ .../NotoSansArabic-ContextEdge-Synthetic.txt | 27 ++ .../NotoSansArabic-ContextEdge-Synthetic.txt | 96 ++++++ .../NotoSansArabic-ContextEdge-Synthetic.json | 129 +++++++ .../NotoSansArabic-ContextEdge-Synthetic.ttf | Bin 0 -> 2632 bytes 9 files changed, 778 insertions(+), 2 deletions(-) create mode 100644 tests/Mpdf/ArabicContextEdgeTest.php create mode 100644 tests/Mpdf/Fixtures/arabic-shape.php create mode 100644 tests/data/fontcache/NotoSansArabic-ContextEdge-Synthetic.json create mode 100644 tests/data/otldump/NotoSansArabic-ContextEdge-Synthetic.txt create mode 100644 tests/data/shaping/NotoSansArabic-ContextEdge-Synthetic.txt create mode 100644 tests/data/subset/NotoSansArabic-ContextEdge-Synthetic.json create mode 100644 tests/data/ttf/NotoSansArabic-ContextEdge-Synthetic.ttf diff --git a/src/Shaper/Arabic.php b/src/Shaper/Arabic.php index dad7134af..824a79085 100644 --- a/src/Shaper/Arabic.php +++ b/src/Shaper/Arabic.php @@ -302,13 +302,17 @@ private static function glyphs($char, $type, &$chars, $i, $scriptTag, $usetags, if ($retk != -1) { $match = true; // If GSUB includes a Backtrack or Lookahead condition (e.g. font ArabicTypesetting) + // Walking over the glyphs the lookup ignores stops at the edge of the run, and a position the + // walk runs out of glyphs before reaching is one the context does not hold, as in HarfBuzz's + // match_backtrack() and match_lookahead(). Past the edge the glyph read is null, which from + // PHP 8 inList() finds in any ignore pattern, so without the isset() the walk never ends. if (isset($arabGlyphs[$char]['prel'][$retk]) && $arabGlyphs[$char]['prel'][$retk]) { $ig = 1; foreach ($arabGlyphs[$char]['prel'][$retk] as $k => $v) { // $k starts 0, 1... if (!isset($chars[$i - $ig - $k])) { $match = false; } elseif (!GlyphString::inList($v, $chars[$i - $ig - $k])) { - while (GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k])) { // ignore + while (isset($chars[$i - $ig - $k]) && GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k])) { $ig++; } if (!isset($chars[$i - $ig - $k])) { @@ -325,7 +329,7 @@ private static function glyphs($char, $type, &$chars, $i, $scriptTag, $usetags, if (!isset($chars[$i + $ig + $k])) { $match = false; } elseif (!GlyphString::inList($v, $chars[$i + $ig + $k])) { - while (GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i + $ig + $k])) { // ignore + while (isset($chars[$i + $ig + $k]) && GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i + $ig + $k])) { $ig++; } if (!isset($chars[$i + $ig + $k])) { diff --git a/tests/Mpdf/ArabicContextEdgeTest.php b/tests/Mpdf/ArabicContextEdgeTest.php new file mode 100644 index 000000000..07c7c5417 --- /dev/null +++ b/tests/Mpdf/ArabicContextEdgeTest.php @@ -0,0 +1,90 @@ + 'utf-8', + 'fontDir' => [__DIR__ . '/../data/ttf'], + 'fontdata' => ['notosansarabiccontextedgesynthetic' => [ + 'R' => 'NotoSansArabic-ContextEdge-Synthetic.ttf', + 'useOTL' => 0xFF, + ]], + 'default_font' => 'notosansarabiccontextedgesynthetic', + ]); + $mpdf->WriteHTML('

' . $html . '

'); + + return array_values(unpack('N*', mb_convert_encoding($mpdf->drawnText[0], 'UTF-32BE', 'UTF-8'))); + } + + public function dataRuns() + { + return [ + 'a word ending in a mark, where the lookahead runs out' => [ + [self::BEH, self::BEH, self::FATHA], + [self::FATHA, self::BEH, self::BEH], + ], + 'a word starting with a mark, where the backtrack runs out' => [ + [self::FATHA, self::BEH, self::BEH], + [self::BEH, self::BEH, self::FATHA], + ], + 'the lookahead met past the mark' => [ + [self::BEH, self::BEH, self::FATHA, self::LOW_ALEF], + [self::LOW_ALEF, self::FATHA, self::DOTLESS_BEH_FINAL, self::BEH], + ], + 'the backtrack met past the mark' => [ + [self::LOW_ALEF, self::FATHA, self::BEH, self::BEH], + [self::BEH, self::DOTLESS_BEH_INITIAL, self::FATHA, self::LOW_ALEF], + ], + ]; + } + + /** + * @dataProvider dataRuns + */ + public function testAFormsContextIsMatchedUpToTheEdgeOfTheRun($codepoints, $expected) + { + $this->assertSame($expected, $this->drawn($codepoints)); + } + +} diff --git a/tests/Mpdf/Fixtures/arabic-shape.php b/tests/Mpdf/Fixtures/arabic-shape.php new file mode 100644 index 000000000..ec9f2c489 --- /dev/null +++ b/tests/Mpdf/Fixtures/arabic-shape.php @@ -0,0 +1,24 @@ + + */ + +require __DIR__ . '/../../../vendor/autoload.php'; + +set_time_limit(5); +error_reporting(E_ERROR); + +list($hexes, $glyphs, $usetags, $marks) = json_decode(base64_decode($argv[1]), true); + +$info = []; +foreach ($hexes as $hex) { + $info[] = ['hex' => $hex, 'uni' => hexdec($hex)]; +} + +\Mpdf\Shaper\Arabic::shape($info, $glyphs, $marks, $usetags, 'arab'); + +echo json_encode($info); diff --git a/tests/Mpdf/Shaper/ArabicTest.php b/tests/Mpdf/Shaper/ArabicTest.php index 513cd289a..c9de77d16 100644 --- a/tests/Mpdf/Shaper/ArabicTest.php +++ b/tests/Mpdf/Shaper/ArabicTest.php @@ -512,6 +512,93 @@ public function dataContextNamingAPlaneSixteenGlyph() ]; } + /** + * A chained rule's backtrack or lookahead is found by walking over the glyphs its lookup ignores, + * and a run that ends in those glyphs ends the walk. The walk read past the edge: on PHP 7 that was + * a notice for each glyph it read there, and from PHP 8 it never returned (#204), which is why + * testAFormsContextWalkReturnsAtTheEdgeOfTheRun() runs the same cases in a process of its own. + * Here the notice is what fails, so a regression cannot hang the suite. + * + * @dataProvider dataContextWalkedToTheEdgeOfTheRun + */ + public function testAFormsContextWalkReadsNothingPastTheEdgeOfTheRun($hexes, $glyphs, $expected) + { + set_error_handler(function ($number, $message, $file, $line) { + throw new \ErrorException($message, 0, $number, $file, $line); + }); + + try { + $forms = $this->shape($hexes, self::ALL_FORMS, 'arab', self::FATHA, $glyphs); + } finally { + restore_error_handler(); + } + + $this->assertSame($expected, $forms); + } + + /** + * The time limit is the child's own rather than a `timeout` around it, so it holds on Windows too, + * and the child reports nothing short of a fatal error, so a walk warning on every step cannot fill + * the stderr pipe and leave it blocked instead of timed out. + * + * @dataProvider dataContextWalkedToTheEdgeOfTheRun + */ + public function testAFormsContextWalkReturnsAtTheEdgeOfTheRun($hexes, $glyphs, $expected) + { + // base64, because Windows argument quoting does not survive the JSON's double quotes + $case = base64_encode(json_encode([$hexes, $glyphs, self::ALL_FORMS, ' ' . self::FATHA])); + $command = escapeshellarg(PHP_BINARY) . ' -d display_errors=stderr ' + . escapeshellarg(__DIR__ . '/../Fixtures/arabic-shape.php') . ' ' . $case; + + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $this->assertSame(0, proc_close($process), $errors); + + $forms = []; + foreach (json_decode($output, true) as $char) { + $forms[] = [$char['hex'], $char['form']]; + } + $this->assertSame($expected, $forms); + } + + public function dataContextWalkedToTheEdgeOfTheRun() + { + // The forms are the presentation forms rather than names, because the run is left holding them + // and shape() reads each back as hex + $ignoreFatha = '((?:(?: ' . self::FATHA . '))*)'; + + return [ + 'a backtrack that runs out at the start of the run' => [ + [self::FATHA, self::DAL], + [self::DAL => ['0FEA9', '0FEAA', 'prel' => [0 => [self::BEH]], 'ignore' => [0 => $ignoreFatha]]], + [[self::FATHA, 0], [self::DAL, 0]], + ], + 'a lookahead that runs out at the end of the run' => [ + [self::BEH, self::FATHA], + [self::BEH => ['0FE8F', 'postl' => [0 => [self::DAL]], 'ignore' => [0 => $ignoreFatha]]], + [[self::BEH, 0], [self::FATHA, 0]], + ], + 'a lookahead whose second position runs out after the first is met' => [ + [self::BEH, self::FATHA, self::DAL, self::FATHA], + [self::BEH => ['0FE8F', '0FE90', '0FE91', 'postl' => [2 => [self::DAL, self::DAL]], 'ignore' => [2 => $ignoreFatha]]], + [[self::BEH, 0], [self::FATHA, 0], [self::DAL, 0], [self::FATHA, 0]], + ], + 'a backtrack met past the ignored glyphs' => [ + [self::BEH, self::FATHA, self::DAL], + [self::DAL => ['0FEA9', '0FEAA', 'prel' => [1 => [self::BEH]], 'ignore' => [1 => $ignoreFatha]]], + [[self::BEH, 0], [self::FATHA, 0], ['0FEAA', 1]], + ], + 'a lookahead met past the ignored glyphs' => [ + [self::BEH, self::FATHA, self::DAL], + [self::BEH => ['0FE8F', '0FE90', '0FE91', 'postl' => [2 => [self::DAL]], 'ignore' => [2 => $ignoreFatha]]], + [['0FE91', 2], [self::FATHA, 0], [self::DAL, 0]], + ], + ]; + } + /** * @return array one [hex, form] pair per character, in logical order */ diff --git a/tests/data/fontcache/NotoSansArabic-ContextEdge-Synthetic.json b/tests/data/fontcache/NotoSansArabic-ContextEdge-Synthetic.json new file mode 100644 index 000000000..07d46f738 --- /dev/null +++ b/tests/data/fontcache/NotoSansArabic-ContextEdge-Synthetic.json @@ -0,0 +1,319 @@ +{ + "_": "Generated by composer fontcache:update. See tests/Mpdf/Fonts/ParserGoldenMaster.php.", + "fullName": "NotoSansArabicContextEdgeSynthetic-Regular", + "mtx": { + "GSUBScriptLang": { + "DFLT": "DFLT ", + "arab": "DFLT ", + "cyrl": "DFLT ", + "dev2": "DFLT ", + "grek": "DFLT ", + "latn": "DFLT " + }, + "GSUBFeatures": { + "DFLT": { + "DFLT": { + "init": [ + 0 + ], + "medi": [ + 1 + ], + "fina": [ + 2 + ] + } + }, + "arab": { + "DFLT": { + "init": [ + 0 + ], + "medi": [ + 1 + ], + "fina": [ + 2 + ] + } + }, + "cyrl": { + "DFLT": { + "init": [ + 0 + ], + "medi": [ + 1 + ], + "fina": [ + 2 + ] + } + }, + "dev2": { + "DFLT": { + "init": [ + 0 + ], + "medi": [ + 1 + ], + "fina": [ + 2 + ] + } + }, + "grek": { + "DFLT": { + "init": [ + 0 + ], + "medi": [ + 1 + ], + "fina": [ + 2 + ] + } + }, + "latn": { + "DFLT": { + "init": [ + 0 + ], + "medi": [ + 1 + ], + "fina": [ + 2 + ] + } + } + }, + "GSUBLookups": [ + { + "Type": 6, + "Flag": 8, + "SubtableCount": 1, + "Subtables": [ + 122 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 148 + ], + "MarkFilteringSet": "" + }, + { + "Type": 6, + "Flag": 8, + "SubtableCount": 1, + "Subtables": [ + 162 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 194 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 208 + ], + "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{0E002}-\\x{0E004}", + "haskernGPOS": false, + "hassmallcapsGSUB": false + }, + "cache": { + "GDEFdata.json": { + "GlyphClassBases": " 00020| 00627| 008AD| 0E000| 0E001| 0E002| 0E003| 0E004", + "GlyphClassMarks": " 0064E| 0E005", + "GlyphClassLigatures": "", + "GlyphClassComponents": "", + "MarkGlyphSets": [ + " 0E005" + ], + "MarkAttachmentType": [] + }, + "GPOS.dat": "234 bytes, sha256 0f363351c5ea276d72ca75127977045024304d1f2b19b04e8cfded715c737a13", + "GPOSdata.json": [ + [ + { + "57349": 0 + } + ], + [ + { + "57349": 0 + } + ] + ], + "GSUB.arab.DFLT.json": { + "rtlSUB": { + "00628": { + "2": "0E004", + "prel": { + "2": [ + "008AD" + ] + }, + "ignore": { + "2": "((?:(?: 0064E| 0E005))*)", + "1": "((?:(?: 0064E| 0E005))*)" + }, + "3": "0E003", + "1": "0E002", + "postl": { + "1": [ + "008AD" + ] + } + } + }, + "finals": "0E002 ", + "rphf": [], + "half": [], + "pref": [], + "blwf": [], + "pstf": [] + }, + "GSUB.dat": "220 bytes, sha256 3ac7cb0c04aead3a9fc0c5f6c607b54931c16a81c5d5edb41654ceb5d58d593e", + "GSUBdata.json": [ + [ + { + "1576": 0 + } + ], + [ + { + "1576": 0 + } + ], + [ + { + "1576": 0 + } + ], + [ + { + "1576": 0 + } + ], + [ + { + "1576": 0 + } + ] + ] + } +} diff --git a/tests/data/otldump/NotoSansArabic-ContextEdge-Synthetic.txt b/tests/data/otldump/NotoSansArabic-ContextEdge-Synthetic.txt new file mode 100644 index 000000000..2388457a5 --- /dev/null +++ b/tests/data/otldump/NotoSansArabic-ContextEdge-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: init medi fina
arab
DFLT: init medi fina
cyrl
DFLT: init medi fina
dev2
DFLT: init medi fina
grek
DFLT: init medi fina
latn
DFLT: init medi 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 latn language DFLT === +

GSUB Tables

+
Lookup #0 [tag: init]
Ignoring: Mark Glyphs
Subtable #0
LookupType 6: Chaining Contextual Substitution Subtable
Format 3: Coverage-based Chaining Context Glyph Substitution
CONTEXT:
Backtrack #0: U+08AD
Input #0:  ب 
Substitution Position: 0
Lookup #3 [tag: init]
Subtable #0
LookupType 1: Single Substitution Subtable
U+0628   ب  » »     M+E004
Lookup #1 [tag: medi]
Subtable #0
LookupType 1: Single Substitution Subtable
U+0628   ب  » »     M+E003
Lookup #2 [tag: fina]
Ignoring: Mark Glyphs
Subtable #0
LookupType 6: Chaining Contextual Substitution Subtable
Format 3: Coverage-based Chaining Context Glyph Substitution
CONTEXT:
Input #0:  ب 
Lookahead #0: U+08AD
Substitution Position: 0
Lookup #4 [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-ContextEdge-Synthetic.txt b/tests/data/shaping/NotoSansArabic-ContextEdge-Synthetic.txt new file mode 100644 index 000000000..b22550262 --- /dev/null +++ b/tests/data/shaping/NotoSansArabic-ContextEdge-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 064E 08AD E000 E001 E002 => 0020 0627 0628 064E 08AD E000 E001 E002 group=SCCMCCCC gpos={"7":{"kashida":2}} +0x80 0020 0627 0628 064E 08AD E000 E001 E002 => 0020 0627 0628 064E 08AD E000 E001 E002 group=SCCMCCCC gpos={"7":{"kashida":2}} +0xFF +kern -liga 0020 0627 0628 064E 08AD E000 E001 E002 => 0020 0627 0628 064E 08AD E000 E001 E002 group=SCCMCCCC gpos={"7":{"kashida":2}} +=== font characters 1 === +0xFF E003 E004 E005 => E003 E004 E005 group=CCM gpos={"2":{"BaseWidth":269,"XPlacement":31,"YPlacement":-3}} +0x80 E003 E004 E005 => E003 E004 E005 group=CCM gpos={"2":{"BaseWidth":269,"XPlacement":31,"YPlacement":-3}} +0xFF +kern -liga E003 E004 E005 => E003 E004 E005 group=CCM gpos={"2":{"BaseWidth":269,"XPlacement":31,"YPlacement":-3}} diff --git a/tests/data/subset/NotoSansArabic-ContextEdge-Synthetic.json b/tests/data/subset/NotoSansArabic-ContextEdge-Synthetic.json new file mode 100644 index 000000000..6e5a80249 --- /dev/null +++ b/tests/data/subset/NotoSansArabic-ContextEdge-Synthetic.json @@ -0,0 +1,129 @@ +{ + "_": "Generated by composer subset:update. See tests/Mpdf/Fonts/SubsetGoldenMaster.php.", + "useOTL=0x00": { + "characters": 6, + "makeSubset": { + "bytes": 1576, + "sha256": "dcdb96f57cd383eedf19465cdd6f6cdfac3e3eb7a1d109c9af772573a29b6ea4", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "100 bytes, checksum 0x0A15122E, sha256 01206629b9b1976eb2133f58b90acc80bd992c2eea0522dae67e318aa0b4dac8", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "348 bytes, checksum 0x4B193AFD, sha256 72af017879d7b5cb74705e75432c75cf858d4a91d8cc6c666297a05e40cf35f6", + "head": "54 bytes, checksum 0x273BB4EB, sha256 953af44a9ea0f49777df3b92e5801c83bdbb3f3c5e582361c5c10e62881dba63", + "hhea": "36 bytes, checksum 0x09D800EF, sha256 ebabb47c05c5494a10ab408895203118d43d45389ca43a4c8e36f5f3708f4e55", + "hmtx": "32 bytes, checksum 0x0CF9015E, sha256 3d78bf2b079ef8409631c4226d8441771b9cf4a9bbd474aa6f5fb01ab00a8af1", + "loca": "18 bytes, checksum 0x01580114, sha256 08d4157aa56b51232a60cdc5bec49509a2b55e0edafda07c36dc3711fe5f910e", + "maxp": "32 bytes, checksum 0x00100067, sha256 d7ee222097d2365abb6565f7458e7529d7d9a5c13b9d12b1c5813bd75c4d06e1", + "name": "602 bytes, checksum 0x351B4D7C, sha256 a3def774242f05f98ccd86071e838297b51d6977d62d5050843374956fa48320", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 65535, + "defaultWidth": 600, + "codeToGlyph": "6 entries, sha256 cd05832cc20763d2baec888f97c96504b92c9dd87781018003995892069fd4d0" + }, + "makeSubsetSIP": { + "bytes": 1560, + "sha256": "7977ab83391dc8ac1b598f1e9bf535f9d051a22b0e42680b8f907f093187a957", + "tables": { + "OS/2": "96 bytes, checksum 0x69A2D689, sha256 6d12188d8c750cd88b4abc92ca8f6b60d03839c85f9c99660729749453539cba", + "cmap": "82 bytes, checksum 0x00420076, sha256 f6d9593a66846b174499232d5c653827aca29fb4afa95c9d48a8b1a05f33bb84", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "348 bytes, checksum 0x4B193AFD, sha256 5cb26f14c78f967800052329067816047ff222ffc04cbc2d661399249d184bcb", + "head": "54 bytes, checksum 0x273BB4EB, sha256 3a7a54b8884ebe1bf346527bf67e96ebf138691f42f3e6ec487ed3afcc6ce789", + "hhea": "36 bytes, checksum 0x09D800EF, sha256 ebabb47c05c5494a10ab408895203118d43d45389ca43a4c8e36f5f3708f4e55", + "hmtx": "32 bytes, checksum 0x0CF9015E, sha256 702795e2dc162157c10308bbcf49a107d874b680074596789fd4951ef157c2b6", + "loca": "18 bytes, checksum 0x014C0112, sha256 8c32e6c969ca9367469dfb6601142a14bfea4db0b1974fd58937239b098f958e", + "maxp": "32 bytes, checksum 0x00100067, sha256 d7ee222097d2365abb6565f7458e7529d7d9a5c13b9d12b1c5813bd75c4d06e1", + "name": "602 bytes, checksum 0x35144D7C, sha256 9a414e77ed3ff545e08019d6eb0c1a54fa3874cc0737e4cac1843d94cc2549c7", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUniChar": 65535, + "defaultWidth": 600 + }, + "repackageTTF": { + "bytes": 2076, + "sha256": "f985836fe626eee4bdae90aa75c296d01ef00d1ff1d3ba83540cdeda385061d5", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "80 bytes, checksum 0x176604B5, sha256 e6150b2b386d85b244823a2f1782890e4abb76097e8949950e045f68300847a4", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "712 bytes, checksum 0xA188F2CF, sha256 be0e31a63f04eca9c536c5a128bfb0af8305bd8b968093ac56201d9728ac9f40", + "head": "54 bytes, checksum 0x273BB4EB, sha256 f516b17a24d254b5893183e6e7db0e333666dda711ca9a853b2d5070179d854f", + "hhea": "36 bytes, checksum 0x09D800F3, sha256 6ca77e5fbd56daf3cfb1b6fa175777fc37efb2dee3484e9b4eafc9b013685f37", + "hmtx": "48 bytes, checksum 0x14C501C4, sha256 beb829ee0b3b845cb6e98594d9558a6718caa5a6a5d0c51d3c14156ccc575788", + "loca": "26 bytes, checksum 0x04D2041D, sha256 7d7cfa93d31416192329dad07396fa208fb36dea393e3ec49d552e916798d08b", + "maxp": "32 bytes, checksum 0x00140067, sha256 9cbbb1d75753b1ffc75c0c0b2bc62dbe39970d0952d3cafaba7080450a4909d1", + "name": "602 bytes, checksum 0x351B4D7C, sha256 a3def774242f05f98ccd86071e838297b51d6977d62d5050843374956fa48320", + "post": "161 bytes, checksum 0xA62A664B, sha256 5b8d5360786a810d8f5eb32a72054f9c8a81ffe63c5ce9dd6ad9f96a6082da2d", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 0 + } + }, + "useOTL=0xFF": { + "characters": 12, + "makeSubset": { + "bytes": 1992, + "sha256": "6a0802412d24a3261579faacf2e1638afea571cc1fe668d9e44f7f90a084e983", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "116 bytes, checksum 0xEA2DF256, sha256 858fa3d0b930efbfd42a1a02aff15a6a53e516696c1f7bc7643f9112b9cb0b09", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "724 bytes, checksum 0x106683EE, sha256 9b7c70ea6d174f85dde0974c87cdad53be0ed647e330f09244f2aac062db1454", + "head": "54 bytes, checksum 0x273BB4EB, sha256 28ed64f53c5f8f7f20a77dc0441beba115080a6772e3b37aca7f8bacd9020fa6", + "hhea": "36 bytes, checksum 0x09D800F3, sha256 6ca77e5fbd56daf3cfb1b6fa175777fc37efb2dee3484e9b4eafc9b013685f37", + "hmtx": "48 bytes, checksum 0x14C501C4, sha256 beb829ee0b3b845cb6e98594d9558a6718caa5a6a5d0c51d3c14156ccc575788", + "loca": "26 bytes, checksum 0x04EA0432, sha256 ea45c3bbe7f5968b94088c5ac2fcef60612554631d8b53d2c4603ca73ac20598", + "maxp": "32 bytes, checksum 0x00140067, sha256 9cbbb1d75753b1ffc75c0c0b2bc62dbe39970d0952d3cafaba7080450a4909d1", + "name": "602 bytes, checksum 0x351B4D7C, sha256 a3def774242f05f98ccd86071e838297b51d6977d62d5050843374956fa48320", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 65535, + "defaultWidth": 600, + "codeToGlyph": "12 entries, sha256 5ce80ef51cc1a796dc4aface13e6f0653c3d26b58e6d2741578f9daf9c817bfe" + }, + "makeSubsetSIP": { + "bytes": 1972, + "sha256": "331771ee04bc7484c65904868479d2e1fed985fcafe3a017f356bfffacc112f7", + "tables": { + "OS/2": "96 bytes, checksum 0x69A2D68F, sha256 fd2a99f2195a1e56bb4e59d20af2b5720421965a32625525a1c755330d490c8e", + "cmap": "94 bytes, checksum 0x006000B5, sha256 75724bdb0aad5c8bd651f10a19b526bbc16e46dd6bce95ecbe992a14008013d8", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "724 bytes, checksum 0x106983F0, sha256 6b70d49a1702b20d6542845a3da8b2916299b00c6d4403e98f4efad27f65385b", + "head": "54 bytes, checksum 0x273BB4EB, sha256 f713df5e403f19107633d9f312e2f67b49ea083792e67fa5fbcfc9ee1515f5f7", + "hhea": "36 bytes, checksum 0x09D800F3, sha256 6ca77e5fbd56daf3cfb1b6fa175777fc37efb2dee3484e9b4eafc9b013685f37", + "hmtx": "48 bytes, checksum 0x14C501C4, sha256 878b29797358aa6ba5b230946534b0f7f7de475126f154c144cee1d37866e512", + "loca": "26 bytes, checksum 0x03F2034C, sha256 5b9e234c7070bf60ac5622aa013fabe4d9e43cbdf2d879a11095dcfd3bc3ebc8", + "maxp": "32 bytes, checksum 0x00140067, sha256 9cbbb1d75753b1ffc75c0c0b2bc62dbe39970d0952d3cafaba7080450a4909d1", + "name": "602 bytes, checksum 0x35144D7C, sha256 9a414e77ed3ff545e08019d6eb0c1a54fa3874cc0737e4cac1843d94cc2549c7", + "post": "32 bytes, checksum 0xFF9F0032, sha256 4fdc83d5e42fe44650fe86f15a645d5cc926d07355ba945e1ede42737607bf83", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUniChar": 65535, + "defaultWidth": 600 + }, + "repackageTTF": { + "bytes": 2104, + "sha256": "c6820acff4d4cdfd8fe7b052ab1620e76a6b3970f8210316437a3d8d30fe9b28", + "tables": { + "OS/2": "96 bytes, checksum 0x83C35E30, sha256 c8d44e1e3e21744fb6447d75613e7d094658598ae11cd4ecd6ea51953585ed86", + "cmap": "108 bytes, checksum 0xECAFEFC8, sha256 fb705da3b20a90b7cda468287c78e3ae892a6894c7ae4bb018c5a3196efc9666", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "712 bytes, checksum 0xA188F2CF, sha256 be0e31a63f04eca9c536c5a128bfb0af8305bd8b968093ac56201d9728ac9f40", + "head": "54 bytes, checksum 0x273BB4EB, sha256 69365b1031c0ea9b54f8880029e54cea827e5d51a0acc915ebdb46e0bb5da720", + "hhea": "36 bytes, checksum 0x09D800F3, sha256 6ca77e5fbd56daf3cfb1b6fa175777fc37efb2dee3484e9b4eafc9b013685f37", + "hmtx": "48 bytes, checksum 0x14C501C4, sha256 beb829ee0b3b845cb6e98594d9558a6718caa5a6a5d0c51d3c14156ccc575788", + "loca": "26 bytes, checksum 0x04D2041D, sha256 7d7cfa93d31416192329dad07396fa208fb36dea393e3ec49d552e916798d08b", + "maxp": "32 bytes, checksum 0x00140067, sha256 9cbbb1d75753b1ffc75c0c0b2bc62dbe39970d0952d3cafaba7080450a4909d1", + "name": "602 bytes, checksum 0x351B4D7C, sha256 a3def774242f05f98ccd86071e838297b51d6977d62d5050843374956fa48320", + "post": "161 bytes, checksum 0xA62A664B, sha256 5b8d5360786a810d8f5eb32a72054f9c8a81ffe63c5ce9dd6ad9f96a6082da2d", + "prep": "7 bytes, checksum 0x68068C85, sha256 907c4106ff9989c1805827d5581bdf107a253b098cb71eb374f5e47c35604452" + }, + "maxUni": 0 + } + } +} diff --git a/tests/data/ttf/NotoSansArabic-ContextEdge-Synthetic.ttf b/tests/data/ttf/NotoSansArabic-ContextEdge-Synthetic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..63407de00de62356ba2395c24e722fabb2f1bda1 GIT binary patch literal 2632 zcmbtWS!`5Q82-IVcOw#hIW|A&}CY-PMLP5(6W}60qI7|CSql1r;F_@X$RZ7 zMFR*LO^n6`^Z{Np7>Pcp@j(-#kQjWxn3!PT1s^~q@*o;RpuK+oxie6f7vi}&=lt9G z|L^=~yCjInK`)Sr($SXIjkK4lh-__;hN4}aJx$*>mVt?Hzk2W$Ly63!c1D~Ae;$18XsRz}{>kjc*uCIO6S2uOX*3A^ zF7WjuGi4<_YC2Sd|o(qF|h*ciKl}ZiW7Uc|v+JbVgesB?T11 zm^$Q^Bi$(qc(ZATc%3W&@QOsU#pp#Zg+%imQ>OfPjgEG9M(G>!+fEr6Ebl14!bsXE z^CeP0sqon=%#dHBO`@DOnJ$qJXnkTQOy@xUCv8Go%5v^ed3GjLEsIwpGpzz|CmVDM z`PFK*!*)v7A@i$M2CZKry7BeDkIDGF>Rc4Ee<}1spPFA|PQ$m5*3mO`nyw2?EEUb7 zL-b3#UZA-F%qFY`jTbE}s$5vKuvfe{^R5Wb{Ggnjj?^jr@Xc6HNjn-h)y>h7Yo{))v3cryYIu| zsA{RNbbIn09w*P&(-c%I!WGW@Az|+zi!Tcg?}hE0h1*lUji(BD?2BtloO3!bS?8)X z9DMyFWvZ{Ra%rQb+p(m(+2LHeq)_wAXzpN{;;(jLOGxi@0a>0Wy+RwtMPoFKdv1s_%PpR`?1`_T_U zHb&#nS4$rm_#CH33gR1tXSUc+`=|z^Qj~x+iJmxtpAJ6e~<4Sz{>}T~KRH|*2{jaC`+cpZs#$x*@&^I+UN`e0PQy~frj>ShPFdEAwaZB?VUbh9TlNIY};PI5xjkT%9x@w_XJ;#-8K%E0xAc*I6YYp>U!1uH?}gy)(z-zUcEXP_E1sbWx=o)!YnD`twB*-x9ZuE5&6Qte z&aH`;kThNy{QY3DaGDGaw_G^B1!w|PkK17%P;jPgGVhI`_DCCfKS_$U&HAlbcI=}b Z)rpE99JEIMSkBg(K8-fVsWKnP!+#USw`%|Z literal 0 HcmV?d00001 From 9a7b87ea3934b5e3ae0508c6c7df16f8675c845a Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 09:53:35 +1000 Subject: [PATCH 5/5] Shape every edge case in one time-limited child process, and shorten the walk's comment (#204) The child-process test spawned PHP once per data-provider case. The fixture now takes all the runs at once and prints each run's [hex, form] pairs by name, so the suite pays one start-up and one time limit, and the test no longer re-reads the child's run into pairs itself. Removing either bound still fails it on "Maximum execution time of 5 seconds exceeded". Co-Authored-By: Claude Opus 5 (1M context) --- src/Shaper/Arabic.php | 7 +++--- tests/Mpdf/Fixtures/arabic-shape.php | 30 +++++++++++++++-------- tests/Mpdf/Shaper/ArabicTest.php | 36 ++++++++++++++-------------- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/Shaper/Arabic.php b/src/Shaper/Arabic.php index 824a79085..fca5d4bbd 100644 --- a/src/Shaper/Arabic.php +++ b/src/Shaper/Arabic.php @@ -302,10 +302,9 @@ private static function glyphs($char, $type, &$chars, $i, $scriptTag, $usetags, if ($retk != -1) { $match = true; // If GSUB includes a Backtrack or Lookahead condition (e.g. font ArabicTypesetting) - // Walking over the glyphs the lookup ignores stops at the edge of the run, and a position the - // walk runs out of glyphs before reaching is one the context does not hold, as in HarfBuzz's - // match_backtrack() and match_lookahead(). Past the edge the glyph read is null, which from - // PHP 8 inList() finds in any ignore pattern, so without the isset() the walk never ends. + // The walk over ignored glyphs stops at the edge of the run, and a position it runs out before + // reaching is not held, as in HarfBuzz's match_backtrack() and match_lookahead(). Past the edge + // the glyph is null, which inList() finds in any pattern from PHP 8, so the walk would not end. if (isset($arabGlyphs[$char]['prel'][$retk]) && $arabGlyphs[$char]['prel'][$retk]) { $ig = 1; foreach ($arabGlyphs[$char]['prel'][$retk] as $k => $v) { // $k starts 0, 1... diff --git a/tests/Mpdf/Fixtures/arabic-shape.php b/tests/Mpdf/Fixtures/arabic-shape.php index ec9f2c489..4d267e322 100644 --- a/tests/Mpdf/Fixtures/arabic-shape.php +++ b/tests/Mpdf/Fixtures/arabic-shape.php @@ -1,10 +1,11 @@ + * Usage: php arabic-shape.php */ require __DIR__ . '/../../../vendor/autoload.php'; @@ -12,13 +13,22 @@ set_time_limit(5); error_reporting(E_ERROR); -list($hexes, $glyphs, $usetags, $marks) = json_decode(base64_decode($argv[1]), true); +list($runs, $usetags, $marks) = json_decode(base64_decode($argv[1]), true); -$info = []; -foreach ($hexes as $hex) { - $info[] = ['hex' => $hex, 'uni' => hexdec($hex)]; -} +$forms = []; +foreach ($runs as $name => $run) { + list($hexes, $glyphs) = $run; + + $info = []; + foreach ($hexes as $hex) { + $info[] = ['hex' => $hex, 'uni' => hexdec($hex)]; + } -\Mpdf\Shaper\Arabic::shape($info, $glyphs, $marks, $usetags, 'arab'); + \Mpdf\Shaper\Arabic::shape($info, $glyphs, $marks, $usetags, 'arab'); + + foreach ($info as $char) { + $forms[$name][] = [$char['hex'], $char['form']]; + } +} -echo json_encode($info); +echo json_encode($forms); diff --git a/tests/Mpdf/Shaper/ArabicTest.php b/tests/Mpdf/Shaper/ArabicTest.php index c9de77d16..097cad861 100644 --- a/tests/Mpdf/Shaper/ArabicTest.php +++ b/tests/Mpdf/Shaper/ArabicTest.php @@ -513,11 +513,9 @@ public function dataContextNamingAPlaneSixteenGlyph() } /** - * A chained rule's backtrack or lookahead is found by walking over the glyphs its lookup ignores, - * and a run that ends in those glyphs ends the walk. The walk read past the edge: on PHP 7 that was - * a notice for each glyph it read there, and from PHP 8 it never returned (#204), which is why - * testAFormsContextWalkReturnsAtTheEdgeOfTheRun() runs the same cases in a process of its own. - * Here the notice is what fails, so a regression cannot hang the suite. + * The walk over the glyphs a chained rule's lookup ignores read past the edge of the run: a notice + * for each glyph on PHP 7, and from PHP 8 a walk that never returned (#204). Here the notice is what + * fails, so a regression cannot hang the suite. * * @dataProvider dataContextWalkedToTheEdgeOfTheRun */ @@ -537,18 +535,24 @@ public function testAFormsContextWalkReadsNothingPastTheEdgeOfTheRun($hexes, $gl } /** - * The time limit is the child's own rather than a `timeout` around it, so it holds on Windows too, - * and the child reports nothing short of a fatal error, so a walk warning on every step cannot fill - * the stderr pipe and leave it blocked instead of timed out. - * - * @dataProvider dataContextWalkedToTheEdgeOfTheRun + * The same cases, all in one child process under a time limit, for a regression that reads past the + * edge without a notice. The limit is the child's own rather than a `timeout` around it, so it holds + * on Windows too, and the child reports nothing short of a fatal error, so a walk warning on every + * step cannot fill the stderr pipe and leave it blocked instead of timed out. */ - public function testAFormsContextWalkReturnsAtTheEdgeOfTheRun($hexes, $glyphs, $expected) + public function testAFormsContextWalkReturnsAtTheEdgeOfTheRun() { + $runs = []; + $expected = []; + foreach ($this->dataContextWalkedToTheEdgeOfTheRun() as $name => $case) { + $runs[$name] = [$case[0], $case[1]]; + $expected[$name] = $case[2]; + } + // base64, because Windows argument quoting does not survive the JSON's double quotes - $case = base64_encode(json_encode([$hexes, $glyphs, self::ALL_FORMS, ' ' . self::FATHA])); + $arg = base64_encode(json_encode([$runs, self::ALL_FORMS, ' ' . self::FATHA])); $command = escapeshellarg(PHP_BINARY) . ' -d display_errors=stderr ' - . escapeshellarg(__DIR__ . '/../Fixtures/arabic-shape.php') . ' ' . $case; + . escapeshellarg(__DIR__ . '/../Fixtures/arabic-shape.php') . ' ' . $arg; $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]); $output = stream_get_contents($pipes[1]); @@ -557,11 +561,7 @@ public function testAFormsContextWalkReturnsAtTheEdgeOfTheRun($hexes, $glyphs, $ fclose($pipes[2]); $this->assertSame(0, proc_close($process), $errors); - $forms = []; - foreach (json_decode($output, true) as $char) { - $forms[] = [$char['hex'], $char['form']]; - } - $this->assertSame($expected, $forms); + $this->assertSame($expected, json_decode($output, true)); } public function dataContextWalkedToTheEdgeOfTheRun()