Skip to content

Build the generated Unicode tables from Unicode 18.0.0 - #265

Open
jakejackson1 wants to merge 5 commits into
gravitypdffrom
chore/unicode-18
Open

jakejackson1 wants to merge 5 commits into
gravitypdffrom
chore/unicode-18

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 18, 2026

Copy link
Copy Markdown
Member

The follow-up to #251, which pinned its generator to Unicode 17.0.0 and said which Unicode mPDF claims
was a maintainer decision. It is 18.0.0 now. One edit — UcdnTables::DEFAULT_VERSION — then
composer ucdn:update and composer arabicjoining:update.

Stacked on #251 (PR #261), so this branch contains its commits too. #261 should merge first.

composer ucdn:update
  Unicode 18.0.0: 1017 records, 178 scripts (3 new), 438 mirror pairs, 219791 bytes written
  New scripts: Jurchen, Proto_Cuneiform, Seal
composer arabicjoining:update
  Unicode 18.0.0: 284 left-joining, 375 right-joining characters, 23538 bytes written

composer otlanguages:update is not part of this and was checked rather than assumed:
OtLanguageTags::DEFAULT_VERSION is a HarfBuzz release, not a Unicode one, and running it over the
rebuilt src/Ucdn.php produces no diff.

The cost, stated up front: the independent oracle goes dark

UcdnAgainstIcuTest compares every one of the 1,114,112 codepoints against ICU — a wholly separate
implementation of the same data — and skips itself when ICU's Unicode release does not match the repo's.
Local ICU 78.2 carries Unicode 17.0, so this change switches that test off until ICU ships Unicode 18.

That matters more than a skip count: it is the only check on src/Ucdn.php that is not the generator
grading its own homework. After this, the regenerated tables are pinned by the round-trip test (which
proves the generator agrees with itself), the golden masters and the shaping suite — and nothing
independent.

Its last word on the tables is on record. At #251's tip with DEFAULT_VERSION still 17.0.0:

$ vendor/bin/phpunit tests/Mpdf/UcdnAgainstIcuTest.php
OK (1 test, 2 assertions)

Every codepoint agreed with ICU 78.2 on category, combining class, bidi class, mirrored, East Asian
width and script. The comparison itself is untouched; the skip is the test protecting itself from
grading Unicode 18 tables against Unicode 17 data, and it is correct to do so.

What moved in the joining tables: exactly 22 characters

$leftJoining 262 → 284, $rightJoining 375 → unchanged. The 22 are contiguous,
U+10ED9..U+10EEE, and all Joining_Type=L — which is why only the left table moves.

This is bounded at the source rather than inferred from the output: the entire data delta between
ArabicShaping-17.0.0.txt and -18.0.0.txt is those 22 added lines, 834 → 856, with nothing removed
and nothing changed. So no character of any other script could have moved.

10ED9; CROWN BEH; L; CROWN BEH
… through …
10EEE; DOTLESS CROWN BEH WITH 2 DOTS BELOW; L; CROWN BEH

At Unicode 17 no in-scope character had Joining_Type=L at all, so L was a column the tables could
not reach.

Why the new test's oracle is not a plain hb-shape run

Released HarfBuzz does not carry Unicode 18's joining data yet. Both 14.3.1 and 14.4.0 (the current
release) generate src/hb-ot-shaper-arabic-table.hh from ArabicShaping-17.0.0.txt — the header says so
— and only main has been regenerated. So hb-shape gives U+10ED9 no joining behaviour, and that is a
gap in the oracle, not a disagreement. For these 22 characters mPDF is ahead of released HarfBuzz.

The oracle is therefore in two parts. The joining type is normative UCD data, the same
ArabicShaping-18.0.0.txt line that HarfBuzz main reads. The semantics of L come from hb-shape
14.3.1 read on a character Unicode 17 already typed L — and HarfBuzz's arabic_state_table, the code
that turns a type into a form, is byte-identical between 14.3.1 and main, so only the data differs:

$ hb-shape --font-file=packages/Middle-East-Scripts-Bundle/fonts/LateefRegOT.ttf \
    --script=arab --direction=rtl --no-clusters --no-positions --unicodes=10ACD,0628
[uni0628.fina|.notdef]        U+10ACD is L at Unicode 17: the Beh after it takes fina
$ ... --unicodes=0628,10ACD
[.notdef|uni0628]             and before it, nominal
$ ... --unicodes=0628,10EC2
[.notdef|uni0628.init]        R, the mirror case, on a character released HarfBuzz does know
$ ... --unicodes=10ED9,0628
[uni0628|.notdef]             the crown letter: released HarfBuzz has no joining for it

