diff --git a/src/Fonts/GlyphString.php b/src/Fonts/GlyphString.php
index ef7847fc5..10308767d 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,64 @@ 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;
+ }
+
+ /**
+ * 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 9b93d60fc..3c59eeea7 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,43 @@ 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, '');
+ }
+
+ /**
+ * 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) {
+ $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.
@@ -196,10 +241,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 b93ca3255..4f4675829 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 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 LookupFlag[]
+ */
+ private $lookupFlags = [];
+
var $Ignores;
var $LuCoverage;
@@ -290,7 +305,23 @@ 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->lookupFlags[$this->fontkey])) {
+ $this->lookupFlags[$this->fontkey] = new LookupFlag($this->fontkey, $gdef);
+ }
+
+ $this->lookupFlag = $this->lookupFlags[$this->fontkey];
+ $this->marks = $this->lookupFlag->marks();
+ }
+
+ /**
+ * @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 +380,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 +435,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 +644,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 +670,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 +695,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 +751,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 +1049,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 +1076,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 +1229,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 +1259,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 +1271,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 +1301,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 +2767,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 +2837,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 +2931,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 +2956,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 +3157,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 +3220,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 +3582,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 +3594,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 +3617,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 +3674,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 +3698,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 +3779,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 +3799,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 );
@@ -4578,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()
{
@@ -4592,22 +4623,28 @@ private function _getCoverageGID()
}
/**
- * The characters a Coverage table covers, as the hex strings the shaper matches against
+ * 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 _getCoverage()
+ private function coverageIndexByHex()
{
$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));
+ if (!isset($this->LuDataCache[$this->otlCacheKey]['coverageIndex'][$offset])) {
+ $indexes = [];
+ foreach (Coverage::glyphs($this->reader) as $index => $glyphID) {
+ $hex = GlyphString::of($this->glyphToChar($glyphID));
+ if (!isset($indexes[$hex])) {
+ $indexes[$hex] = $index;
+ }
}
- $this->LuDataCache[$this->otlCacheKey]['coverage'][$offset] = $g;
+ $this->LuDataCache[$this->otlCacheKey]['coverageIndex'][$offset] = $indexes;
}
- return $this->LuDataCache[$this->otlCacheKey]['coverage'][$offset];
+ return $this->LuDataCache[$this->otlCacheKey]['coverageIndex'][$offset];
}
/**
@@ -4620,7 +4657,7 @@ private function _getCoverage()
* 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
*/
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..fca5d4bbd 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;
/**
@@ -301,18 +302,21 @@ 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)
+ // 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...
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 (isset($chars[$i - $ig - $k]) && GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k])) {
$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 +327,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 (isset($chars[$i + $ig + $k]) && GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i + $ig + $k])) {
$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/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..4d267e322
--- /dev/null
+++ b/tests/Mpdf/Fixtures/arabic-shape.php
@@ -0,0 +1,34 @@
+
+ */
+
+require __DIR__ . '/../../../vendor/autoload.php';
+
+set_time_limit(5);
+error_reporting(E_ERROR);
+
+list($runs, $usetags, $marks) = json_decode(base64_decode($argv[1]), true);
+
+$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');
+
+ foreach ($info as $char) {
+ $forms[$name][] = [$char['hex'], $char['form']];
+ }
+}
+
+echo json_encode($forms);
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..097cad861 100644
--- a/tests/Mpdf/Shaper/ArabicTest.php
+++ b/tests/Mpdf/Shaper/ArabicTest.php
@@ -479,17 +479,137 @@ 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]],
+ ],
+ ];
+ }
+
+ /**
+ * 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
+ */
+ 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 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()
+ {
+ $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
+ $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') . ' ' . $arg;
+
+ $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);
+
+ $this->assertSame($expected, json_decode($output, true));
+ }
+
+ 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
*/
- 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/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/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
+
+GPOS Tables
+GPOS Scripts & Languages
+
+
+=== 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/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
arab
cyrl
dev2
grek
latn
+
+GPOS Tables
+GPOS Scripts & Languages
+
+
DFLT
arab
cyrl
dev2
grek
latn
+
+
+=== 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/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/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/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/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/NotoSans-PlaneSixteenMark-Synthetic.ttf b/tests/data/ttf/NotoSans-PlaneSixteenMark-Synthetic.ttf
new file mode 100644
index 000000000..bfc4e9a05
Binary files /dev/null and b/tests/data/ttf/NotoSans-PlaneSixteenMark-Synthetic.ttf differ
diff --git a/tests/data/ttf/NotoSansArabic-ContextEdge-Synthetic.ttf b/tests/data/ttf/NotoSansArabic-ContextEdge-Synthetic.ttf
new file mode 100644
index 000000000..63407de00
Binary files /dev/null and b/tests/data/ttf/NotoSansArabic-ContextEdge-Synthetic.ttf differ