Stop an Arabic form's context walk at the edge of the run (#204) - #210
Merged
jakejackson1 merged 2 commits intoSep 17, 2026
Merged
Conversation
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
jakejackson1
force-pushed
the
fix/193-glyph-class-membership
branch
from
September 17, 2026 01:47
d9451ed to
616f1b0
Compare
jakejackson1
force-pushed
the
fix/204-arabic-context-walk
branch
from
September 17, 2026 01:47
01f1c83 to
3a53d14
Compare
Member
Author
|
Rebased: #190 was squash-merged into |
jakejackson1
merged commit Sep 17, 2026
1a7d91b
into
fix/193-glyph-class-membership
27 checks passed
Member
Author
|
This PR merged into a base branch that had already been squash-merged, so its change never reached |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #204.
What
Shaper\Arabic::glyphs()checks a joining form's backtrack (prel) and lookahead (postl) by walking over the glyphs the lookup ignores. Neither walk stopped at the edge of the run. Both now checkisset($chars[...])before they look the glyph up in the ignore pattern:The
isset()check after the walk already marked a position the run doesn't have as not matched, so that is now the result whenever the walk runs out.skipTransparent()is the only otherwhileover$charsinArabic.php, and it was already bounded.Reproduced first, on this base (#205's
inList()in place)The snippet from the issue, plus a lookahead version (BEH FATHA, where BEH's isolated form has a
postlof DAL):d9451edtimeout 10exits 124 (hangs)37f1e92(before #205)d9451edUndefined offset: -1Undefined offset: 2inList()doesn't change the hang. It still looks for the glyph withstrpos($glyphs, $glyph). With anullglyph, PHP 8 searches for""and finds it at offset 0.strlen(null)is 0, and the character after offset 0 is the pattern's opening(, which isn't a hex digit, soinList()returns true on every pass. On PHP 7,nullis searched for as"\0", which isn't found, so the loop stops. The result there was already right, because theisset()after the loop turns it into no match. PHP 7 only had the notices.Why "no match" is correct
HarfBuzz 14.3.1,
src/hb-ot-layout-gsubgpos.hh.match_lookahead()fails the rule as soon as the skippy iterator can't find the next position:The iterator's
next()skips glyphs only whileidx < stop, and returnsfalseonce it reaches the end of the buffer:match_backtrack()works the same way withskippy_iter.prev(), which stops atidx > 0and returnsfalse.Tests
ArabicTest::testAFormsContextWalkReadsNothingPastTheEdgeOfTheRunruns in-process with an error handler that turns every error into anErrorException. That covers the PHP 7 notices, and it means a regression fails on the first notice, so this test can't hang. The data provider covers:ArabicTest::testAFormsContextWalkReturnsAtTheEdgeOfTheRunruns all the same cases in one child process (tests/Mpdf/Fixtures/arabic-shape.php), started withproc_openandbypass_shellthe wayShapingTraceTestdoes it:set_time_limit(5)on itself instead of relying on atimeoutbinary, so the limit also works on Windows. There it counts wall-clock time; on Unix it counts CPU time, and a runaway loop uses CPU.error_reporting(E_ERROR), so a walk that warns on every pass can't fill the stderr pipe and block. It still reports the fatal "Maximum execution time" error.ArabicContextEdgeTestis end to end through a real font withTextRecordingMpdf, usingdir="rtl". Expected glyphs, in visual order:hb-shape14.3.1064E 0628 0628uni064E uni0628 uni06280628 0628 064Euni0628 uni0628 uni064E08AD 064E E002 0628uni08AD uni064E uni066E.fina uni06280628 E004 064E 08ADuni0628 uni066E.init uni064E uni08ADThe synthetic font
NotoSansArabic-ContextEdge-Synthetic.ttfstarts fromNotoSansArabic-Joining-Subset(Noto Sans Arabic 2.012, OFL 1.1) and was rebuilt with fontTools 4.59.2:uni064Eat U+064E. It reusesdotbelowar's outline and advance and is in GDEF class 3.init: a Type 6 Format 3 lookup with IgnoreMarks. Backtrack isuni08AD, input isuni0628, and it appliesuni0628 → uni066E.init.fina: the same, with a lookahead ofuni08AD, applyinguni0628 → uni066E.fina.medi: a single substitution.ccmp. mPDF works out joining afterccmp, from glyphs rather than characters, so a beh thatccmpsplits into a rasm and a dot joins nothing. That is a separate bug, opened as A letter ccmp takes apart into a base and a mark joins nothing in the Arabic shaper #209.The font puts
prel/postlinto itsrtlSUB(seetests/data/fontcache/NotoSansArabic-ContextEdge-Synthetic.json). It is independent of #195'sGSUB62Positionsfont, which is on a sibling branch.Fixtures
I deleted
tests/Mpdf/tmp/mpdf,tmp/mpdfandtmp/ttfontdata, then rancomposer fontcache:update all,otldump:update all,shaping:update allandsubset:update all. The only new files are the four fixtures for the new font, and no existing fixture changed. In the new shaping fixture, the Arabic run and the font's own characters come back unchanged. Only a beh with a low alef on the matching side takes a form, and none of those runs has one.Mutation check
I removed each
isset()in turn and ran--filter 'ContextWalk|ArabicContextEdge'. These runs were made before /simplify, when the child ran once per case; after batching, removing the lookahead bound still fails the single child test with the same time-limit fatal (5.1s).Fatal error: Maximum execution time of 5 seconds exceeded.ErrorException: Undefined array key -1.ArabicContextEdgeTest"a word starting with a mark" errored with the same message.Undefined array key 2and4.ArabicContextEdgeTest"a word ending in a mark" errored withUndefined array key 3.Nothing hung.
Verification
vendor/bin/phpunit: 2095 tests, 5983 assertions, 1 skipped (base: 2078 / 5955 / 1)--group=snapshot: 79 tests, 110 assertionscomposer cs: cleanArabic.phpStack
Base is
fix/193-glyph-class-membership(#205), still open with headd9451ed. #186 and #188 further down have merged, and #190 already targetsgravitypdf. #205 hasn't moved, so this branch didn't need a rebase./simplify
prel/postlwalks into one direction-parameterised loop. The behaviour would be the same, but the diff would be wider and the upstream change harder to mirror.proc_openhelper withShapingTraceTest. There are only two uses.GlyphString::inList()refuse a null or empty glyph. That is outsideArabic.phpand would hide the out-of-range read rather than bound it.Upstream mirror
mpdf/mpdfdevelopment(744f75a) has the same two loops inOtl::get_arab_glyphs()(src/Otl.php:2903), whicharabic_shaper()(:2842) calls. There they usestrpos()directly, and on PHP 8strpos($ignore, null)also finds"":src/Otl.php:2957:while (strpos($this->arabGlyphs[$char]['ignore'][$retk], $chars[$i - $ig - $k]) !== false)becomeswhile (isset($chars[$i - $ig - $k]) && strpos(...) !== false)src/Otl.php:2974: the same, with$chars[$i + $ig + $k]The upstream PR needs a
CHANGELOG.mdentry; this one doesn't have one.🤖 Generated with Claude Code