diff --git a/src/Bidi.php b/src/Bidi.php index e4988e9be..967a17e5e 100644 --- a/src/Bidi.php +++ b/src/Bidi.php @@ -2,6 +2,7 @@ namespace Mpdf; +use Mpdf\Shaper\OtlData; use Mpdf\Utils\UtfString; /** @@ -11,8 +12,9 @@ * resolved and in string form. Nothing here reads a font table - this is the Unicode algorithm over * the classes Ucdn supplies, which is why it sits beside Ucdn rather than under Fonts. * - * prepare() takes an Otl because resolving the explicit embedding controls means deleting them from - * the text afterwards, and removing a character from text and its OTLdata together is Otl's job. + * prepare() takes the document's text encoding because resolving the explicit embedding controls + * means deleting them from the text afterwards, which Shaper\OtlData does to the text and its OTLdata + * together, counting characters in that encoding. * * @see https://www.unicode.org/reports/tr9/ * @@ -445,7 +447,7 @@ public static function sort($ta, $str, $dir, &$chunkOTLdata, $useGPOS) * from Mpdf::printbuffer(); reorder() then rearranges, from WriteFlowingBlock() and * finishFlowingBlock(), once line breaks have divided the paragraph into lines. */ - public static function prepare(&$para, $dir, Otl $otl) + public static function prepare(&$para, $dir, $encoding) { // Set the initial paragraph embedding level @@ -653,11 +655,11 @@ public static function prepare(&$para, $dir, Otl $otl) // Paragraph separators are not included in the embedding. // X9. Remove all RLE, LRE, RLO, LRO, and PDF codes. if ($controlchars) { - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xaa"); - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xab"); - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xac"); - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xad"); - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xae"); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xaa", $encoding); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xab", $encoding); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xac", $encoding); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xad", $encoding); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x80\xae", $encoding); preg_replace("/\x{202a}-\x{202e}/u", '', $para[$nc][0]); } } @@ -1011,10 +1013,10 @@ public static function prepare(&$para, $dir, Otl $otl) $numchunks = count($para); if ($controlchars) { for ($nc = 0; $nc < $numchunks; $nc++) { - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa6"); - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa7"); - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa8"); - $otl->removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa9"); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa6", $encoding); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa7", $encoding); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa8", $encoding); + OtlData::removeChar($para[$nc][0], $para[$nc][18], "\xe2\x81\xa9", $encoding); preg_replace("/\x{2066}-\x{2069}/u", '', $para[$nc][0]); } // Remove any blank chunks made by removing directional codes diff --git a/src/Mpdf.php b/src/Mpdf.php index 8667fc107..694ee790f 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -13,6 +13,7 @@ use Mpdf\Output\Destination; use Mpdf\PsrLogAwareTrait\MpdfPsrLogAwareTrait; use Mpdf\QrCode; +use Mpdf\Shaper\OtlData; use Mpdf\Utils\Arrays; use Mpdf\Utils\NumericString; use Mpdf\Utils\UtfString; @@ -6141,8 +6142,8 @@ function MultiCell( /* -- OTL -- */ if (isset($OTLdata)) { - $tmpOTLdata = $this->otl->sliceOTLdata($OTLdata, $j, $i - $j); - $this->otl->trimOTLdata($tmpOTLdata, false, true); + $tmpOTLdata = OtlData::slice($OTLdata, $j, $i - $j); + OtlData::trim($tmpOTLdata, false, true); $this->magic_reverse_dir($tmp, $directionality, $tmpOTLdata); } /* -- END OTL -- */ @@ -6191,8 +6192,8 @@ function MultiCell( /* -- OTL -- */ if (isset($OTLdata)) { - $tmpOTLdata = $this->otl->sliceOTLdata($OTLdata, $j, $i - $j); - $this->otl->trimOTLdata($tmpOTLdata, false, true); + $tmpOTLdata = OtlData::slice($OTLdata, $j, $i - $j); + OtlData::trim($tmpOTLdata, false, true); $this->magic_reverse_dir($tmp, $directionality, $tmpOTLdata); } /* -- END OTL -- */ @@ -6206,8 +6207,8 @@ function MultiCell( /* -- OTL -- */ if (isset($OTLdata)) { - $tmpOTLdata = $this->otl->sliceOTLdata($OTLdata, $j, $sep - $j); - $this->otl->trimOTLdata($tmpOTLdata, false, true); + $tmpOTLdata = OtlData::slice($OTLdata, $j, $sep - $j); + OtlData::trim($tmpOTLdata, false, true); } /* -- END OTL -- */ @@ -6382,8 +6383,8 @@ function MultiCell( /* -- OTL -- */ if (isset($OTLdata)) { - $tmpOTLdata = $this->otl->sliceOTLdata($OTLdata, $j, $i - $j); - $this->otl->trimOTLdata($tmpOTLdata, false, true); + $tmpOTLdata = OtlData::slice($OTLdata, $j, $i - $j); + OtlData::trim($tmpOTLdata, false, true); $this->magic_reverse_dir($tmp, $directionality, $tmpOTLdata); } /* -- END OTL -- */ @@ -6646,7 +6647,7 @@ function finishFlowingBlock($endofblock = false, $next = '') $content[count($content) - 1] = substr($content[count($content) - 1], 0, (strlen($content[count($content) - 1]) - $strip)); /* -- OTL -- */ if (isset($this->CurrentFont['useOTL']) && $this->CurrentFont['useOTL']) { - $this->otl->trimOTLdata($cOTLdata[count($cOTLdata) - 1], false, true); + OtlData::trim($cOTLdata[count($cOTLdata) - 1], false, true); } /* -- END OTL -- */ } @@ -6735,9 +6736,9 @@ function finishFlowingBlock($endofblock = false, $next = '') /* -- OTL -- */ // mPDF 5.7.1 if (isset($this->CurrentFont['useOTL']) && $this->CurrentFont['useOTL']) { - $this->otl->removeChar($chunk, $cOTLdata[$k], "\xc2\xad"); - $this->otl->removeChar($chunk, $cOTLdata[$k], "\xe2\x80\x8b"); - $this->otl->replaceSpace($chunk, $cOTLdata[$k]); + OtlData::removeChar($chunk, $cOTLdata[$k], "\xc2\xad", $this->mb_enc); + OtlData::removeChar($chunk, $cOTLdata[$k], "\xe2\x80\x8b", $this->mb_enc); + OtlData::nbspToSpace($chunk, $cOTLdata[$k], $this->mb_enc); $content[$k] = $chunk; } /* -- END OTL -- */ else { // *OTL* $content[$k] = $chunk = str_replace("\xc2\xad", '', $chunk); @@ -8381,7 +8382,7 @@ function WriteFlowingBlock($s, $sOTLdata) $savedPreFont[] = $font[$cutcontentctr]; /* -- OTL -- */ if (!empty($sOTLdata)) { - $savedPreOTLdata[] = $this->otl->splitOTLdata($cOTLdata[$cutcontentctr], $cutcharctr, $cutcharctr); + $savedPreOTLdata[] = OtlData::split($cOTLdata[$cutcontentctr], $cutcharctr, $cutcharctr); } /* -- END OTL -- */ @@ -8402,7 +8403,7 @@ function WriteFlowingBlock($s, $sOTLdata) } if (!empty($sOTLdata)) { - $savedPreOTLdata[] = $this->otl->splitOTLdata($cOTLdata[(count($cOTLdata) - 1)], mb_strlen($currContent, $this->mb_enc)); + $savedPreOTLdata[] = OtlData::split($cOTLdata[(count($cOTLdata) - 1)], mb_strlen($currContent, $this->mb_enc)); } if (strpos($contentB[(count($contentB) - 1)], 'R') !== false) { // ??? @@ -8427,7 +8428,7 @@ function WriteFlowingBlock($s, $sOTLdata) $next = count($savedPreContent) - 1; $savedPreContent[$next] = '-' . $savedPreContent[$next]; if (!empty($savedPreOTLdata[$next])) { - $this->otl->prependOTLchar($savedPreOTLdata[$next], $charData, 'C'); + OtlData::prependChar($savedPreOTLdata[$next], $charData, 'C'); } } } @@ -8445,7 +8446,7 @@ function WriteFlowingBlock($s, $sOTLdata) if (!is_array($breakfound)) { $savedFont = $this->saveFont(); if (!empty($sOTLdata)) { - $savedOTLdata = $this->otl->splitOTLdata($cOTLdata[(count($cOTLdata) - 1)], mb_strlen($currContent, $this->mb_enc)); + $savedOTLdata = OtlData::split($cOTLdata[(count($cOTLdata) - 1)], mb_strlen($currContent, $this->mb_enc)); } } @@ -8465,7 +8466,7 @@ function WriteFlowingBlock($s, $sOTLdata) } // *CJK-FONTS* /* -- OTL -- */ if (isset($this->CurrentFont['useOTL']) && $this->CurrentFont['useOTL']) { - $this->otl->trimOTLdata($cOTLdata[count($cOTLdata) - 1], false, true); // NB also does U+3000 + OtlData::trim($cOTLdata[count($cOTLdata) - 1], false, true); // NB also does U+3000 } /* -- END OTL -- */ @@ -8515,9 +8516,9 @@ function WriteFlowingBlock($s, $sOTLdata) if (!$this->usingCoreFont) { /* -- OTL -- */ if ((isset($this->CurrentFont['useOTL']) && $this->CurrentFont['useOTL']) || !empty($sOTLdata)) { - $this->otl->removeChar($chunk, $cOTLdata[$k], "\xc2\xad"); - $this->otl->removeChar($chunk, $cOTLdata[$k], "\xe2\x80\x8b"); - $this->otl->replaceSpace($chunk, $cOTLdata[$k]); // NBSP -> space + OtlData::removeChar($chunk, $cOTLdata[$k], "\xc2\xad", $this->mb_enc); + OtlData::removeChar($chunk, $cOTLdata[$k], "\xe2\x80\x8b", $this->mb_enc); + OtlData::nbspToSpace($chunk, $cOTLdata[$k], $this->mb_enc); if (preg_match("/([" . $this->pregCURSchars . "])/u", $chunk)) { $inclCursive = true; } @@ -8938,7 +8939,7 @@ function WriteFlowingBlock($s, $sOTLdata) if ($this->checkCJK && $currContent == "\xe3\x80\x80") { $currContent = ''; if (isset($this->CurrentFont['useOTL']) && $this->CurrentFont['useOTL']) { - $this->otl->trimOTLdata($cOTLdata[count($cOTLdata) - 1], true, false); // left trim U+3000 + OtlData::trim($cOTLdata[count($cOTLdata) - 1], true, false); // left trim U+3000 } } /* -- END CJK-FONTS -- */ @@ -13734,7 +13735,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c } $e = $this->otl->applyOTL($e, $this->CurrentFont['useOTL']); $this->OTLdata = $this->otl->OTLdata; - $this->otl->removeChar($e, $this->OTLdata, "\xef\xbb\xbf"); // Remove ZWNBSP (also Byte order mark FEFF) + OtlData::removeChar($e, $this->OTLdata, "\xef\xbb\xbf", $this->mb_enc); // Remove ZWNBSP (also Byte order mark FEFF) } /* -- END OTL -- */ else { // removes U+200E/U+200F LTR and RTL mark and U+200C/U+200D Zero-width Joiner and Non-joiner @@ -13758,7 +13759,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c if ($this->specialcontent == "type=select") { $e = ltrim($e); if (!empty($this->OTLdata)) { - $this->otl->trimOTLdata($this->OTLdata, true, false); + OtlData::trim($this->OTLdata, true, false); } // *OTL* $stringwidth = $this->GetStringWidth($e); if (!isset($this->selectoption['MAXWIDTH']) || $stringwidth > $this->selectoption['MAXWIDTH']) { @@ -13796,7 +13797,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c if (($this->ignorefollowingspaces) && !$this->ispre) { $e = ltrim($e); if (!empty($this->OTLdata)) { - $this->otl->trimOTLdata($this->OTLdata, true, false); + OtlData::trim($this->OTLdata, true, false); } // *OTL* } if ($e || $e === '0') { @@ -13862,7 +13863,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c if ($this->ignorefollowingspaces && !$this->ispre) { $e = ltrim($e); if (!empty($this->OTLdata)) { - $this->otl->trimOTLdata($this->OTLdata, true, false); + OtlData::trim($this->OTLdata, true, false); } // *OTL* } if ($e || $e === '0') { @@ -16259,10 +16260,7 @@ function printbuffer($arrayaux, $blockstate = 0, $is_table = false, $table_draft // mPDF 6 // Process bidirectional text ready for bidi-re-ordering (which is done after line-breaks are established in WriteFlowingBlock etc.) if (($blockdir == 'rtl' || $this->biDirectional) && !$table_draft) { - if (empty($this->otl)) { - $this->otl = new Otl($this, $this->fontCache); - } - Bidi::prepare($arrayaux, $blockdir, $this->otl); + Bidi::prepare($arrayaux, $blockdir, $this->mb_enc); $array_size = count($arrayaux); } @@ -16310,7 +16308,7 @@ function printbuffer($arrayaux, $blockstate = 0, $is_table = false, $table_draft if ($i == 0 && $vetor[0] != "\n" && ! $this->ispre) { $vetor[0] = ltrim($vetor[0]); if (!empty($vetor[18])) { - $this->otl->trimOTLdata($vetor[18], true, false); + OtlData::trim($vetor[18], true, false); } // *OTL* } @@ -17055,7 +17053,7 @@ function PaintDivBB($divider = '', $blockstate = 0, $blvl = 0) $legend = $this->blk[$blvl]['border_legend']; // Same structure array as textbuffer $txt = $legend[0] = ltrim($legend[0]); if (!empty($legend[18])) { - $this->otl->trimOTLdata($legend[18], true, false); + OtlData::trim($legend[18], true, false); } // *OTL* // Set font, size, style, color $this->SetFont($legend[4], $legend[2], $legend[11]); @@ -19207,7 +19205,7 @@ function TableCheckMinWidth($maxwidth, $forcewrap = 0, $textbuffer = [], $checkl } $line = trim($line); if (!empty($OTLdata)) { - $this->otl->trimOTLdata($OTLdata, true, true); + OtlData::trim($OTLdata, true, true); } // *OTL* // SET FONT SIZE/STYLE from $chunk[n] // FONTSIZE diff --git a/src/Otl.php b/src/Otl.php index 2b3e36a87..b93ca3255 100644 --- a/src/Otl.php +++ b/src/Otl.php @@ -20,6 +20,8 @@ use Mpdf\Shaper\Indic; use Mpdf\Shaper\LineBreaking; use Mpdf\Shaper\Myanmar; +use Mpdf\Shaper\OtlData; +use Mpdf\Shaper\OtlTags; use Mpdf\Shaper\Sea; use Mpdf\Utils\UtfString; @@ -223,7 +225,7 @@ function applyOTL($str, $useOTL) $this->assocMarks = []; // assocMarks[$posarr mpos] => array(compID, ligPos) if ($this->debugOTL) { - $this->_dumpproc('BEGIN', '-', '-', '-', '-', -1, '-', 0); + echo OtlDump::shapingStep($this->OTLdata, 'BEGIN', '-', '-', '-', '-', -1, '-', 0); } $this->markWordBoundaries($scriptblock); @@ -244,7 +246,7 @@ function applyOTL($str, $useOTL) } if ($this->debugOTL) { - $this->_dumpproc('END', '-', '-', '-', '-', 0, '-', 0); + echo OtlDump::shapingStep($this->OTLdata, 'END', '-', '-', '-', '-', 0, '-', 0); exit; } @@ -1354,11 +1356,11 @@ private function selectScriptAndLanguage($scriptblock, $useOTL) $ScriptLang = $this->mpdf->CurrentFont['GSUBScriptLang']; if (count($ScriptLang)) { - list($GSUBscriptTag, $is_old_spec) = $this->_getOTLscriptTag($ScriptLang, $scripttag, $scriptblock, $this->shaper, $useOTL, 'GSUB'); + list($GSUBscriptTag, $is_old_spec) = OtlTags::script($ScriptLang, $scripttag, $scriptblock, $this->shaper, $useOTL); if ($this->mpdf->fontLanguageOverride && strpos($ScriptLang[$GSUBscriptTag], $this->mpdf->fontLanguageOverride) !== false) { $GSUBlangsys = str_pad($this->mpdf->fontLanguageOverride, 4); } elseif ($GSUBscriptTag && isset($ScriptLang[$GSUBscriptTag]) && $ScriptLang[$GSUBscriptTag] != '') { - $GSUBlangsys = $this->_getOTLLangTag($this->mpdf->currentLang, $ScriptLang[$GSUBscriptTag]); + $GSUBlangsys = OtlTags::language($this->mpdf->currentLang, $ScriptLang[$GSUBscriptTag]); } } $ScriptLang = $this->mpdf->CurrentFont['GPOSScriptLang']; @@ -1370,11 +1372,11 @@ private function selectScriptAndLanguage($scriptblock, $useOTL) } // else repeat for GPOS // [Font XBRiyaz has GSUB tables for latn, but not GPOS for latn] elseif (count($ScriptLang)) { - list($GPOSscriptTag, $dummy) = $this->_getOTLscriptTag($ScriptLang, $scripttag, $scriptblock, $this->shaper, $useOTL, 'GPOS'); + list($GPOSscriptTag, $dummy) = OtlTags::script($ScriptLang, $scripttag, $scriptblock, $this->shaper, $useOTL); if ($GPOSscriptTag && $this->mpdf->fontLanguageOverride && strpos($ScriptLang[$GPOSscriptTag], $this->mpdf->fontLanguageOverride) !== false) { $GPOSlangsys = str_pad($this->mpdf->fontLanguageOverride, 4); } elseif ($GPOSscriptTag && isset($ScriptLang[$GPOSscriptTag]) && $ScriptLang[$GPOSscriptTag] != '') { - $GPOSlangsys = $this->_getOTLLangTag($this->mpdf->currentLang, $ScriptLang[$GPOSscriptTag]); + $GPOSlangsys = OtlTags::language($this->mpdf->currentLang, $ScriptLang[$GPOSscriptTag]); } } @@ -2084,7 +2086,7 @@ private function _applyGSUBsingleSubst($lookupID, $subtable, $ptr, $currGlyph, $ $substitute = $this->glyphToChar($GlyphID); $this->GSUBsubstitute($ptr, $substitute, $Type); if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } return 1; @@ -2125,7 +2127,7 @@ private function _applyGSUBmultipleSubst($lookupID, $subtable, $ptr, $currGlyph, // What it puts there is what the cursor moves by, which for the empty sequence is nothing $shift = $this->GSUBsubstitute($ptr, $SubstituteGlyphs, $Type); if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } return $shift; @@ -2177,7 +2179,7 @@ private function _applyGSUBalternateSubst($lookupID, $subtable, $ptr, $currGlyph $substitute = $this->glyphToChar($GlyphID); $this->GSUBsubstitute($ptr, $substitute, $Type); if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } return 1; @@ -2246,7 +2248,7 @@ private function _applyGSUBligatureSubst($lookupID, $subtable, $ptr, $currGlyph, if ($match) { $shift = $this->GSUBsubstitute($ptr, $substitute, $Type, $GlyphPos); // GlyphPos contains positions to set null if ($this->debugOTL && $shift) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } if ($shift) { return ($spos - $ptr + 1 - ($CompCount - 1)); @@ -2301,7 +2303,7 @@ private function _applyGSUBcontextSubstFormat1($lookupID, $subtable, $ptr, $curr $matched = $this->checkContextMatch($Input, [], [], $ignore, $ptr); if ($matched) { if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } return $this->_applyGSUBlookupRecords($SubstCount, $matched, $currentTag, $is_old_spec, $tagInt); } @@ -2361,7 +2363,7 @@ private function _applyGSUBcontextSubstFormat2($lookupID, $subtable, $ptr, $curr $matched = $this->checkContextMatchMultiple($inputGlyphs, [], [], $ignore, $ptr, $class0excl); if ($matched) { if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } return $this->_applyGSUBlookupRecords($SubstCount, $matched, $currentTag, $is_old_spec, $tagInt); } @@ -2395,7 +2397,7 @@ private function _applyGSUBcontextSubstFormat3($lookupID, $subtable, $ptr, $curr $matched = $this->checkContextMatchMultiple($CoverageInputGlyphs, [], [], $ignore, $ptr); if ($matched) { if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } $this->reader->seek($save_pos); // Return to just after the Coverage table offsets @@ -2441,7 +2443,7 @@ private function _applyGSUBchainContextSubstFormat1($lookupID, $subtable, $ptr, $matched = $this->checkContextMatch($Input, $Backtrack, $Lookahead, $ignore, $ptr); if ($matched) { if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } $SubstCount = $this->reader->readUInt16(); return $this->_applyGSUBlookupRecords($SubstCount, $matched, $currentTag, $is_old_spec, $tagInt); @@ -2510,7 +2512,7 @@ private function _applyGSUBchainContextSubstFormat2($lookupID, $subtable, $ptr, $matched = $this->checkContextMatchMultiple($inputGlyphs, $backtrackGlyphs, $lookaheadGlyphs, $ignore, $ptr, $class0excl, $bclass0excl, $lclass0excl); if ($matched) { if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } $SubstCount = $this->reader->readUInt16(); return $this->_applyGSUBlookupRecords($SubstCount, $matched, $currentTag, $is_old_spec, $tagInt); @@ -2549,7 +2551,7 @@ private function _applyGSUBchainContextSubstFormat3($lookupID, $subtable, $ptr, $matched = $this->checkContextMatchMultiple($CoverageInputGlyphs, $CoverageBacktrackGlyphs, $CoverageLookaheadGlyphs, $ignore, $ptr); if ($matched) { if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } $this->reader->seek($save_pos); // Return to just after SubstCount @@ -2621,7 +2623,7 @@ private function _applyGSUBreverseChainSingleSubst($lookupID, $subtable, $ptr, $ $this->GSUBsubstitute($ptr, $substitute, $Type); if ($this->debugOTL) { - $this->_dumpproc('GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GSUB', $lookupID, $subtable, $Type, $SubstFormat, $ptr, $currGlyph, $level); } return 1; @@ -3303,7 +3305,7 @@ private function _applyGPOSsingleAdjustment($lookupID, $subtable, $ptr, $currGly } $this->_applyGPOSvaluerecord($ptr, $Value); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return 1; } @@ -3383,12 +3385,12 @@ private function _applyGPOSpairAdjustmentFormat1($lookupID, $subtable, $ptr, $cu if ($ValueFormat2) { $this->_applyGPOSvaluerecord($matchedpos, $Value2); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $matchedpos - $ptr + 1; } if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $matchedpos - $ptr; } else { @@ -3463,12 +3465,12 @@ private function _applyGPOSpairAdjustmentFormat2($lookupID, $subtable, $ptr, $cu if ($ValueFormat2) { $this->_applyGPOSvaluerecord($matchedpos, $Value2); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $matchedpos - $ptr + 1; } if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $matchedpos - $ptr; } @@ -3525,7 +3527,7 @@ private function _applyGPOScursiveAttachment($lookupID, $subtable, $ptr, $currGl $this->Exit[$ptr] = ['X' => $x, 'Y' => $y, 'dir' => $dir]; } if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return 1; } @@ -3614,7 +3616,7 @@ private function _applyGPOSmarkToBase($lookupID, $subtable, $ptr, $currGlyph, $c $this->OTLdata[$ptr]['GPOSinfo']['XPlacement'] = $prevXPlacement + $BaseRecord['AnchorX'] - $MarkRecord['AnchorX']; $this->OTLdata[$ptr]['GPOSinfo']['YPlacement'] = $prevYPlacement + $BaseRecord['AnchorY'] - $MarkRecord['AnchorY']; if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return 1; } @@ -3720,7 +3722,7 @@ private function _applyGPOSmarkToLigature($lookupID, $subtable, $ptr, $currGlyph $this->OTLdata[$ptr]['GPOSinfo']['XPlacement'] = $prevXPlacement + $LigatureRecord['AnchorX'] - $MarkRecord['AnchorX']; $this->OTLdata[$ptr]['GPOSinfo']['YPlacement'] = $prevYPlacement + $LigatureRecord['AnchorY'] - $MarkRecord['AnchorY']; if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return 1; } @@ -3817,7 +3819,7 @@ private function _applyGPOSmarkToMark($lookupID, $subtable, $ptr, $currGlyph, $c $this->OTLdata[$ptr]['GPOSinfo']['XPlacement'] = $prevXPlacement + $Mark2Record['AnchorX'] - $Mark1Record['AnchorX']; $this->OTLdata[$ptr]['GPOSinfo']['YPlacement'] = $prevYPlacement + $Mark2Record['AnchorY'] - $Mark1Record['AnchorY']; if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return 1; } @@ -3867,7 +3869,7 @@ private function _applyGPOScontextPosFormat1($lookupID, $subtable, $ptr, $currGl if ($matched) { $shift = $this->_applyGPOSlookupRecords($PosCount, $matched, $tag, $is_old_spec); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $shift; @@ -3928,7 +3930,7 @@ private function _applyGPOScontextPosFormat2($lookupID, $subtable, $ptr, $currGl if ($matched) { $shift = $this->_applyGPOSlookupRecords($PosCount, $matched, $tag, $is_old_spec); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $shift; @@ -3965,7 +3967,7 @@ private function _applyGPOScontextPosFormat3($lookupID, $subtable, $ptr, $currGl $this->reader->seek($save_pos); // Return to just after the Coverage table offsets $shift = $this->_applyGPOSlookupRecords($PosCount, $matched, $tag, $is_old_spec); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $shift; @@ -4018,7 +4020,7 @@ private function _applyGPOSchainContextPosFormat1($lookupID, $subtable, $ptr, $c $PosCount = $this->reader->readUInt16(); $shift = $this->_applyGPOSlookupRecords($PosCount, $matched, $tag, $is_old_spec); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $shift; @@ -4088,7 +4090,7 @@ private function _applyGPOSchainContextPosFormat2($lookupID, $subtable, $ptr, $c $PosCount = $this->reader->readUInt16(); $shift = $this->_applyGPOSlookupRecords($PosCount, $matched, $tag, $is_old_spec); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $shift; @@ -4129,7 +4131,7 @@ private function _applyGPOSchainContextPosFormat3($lookupID, $subtable, $ptr, $c $this->reader->seek($save_pos); // Return to just after PosCount $shift = $this->_applyGPOSlookupRecords($PosCount, $matched, $tag, $is_old_spec); if ($this->debugOTL) { - $this->_dumpproc('GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); + echo OtlDump::shapingStep($this->OTLdata, 'GPOS', $lookupID, $subtable, $Type, $PosFormat, $ptr, $currGlyph, $level); } return $shift; @@ -4515,217 +4517,51 @@ private function getGCOMignoreSet($flag, $MarkFilteringSet) } /** - * Cut a laid-out run in two, at a line break. - * - * What is left of the run keeps everything up to the cut; what is returned starts at the restart - * position, which is past the cut where the break took a space with it. - * - * @param array $cOTLdata The run, truncated in place to the part before the break - * @param int $OTLcutoffpos Where the first part ends - * @param int|string $OTLrestartpos Where the second part begins, or '' for the cutoff - * - * @return array The part after the break + * @deprecated Use Mpdf\Shaper\OtlData::split() */ public function splitOTLdata(&$cOTLdata, $OTLcutoffpos, $OTLrestartpos = '') { - if (!$OTLrestartpos) { - $OTLrestartpos = $OTLcutoffpos; - } - $newOTLdata = ['GPOSinfo' => [], 'char_data' => []]; - $newOTLdata['group'] = substr($cOTLdata['group'], $OTLrestartpos); - $cOTLdata['group'] = substr($cOTLdata['group'], 0, $OTLcutoffpos); - - if (isset($cOTLdata['GPOSinfo']) && $cOTLdata['GPOSinfo']) { - foreach ($cOTLdata['GPOSinfo'] as $k => $val) { - if ($k >= $OTLrestartpos) { - $newOTLdata['GPOSinfo'][($k - $OTLrestartpos)] = $val; - } - if ($k >= $OTLcutoffpos) { - unset($cOTLdata['GPOSinfo'][$k]); - //$cOTLdata['GPOSinfo'][$k] = array(); - } - } - } - if (isset($cOTLdata['char_data'])) { - $newOTLdata['char_data'] = array_slice($cOTLdata['char_data'], $OTLrestartpos); - array_splice($cOTLdata['char_data'], $OTLcutoffpos); - } - - // Not necessary - easier to debug - if (isset($cOTLdata['GPOSinfo'])) { - ksort($cOTLdata['GPOSinfo']); - } - if (isset($newOTLdata['GPOSinfo'])) { - ksort($newOTLdata['GPOSinfo']); - } - - return $newOTLdata; + return OtlData::split($cOTLdata, $OTLcutoffpos, $OTLrestartpos); } /** - * A copy of part of a laid-out run, with the positioning renumbered to start at zero. - * - * @param array $OTLdata The run - * @param int $pos Where the part begins - * @param int $len How many characters of it to take - * - * @return array The part, as a run of its own + * @deprecated Use Mpdf\Shaper\OtlData::slice() */ public function sliceOTLdata($OTLdata, $pos, $len) { - // applyOTL() leaves OTLdata empty for a blank string, so every key here is optional - $newOTLdata = ['GPOSinfo' => [], 'char_data' => []]; - $newOTLdata['group'] = isset($OTLdata['group']) ? substr($OTLdata['group'], $pos, $len) : ''; - - if (!empty($OTLdata['GPOSinfo'])) { - foreach ($OTLdata['GPOSinfo'] as $k => $val) { - if ($k >= $pos && $k < ($pos + $len)) { - $newOTLdata['GPOSinfo'][($k - $pos)] = $val; - } - } - } - - if (isset($OTLdata['char_data'])) { - $newOTLdata['char_data'] = array_slice($OTLdata['char_data'], $pos, $len); - } - - // Not necessary - easier to debug - if ($newOTLdata['GPOSinfo']) { - ksort($newOTLdata['GPOSinfo']); - } - - return $newOTLdata; + return OtlData::slice($OTLdata, $pos, $len); } /** - * Put one character at the front of a laid-out run, moving the rest of it along by one. - * - * @param array $cOTLdata The run - * @param array $charData The character's entry, as Bidi::prepare() would have left it - * @param string $group Its class in the run's group string + * @deprecated Use Mpdf\Shaper\OtlData::prependChar() */ public function prependOTLchar(&$cOTLdata, $charData, $group) { - // applyOTL() leaves OTLdata empty for a blank string, so every key here is optional - $cOTLdata += ['group' => '', 'char_data' => [], 'GPOSinfo' => []]; - - $cOTLdata['group'] = $group . $cOTLdata['group']; - array_unshift($cOTLdata['char_data'], $charData); - - if ($cOTLdata['GPOSinfo']) { - $newGPOSinfo = []; - foreach ($cOTLdata['GPOSinfo'] as $k => $val) { - $newGPOSinfo[$k + 1] = $val; - } - $cOTLdata['GPOSinfo'] = $newGPOSinfo; - } + OtlData::prependChar($cOTLdata, $charData, $group); } /** - * Remove one or more occurrences of $char (single character) from $txt and adjust OTLdata + * @deprecated Use Mpdf\Shaper\OtlData::removeChar() */ public function removeChar(&$txt, &$cOTLdata, $char) { - while (mb_strpos($txt, $char, 0, $this->mpdf->mb_enc) !== false) { - $pos = mb_strpos($txt, $char, 0, $this->mpdf->mb_enc); - $newGPOSinfo = []; - $cOTLdata['group'] = substr_replace($cOTLdata['group'], '', $pos, 1); - if ($cOTLdata['GPOSinfo']) { - foreach ($cOTLdata['GPOSinfo'] as $k => $val) { - if ($k > $pos) { - $newGPOSinfo[($k - 1)] = $val; - } elseif ($k != $pos) { - $newGPOSinfo[$k] = $val; - } - } - $cOTLdata['GPOSinfo'] = $newGPOSinfo; - } - if (isset($cOTLdata['char_data'])) { - array_splice($cOTLdata['char_data'], $pos, 1); - } - - $txt = preg_replace("/" . $char . "/", '', $txt, 1); - } + OtlData::removeChar($txt, $cOTLdata, $char, $this->mpdf->mb_enc); } /** - * Remove one or more occurrences of $char (single character) from $txt and adjust OTLdata + * @deprecated Use Mpdf\Shaper\OtlData::nbspToSpace() */ public function replaceSpace(&$txt, &$cOTLdata) { - $char = chr(194) . chr(160); // NBSP - while (mb_strpos($txt, $char, 0, $this->mpdf->mb_enc) !== false) { - $pos = mb_strpos($txt, $char, 0, $this->mpdf->mb_enc); - if ($cOTLdata['char_data'][$pos]['uni'] == 160) { - $cOTLdata['char_data'][$pos]['uni'] = 32; - } - $txt = preg_replace("/" . $char . "/", ' ', $txt, 1); - } + OtlData::nbspToSpace($txt, $cOTLdata, $this->mpdf->mb_enc); } /** - * Drop the spaces from the ends of a laid-out run, and the positioning that went with them. - * - * @param array $cOTLdata The run, trimmed in place - * @param bool $Left Whether to trim the start - * @param bool $Right Whether to trim the end + * @deprecated Use Mpdf\Shaper\OtlData::trim() */ public function trimOTLdata(&$cOTLdata, $Left = true, $Right = true) { - $len = (!is_array($cOTLdata) || $cOTLdata['char_data'] === null) ? 0 : count($cOTLdata['char_data']); - $nLeft = 0; - $nRight = 0; - for ($i = 0; $i < $len; $i++) { - if ($cOTLdata['char_data'][$i]['uni'] == 32 || $cOTLdata['char_data'][$i]['uni'] == 12288) { - $nLeft++; - } // 12288 = 0x3000 = CJK space - else { - break; - } - } - for ($i = ($len - 1); $i >= 0; $i--) { - if ($cOTLdata['char_data'][$i]['uni'] == 32 || $cOTLdata['char_data'][$i]['uni'] == 12288) { - $nRight++; - } // 12288 = 0x3000 = CJK space - else { - break; - } - } - - // Trim Right - if ($Right && $nRight) { - $cOTLdata['group'] = substr($cOTLdata['group'], 0, strlen($cOTLdata['group']) - $nRight); - if ($cOTLdata['GPOSinfo']) { - foreach ($cOTLdata['GPOSinfo'] as $k => $val) { - if ($k >= $len - $nRight) { - unset($cOTLdata['GPOSinfo'][$k]); - } - } - } - if (isset($cOTLdata['char_data'])) { - for ($i = 0; $i < $nRight; $i++) { - array_pop($cOTLdata['char_data']); - } - } - } - // Trim Left - if ($Left && $nLeft) { - $cOTLdata['group'] = substr($cOTLdata['group'], $nLeft); - if ($cOTLdata['GPOSinfo']) { - $newPOSinfo = []; - foreach ($cOTLdata['GPOSinfo'] as $k => $val) { - if ($k >= $nLeft) { - $newPOSinfo[$k - $nLeft] = $cOTLdata['GPOSinfo'][$k]; - } - } - $cOTLdata['GPOSinfo'] = $newPOSinfo; - } - if (isset($cOTLdata['char_data'])) { - for ($i = 0; $i < $nLeft; $i++) { - array_shift($cOTLdata['char_data']); - } - } - } + OtlData::trim($cOTLdata, $Left, $Right); } /** @@ -4933,262 +4769,4 @@ private function getClassZeroExclusions($offset) return $this->LuDataCache[$this->otlCacheKey]['class0excl'][$offset]; } - - /** - * Pick the OpenType script tag to lay the text out under, from what the font offers. - * - * The tag Unicode implies is only a first choice: a font may offer the v2 Indic tag and not the - * old one or the other way round, may offer nothing for the script and still have a default - * entry, and may offer a script mPDF has no shaper for. This settles all of that, and says which - * Indic specification the chosen tag implies. - * - * @param array $ScriptLang The scripts this table offers, and the language systems under each - * @param string $scripttag The tag the text's Unicode script implies - * @param int $scriptblock The text's Unicode script - * @param string $shaper The shaper picked for it, where there is one - * @param int $useOTL Which script groups the document asked to be laid out this way - * @param string $mode 'GSUB' or 'GPOS', which may not offer the same scripts - * - * @return array The tag to use, or '' for none, and whether it implies the original Indic - * specification rather than the v2 one - */ - private function _getOTLscriptTag($ScriptLang, $scripttag, $scriptblock, $shaper, $useOTL, $mode) - { - // ScriptLang is the array of available script/lang tags supported by the font - // $scriptblock is the (number/code) for the script of the actual text string based on Unicode properties (Ucdn::$uni_scriptblock) - // $scripttag is the default tag derived from $scriptblock - /* - https://learn.microsoft.com/en-us/typography/opentype/spec/ttoreg - https://learn.microsoft.com/en-us/typography/opentype/spec/scripttags - - Values for useOTL - - Bit dn hn Value - 1 1 0x0001 GSUB/GPOS - Latin scripts - 2 2 0x0002 GSUB/GPOS - Cyrillic scripts - 3 4 0x0004 GSUB/GPOS - Greek scripts - 4 8 0x0008 GSUB/GPOS - CJK scripts (excluding Hangul-Jamo) - 5 16 0x0010 (Reserved) - 6 32 0x0020 (Reserved) - 7 64 0x0040 (Reserved) - 8 128 0x0080 GSUB/GPOS - All other scripts (including all RTL scripts, complex scripts with shapers etc) - - NB If change for RTL - cf. function magic_reverse_dir in mpdf.php to update - - */ - - if ($scriptblock == Ucdn::SCRIPT_LATIN) { - if (!($useOTL & 0x01)) { - return ['', false]; - } - } elseif ($scriptblock == Ucdn::SCRIPT_CYRILLIC) { - if (!($useOTL & 0x02)) { - return ['', false]; - } - } elseif ($scriptblock == Ucdn::SCRIPT_GREEK) { - if (!($useOTL & 0x04)) { - return ['', false]; - } - } elseif ($scriptblock >= Ucdn::SCRIPT_HIRAGANA && $scriptblock <= Ucdn::SCRIPT_YI) { - if (!($useOTL & 0x08)) { - return ['', false]; - } - } else { - if (!($useOTL & 0x80)) { - return ['', false]; - } - } - - // If availabletags includes scripttag - choose - if (isset($ScriptLang[$scripttag])) { - return [$scripttag, false]; - } - - // If INDIC (or Myanmar) and available tag not includes new version, check if includes old version & choose old version - if ($shaper) { - switch ($scripttag) { - case 'bng2': - if (isset($ScriptLang['beng'])) { - return ['beng', true]; - } - // fallthrough - case 'dev2': - if (isset($ScriptLang['deva'])) { - return ['deva', true]; - } - // fallthrough - case 'gjr2': - if (isset($ScriptLang['gujr'])) { - return ['gujr', true]; - } - // fallthrough - case 'gur2': - if (isset($ScriptLang['guru'])) { - return ['guru', true]; - } - // fallthrough - case 'knd2': - if (isset($ScriptLang['knda'])) { - return ['knda', true]; - } - // fallthrough - case 'mlm2': - if (isset($ScriptLang['mlym'])) { - return ['mlym', true]; - } - // fallthrough - case 'ory2': - if (isset($ScriptLang['orya'])) { - return ['orya', true]; - } - // fallthrough - case 'tml2': - if (isset($ScriptLang['taml'])) { - return ['taml', true]; - } - // fallthrough - case 'tel2': - if (isset($ScriptLang['telu'])) { - return ['telu', true]; - } - // fallthrough - case 'mym2': - if (isset($ScriptLang['mymr'])) { - return ['mymr', true]; - } - } - } - - // choose DFLT if present - if (isset($ScriptLang['DFLT'])) { - return ['DFLT', false]; - } - // else choose dflt if present - if (isset($ScriptLang['dflt'])) { - return ['dflt', false]; - } - // else return no scriptTag - if (isset($ScriptLang['latn'])) { - return ['latn', false]; - } - // else return no scriptTag - return ['', false]; - } - - /** - * Pick the OpenType language system tag from the document's language, out of what the script - * offers. - * - * An IETF tag is tried from the most specific part down - the language with its script or region, - * then the language alone - so that a font offering only the broader entry is still matched. - * - * @param string $ietf The language of the text, as an IETF tag, e.g. 'sr-Cyrl' - * @param string $available The language systems this script offers, space separated - * - * @return string The tag to use, or '' to fall back to the script's default - */ - private function _getOTLLangTag($ietf, $available) - { - // http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes - // https://learn.microsoft.com/en-us/typography/opentype/spec/languagetags - // IETF tag = e.g. en-US, und-Arab, sr-Cyrl cf. class LangToFont - if ($available == '') { - return ''; - } - - $tags = $ietf - ? preg_split('/-/', $ietf) - : []; - - $lang = ''; - $country = ''; - $script = ''; - - $lang = isset($tags[0]) - ? strtolower($tags[0]) - : ''; - - if (isset($tags[1]) && $tags[1]) { - if (strlen($tags[1]) == 2) { - $country = strtolower($tags[1]); - } - } - - if (isset($tags[2]) && $tags[2]) { - $country = strtolower($tags[2]); - } - - if ($lang != '' && isset(Ucdn::$ot_languages[$lang])) { - $langsys = Ucdn::$ot_languages[$lang]; - } elseif ($lang != '' && $country != '' && isset(Ucdn::$ot_languages[$lang . '' . $country])) { - $langsys = Ucdn::$ot_languages[$lang . '' . $country]; - } else { - $langsys = "DFLT"; - } - - if (strpos($available, $langsys) === false) { - if (strpos($available, "DFLT") !== false) { - return "DFLT"; - } else { - return ''; - } - } - - return $langsys; - } - - /** - * Echo the state of the run at one step of shaping, for the debugOTL trace. - * - * @param string $GPOSSUB 'GSUB' or 'GPOS', or a marker for the beginning or end of the run - * @param int $lookupID The lookup that applied - * @param int $subtable Which of its subtables - * @param int $Type The lookup's type - * @param int $Format The subtable's format - * @param int $ptr Where in the run it applied - * @param string $currGlyph The glyph it applied at, as hex - * @param int $level 0 for a lookup applied directly, 1 for one nested in a context rule - */ - private function _dumpproc($GPOSSUB, $lookupID, $subtable, $Type, $Format, $ptr, $currGlyph, $level) - { - echo '
'; - echo $GPOSSUB . ' LookupID #' . $lookupID . ' Subtable#' . $subtable . ' Type: ' . $Type . ' Format: ' . $Format . '
'; - echo '
'; - echo 'Glyph position: ' . $ptr . ' Current Glyph: ' . $currGlyph . '
'; - - for ($i = 0; $i < count($this->OTLdata); $i++) { - if ($i == $ptr) { - echo ''; - } - echo $this->OTLdata[$i]['hex'] . ' '; - if ($i == $ptr) { - echo ''; - } - } - echo '
'; - - for ($i = 0; $i < count($this->OTLdata); $i++) { - if ($i == $ptr) { - echo ''; - } - echo str_pad($this->OTLdata[$i]['uni'], 5) . ' '; - if ($i == $ptr) { - echo ''; - } - } - echo '
'; - - if ($GPOSSUB == 'GPOS') { - for ($i = 0; $i < count($this->OTLdata); $i++) { - if (!empty($this->OTLdata[$i]['GPOSinfo'])) { - echo $this->OTLdata[$i]['hex'] . ' &#x' . $this->OTLdata[$i]['hex'] . '; '; - print_r($this->OTLdata[$i]['GPOSinfo']); - echo ' '; - } - } - } - - echo '
'; - echo '
'; - } } diff --git a/src/OtlDump.php b/src/OtlDump.php index 177cb9a88..cd45c34e3 100644 --- a/src/OtlDump.php +++ b/src/OtlDump.php @@ -1678,6 +1678,55 @@ private function reportGSUBrule(array $Lookup, array $SubstLookupRecord, array $ } } + /** + * The run as it stands at one step of shaping, for Otl's debugOTL trace. + * + * Static, because it is the shaper that reports as it goes, with no font dump to hand. + * + * @param array $OTLdata The run, one entry per glyph + * @param string $GPOSSUB 'GSUB' or 'GPOS', or 'BEGIN' or 'END' of the run + * @param int|string $lookupID The lookup that applied, '-' at either end of the run + * @param int|string $subtable Which of its subtables + * @param int|string $Type The lookup's type + * @param int|string $Format The subtable's format + * @param int $ptr Where in the run it applied, shown in bold + * @param string $currGlyph The glyph it applied at, as hex + * @param int $level 0 for a lookup applied directly, 1 for one nested in a context rule + * + * @return string HTML + */ + public static function shapingStep(array $OTLdata, $GPOSSUB, $lookupID, $subtable, $Type, $Format, $ptr, $currGlyph, $level) + { + $html = '
'; + $html .= $GPOSSUB . ' LookupID #' . $lookupID . ' Subtable#' . $subtable . ' Type: ' . $Type . ' Format: ' . $Format . '
'; + $html .= '
'; + $html .= 'Glyph position: ' . $ptr . ' Current Glyph: ' . $currGlyph . '
'; + + $hex = ''; + $uni = ''; + for ($i = 0; $i < count($OTLdata); $i++) { + $h = $OTLdata[$i]['hex'] . ' '; + $u = str_pad($OTLdata[$i]['uni'], 5) . ' '; + if ($i == $ptr) { + $h = '' . $h . ''; + $u = '' . $u . ''; + } + $hex .= $h; + $uni .= $u; + } + $html .= $hex . '
' . $uni . '
'; + + if ($GPOSSUB == 'GPOS') { + for ($i = 0; $i < count($OTLdata); $i++) { + if (!empty($OTLdata[$i]['GPOSinfo'])) { + $html .= $OTLdata[$i]['hex'] . ' &#x' . $OTLdata[$i]['hex'] . '; ' . print_r($OTLdata[$i]['GPOSinfo'], true) . ' '; + } + } + } + + return $html . '
'; + } + /** * @return string[] One "hex|hex|hex" string of the alternatives each Coverage table holds */ diff --git a/src/Shaper/OtlData.php b/src/Shaper/OtlData.php new file mode 100644 index 000000000..b29ee77b6 --- /dev/null +++ b/src/Shaper/OtlData.php @@ -0,0 +1,240 @@ + [], 'char_data' => []]; + $newOTLdata['group'] = substr($cOTLdata['group'], $OTLrestartpos); + $cOTLdata['group'] = substr($cOTLdata['group'], 0, $OTLcutoffpos); + + if (isset($cOTLdata['GPOSinfo']) && $cOTLdata['GPOSinfo']) { + foreach ($cOTLdata['GPOSinfo'] as $k => $val) { + if ($k >= $OTLrestartpos) { + $newOTLdata['GPOSinfo'][($k - $OTLrestartpos)] = $val; + } + if ($k >= $OTLcutoffpos) { + unset($cOTLdata['GPOSinfo'][$k]); + } + } + } + if (isset($cOTLdata['char_data'])) { + $newOTLdata['char_data'] = array_slice($cOTLdata['char_data'], $OTLrestartpos); + array_splice($cOTLdata['char_data'], $OTLcutoffpos); + } + + if (isset($cOTLdata['GPOSinfo'])) { + ksort($cOTLdata['GPOSinfo']); + } + ksort($newOTLdata['GPOSinfo']); + + return $newOTLdata; + } + + /** + * A copy of part of a run, with the positioning renumbered to start at zero. + * + * @param array $OTLdata The run + * @param int $pos Where the part begins + * @param int $len How many characters of it to take + * + * @return array The part, as a run of its own + */ + public static function slice($OTLdata, $pos, $len) + { + // applyOTL() leaves OTLdata empty for a blank string, so every key here is optional + $newOTLdata = ['GPOSinfo' => [], 'char_data' => []]; + $newOTLdata['group'] = isset($OTLdata['group']) ? substr($OTLdata['group'], $pos, $len) : ''; + + if (!empty($OTLdata['GPOSinfo'])) { + foreach ($OTLdata['GPOSinfo'] as $k => $val) { + if ($k >= $pos && $k < ($pos + $len)) { + $newOTLdata['GPOSinfo'][($k - $pos)] = $val; + } + } + } + + if (isset($OTLdata['char_data'])) { + $newOTLdata['char_data'] = array_slice($OTLdata['char_data'], $pos, $len); + } + + if ($newOTLdata['GPOSinfo']) { + ksort($newOTLdata['GPOSinfo']); + } + + return $newOTLdata; + } + + /** + * Put one character at the front of a run, moving the rest of it along by one. + * + * @param array $cOTLdata The run + * @param array $charData The character's entry, as Bidi::prepare() would have left it + * @param string $group Its group: S, M or C + */ + public static function prependChar(&$cOTLdata, $charData, $group) + { + // applyOTL() leaves OTLdata empty for a blank string, so every key here is optional + $cOTLdata += ['group' => '', 'char_data' => [], 'GPOSinfo' => []]; + + $cOTLdata['group'] = $group . $cOTLdata['group']; + array_unshift($cOTLdata['char_data'], $charData); + + if ($cOTLdata['GPOSinfo']) { + $newGPOSinfo = []; + foreach ($cOTLdata['GPOSinfo'] as $k => $val) { + $newGPOSinfo[$k + 1] = $val; + } + $cOTLdata['GPOSinfo'] = $newGPOSinfo; + } + } + + /** + * Take every occurrence of a character out of a text and out of the run laid out from it. + * + * @param string $txt The text, edited in place + * @param array $cOTLdata Its run, edited in place + * @param string $char One character, as the text encodes it. Not a regex metacharacter: it is + * put into a pattern as it is. + * @param string $encoding The text's encoding, Mpdf::$mb_enc + */ + public static function removeChar(&$txt, &$cOTLdata, $char, $encoding) + { + while (mb_strpos($txt, $char, 0, $encoding) !== false) { + $pos = mb_strpos($txt, $char, 0, $encoding); + $newGPOSinfo = []; + $cOTLdata['group'] = substr_replace($cOTLdata['group'], '', $pos, 1); + if ($cOTLdata['GPOSinfo']) { + foreach ($cOTLdata['GPOSinfo'] as $k => $val) { + if ($k > $pos) { + $newGPOSinfo[($k - 1)] = $val; + } elseif ($k != $pos) { + $newGPOSinfo[$k] = $val; + } + } + $cOTLdata['GPOSinfo'] = $newGPOSinfo; + } + if (isset($cOTLdata['char_data'])) { + array_splice($cOTLdata['char_data'], $pos, 1); + } + + $txt = preg_replace("/" . $char . "/", '', $txt, 1); + } + } + + /** + * Turn every no-break space in a text into a space, in the text and in the run laid out from it. + * + * @param string $txt The text as UTF-8, edited in place + * @param array $cOTLdata Its run, edited in place + * @param string $encoding Mpdf::$mb_enc + */ + public static function nbspToSpace(&$txt, &$cOTLdata, $encoding) + { + $nbsp = "\xc2\xa0"; + while (mb_strpos($txt, $nbsp, 0, $encoding) !== false) { + $pos = mb_strpos($txt, $nbsp, 0, $encoding); + if ($cOTLdata['char_data'][$pos]['uni'] == 160) { + $cOTLdata['char_data'][$pos]['uni'] = 32; + } + $txt = preg_replace("/" . $nbsp . "/", ' ', $txt, 1); + } + } + + /** + * Drop the spaces, U+0020 and U+3000 IDEOGRAPHIC SPACE, from the ends of a run, and the positioning + * that went with them. + * + * @param array $cOTLdata The run, trimmed in place + * @param bool $left Whether to trim the start + * @param bool $right Whether to trim the end + */ + public static function trim(&$cOTLdata, $left = true, $right = true) + { + $len = (!is_array($cOTLdata) || $cOTLdata['char_data'] === null) ? 0 : count($cOTLdata['char_data']); + $nLeft = 0; + $nRight = 0; + for ($i = 0; $i < $len; $i++) { + if ($cOTLdata['char_data'][$i]['uni'] == 32 || $cOTLdata['char_data'][$i]['uni'] == 12288) { + $nLeft++; + } else { + break; + } + } + for ($i = ($len - 1); $i >= 0; $i--) { + if ($cOTLdata['char_data'][$i]['uni'] == 32 || $cOTLdata['char_data'][$i]['uni'] == 12288) { + $nRight++; + } else { + break; + } + } + + if ($right && $nRight) { + $cOTLdata['group'] = substr($cOTLdata['group'], 0, strlen($cOTLdata['group']) - $nRight); + if ($cOTLdata['GPOSinfo']) { + foreach ($cOTLdata['GPOSinfo'] as $k => $val) { + if ($k >= $len - $nRight) { + unset($cOTLdata['GPOSinfo'][$k]); + } + } + } + if (isset($cOTLdata['char_data'])) { + for ($i = 0; $i < $nRight; $i++) { + array_pop($cOTLdata['char_data']); + } + } + } + + if ($left && $nLeft) { + $cOTLdata['group'] = substr($cOTLdata['group'], $nLeft); + if ($cOTLdata['GPOSinfo']) { + $newPOSinfo = []; + foreach ($cOTLdata['GPOSinfo'] as $k => $val) { + if ($k >= $nLeft) { + $newPOSinfo[$k - $nLeft] = $cOTLdata['GPOSinfo'][$k]; + } + } + $cOTLdata['GPOSinfo'] = $newPOSinfo; + } + if (isset($cOTLdata['char_data'])) { + for ($i = 0; $i < $nLeft; $i++) { + array_shift($cOTLdata['char_data']); + } + } + } + } + +} diff --git a/src/Shaper/OtlTags.php b/src/Shaper/OtlTags.php new file mode 100644 index 000000000..56576e8d6 --- /dev/null +++ b/src/Shaper/OtlTags.php @@ -0,0 +1,151 @@ + 'beng', + 'dev2' => 'deva', + 'gjr2' => 'gujr', + 'gur2' => 'guru', + 'knd2' => 'knda', + 'mlm2' => 'mlym', + 'ory2' => 'orya', + 'tml2' => 'taml', + 'tel2' => 'telu', + 'mym2' => 'mymr', + ]; + + /** + * The script tag a run is laid out with, out of what the table offers. + * + * The tag Unicode implies is only a first choice: a font may offer the v2 Indic tag and not the + * original one or the other way round, may offer nothing for the script and still have a default + * entry, and may offer a script mPDF has no shaper for. + * + * @param array $ScriptLang The scripts the table offers, each with its language systems + * @param string $scripttag The tag the text's Unicode script implies + * @param int $scriptblock The text's Unicode script, as Ucdn::SCRIPT_* + * @param string $shaper The shaper picked for it, or '' where there is none + * @param int $useOTL The useOTL mask of the font + * + * @return array The tag, or '' for none, and whether it is an original Indic tag rather than a v2 one + */ + public static function script(array $ScriptLang, $scripttag, $scriptblock, $shaper, $useOTL) + { + if (!($useOTL & self::useOTLbit($scriptblock))) { + return ['', false]; + } + + if (isset($ScriptLang[$scripttag])) { + return [$scripttag, false]; + } + + if ($shaper) { + $reached = false; + foreach (self::$originalIndicTags as $v2 => $original) { + $reached = $reached || $v2 === $scripttag; + if ($reached && isset($ScriptLang[$original])) { + return [$original, true]; + } + } + } + + foreach (['DFLT', 'dflt', 'latn'] as $fallback) { + if (isset($ScriptLang[$fallback])) { + return [$fallback, false]; + } + } + + return ['', false]; + } + + /** + * The language system tag a run is laid out with, out of what the script offers. + * + * @param string $ietf The language of the text as an IETF tag, e.g. 'sr-Cyrl' or 'zh-Hant-HK' + * @param string $available The language systems the script offers, as one string of tags + * + * @return string The tag, 'DFLT' where the script offers that and not the language, or '' for neither + */ + public static function language($ietf, $available) + { + if ($available == '') { + return ''; + } + + $tags = $ietf ? explode('-', $ietf) : []; + + $lang = isset($tags[0]) ? strtolower($tags[0]) : ''; + + // The region, where the second subtag is one; a third subtag always is, the second being + // the script + $country = ''; + if (isset($tags[1]) && strlen($tags[1]) == 2) { + $country = strtolower($tags[1]); + } + if (isset($tags[2]) && $tags[2]) { + $country = strtolower($tags[2]); + } + + if ($lang != '' && isset(Ucdn::$ot_languages[$lang])) { + $langsys = Ucdn::$ot_languages[$lang]; + } elseif ($lang != '' && $country != '' && isset(Ucdn::$ot_languages[$lang . $country])) { + // Never matches: the region keys are written 'zh-hk' (#191) + $langsys = Ucdn::$ot_languages[$lang . $country]; + } else { + $langsys = 'DFLT'; + } + + if (strpos($available, $langsys) !== false) { + return $langsys; + } + + return strpos($available, 'DFLT') !== false ? 'DFLT' : ''; + } + + /** + * The bit of the useOTL config that opens a script to OpenType layout. + * + * @param int $scriptblock A Unicode script, as Ucdn::SCRIPT_* + * + * @return int 0x01 Latin, 0x02 Cyrillic, 0x04 Greek, 0x08 CJK other than Hangul, 0x80 everything else + */ + private static function useOTLbit($scriptblock) + { + if ($scriptblock == Ucdn::SCRIPT_LATIN) { + return 0x01; + } + if ($scriptblock == Ucdn::SCRIPT_CYRILLIC) { + return 0x02; + } + if ($scriptblock == Ucdn::SCRIPT_GREEK) { + return 0x04; + } + if ($scriptblock >= Ucdn::SCRIPT_HIRAGANA && $scriptblock <= Ucdn::SCRIPT_YI) { + return 0x08; + } + + return 0x80; + } + +} diff --git a/src/Tag/BlockTag.php b/src/Tag/BlockTag.php index aad49d605..ece9fff9d 100644 --- a/src/Tag/BlockTag.php +++ b/src/Tag/BlockTag.php @@ -9,6 +9,7 @@ use Mpdf\Conversion\DecToOther; use Mpdf\Conversion\DecToRoman; use Mpdf\Mpdf; +use Mpdf\Shaper\OtlData; use Mpdf\Utils\Arrays; use Mpdf\Utils\UtfString; @@ -1241,7 +1242,7 @@ public function close(&$ahtml, &$ihtml) ); /* -- OTL -- */ if (!empty($this->mpdf->CurrentFont['useOTL'])) { - $this->otl->trimOTLdata($this->mpdf->textbuffer[count($this->mpdf->textbuffer) - 1][18], false); // mPDF 6 ZZZ99K + OtlData::trim($this->mpdf->textbuffer[count($this->mpdf->textbuffer) - 1][18], false); // mPDF 6 ZZZ99K } /* -- END OTL -- */ } diff --git a/src/Tag/Br.php b/src/Tag/Br.php index 125a18a7b..74a182f5f 100644 --- a/src/Tag/Br.php +++ b/src/Tag/Br.php @@ -2,6 +2,8 @@ namespace Mpdf\Tag; +use Mpdf\Shaper\OtlData; + class Br extends Tag { @@ -71,7 +73,7 @@ public function open($attr, &$ahtml, &$ihtml) $this->mpdf->textbuffer[count($this->mpdf->textbuffer) - 1][0] ); if (!empty($this->mpdf->textbuffer[count($this->mpdf->textbuffer) - 1][18])) { - $this->otl->trimOTLdata($this->mpdf->textbuffer[count($this->mpdf->textbuffer) - 1][18], false); + OtlData::trim($this->mpdf->textbuffer[count($this->mpdf->textbuffer) - 1][18], false); } // *OTL* } $this->mpdf->_saveTextBuffer($blockpre . $inlinepre . "\n" . $inlinepost . $blockpost); diff --git a/tests/Mpdf/BidiTest.php b/tests/Mpdf/BidiTest.php index cab175e15..2d1c3fbcb 100644 --- a/tests/Mpdf/BidiTest.php +++ b/tests/Mpdf/BidiTest.php @@ -93,9 +93,27 @@ public function testABracketInARtlRunIsMirrored() } /** - * @return array [the codepoints in display order, the strong-direction bitmask] + * X9: the embedding controls are resolved into levels, then deleted from the text and from its + * OTLdata together, positions counted in characters of the document's encoding. */ - private function sort($unicode, $dir) + public function testPrepareTakesTheEmbeddingControlsOutOfTheTextAndItsRun() + { + $rle = 0x202B; + $pdf = 0x202C; + $para = [$this->chunk([0x41, $rle, self::ALEF, self::BET, $pdf, 0x42])]; + + Bidi::prepare($para, 'ltr', 'UTF-8'); + + $this->assertSame('A' . UtfString::code2utf(self::ALEF) . UtfString::code2utf(self::BET) . 'B', $para[0][0]); + $this->assertSame('CCCC', $para[0][18]['group']); + $this->assertSame([0x41, self::ALEF, self::BET, 0x42], array_column($para[0][18]['char_data'], 'uni')); + $this->assertSame([0, 1, 1, 0], array_column($para[0][18]['char_data'], 'level')); + } + + /** + * @return array A textbuffer chunk of the characters: the text at 0 and its OTLdata at 18 + */ + private function chunk($unicode) { $otlData = ['group' => '', 'GPOSinfo' => [], 'char_data' => []]; $str = ''; @@ -106,7 +124,17 @@ private function sort($unicode, $dir) $str .= UtfString::code2utf($char); } - list($ordered, $strong) = Bidi::sort($unicode, $str, $dir, $otlData, false); + return [0 => $str, 18 => $otlData]; + } + + /** + * @return array [the codepoints in display order, the strong-direction bitmask] + */ + private function sort($unicode, $dir) + { + $chunk = $this->chunk($unicode); + + list($ordered, $strong) = Bidi::sort($unicode, $chunk[0], $dir, $chunk[18], false); return [array_values(unpack('N*', mb_convert_encoding($ordered, 'UTF-32BE', 'UTF-8'))), $strong]; } diff --git a/tests/Mpdf/Fixtures/shaping-trace.php b/tests/Mpdf/Fixtures/shaping-trace.php new file mode 100644 index 000000000..1b82e89ee --- /dev/null +++ b/tests/Mpdf/Fixtures/shaping-trace.php @@ -0,0 +1,27 @@ + + */ + +require __DIR__ . '/../../../vendor/autoload.php'; + +$tempDir = $argv[1]; + +$mpdf = new \Mpdf\Mpdf([ + 'mode' => 'utf-8', + 'tempDir' => $tempDir, + 'fontDir' => [__DIR__ . '/../../data/ttf'], + 'fontdata' => ['joining' => [ + 'R' => 'NotoSansArabic-Joining-Subset.ttf', + 'useOTL' => 0xFF, + ]], + 'default_font' => 'joining', +]); + +$otl = new \Mpdf\Otl($mpdf, new \Mpdf\Fonts\FontCache(new \Mpdf\Cache($tempDir . '/mpdf/ttfontdata'))); +$otl->debugOTL = true; +$otl->applyOTL("\xD8\xA8\xD8\xA8", 0xFF); diff --git a/tests/Mpdf/OtlTest.php b/tests/Mpdf/OtlTest.php index 3a2798f10..8722125ba 100644 --- a/tests/Mpdf/OtlTest.php +++ b/tests/Mpdf/OtlTest.php @@ -3,6 +3,7 @@ namespace Mpdf; use Mpdf\Fonts\FontCache; +use Mpdf\Shaper\OtlData; class OtlTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase { @@ -60,4 +61,49 @@ public function testSliceOfEmptyDataReturnsAnEmptyStructure() $this->assertSame([], $slice['char_data']); } + /** + * The six were public on Otl, which Mpdf exposes as $otl, and stay there as delegates. + */ + public function testTheDeprecatedRunMethodsGiveWhatOtlDataGives() + { + $run = [ + 'group' => 'SCCS', + 'GPOSinfo' => [1 => ['XAdvance' => 10], 3 => ['XAdvance' => 20]], + 'char_data' => [['uni' => 0x20], ['uni' => 0x1F600], ['uni' => 0xAD], ['uni' => 0xA0]], + ]; + $text = " \xf0\x9f\x98\x80\xc2\xad\xc2\xa0"; + $this->mpdf->mb_enc = 'UTF-8'; + + $expected = $run; + $actual = $run; + $this->assertSame(OtlData::split($expected, 2, 3), $this->otl->splitOTLdata($actual, 2, 3)); + $this->assertSame($expected, $actual); + + $this->assertSame(OtlData::slice($run, 1, 2), $this->otl->sliceOTLdata($run, 1, 2)); + + $expected = $run; + $actual = $run; + OtlData::prependChar($expected, ['uni' => 0x2D], 'C'); + $this->otl->prependOTLchar($actual, ['uni' => 0x2D], 'C'); + $this->assertSame($expected, $actual); + + $expected = [$text, $run]; + $actual = [$text, $run]; + OtlData::removeChar($expected[0], $expected[1], "\xc2\xad", 'UTF-8'); + $this->otl->removeChar($actual[0], $actual[1], "\xc2\xad"); + $this->assertSame($expected, $actual); + + $expected = [$text, $run]; + $actual = [$text, $run]; + OtlData::nbspToSpace($expected[0], $expected[1], 'UTF-8'); + $this->otl->replaceSpace($actual[0], $actual[1]); + $this->assertSame($expected, $actual); + + $expected = $run; + $actual = $run; + OtlData::trim($expected, false, true); + $this->otl->trimOTLdata($actual, false, true); + $this->assertSame($expected, $actual); + } + } diff --git a/tests/Mpdf/Shaper/OtlDataTest.php b/tests/Mpdf/Shaper/OtlDataTest.php new file mode 100644 index 000000000..eadf890ba --- /dev/null +++ b/tests/Mpdf/Shaper/OtlDataTest.php @@ -0,0 +1,365 @@ +laidOut([0x41, 0x42, 0x43, 0x44], [1 => 'b', 3 => 'd']); + + $after = OtlData::split($run, 2); + + $this->assertSame('CC', $run['group']); + $this->assertSame([0x41, 0x42], $this->unis($run)); + $this->assertSame([1 => 'b'], $run['GPOSinfo']); + + $this->assertSame('CC', $after['group']); + $this->assertSame([0x43, 0x44], $this->unis($after)); + $this->assertSame([1 => 'd'], $after['GPOSinfo']); + } + + /** + * A break at a space ends the line before the space and starts the next after it, so the space + * belongs to neither. + */ + public function testSplitLeavesOutWhatIsBetweenTheCutAndTheRestart() + { + $run = $this->laidOut([0x41, 0x20, 0x42], [0 => 'a', 1 => 'space', 2 => 'b']); + + $after = OtlData::split($run, 1, 2); + + $this->assertSame('C', $run['group']); + $this->assertSame([0x41], $this->unis($run)); + $this->assertSame([0 => 'a'], $run['GPOSinfo']); + + $this->assertSame('C', $after['group']); + $this->assertSame([0x42], $this->unis($after)); + $this->assertSame([0 => 'b'], $after['GPOSinfo']); + } + + /** + * Mpdf passes the same position for both where nothing is left out, and 0 reads as no restart. + */ + public function testSplitRestartsAtTheCutWhereNoRestartIsGiven() + { + foreach (['', 0] as $restart) { + $run = $this->laidOut([0x41, 0x42, 0x43]); + + $after = OtlData::split($run, 1, $restart); + + $this->assertSame([0x41], $this->unis($run)); + $this->assertSame([0x42, 0x43], $this->unis($after)); + } + } + + public function testSplitAtTheStartLeavesAnEmptyRunBehind() + { + $run = $this->laidOut([0x41, 0x42], [0 => 'a']); + + $after = OtlData::split($run, 0); + + $this->assertSame('', $run['group']); + $this->assertSame([], $run['char_data']); + $this->assertSame([], $run['GPOSinfo']); + + $this->assertSame('CC', $after['group']); + $this->assertSame([0x41, 0x42], $this->unis($after)); + $this->assertSame([0 => 'a'], $after['GPOSinfo']); + } + + public function testSplitAtTheEndReturnsAnEmptyRun() + { + $run = $this->laidOut([0x41, 0x42], [1 => 'b']); + + $after = OtlData::split($run, 2); + + $this->assertSame('CC', $run['group']); + $this->assertSame([1 => 'b'], $run['GPOSinfo']); + + $this->assertSame('', (string) $after['group']); + $this->assertSame([], $after['char_data']); + $this->assertSame([], $after['GPOSinfo']); + } + + public function testSplitPutsThePositioningOfBothPartsInCharacterOrder() + { + $run = $this->laidOut([0x41, 0x42, 0x43, 0x44]); + $run['GPOSinfo'] = [3 => 'd', 1 => 'b', 2 => 'c', 0 => 'a']; + + $after = OtlData::split($run, 2); + + $this->assertSame([0 => 'a', 1 => 'b'], $run['GPOSinfo']); + $this->assertSame([0 => 'c', 1 => 'd'], $after['GPOSinfo']); + } + + public function testSplitOfARunWithoutPositioningLeavesItWithoutAny() + { + $run = $this->laidOut([0x41, 0x42]); + unset($run['GPOSinfo']); + + $after = OtlData::split($run, 1); + + $this->assertArrayNotHasKey('GPOSinfo', $run); + $this->assertSame([], $after['GPOSinfo']); + } + + public function testSliceTakesOnlyThePositioningInsideIt() + { + $run = $this->laidOut([0x41, 0x42, 0x43, 0x44], [0 => 'a', 1 => 'b', 3 => 'd']); + + $slice = OtlData::slice($run, 1, 2); + + $this->assertSame('CC', $slice['group']); + $this->assertSame([0x42, 0x43], $this->unis($slice)); + $this->assertSame([0 => 'b'], $slice['GPOSinfo']); + $this->assertSame($this->laidOut([0x41, 0x42, 0x43, 0x44], [0 => 'a', 1 => 'b', 3 => 'd']), $run, 'the run sliced is not changed'); + } + + public function testSliceOfNothingIsAnEmptyRun() + { + $run = $this->laidOut([0x41, 0x42], [0 => 'a']); + + $atTheEnd = OtlData::slice($run, 2, 1); + $ofNoLength = OtlData::slice($run, 0, 0); + + $this->assertSame('', (string) $atTheEnd['group']); + $this->assertSame([], $atTheEnd['char_data']); + $this->assertSame([], $atTheEnd['GPOSinfo']); + + $this->assertSame('', (string) $ofNoLength['group']); + $this->assertSame([], $ofNoLength['char_data']); + $this->assertSame([], $ofNoLength['GPOSinfo']); + } + + public function testSlicePutsThePositioningInCharacterOrder() + { + $run = $this->laidOut([0x41, 0x42, 0x43]); + $run['GPOSinfo'] = [2 => 'c', 1 => 'b']; + + $this->assertSame([0 => 'b', 1 => 'c'], OtlData::slice($run, 1, 2)['GPOSinfo']); + } + + /** + * applyOTL() leaves OTLdata an empty array for a blank string. See mpdf/mpdf#2158. + */ + public function testSliceOfAnEmptyArrayIsAnEmptyRun() + { + $this->assertSame(['GPOSinfo' => [], 'char_data' => [], 'group' => ''], OtlData::slice([], 0, 0)); + } + + public function testPrependCharMovesTheRestOfTheRunAlongByOne() + { + $run = $this->laidOut([0x42, 0x43], [0 => 'b', 1 => 'c']); + + OtlData::prependChar($run, ['uni' => 0x2D, 'bidi_class' => 12], 'C'); + + $this->assertSame('CCC', $run['group']); + $this->assertSame([0x2D, 0x42, 0x43], $this->unis($run)); + $this->assertSame([1 => 'b', 2 => 'c'], $run['GPOSinfo']); + } + + public function testPrependCharToAnEmptyArrayMakesARunOfOne() + { + $run = []; + + OtlData::prependChar($run, ['uni' => 0x2D], 'C'); + + $this->assertSame(['group' => 'C', 'char_data' => [['uni' => 0x2D]], 'GPOSinfo' => []], $run); + } + + public function testRemoveCharTakesEveryOccurrenceOutOfTheTextAndTheRun() + { + $text = $this->text([0x41, self::SOFT_HYPHEN, 0x42, self::SOFT_HYPHEN, 0x43]); + $run = $this->laidOut([0x41, self::SOFT_HYPHEN, 0x42, self::SOFT_HYPHEN, 0x43], [0 => 'a', 1 => 'shy', 2 => 'b', 4 => 'c']); + + OtlData::removeChar($text, $run, "\xc2\xad", 'UTF-8'); + + $this->assertSame('ABC', $text); + $this->assertSame('CCC', $run['group']); + $this->assertSame([0x41, 0x42, 0x43], $this->unis($run)); + $this->assertSame([0 => 'a', 1 => 'b', 2 => 'c'], $run['GPOSinfo']); + } + + /** + * The text is UTF-8 and the run has one entry per character, so the position found in the text + * has to be counted in characters: counted in bytes, the four-byte emoji ahead of the hyphen + * would put it three entries too far along. + */ + public function testRemoveCharCountsPositionsInCharactersNotBytes() + { + $text = $this->text([self::EMOJI, 0x41, self::SOFT_HYPHEN, 0x42]); + $run = $this->laidOut([self::EMOJI, 0x41, self::SOFT_HYPHEN, 0x42], [0 => 'emoji', 3 => 'b']); + + OtlData::removeChar($text, $run, "\xc2\xad", 'UTF-8'); + + $this->assertSame($this->text([self::EMOJI, 0x41, 0x42]), $text); + $this->assertSame('CCC', $run['group']); + $this->assertSame([self::EMOJI, 0x41, 0x42], $this->unis($run)); + $this->assertSame([0 => 'emoji', 2 => 'b'], $run['GPOSinfo']); + } + + public function testRemoveCharAtEitherEnd() + { + $text = $this->text([self::SOFT_HYPHEN, 0x41, self::SOFT_HYPHEN]); + $run = $this->laidOut([self::SOFT_HYPHEN, 0x41, self::SOFT_HYPHEN], [0 => 'first', 1 => 'a', 2 => 'last']); + + OtlData::removeChar($text, $run, "\xc2\xad", 'UTF-8'); + + $this->assertSame('A', $text); + $this->assertSame('C', $run['group']); + $this->assertSame([0x41], $this->unis($run)); + $this->assertSame([0 => 'a'], $run['GPOSinfo']); + } + + public function testRemoveCharOfACharacterNotInTheTextChangesNothing() + { + $text = 'AB'; + $run = $this->laidOut([0x41, 0x42], [1 => 'b']); + + OtlData::removeChar($text, $run, "\xc2\xad", 'UTF-8'); + + $this->assertSame('AB', $text); + $this->assertSame($this->laidOut([0x41, 0x42], [1 => 'b']), $run); + } + + public function testNbspToSpaceTurnsEachNoBreakSpaceIntoASpaceInTheTextAndTheRun() + { + $text = $this->text([self::EMOJI, self::NBSP, 0x41, self::NBSP]); + $run = $this->laidOut([self::EMOJI, self::NBSP, 0x41, self::NBSP]); + + OtlData::nbspToSpace($text, $run, 'UTF-8'); + + $this->assertSame($this->text([self::EMOJI, 0x20, 0x41, 0x20]), $text); + $this->assertSame([self::EMOJI, 0x20, 0x41, 0x20], $this->unis($run)); + $this->assertSame('CCCC', $run['group'], 'the group is not changed'); + } + + /** + * Shaping may have put something else in the no-break space's place in the run, and that is not + * turned into a space. + */ + public function testNbspToSpaceLeavesACharacterTheRunDoesNotHoldAsANoBreakSpace() + { + $text = $this->text([0x41, self::NBSP]); + $run = $this->laidOut([0x41, 0xE000]); + + OtlData::nbspToSpace($text, $run, 'UTF-8'); + + $this->assertSame('A ', $text); + $this->assertSame([0x41, 0xE000], $this->unis($run)); + } + + public function testTrimDropsSpacesAndIdeographicSpacesFromBothEnds() + { + $run = $this->laidOut([0x20, self::IDEOGRAPHIC_SPACE, 0x41, 0x20, 0x42, self::IDEOGRAPHIC_SPACE, 0x20], [1 => 'lead', 2 => 'a', 4 => 'b', 6 => 'trail']); + + OtlData::trim($run); + + $this->assertSame('CSC', $run['group']); + $this->assertSame([0x41, 0x20, 0x42], $this->unis($run)); + $this->assertSame([0 => 'a', 2 => 'b'], $run['GPOSinfo']); + } + + public function testTrimOfTheStartOnly() + { + $run = $this->laidOut([0x20, 0x41, 0x20], [0 => 'lead', 1 => 'a', 2 => 'trail']); + + OtlData::trim($run, true, false); + + $this->assertSame('CS', $run['group']); + $this->assertSame([0x41, 0x20], $this->unis($run)); + $this->assertSame([0 => 'a', 1 => 'trail'], $run['GPOSinfo']); + } + + public function testTrimOfTheEndOnly() + { + $run = $this->laidOut([0x20, 0x41, 0x20], [0 => 'lead', 1 => 'a', 2 => 'trail']); + + OtlData::trim($run, false, true); + + $this->assertSame('SC', $run['group']); + $this->assertSame([0x20, 0x41], $this->unis($run)); + $this->assertSame([0 => 'lead', 1 => 'a'], $run['GPOSinfo']); + } + + public function testTrimOfARunWithNoSpaceAtEitherEndChangesNothing() + { + $run = $this->laidOut([0x41, 0x20, 0x42], [1 => 'space']); + + OtlData::trim($run); + + $this->assertSame($this->laidOut([0x41, 0x20, 0x42], [1 => 'space']), $run); + } + + public function testTrimOfARunOfSpacesLeavesNothing() + { + $run = $this->laidOut([0x20, self::IDEOGRAPHIC_SPACE, 0x20], [1 => 'space']); + + OtlData::trim($run); + + $this->assertSame('', (string) $run['group']); + $this->assertSame([], $run['char_data']); + $this->assertSame([], $run['GPOSinfo']); + } + + /** + * A chunk that was never laid out carries no run at all. + */ + public function testTrimOfSomethingThatIsNotARunChangesNothing() + { + $run = null; + OtlData::trim($run); + $this->assertNull($run); + + $run = ['group' => '', 'char_data' => null, 'GPOSinfo' => []]; + OtlData::trim($run); + $this->assertSame(['group' => '', 'char_data' => null, 'GPOSinfo' => []], $run); + } + + /** + * @param int[] $unicode + * @param array $GPOSinfo + * + * @return array A run of the characters, with spaces grouped S and everything else C + */ + private function laidOut($unicode, $GPOSinfo = []) + { + $run = ['group' => '', 'char_data' => [], 'GPOSinfo' => $GPOSinfo]; + foreach ($unicode as $char) { + $run['group'] .= ($char == 0x20 || $char == self::IDEOGRAPHIC_SPACE) ? 'S' : 'C'; + $run['char_data'][] = ['bidi_class' => 0, 'uni' => $char]; + } + + return $run; + } + + private function unis($run) + { + return array_column($run['char_data'], 'uni'); + } + + private function text($unicode) + { + return implode('', array_map([UtfString::class, 'code2utf'], $unicode)); + } + +} diff --git a/tests/Mpdf/Shaper/OtlTagsTest.php b/tests/Mpdf/Shaper/OtlTagsTest.php new file mode 100644 index 000000000..eefdb4f24 --- /dev/null +++ b/tests/Mpdf/Shaper/OtlTagsTest.php @@ -0,0 +1,118 @@ +assertSame(['arab', false], OtlTags::script(['arab' => 'DFLT', 'DFLT' => 'DFLT'], 'arab', Ucdn::SCRIPT_ARABIC, 'A', 0xFF)); + } + + /** + * @dataProvider scriptGroups + */ + public function testAScriptIsLaidOutOnlyWhereUseOTLOpensItsGroup($scriptblock, $bit) + { + $offered = ['latn' => 'DFLT']; + + $this->assertSame(['latn', false], OtlTags::script($offered, 'latn', $scriptblock, '', $bit)); + $this->assertSame(['', false], OtlTags::script($offered, 'latn', $scriptblock, '', 0xFF & ~$bit)); + } + + public function scriptGroups() + { + return [ + 'Latin' => [Ucdn::SCRIPT_LATIN, 0x01], + 'Cyrillic' => [Ucdn::SCRIPT_CYRILLIC, 0x02], + 'Greek' => [Ucdn::SCRIPT_GREEK, 0x04], + 'Hiragana, the first CJK script' => [Ucdn::SCRIPT_HIRAGANA, 0x08], + 'Han' => [Ucdn::SCRIPT_HAN, 0x08], + 'Yi, the last CJK script' => [Ucdn::SCRIPT_YI, 0x08], + 'Hangul, which is not in the CJK group' => [Ucdn::SCRIPT_HANGUL, 0x80], + 'Arabic' => [Ucdn::SCRIPT_ARABIC, 0x80], + 'Devanagari' => [Ucdn::SCRIPT_DEVANAGARI, 0x80], + ]; + } + + public function testAFontWithOnlyTheOriginalIndicTagIsLaidOutToTheOriginalSpecification() + { + $this->assertSame(['deva', true], OtlTags::script(['deva' => 'DFLT', 'DFLT' => 'DFLT'], 'dev2', Ucdn::SCRIPT_DEVANAGARI, 'I', 0xFF)); + $this->assertSame(['mymr', true], OtlTags::script(['mymr' => 'DFLT'], 'mym2', Ucdn::SCRIPT_MYANMAR, 'M', 0xFF)); + } + + public function testTheV2TagIsPreferredOverTheOriginal() + { + $this->assertSame(['dev2', false], OtlTags::script(['deva' => 'DFLT', 'dev2' => 'DFLT'], 'dev2', Ucdn::SCRIPT_DEVANAGARI, 'I', 0xFF)); + } + + public function testTheOriginalIndicTagIsNotTriedWithoutAShaper() + { + $this->assertSame(['DFLT', false], OtlTags::script(['deva' => 'DFLT', 'DFLT' => 'DFLT'], 'dev2', Ucdn::SCRIPT_DEVANAGARI, '', 0xFF)); + } + + /** + * Pins #192 as it stands: a v2 tag reaches the original tags listed after its own, but never + * those before it. + */ + public function testAV2TagFallsThroughToTheOriginalTagsListedAfterItsOwn() + { + $this->assertSame(['deva', true], OtlTags::script(['deva' => 'DFLT', 'DFLT' => 'DFLT'], 'bng2', Ucdn::SCRIPT_BENGALI, 'I', 0xFF)); + $this->assertSame(['mymr', true], OtlTags::script(['mymr' => 'DFLT'], 'tml2', Ucdn::SCRIPT_TAMIL, 'I', 0xFF)); + $this->assertSame(['DFLT', false], OtlTags::script(['beng' => 'DFLT', 'DFLT' => 'DFLT'], 'dev2', Ucdn::SCRIPT_DEVANAGARI, 'I', 0xFF)); + } + + public function testATagThatIsNotAV2IndicTagHasNoOriginalToFallBackTo() + { + $this->assertSame(['', false], OtlTags::script(['deva' => 'DFLT'], 'arab', Ucdn::SCRIPT_ARABIC, 'A', 0xFF)); + } + + /** + * @dataProvider fallbacks + */ + public function testAScriptTheFontDoesNotOfferFallsBackToDFLTThenDfltThenLatin(array $offered, $expected) + { + $this->assertSame([$expected, false], OtlTags::script($offered, 'arab', Ucdn::SCRIPT_ARABIC, 'A', 0xFF)); + } + + public function fallbacks() + { + return [ + 'DFLT before dflt' => [['latn' => 'DFLT', 'dflt' => 'DFLT', 'DFLT' => 'DFLT'], 'DFLT'], + 'dflt before latn' => [['latn' => 'DFLT', 'dflt' => 'DFLT'], 'dflt'], + 'latn last' => [['cyrl' => 'DFLT', 'latn' => 'DFLT'], 'latn'], + 'nothing' => [['cyrl' => 'DFLT'], ''], + 'no scripts at all' => [[], ''], + ]; + } + + /** + * @dataProvider languages + */ + public function testTheLanguageSystemComesFromTheLanguageSubtag($ietf, $available, $expected) + { + $this->assertSame($expected, OtlTags::language($ietf, $available)); + } + + public function languages() + { + return [ + 'a language alone' => ['tr', 'DFLT AZE TRK ', 'TRK '], + 'a language with its region' => ['sr-RS', 'DFLT SRB ', 'SRB '], + 'a language with its script' => ['sr-Cyrl', 'SRB ', 'SRB '], + 'a language with its script and region' => ['sr-Cyrl-RS', 'SRB ', 'SRB '], + 'upper case' => ['TR', 'TRK ', 'TRK '], + 'a language the script does not offer' => ['tr', 'DFLT SRB ', 'DFLT'], + 'a language the script does not offer, and no default' => ['tr', 'SRB ', ''], + 'a language with no OpenType tag' => ['xx', 'DFLT SRB ', 'DFLT'], + 'a language with no OpenType tag, and no default' => ['xx', 'SRB ', ''], + 'no language' => ['', 'DFLT TRK ', 'DFLT'], + 'no language, and no default' => [null, 'TRK ', ''], + 'a script offering no language systems' => ['tr', '', ''], + ]; + } + +} diff --git a/tests/Mpdf/ShapingTraceTest.php b/tests/Mpdf/ShapingTraceTest.php new file mode 100644 index 000000000..47199e3a5 --- /dev/null +++ b/tests/Mpdf/ShapingTraceTest.php @@ -0,0 +1,94 @@ + '00627', 'uni' => 0x627], + ['hex' => '00644', 'uni' => 0x644, 'GPOSinfo' => ['XAdvance' => -120, 'YPlacement' => 30]], + ['hex' => '0064E', 'uni' => 0x64E, 'GPOSinfo' => []], + ]; + + public function testAStepShowsTheLookupAndTheRunWithTheGlyphItAppliedAtInBold() + { + $this->assertSame( + '
GSUB LookupID #3 Subtable#0 Type: 4 Format: 1
' + . '
Glyph position: 1 Current Glyph: 00644
' + . '00627 00644 0064E
' + . '1575 1604 1614
' + . '
', + OtlDump::shapingStep($this->run, 'GSUB', 3, 0, 4, 1, 1, '00644', 0) + ); + } + + public function testAGPOSStepAlsoShowsThePositioningOfEveryGlyphThatHasAny() + { + $this->assertSame( + '
GPOS LookupID #12 Subtable#2 Type: 6 Format: 1
' + . '
Glyph position: 2 Current Glyph: 0064E
' + . '00627 00644 0064E
' + . '1575 1604 1614
' + . "00644 ل Array\n(\n [XAdvance] => -120\n [YPlacement] => 30\n)\n " + . '
', + OtlDump::shapingStep($this->run, 'GPOS', 12, 2, 6, 1, 2, '0064E', 1) + ); + } + + public function testTheStartOfARunHasNoGlyphInBold() + { + $this->assertSame( + '
BEGIN LookupID #- Subtable#- Type: - Format: -
' + . '
Glyph position: -1 Current Glyph: -
' + . '00627 00644 0064E
' + . '1575 1604 1614
' + . '
', + OtlDump::shapingStep($this->run, 'BEGIN', '-', '-', '-', '-', -1, '-', 0) + ); + } + + public function testAnEmptyRunShowsTheLookupAlone() + { + $this->assertSame( + '
GSUB LookupID #0 Subtable#0 Type: 1 Format: 1
' + . '
Glyph position: 0 Current Glyph: -
' + . '

' + . '
', + OtlDump::shapingStep([], 'GSUB', 0, 0, 1, 1, 0, '-', 0) + ); + } + + /** + * The trace ends the process once the run is shaped, so it is run in one of its own. + */ + public function testTheShaperTracesTheRunFromBeginningToEnd() + { + $tempDir = sys_get_temp_dir() . '/mpdf-shaping-trace-' . uniqid('', true); + + $command = escapeshellarg(PHP_BINARY) . ' -d display_errors=stderr ' + . escapeshellarg(__DIR__ . '/Fixtures/shaping-trace.php') . ' ' . escapeshellarg($tempDir); + + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]); + $trace = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $this->assertSame(0, proc_close($process), $errors); + + $steps = preg_split('/(?=