Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 80 additions & 21 deletions src/Otl.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,20 +206,22 @@ function applyOTL($str, $useOTL)
list($GSUBscriptTag, $GSUBlangsys, $GPOSscriptTag, $GPOSlangsys, $is_old_spec)
= $this->selectScriptAndLanguage($scriptblock, $useOTL);

if (!$GSUBscriptTag && !$GSUBlangsys && !$GPOSscriptTag && !$GPOSlangsys) {
$this->shaper = $this->shaperForScriptTag($this->shaper, $scriptblock, $GSUBscriptTag);

// A run the font offers no script for still goes to its shaper, which reorders it with
// nothing to substitute. Every script with a shaper is one useOTL opens with 0x80, and a
// document that has not opened it gets nothing.
$shapeWithoutTables = $this->shaper && ($useOTL & 0x80);

if (!$GSUBscriptTag && !$GSUBlangsys && !$GPOSscriptTag && !$GPOSlangsys && !$shapeWithoutTables) {
$this->removeJoinControls(false);
$this->schOTLdata[$sch] = $this->OTLdata;
$this->OTLdata = [];
continue;
}

// Don't use MYANMAR shaper unless using v2 scripttag
if ($this->shaper == 'M' && $GSUBscriptTag != 'mym2') {
$this->shaper = '';
}

$GSUBFeatures = (isset($this->mpdf->CurrentFont['GSUBFeatures'][$GSUBscriptTag][$GSUBlangsys]) ? $this->mpdf->CurrentFont['GSUBFeatures'][$GSUBscriptTag][$GSUBlangsys] : false);
$GPOSFeatures = (isset($this->mpdf->CurrentFont['GPOSFeatures'][$GPOSscriptTag][$GPOSlangsys]) ? $this->mpdf->CurrentFont['GPOSFeatures'][$GPOSscriptTag][$GPOSlangsys] : false);
$GSUBFeatures = $this->features('GSUB', $GSUBscriptTag, $GSUBlangsys);
$GPOSFeatures = $this->features('GPOS', $GPOSscriptTag, $GPOSlangsys);

$this->assocLigs = []; // Ligatures[$posarr lpos] => nc
$this->assocMarks = []; // assocMarks[$posarr mpos] => array(compID, ligPos)
Expand Down Expand Up @@ -475,11 +477,17 @@ private function insertWordBoundaries($scriptblock)
*/
private function applyGSUB($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $scriptblock, $is_old_spec)
{
if (!$GSUBscriptTag || !$GSUBlangsys || !$GSUBFeatures) {
if (!$GSUBFeatures && !$this->shaper) {
return '';
}

$this->loadGsubData($GSUBscriptTag, $GSUBlangsys);
$this->loadGsubDerivedData($GSUBscriptTag, $GSUBlangsys);

// A run with nothing to substitute still goes to its shaper, and its font need not have a
// GSUB table at all
if ($GSUBFeatures) {
$this->loadGsubLookups();
}

// 5. GSUB - Shaper
if ($this->shaper == 'A') {
Expand Down Expand Up @@ -508,16 +516,14 @@ private function applyGSUB($GSUBscriptTag, $GSUBlangsys, $GSUBFeatures, $scriptb
}

/**
* Phase 4: what this font's GSUB table says, for this script and language system.
* What this font's GSUB table says, for this script and language system.
*
* Three things, cached for the life of the document because a document sets the same font for
* line after line: the derived tables the shapers work from, which the parser builds per script
* and language; the coverage of every lookup, which is how a lookup is passed over without being
* read; and the lookup list itself.
* Cached for the life of the document because a document sets the same font for line after line:
* the derived tables the shapers work from, which the parser builds per script and language, and
* which are empty where it built none.
*/
private function loadGsubData($GSUBscriptTag, $GSUBlangsys)
private function loadGsubDerivedData($GSUBscriptTag, $GSUBlangsys)
{
$this->readTable('GSUB');
$this->GSUBfont = $this->fontkey . '.GSUB.' . $GSUBscriptTag . '.' . $GSUBlangsys;

if (!isset($this->GSUBdata[$this->GSUBfont])) {
Expand All @@ -540,6 +546,15 @@ private function loadGsubData($GSUBscriptTag, $GSUBlangsys)
];
}
}
}

/**
* The coverage of every lookup, which is how a lookup is passed over without being read, and the
* lookup list itself.
*/
private function loadGsubLookups()
{
$this->readTable('GSUB');

$fontCacheFilename = $this->fontkey . '.GSUBdata.json';
if (!isset($this->GSUBdata[$this->fontkey]) && $this->fontCache->jsonHas($fontCacheFilename)) {
Expand Down Expand Up @@ -1330,6 +1345,50 @@ private function selectShaper($scriptblock)
}
}

/**
* Which shaper a run gets once the font's GSUB script for it is known.
*
* As HarfBuzz's hb_ot_shaper_categorize(): a font designed for DFLT, or one where the choice fell
* through to latn, is laid out by its features alone, and no script at all is not a reason to
* skip the shaper. Khmer, Thai and Lao keep theirs whatever was chosen, Arabic too, and Syriac
* everywhere but under DFLT. Myanmar also gives the pre-specification mymr to the default shaper.
*
* @param string $shaper The shaper selectShaper() picked for the run's script
* @param int $scriptblock The run's Unicode script, as Ucdn::SCRIPT_*
* @param string $GSUBscriptTag The GSUB script chosen for it, or '' for none
*
* @return string The shaper, as selectShaper() names it
*/
private function shaperForScriptTag($shaper, $scriptblock, $GSUBscriptTag)
{
if ($shaper == 'K' || $shaper == 'T' || $shaper == 'L' || $scriptblock == Ucdn::SCRIPT_ARABIC) {
return $shaper;
}

if ($scriptblock == Ucdn::SCRIPT_SYRIAC) {
return $GSUBscriptTag == 'DFLT' ? '' : $shaper;
}

if ($GSUBscriptTag == 'DFLT' || $GSUBscriptTag == 'latn' || ($shaper == 'M' && $GSUBscriptTag == 'mymr')) {
return '';
}

return $shaper;
}

/**
* @param string $table 'GSUB' or 'GPOS'
*
* @return array The features the table offers under a script and language system, by tag, or
* none where it offers neither
*/
private function features($table, $scriptTag, $langsys)
{
return isset($this->mpdf->CurrentFont[$table . 'Features'][$scriptTag][$langsys])
? $this->mpdf->CurrentFont[$table . 'Features'][$scriptTag][$langsys]
: [];
}

/**
* Phase 3: which script and language system of the font to lay this run out with.
*
Expand Down Expand Up @@ -1531,7 +1590,7 @@ function _applyGSUBrules($usetags, $scriptTag, $langsys)
// - Implemented in functions checkContextMatch and checkContextMatchMultiple by failing to match if outside scope of current 'syllable'
// if $this->restrictToSyllable is true

$GSUBFeatures = $this->mpdf->CurrentFont['GSUBFeatures'][$scriptTag][$langsys];
$GSUBFeatures = $this->features('GSUB', $scriptTag, $langsys);
$LookupList = [];
foreach ($GSUBFeatures as $tag => $arr) {
if (strpos($usetags, $tag) !== false) {
Expand Down Expand Up @@ -1592,7 +1651,7 @@ function _applyGSUBrulesSingly($usetags, $scriptTag, $langsys)
{
// Features are applied one at a time, working through each codepoint

$GSUBFeatures = $this->mpdf->CurrentFont['GSUBFeatures'][$scriptTag][$langsys];
$GSUBFeatures = $this->features('GSUB', $scriptTag, $langsys);

// A reverse Lookup runs the other way down the glyphs, so it cannot share the cursor the rest
// of the list walks forward. Taking each over the whole run up front costs nothing here: this
Expand Down Expand Up @@ -1680,7 +1739,7 @@ function _applyGSUBrulesMyanmar($usetags, $scriptTag, $langsys)
// $usetags = locl ccmp rphf pref blwf pstf';
// applied to all characters

$GSUBFeatures = $this->mpdf->CurrentFont['GSUBFeatures'][$scriptTag][$langsys];
$GSUBFeatures = $this->features('GSUB', $scriptTag, $langsys);

// ALL should be applied one syllable at a time
// Implemented in functions checkContextMatch and checkContextMatchMultiple by failing to match if outside scope of current 'syllable'
Expand Down Expand Up @@ -1753,7 +1812,7 @@ function _applyGSUBrulesIndic($usetags, $scriptTag, $langsys, $is_old_spec)
// rphf, pref, blwf, half, abvf, pstf, and init are only applied where ['mask'] indicates: Indic::FLAG(Indic::RPHF);
// The rest are applied to all characters

$GSUBFeatures = $this->mpdf->CurrentFont['GSUBFeatures'][$scriptTag][$langsys];
$GSUBFeatures = $this->features('GSUB', $scriptTag, $langsys);

// ALL should be applied one syllable at a time
// Implemented in functions checkContextMatch and checkContextMatchMultiple by failing to match if outside scope of current 'syllable'
Expand Down
36 changes: 20 additions & 16 deletions tests/Mpdf/Shaper/SeaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
* pass makes glyphs the categorising pass never saw, and Otl handed them on without a
* ['sea_category'] of their own - which the reorderer reads on the last glyph of a broken cluster.
*
* Lanna Alif is the font throughout. Its 'ccmp' ligates a Sakot with the consonant after it into a
* subscript form, and the glyph that comes out has no codepoint of its own, so it is mapped into the
* Private Use Area as the subset is built.
* The shaper is only reached under a Tai Tham script tag, and no Tai Tham font in reach offers one:
* Lanna Alif has latn, and Noto Sans Tai Tham DFLT and latn, under which HarfBuzz and mPDF both use
* the default shaper. NotoSansTaiTham-LanaScript-Synthetic is a subset of Noto Sans Tai Tham 2.002
* (OFL 1.1) with its script retagged lana and its Sakot ligatures moved from 'liga' to 'ccmp'. The
* ligature has no codepoint of its own, so it is mapped into the Private Use Area as the subset is
* built. `hb-shape` draws what each test below expects.
*
* What the read turns on is that the last glyph of the cluster came out of that pass, not how long
* the cluster is. Lanna Alif carries U+25CC, so a dotted circle is inserted ahead of the ligature
* and the cluster is two elements; Noto Sans Tai Tham carries none, and the same cluster is one.
* Both raised the warning.
* the cluster is. The font has no dotted circle to put in front of the ligature, so the cluster is
* one glyph; Lanna Alif, which has one, warned on a cluster of two.
*/
class SeaTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase
{
Expand All @@ -28,11 +30,8 @@ class SeaTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase
/** U+1A60 TAI THAM SIGN SAKOT, which subscripts the consonant after it */
const SAKOT = 0x1A60;

/** U+25CC DOTTED CIRCLE, inserted in front of a cluster with no base consonant */
const DOTTED_CIRCLE = 0x25CC;

/** The subscript High Ka that Lanna Alif's 'ccmp' substitutes for Sakot + High Ka */
const SAKOT_HIGH_KA = 0xF001;
/** The subscript High Ka that the font's 'ccmp' substitutes for Sakot + High Ka */
const SAKOT_HIGH_KA = 0xE002;

/**
* A Sakot with no consonant in front of it is a broken cluster, and the reorderer reads the
Expand All @@ -57,13 +56,12 @@ public function testAGlyphSubstitutedBeforeTheReorderingCarriesACategoryIntoIt()
restore_error_handler();

$this->assertSame([], $raised);
$this->assertSame([self::DOTTED_CIRCLE, self::SAKOT_HIGH_KA], $drawn);
$this->assertSame([self::SAKOT_HIGH_KA], $drawn);
}

/**
* The same Sakot inside a well-formed cluster, which is what the rule is for: the consonant in
* front of it is the base, no dotted circle is needed, and the pair after it is subscripted under
* it.
* front of it is the base, and the pair after it is subscripted under it.
*/
public function testASakotInsideAClusterSubscriptsTheConsonantAfterIt()
{
Expand Down Expand Up @@ -94,8 +92,14 @@ private function drawn($codepoints)
$html .= sprintf('&#x%04X;', $codepoint);
}

$mpdf = new TextRecordingMpdf();
$mpdf->WriteHTML('<p style="font-family:lannaalif">' . $html . '</p>');
$mpdf = new TextRecordingMpdf([
'fontDir' => [__DIR__ . '/../../data/ttf'],
'fontdata' => ['notosanstaithamlanascriptsynthetic' => [
'R' => 'NotoSansTaiTham-LanaScript-Synthetic.ttf',
'useOTL' => 0xFF,
]],
]);
$mpdf->WriteHTML('<p style="font-family:notosanstaithamlanascriptsynthetic">' . $html . '</p>');

return array_values(unpack('N*', mb_convert_encoding($mpdf->drawnText[0], 'UTF-32BE', 'UTF-8')));
}
Expand Down
95 changes: 95 additions & 0 deletions tests/Mpdf/ShaperChoiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Mpdf;

/**
* Whether a run goes to the shaper its script needs is decided on the GSUB script chosen for it, as
* HarfBuzz's hb_ot_shaper_categorize() decides: a font designed for DFLT, or one where the choice fell
* through to latn, is laid out by its features alone, and a font with no script for the run still
* has it shaped.
*
* Both Bengali fonts are subsets of Noto Sans Bengali 3.011 (OFL 1.1) with their GSUB replaced, each
* with one 'locl' lookup giving KA another glyph. NotoSansBengali-DevaScript-Synthetic offers DFLT,
* whose lookup gives KA the glyph of GA, and deva; NotoSansBengali-GuruScript-Synthetic offers only
* guru, whose lookup a Bengali run never reaches. `hb-shape` 14.3.1 draws what each test expects.
*/
class ShaperChoiceTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase
{

const KA = 0x0995;

const GA = 0x0997;

const SSA = 0x09B7;

/** U+09BF BENGALI VOWEL SIGN I, written after the consonant and drawn before it */
const I = 0x09BF;

const VIRAMA = 0x09CD;

public function testAnIndicRunUnderTheDefaultScriptIsLeftInTheOrderItWasWritten()
{
$this->assertSame([self::GA, self::I], $this->drawn('NotoSansBengali-DevaScript-Synthetic', [self::KA, self::I]));
}

public function testAnIndicRunTheFontOffersNoScriptForIsReorderedWithNothingSubstituted()
{
$this->assertSame([self::I, self::KA], $this->drawn('NotoSansBengali-GuruScript-Synthetic', [self::KA, self::I]));
}

/**
* The original specification puts the matra before the base consonant, SSA, and after the KA and
* virama in front of it. The v2 specification would put it before the whole conjunct.
*/
public function testAnIndicRunWithNoScriptIsReorderedToTheOriginalSpecification()
{
$this->assertSame(
[self::KA, self::VIRAMA, self::I, self::SSA],
$this->drawn('NotoSansBengali-GuruScript-Synthetic', [self::KA, self::VIRAMA, self::SSA, self::I])
);
}

public function testARunInAScriptTheDocumentHasNotOpenedToOpenTypeLayoutIsNotShaped()
{
$this->assertSame([self::KA, self::I], $this->drawn('NotoSansBengali-GuruScript-Synthetic', [self::KA, self::I], 0x01));
}

/**
* Lanna Alif offers only latn. Its 'ccmp' ligates a Sakot with the High Ka after it, and with no
* South East Asian shaper the broken cluster gets no dotted circle in front of it.
*/
public function testATaiThamRunUnderLatnIsLaidOutByTheFontsFeaturesAlone()
{
$mpdf = new TextRecordingMpdf();
$mpdf->WriteHTML('<p style="font-family:lannaalif">&#x1A60;&#x1A20;</p>');

// The ligature has no codepoint of its own, and is mapped into the Private Use Area
$this->assertSame([0xF001], $this->codepoints($mpdf->drawnText[0]));
}

private function drawn($font, array $codepoints, $useOTL = 0xFF)
{
$key = strtolower(str_replace('-', '', $font));

$mpdf = new TextRecordingMpdf([
'mode' => 'utf-8',
'fontDir' => [__DIR__ . '/../data/ttf'],
'fontdata' => [$key => ['R' => $font . '.ttf', 'useOTL' => $useOTL]],
'default_font' => $key,
]);

$html = '';
foreach ($codepoints as $codepoint) {
$html .= sprintf('&#x%04X;', $codepoint);
}
$mpdf->WriteHTML('<p>' . $html . '</p>');

return $this->codepoints($mpdf->drawnText[0]);
}

private function codepoints($text)
{
return array_values(unpack('N*', mb_convert_encoding($text, 'UTF-32BE', 'UTF-8')));
}

}
Loading
Loading