Both new tests fail at #251's tip:

1) …testALetterAfterALeftJoiningCrownLetterTakesItsFinalForm
-    0 => 'B_FINA'   -    1 => 1
+    0 => 'B_ISOL'   +    1 => 0
2) …testABehAfterALeftJoiningCrownLetterDrawsTheSameFormAsAfterAMeem
-'U+E064'            (Lateef's uni0628.fina)
+'U+0628'            (the nominal Beh)
Tests: 2, Assertions: 2, Failures: 2.

The shape of the src/Ucdn.php diff

2,145 changed lines, and it decomposes completely:

  • 13,010 codepoints changed record, of which 13,007 were unassigned at 17 and now carry a script.
    By script: Seal 11,328, Jurchen 965, Proto-Cuneiform 164, Latin 161, Cuneiform 159, Common 153,
    Arabic 39, Inherited 11, then 16 scripts with six or fewer. No codepoint moved from one named script
    to another.
  • The 39 new Arabic codepoints are U+10EC9..10ECF, U+10ED9..10EEE and U+10EF0..10EF9 — the 22 crown
    letters plus 17 marks and digits.
  • 3 new scripts, appended at 175/176/177 with the ISO 15924 codes jurc, pcun, seal.
  • 10 new mirror pairs, matching BidiMirroring.txt's ten new lines exactly; none removed or changed.
  • 3 already-assigned characters reclassified: U+1B3A, U+1B3C and U+1B42 (Balinese vowel signs) move
    Bidi_Class NSM → L. That is the whole of the non-assignment delta.
  • The remaining bulk is the index tables re-packing. $ucd_records 1013 → 1017, the first new record
    inserted at slot 383, so record numbers from 383 up shift.

No existing script's number moved. The only change to the SCRIPT_* constants is three appended
lines; SCRIPT_INHERITED is still 40 and SCRIPT_UNKNOWN still 102, which three call sites depend on.

Three literals, because there were three copies

src/Otl.php:359 read if ($sbl && $sbl != 40 && $sbl != 102)SCRIPT_INHERITED and
SCRIPT_UNKNOWN as bare numbers, on the line that decides which script starts a run and therefore
which shaper a character reaches. Otl::analyseCharacters()'s loop is copy-pasted verbatim into
src/Mpdf.php:26932 and src/Image/Svg.php:3343, and both carried the same two literals. Fixing one of
three would be worse than fixing none, so all three now name the constants. Behaviour-identical — the
numbers held — and one grep now finds every site.

The rule is still stated in four places and the three src/ sites are the same ~20-line loop. That is a
refactor of its own: #266.

Two tests this invalidated

  • UcdnTablesTest::testANewScriptIsAppendedAndTheRestKeepTheirNumbers hardcoded Adlam 175 and Toto 176,
    which is one past the last number in use and therefore moves at every Unicode release. It now derives
    the next number. Every failure the literals caught is still caught — appending into the holes Adlam
    (103) and Toto (168) leave, appending in read order, appending with a gap. SCRIPT_UNKNOWN === 102
    stays hardcoded as the stability anchor. Verified passing at both 17 and 18.
  • ArabicJoiningTest's Unknown-script case used U+10EE8 to stand for "a joining type Ucdn's script table
    has not caught up with". At 18 that character has a script, so the case went dead while staying green.
    It is U+FDD0 now, a noncharacter Unicode's stability policy will never assign, so it holds at every
    release. Mutation-checked: removing the SCRIPT_UNKNOWN clause from inScope() fails it.

Verified

composer test                                        2534 tests, 6712 assertions, 1 skipped, OK
composer test -- --group=snapshot                    79 tests, 109 assertions, OK
composer cs                                          clean
phpstan --no-progress --memory-limit=2G              33 errors, all pre-existing

Base at #251's tip: 2532 / 6702 / 3 skipped. The one remaining skip is UcdnAgainstIcuTest, for the
reason above.

No golden master and no snapshot moved. All four generators were re-run over the whole corpus after
deleting tests/Mpdf/tmp/mpdf; every fixture was rewritten and every one came back byte-identical.

utils/data/ucd/18.0.0/ is gitignored and not committed, as the other generators' sources are.

🤖 Generated with Claude Code

jakejackson1 and others added 5 commits September 18, 2026 12:20
…a recently added character joins (#251)

Shaper\Arabic's two joining tables stopped at the blocks Unicode had when they
were last extended by hand. A character in neither table joins nothing and
unjoins its neighbour, so the letter before a recently added one was drawn with
no form where HarfBuzz gives it init or medi.

Both tables are now written by composer arabicjoining:update from
ArabicShaping.txt, beside a test that the checked-in tables are what the
generator writes - the pattern Ucdn::$ot_languages already uses. What is in
scope is read from Ucdn's script for each character, because the script is what
routes a run to this shaper, which is why the generator reads the same Unicode
version Ucdn's scripts were built from.

77 characters change: the 72 the issue measured over Mandaic, the Syriac
Supplement and Arabic Extended-A and -B, and five in Arabic Extended-C, which is
script Arabic and so resolved here for the same reason. U+0847 MANDAIC IT was the
one filed in the wrong table and is now right-joining only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eads them (#251)

The scope test read Ucdn's script and admitted Common and Inherited, on the
grounds that a character Ucdn has no script for reaches no shaper. It does:
Otl::analyseCharacters() will not start a run on Unknown any more than on those
two, so the character stays in the run before it and the Arabic shaper is handed
it. Measured through Lateef, a Beh after U+10EE8 CROWN FEH - Arabic and
left-joining as of Unicode 18, Unknown to Ucdn's Unicode 17 - is drawn with no
form until the table has it, which is this defect with a shorter fuse. Unknown is
now in scope with the other two, and the tables are no longer tied to Ucdn's
release to be correct.

The four scripts are Otl::selectShaper()'s decision rather than the generator's,
so a test holds the two lists together; a fifth script in that branch would
otherwise bring the defect back for it with nothing failing.

Also: the version line each generator rewrites was a third copy of one
preg_replace, so it moves to GeneratedTable and gains the missing-marker check
replaceArray() already had; the partition reads the three joining types it wants
rather than the two it does not; and U+0883 to U+0885 and U+07FA are Join_Causing
with scripts of their own, so the comment claiming Join_Causing means "no script"
is gone.

No table content moves: no character of Unicode 17 is Unknown to Ucdn 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UcdnTables::DEFAULT_VERSION is the one place the repository names its Unicode
release, and ArabicJoining reads it, so raising it and running both generators
is the whole change:

    composer ucdn:update            1017 records, 178 scripts (3 new), 438 mirror pairs
    composer arabicjoining:update   284 left-joining, 375 right-joining characters

composer otlanguages:update is not part of it. Ucdn::$ot_languages comes from
HarfBuzz's table rather than the UCD, and running it over the rebuilt class
writes the same bytes.

The joining tables gain exactly the 22 characters of ArabicShaping.txt's whole
17-to-18 delta, U+10ED9..U+10EEE, the Arabic Extended-C crown letters. All 22
are Joining_Type=L, so they enter $leftJoining alone and $rightJoining does not
move - the first character in any script this shaper is called for to join
forwards and not back. src/Ucdn.php gives them SCRIPT_ARABIC, where Unicode 17
had them as SCRIPT_UNKNOWN.

Ucdn's larger half moves in four ways. 13,007 codepoints were unassigned and
now carry a script. Three scripts are appended - Jurchen 175, Proto-Cuneiform
176 and Seal 177 - and no script that had a number changed it, which is what
the rest of mPDF depends on: Otl compares those numbers by range. Ten mirror
pairs are added, matching BidiMirroring.txt's ten new lines. The remaining
2,145 lines are the index tables re-packing around four new records, the first
of which is first seen at U+1B3A and so shifts every record number after it.

The one already-assigned character to be reclassified is Balinese: U+1B3A,
U+1B3C and U+1B42 move from Bidi_Class=NSM to L.

Otl::analyseCharacters() wrote SCRIPT_INHERITED and SCRIPT_UNKNOWN as the bare
literals 40 and 102, on the line that decides which script folds into the run
before it. Both numbers held, but a Unicode bump is when that line would fail
silently, so it reads the constants now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e cannot assign

ArabicJoiningTest asserted that a joining type Ucdn's script table has not caught
up with is still written, with U+10EE8 CROWN FEH standing for it. Unicode 18 gives
that character SCRIPT_ARABIC, so it now reaches ArabicJoining::inScope() by script
and the SCRIPT_UNKNOWN clause it was written for is reached by nothing - the test
stayed green while the branch went uncovered. It reads U+FDD0 now, a noncharacter
that Unicode's stability policy will never assign, so the case holds at every
release rather than needing the next one's newest codepoint. Removing that clause
fails the test again.

Otl::analyseCharacters()'s script-block loop is copied verbatim into Mpdf.php and
Image/Svg.php, and both still spelled SCRIPT_INHERITED and SCRIPT_UNKNOWN as 40
and 102; they read the constants too, so one grep finds all three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

create-upstream-pr enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant