From d20c3c17edbd7660d13283e45d2117c66f21ef10 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 09:46:59 +1000 Subject: [PATCH 1/2] Stop an Arabic form's context walk at the edge of the run (#204) Shaper\Arabic::glyphs() matches a joining form's backtrack (prel) and lookahead (postl) by walking over the glyphs the lookup ignores, and nothing stopped that walk at the edge of the run. Where a word starts or ends in ignored glyphs, $chars[...] past the edge is null. On PHP 7 that was a notice per glyph read before the loop gave up; from PHP 8 GlyphString::inList() searches the ignore pattern for "", finds it, and the loop never returns. A word ending in BEH FATHA, where beh's final or isolated form has a lookahead, was enough. Both walks now test isset() before reading the ignore pattern. The check after the walk already refused a position the run does not hold, which is what HarfBuzz's match_backtrack()/match_lookahead() do when skippy_iter runs out of glyphs, so no other result changes. NotoSansArabic-ContextEdge-Synthetic is NotoSansArabic-Joining-Subset (Noto Sans Arabic 2.012, OFL 1.1) rebuilt in fontTools 4.59.2: a FATHA (uni064E, dotbelowar's outline, GDEF mark class, U+064E) is added; GSUB is replaced by 'init' and 'fina' Chaining Context Substitutions (Type 6 Format 3, IgnoreMarks) - beh takes uni066E.init after uni08AD and uni066E.fina before it - and a single 'medi'; 'ccmp' is dropped, since the shaper resolves joining after it and a beh it takes apart joins nothing (#209); name IDs 1, 4 and 6 are renamed. hb-shape 14.3.1 draws what ArabicContextEdgeTest expects for BEH BEH FATHA, FATHA BEH BEH, and the same with LOW ALEF past the mark. Regenerating fontcache, otldump, shaping and subset from cold adds the new font's four fixtures and moves no existing one. Co-Authored-By: Claude Opus 5 (1M context) --- src/Shaper/Arabic.php | 8 +- tests/Mpdf/ArabicContextEdgeTest.php | 90 +++++ tests/Mpdf/Fixtures/arabic-shape.php | 24 ++ tests/Mpdf/Shaper/ArabicTest.php | 87 +++++ .../NotoSansArabic-ContextEdge-Synthetic.json | 319 ++++++++++++++++++ .../NotoSansArabic-ContextEdge-Synthetic.txt | 27 ++ .../NotoSansArabic-ContextEdge-Synthetic.txt | 96 ++++++ .../NotoSansArabic-ContextEdge-Synthetic.json | 129 +++++++ .../NotoSansArabic-ContextEdge-Synthetic.ttf | Bin 0 -> 2632 bytes 9 files changed, 778 insertions(+), 2 deletions(-) create mode 100644 tests/Mpdf/ArabicContextEdgeTest.php create mode 100644 tests/Mpdf/Fixtures/arabic-shape.php create mode 100644 tests/data/fontcache/NotoSansArabic-ContextEdge-Synthetic.json create mode 100644 tests/data/otldump/NotoSansArabic-ContextEdge-Synthetic.txt create mode 100644 tests/data/shaping/NotoSansArabic-ContextEdge-Synthetic.txt create mode 100644 tests/data/subset/NotoSansArabic-ContextEdge-Synthetic.json create mode 100644 tests/data/ttf/NotoSansArabic-ContextEdge-Synthetic.ttf diff --git a/src/Shaper/Arabic.php b/src/Shaper/Arabic.php index dad7134af..824a79085 100644 --- a/src/Shaper/Arabic.php +++ b/src/Shaper/Arabic.php @@ -302,13 +302,17 @@ private static function glyphs($char, $type, &$chars, $i, $scriptTag, $usetags, if ($retk != -1) { $match = true; // If GSUB includes a Backtrack or Lookahead condition (e.g. font ArabicTypesetting) + // Walking over the glyphs the lookup ignores stops at the edge of the run, and a position the + // walk runs out of glyphs before reaching is one the context does not hold, as in HarfBuzz's + // match_backtrack() and match_lookahead(). Past the edge the glyph read is null, which from + // PHP 8 inList() finds in any ignore pattern, so without the isset() the walk never ends. if (isset($arabGlyphs[$char]['prel'][$retk]) && $arabGlyphs[$char]['prel'][$retk]) { $ig = 1; foreach ($arabGlyphs[$char]['prel'][$retk] as $k => $v) { // $k starts 0, 1... if (!isset($chars[$i - $ig - $k])) { $match = false; } elseif (!GlyphString::inList($v, $chars[$i - $ig - $k])) { - while (GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k])) { // ignore + while (isset($chars[$i - $ig - $k]) && GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k])) { $ig++; } if (!isset($chars[$i - $ig - $k])) { @@ -325,7 +329,7 @@ private static function glyphs($char, $type, &$chars, $i, $scriptTag, $usetags, if (!isset($chars[$i + $ig + $k])) { $match = false; } elseif (!GlyphString::inList($v, $chars[$i + $ig + $k])) { - while (GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i + $ig + $k])) { // ignore + while (isset($chars[$i + $ig + $k]) && GlyphString::inList($arabGlyphs[$char]['ignore'][$retk], $chars[$i + $ig + $k])) { $ig++; } if (!isset($chars[$i + $ig + $k])) { diff --git a/tests/Mpdf/ArabicContextEdgeTest.php b/tests/Mpdf/ArabicContextEdgeTest.php new file mode 100644 index 000000000..07c7c5417 --- /dev/null +++ b/tests/Mpdf/ArabicContextEdgeTest.php @@ -0,0 +1,90 @@ + 'utf-8', + 'fontDir' => [__DIR__ . '/../data/ttf'], + 'fontdata' => ['notosansarabiccontextedgesynthetic' => [ + 'R' => 'NotoSansArabic-ContextEdge-Synthetic.ttf', + 'useOTL' => 0xFF, + ]], + 'default_font' => 'notosansarabiccontextedgesynthetic', + ]); + $mpdf->WriteHTML('

' . $html . '

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

GDEF table

+

Glyph classes

+

Glyph class 1

+
Base glyph (single character, spacing glyph)
+
ا ࢭ     
+

Glyph class 3

+
Mark glyph (non-spacing combining glyph)
+
◌َ ◌
+

Mark Glyph Sets

+

Mark Glyph Set class: 0

+
◌
+

GSUB Tables

+

GSUB Scripts & Languages

+
+
DFLT
DFLT: init medi fina
arab
DFLT: init medi fina
cyrl
DFLT: init medi fina
dev2
DFLT: init medi fina
grek
DFLT: init medi fina
latn
DFLT: init medi fina
+
+

GPOS Tables

+

GPOS Scripts & Languages

+
+
DFLT
DFLT: mark mkmk
arab
DFLT: mark mkmk
cyrl
DFLT: mark mkmk
dev2
DFLT: mark mkmk
grek
DFLT: mark mkmk
latn
DFLT: mark mkmk
+
+ +=== detail: script latn language DFLT === +

GSUB Tables

+
Lookup #0 [tag: init]
Ignoring: Mark Glyphs
Subtable #0
LookupType 6: Chaining Contextual Substitution Subtable
Format 3: Coverage-based Chaining Context Glyph Substitution
CONTEXT:
Backtrack #0: U+08AD
Input #0:  ب 
Substitution Position: 0
Lookup #3 [tag: init]
Subtable #0
LookupType 1: Single Substitution Subtable
U+0628   ب  » »     M+E004
Lookup #1 [tag: medi]
Subtable #0
LookupType 1: Single Substitution Subtable
U+0628   ب  » »     M+E003
Lookup #2 [tag: fina]
Ignoring: Mark Glyphs
Subtable #0
LookupType 6: Chaining Contextual Substitution Subtable
Format 3: Coverage-based Chaining Context Glyph Substitution
CONTEXT:
Input #0:  ب 
Lookahead #0: U+08AD
Substitution Position: 0
Lookup #4 [tag: fina]
Subtable #0
LookupType 1: Single Substitution Subtable
U+0628   ب  » »     M+E002
+

GPOS Tables

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