From f42b6b675343a376f9584c8298a2a1b4ad23e773 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Wed, 16 Sep 2026 14:34:00 +1000 Subject: [PATCH] Join a letter to the base before it however many marks are between them shape() found the character behind each position through a nested if that stepped over at most three transparent-joining characters, reaching $chars[$i - 4] at the furthest. A base carrying four or more marks therefore stopped joining to the letter after it: the walk landed on a mark, nothing in $leftJoining holds a mark, the form came out isolated, and the Syriac corpus states no isolated form, so the nominal code point was drawn instead. Nothing limits how many marks a base carries. $transparentJoin is the Unicode Transparent-Joining table together with GDEF's mark class, and both are open-ended. skipTransparent(), added for #142, is the same walk without the limit, so the lookback goes through it. Estrangelo Edessa, BETH either side, the vowels U+0730, U+0733, U+0735, U+0737 and U+0739 between them, the glyphs each run draws in logical order: run drawn before drawn after BETH + BETH 0E008 0E00A unchanged BETH + 3 vowels + BETH 0E008 00730 00733 00735 0E00A unchanged BETH + 4 vowels + BETH ... 00737 00712 ... 00737 0E00A BETH + 5 vowels + BETH ... 00739 00712 ... 00739 0E00A 0E00A is the final BETH, 00712 the nominal letter the document asked for. The forward direction was never capped and is untouched: shape() walks the run backwards and $nextChar is whatever non-transparent character it last passed, which a transparent one leaves alone however many of them follow. No fixture moved. The shaping golden masters run an unpointed Syriac string, so nothing in the corpus stacks marks deep enough to have caught this. Co-Authored-By: Claude Opus 5 (1M context) --- src/Shaper/Arabic.php | 19 ++---------- tests/Mpdf/Shaper/ArabicTest.php | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/Shaper/Arabic.php b/src/Shaper/Arabic.php index f2264e9d9..706b10a62 100644 --- a/src/Shaper/Arabic.php +++ b/src/Shaper/Arabic.php @@ -174,27 +174,14 @@ public static function shape(&$info, $arabGlyphs, $glyphClassMarks, $usetags, $s $chars[] = $info[$i]['hex']; } - $crntChar = null; - $prevChar = null; $nextChar = null; $output = []; $max = count($chars); for ($i = $max - 1; $i >= 0; $i--) { $crntChar = $chars[$i]; - if ($i > 0) { - $prevChar = hexdec($chars[$i - 1]); - } else { - $prevChar = null; - } - if ($prevChar && isset($transparentJoin[$prevChar]) && isset($chars[$i - 2])) { - $prevChar = hexdec($chars[$i - 2]); - if ($prevChar && isset($transparentJoin[$prevChar]) && isset($chars[$i - 3])) { - $prevChar = hexdec($chars[$i - 3]); - if ($prevChar && isset($transparentJoin[$prevChar]) && isset($chars[$i - 4])) { - $prevChar = hexdec($chars[$i - 4]); - } - } - } + // joining sees the base a mark is written on, however many marks the base carries + $n = self::skipTransparent($chars, $i, -1, $transparentJoin); + $prevChar = isset($chars[$n]) ? hexdec($chars[$n]) : null; if ($crntChar && isset($transparentJoin[hexdec($crntChar)])) { // If next_char = RightJoining && prev_char = LeftJoining: if (isset($chars[$i + 1]) && $chars[$i + 1] && isset(self::$rightJoining[hexdec($chars[$i + 1])]) && $prevChar && isset(self::$leftJoining[$prevChar])) { diff --git a/tests/Mpdf/Shaper/ArabicTest.php b/tests/Mpdf/Shaper/ArabicTest.php index b64f38d54..b8f0f1ac3 100644 --- a/tests/Mpdf/Shaper/ArabicTest.php +++ b/tests/Mpdf/Shaper/ArabicTest.php @@ -35,6 +35,9 @@ class ArabicTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase /** U+073A SYRIAC HBASA ABOVE */ const HBASA = '0073A'; + /** U+073B SYRIAC HBASA BELOW */ + const HBASA_BELOW = '0073B'; + /** U+0712 SYRIAC LETTER BETH, dual-joining */ const BETH = '00712'; @@ -117,6 +120,40 @@ public function testAMarkBetweenTwoLettersDoesNotBreakTheirJoin() $this->assertSame([['B_INIT', 2], [self::FATHA, 0], ['B_FINA', 1]], $forms); } + /** + * Nothing limits how many marks a base carries, and the lookback stepped over at most three of + * them: a fourth left the following letter reading a mark as the character behind it, and a mark + * joins nothing. GravityPDF/mpdf#154. + * + * The first letter is asserted alongside the last because the two directions are found + * differently - forwards is the last letter the backwards walk over the run passed, which never + * had a limit - and it is the pair that says the marks are invisible to joining from either side. + */ + public function testALetterJoinsToTheBaseBeforeItHoweverManyMarksAreBetweenThem() + { + $four = [self::BETH, self::PTHAHA, self::ZQAPHA, self::RBASA, self::HBASA, self::BETH]; + $five = [self::BETH, self::PTHAHA, self::ZQAPHA, self::RBASA, self::HBASA, self::HBASA_BELOW, self::BETH]; + + $forms = $this->shape($four, self::ALL_FORMS, 'syrc'); + + $this->assertSame(['BE_INIT', 2], $forms[0]); + $this->assertSame(['BE_FINA', 1], $forms[5]); + $this->assertSame(['BE_FINA', 1], $this->shape($five, self::ALL_FORMS, 'syrc')[6]); + } + + /** + * The marks counted are the Transparent-Joining table together with GDEF's mark class, so a mark + * only the font declares takes up a place in the walk like any other. + */ + public function testAMarkOnlyGdefDeclaresIsSteppedOverWithTheRest() + { + $run = [self::BETH, self::PTHAHA, self::ZQAPHA, self::RBASA, self::COMBINING_GRAVE, self::BETH]; + + $forms = $this->shape($run, self::ALL_FORMS, 'syrc', self::COMBINING_GRAVE); + + $this->assertSame(['BE_FINA', 1], $forms[5]); + } + /** * The four form features can be switched off through OTLtags, in which case the character is left * as it came in rather than substituted @@ -300,6 +337,21 @@ public function testAPointedWordDrawsTheSameAlaphAsTheUnpointedWord() } } + /** + * Through a real font: the marks are drawn where they were written and the letters either side + * read as though they were not there, so a heavily pointed word has to draw the same two BETHs as + * the unpointed one. Estrangelo Edessa states no isolated BETH either, so the second letter losing + * its final form left the nominal U+0712 behind. + */ + public function testAHeavilyPointedWordDrawsTheSameLettersAsTheUnpointedWord() + { + $unpointed = $this->render([self::BETH, self::BETH]); + $pointed = $this->render([self::BETH, self::PTHAHA, self::ZQAPHA, self::RBASA, self::HBASA, self::BETH]); + + $this->assertSame($unpointed[0], $pointed[0]); + $this->assertSame($unpointed[1], $pointed[5]); + } + /** * The same character through a real font, which is where the wrong entry showed: Estrangelo Edessa * carries a BETH for each of the four forms, and the form the shaper asks for is the glyph that