From 65953dad6a1272bf3010ecae5bcb2702c6e4875e Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 09:03:40 +1000 Subject: [PATCH 1/3] 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/3] 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/3] 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) {