Skip to content

Join a letter to the base before it however many marks are between them - #161

Merged
jakejackson1 merged 1 commit into
gravitypdffrom
fix/154-transparent-walk-uncapped
Sep 16, 2026
Merged

jakejackson1 merged 1 commit into
gravitypdffrom
fix/154-transparent-walk-uncapped

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Fixes #154.

Stacks on #155, which added the skipTransparent() helper this uses, and should merge after it.
The base is fix/142-syriac-alaph-transparent, so the diff here is the one commit; GitHub retargets
it when #155 goes in.

A walk that stopped after three

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]);
		}
	}
}

Three nested reads, $chars[$i - 4] at the furthest. Four marks between two letters and the walk
lands on the first of them rather than on the base. A mark is in neither joining table, so $form
stays 0, the letter asks for its isolated form, and Estrangelo Edessa - like the rest of the Syriac
corpus - states none, so the nominal code point is what gets drawn.

Nothing bounds how many marks a base carries. $transparentJoin is Unicode's Transparent-Joining
table together with GDEF's mark class, and both are open-ended, so any fixed depth is a guess about
how much pointing a document uses. skipTransparent() is the same walk with no limit, and the
nested if becomes two lines that go through it.

Measured

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. 0E00A is the final BETH the font states; 00712
is the nominal letter left behind when no form is found.

run drawn before drawn after second BETH
BETH + BETH 0E008 0E00A unchanged final
BETH + 1 vowel + BETH 0E008 00730 0E00A unchanged final
BETH + 2 vowels + BETH 0E008 00730 00733 0E00A unchanged final
BETH + 3 vowels + BETH 0E008 00730 00733 00735 0E00A unchanged final
BETH + 4 vowels + BETH 0E008 ... 00737 00712 0E008 ... 00737 0E00A none → final
BETH + 5 vowels + BETH 0E008 ... 00739 00712 0E008 ... 00739 0E00A none → final

Three marks was the last row that worked, which is the cap read back out of the rendering. Every row
now draws what the unpointed row draws, and no row that already worked moved.

The other direction was never capped

shape() walks the run backwards, so the character in front of $i is one the loop has already
passed, and $nextChar is simply carried:

if ($crntChar && isset($transparentJoin[hexdec($crntChar)])) {
	...
	continue;          // a mark returns here without touching $nextChar
}
...
$nextChar = $crntChar; // only a non-transparent character updates it

A mark never overwrites it, so any number of them leave it holding the last letter seen - which is
what skipTransparent() would return, obtained for free from the loop's own carry. That is why the
lookback was hand-rolled in the first place: only the backward direction needed a walk, and the walk
it got was bounded. Both directions are now unbounded.

The test asserts the first letter's initial form beside the last letter's final form for that
reason. The first assertion passes on the base branch and the second does not, which is the pair
saying the marks are invisible from either side and that only one side was broken.

No fixture moved

The whole suite, the snapshot suite with imagick and ghostscript present, and the shaping golden
masters are all green with no fixture rewritten. That is not luck: ShapingGoldenMaster's Syriac run
is [0x710, 0x712, 0x713, 0x715] - four letters and no mark - and its Arabic run carries a tatweel
rather than a vowel, so nothing in the corpus stacks marks deep enough to have seen this. Which is
the reason the change was held out of #155 rather than folded into it.

The tests added go with the ones #155 put in ArabicTest: four and five marks between two BETHs
through the fake rtlSUB table, the same through Estrangelo Edessa, and a run whose last mark is
declared by GDEF rather than by the Unicode table, since $transparentJoin is the union of the two
and the walk has to count both.

🤖 Generated with Claude Code

@jakejackson1 jakejackson1 added bug Something isn't working create-upstream-pr labels Sep 16, 2026
@jakejackson1
jakejackson1 changed the base branch from fix/142-syriac-alaph-transparent to gravitypdf September 16, 2026 04:44
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) <noreply@anthropic.com>
@jakejackson1
jakejackson1 force-pushed the fix/154-transparent-walk-uncapped branch from 79aac8d to f42b6b6 Compare September 16, 2026 04:46
@jakejackson1
jakejackson1 merged commit 038ac92 into gravitypdf Sep 16, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working create-upstream-pr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Four or more marks on a base stop the letter after them joining to it

1 participant