Skip to content

Select ZHS or ZHT from a Chinese script subtag, ZHS for zh alone, and ZHTM then ZHH for Macao (#201) - #207

Merged
jakejackson1 merged 2 commits into
fix/191-chinese-region-langsysfrom
fix/201-chinese-script-subtags
Sep 17, 2026
Merged

jakejackson1 merged 2 commits into
fix/191-chinese-region-langsysfrom
fix/201-chinese-script-subtags

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #201

What

OtlTags::language() read only the language subtag, then the language and region together. No key mapped a script subtag or bare zh, so:

  • zh, zh-Hans and zh-Hant got the script's DFLT entry
  • a script that disagreed with its region took the region's tag: zh-Hans-HK got ZHH, zh-Hant-CN got ZHS
  • Macao got ZHT

Chinese now follows HarfBuzz 14.3.1.

What HarfBuzz does

I read hb_ot_tags_from_complex_language() (src/hb-ot-tag-table.hh, case 'z') and hb_ot_tags_from_language() (src/hb-ot-tag.cc). HarfBuzz lower-cases the tag and tries these rules in order, and the first match wins:

# HarfBuzz test tags, in the order tried
1 zh-hant-hk prefix ZHH
2 zh-hant-mo prefix ZHTM, ZHH
3 zh-min-nan exactly ZHS
4 zh-hans prefix ZHS
5 zh-hant prefix ZHT
6 zh-min exactly ZHS
7 zh- and a -hk subtag anywhere ZHH
8 zh- and a -mo subtag anywhere ZHTM, ZHH
9 zh- and a -tw subtag anywhere ZHT
10 a three-letter second subtag (extlang), e.g. zh-yue that language's tag
11 zh in the two-letter table (so zh-CN, zh-SG, zh-US, zh-Latn, zh-hakka, …) ZHS

OtlTags::chinese() implements rules 1-9 and 11. Rules 3 and 6 need no code, because they give the same ZHS as rule 11. Rule 10 is general to every language, and mPDF has no key for yue, lzh or the other Chinese languages, so it's in #206 along with the non-zh complex mappings. zh-yueZHS here, where HarfBuzz takes ZHH.

Several candidates, and no change to Otl.php

language() still returns one tag. It now tries each candidate against the script's language systems in order, then falls back to DFLT, then '', as before. Macao is ['ZHTM', 'ZHH '], and every other language is a single candidate. Otl.php is untouched.

The generic region lookup (Ucdn::$ot_languages[$lang . '-' . $country]) is gone. Only the zh- keys ever used it, and zh-mo => 'ZHT ' is one of the mappings this changes. Those five Ucdn entries are no longer read. I left Ucdn.php alone to keep the change to OtlTags.php. A comment on chinese() says why they aren't read.

Behaviour change: bare zhZHS

Any document with lang="zh", zh-CN, zh-SG or an unrecognised region such as zh-US now selects ZHS where the font offers it. Before, zh and zh-US fell to DFLT. This is what HarfBuzz does and what the issue asks for. I checked the blast radius:

  • No font in tests/data/ttf or packages/*/fonts (85 files) has a ZH* language system, apart from the two synthetic test fonts.
  • No test, snapshot or golden master sets lang="zh…".

Test font and tests

NotoSansTC-MacaoLangSys-Synthetic.ttf is #191's NotoSansTC-RegionLangSys-Synthetic rebuilt with one more glyph and language system. It's built from the same script in fontTools 4.59.2 (Noto Sans TC 2.004, OFL 1.1). #191's font isn't touched, so its fixtures don't move. In each font, 骨 U+9AA8 is drawn as:

language system glyph RegionLangSys MacaoLangSys
ZHH 三 U+4E09
ZHS 一 U+4E00
ZHT 二 U+4E8C
ZHTM 四 U+56DB

tests/Mpdf/ChineseRegionLangSysTest.php is renamed to ChineseLangSysTest.php. It draws <p lang="…">骨</p> through TextRecordingMpdf:

OtlTagsTest: #191's pinned "zh, zh-Hant and zh-HansDFLT" test and its region provider become one provider with 20 rows. They cover the issue's table, every HarfBuzz rule above, upper and lower case, and zh-USZHS (was DFLT). testMacaoFallsBackToHongKong covers zh-MO and zh-Hant-MO with no ZHTM offered (→ ZHH), and with neither ZHTM nor ZHH (→ DFLT).

Comparison with hb-shape --language 14.3.1

Glyph drawn for 骨. mPDF is TextRecordingMpdf and HarfBuzz is hb-shape, both on the same font file:

lang before (RegionLangSys) mPDF RegionLangSys hb RegionLangSys mPDF MacaoLangSys hb MacaoLangSys
zh 骨 (DFLT) 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-Hans 骨 (DFLT) 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-Hant 骨 (DFLT) 二 ZHT ZHT ✓ 二 ZHT ZHT ✓
ZH-HANT 骨 (DFLT) 二 ZHT ZHT ✓ 二 ZHT ZHT ✓
zh-Hans-HK 三 ZHH 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-Hans-TW 二 ZHT 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-Hans-MO 二 ZHT 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-Hant-CN 一 ZHS 二 ZHT ZHT ✓ 二 ZHT ZHT ✓
zh-Hant-HK 三 ZHH 三 ZHH ZHH ✓ 三 ZHH ZHH ✓
zh-Hant-MO 二 ZHT 三 ZHH ZHH ✓ 四 ZHTM ZHTM ✓
zh-MO 二 ZHT 三 ZHH ZHH ✓ 四 ZHTM ZHTM ✓
zh-HK 三 ZHH 三 ZHH ZHH ✓ 三 ZHH ZHH ✓
zh-TW 二 ZHT 二 ZHT ZHT ✓ 二 ZHT ZHT ✓
zh-CN 一 ZHS 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-SG 一 ZHS 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-US 骨 (DFLT) 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-Latn-HK 三 ZHH 三 ZHH ZHH ✓ 三 ZHH ZHH ✓
zh-min-nan 骨 (DFLT) 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-hakka 骨 (DFLT) 一 ZHS ZHS ✓ 一 ZHS ZHS ✓
zh-yue 骨 (DFLT) 一 ZHS ZHH (extlang, #206) 一 ZHS ZHH (extlang, #206)

Fixtures

Regenerated from cold. I deleted tests/Mpdf/tmp/mpdf, tmp/mpdf and tmp/ttfontdata, then ran composer fontcache:update all, otldump:update all, shaping:update all and subset:update all. git status showed only the new font's five files. No existing fixture moved.

  • The new otldump lists hani with ZHH, ZHS, ZHT and ZHTM, each with locl.
  • The new fontcache has "hani": "DFLT ZHH ZHS ZHT ZHTM ".
  • The shaping master sets no language, so 骨 comes back unchanged.

Mutation check

Each mutation was applied alone to OtlTags.php against the full suite:

mutation failures
region before script (move the hk/mo/tw loop above the hans/hant checks) 4: ChineseLangSysTest zh-Hans-HK, and OtlTagsTest zh-Hans-HK, zh-Hans-TW, zh-Hans-MO
no bare zh (chinese() ends return []) 7: ChineseLangSysTest zh, zh-CN, and OtlTagsTest zh, zh-CN, zh-SG, zh-US, zh-min-nan
no ZHH fallback ('mo' => ['ZHTM']) 2: ChineseLangSysTest "zh-MO, where the font has no ZHTM", and OtlTagsTest::testMacaoFallsBackToHongKong

Mirroring upstream

Upstream's version is Otl::_getOTLLangTag() (src/Otl.php:6150 on development). It still has the $lang . '' . $country key that #202 fixed. The mirror of this PR replaces that function's body:

  • the $country extraction and the region branch go
  • a zh first subtag gets the candidate list from a new private method with the same body as OtlTags::chinese()
  • any other language gets [Ucdn::$ot_languages[$lang]]
  • the candidates are tried in order before the DFLT/'' fallback

The $lang . '' bug disappears with the region branch, so the upstream PR can carry #191 and #201 together. Both callers (Otl.php:284, :300) are unchanged. The test fonts, ChineseLangSysTest and the OtlTagsTest rows (through reflection on _getOTLLangTag) can be carried over. The upstream PR also needs a CHANGELOG.md entry.

Base

Stacked on #202 (fix/191-chinese-region-langsys) → #200#198#190#188#186gravitypdf. Before pushing I checked all six: all open, none merged, and fix/191-chinese-region-langsys is still at b38b6fd. So this targets it without a rebase.

/simplify

Four review passes: reuse, simplification, efficiency and altitude.

Applied:

  • The region table is a private static $chineseRegions beside $originalIndicTags, so it isn't rebuilt on every call.
  • The two hant branches are folded into one.
  • $candidates is initialised once, so the empty else is gone.

Skipped:

The mutation check above was re-run after these changes, with the same results. The verification numbers are also from after the changes.

Verification

  • vendor/bin/phpunit: 2110 tests, 5991 assertions, 1 skipped (base is 2084 / 5955 / 1)
  • vendor/bin/phpunit --group=snapshot: OK, 79 tests, 110 assertions
  • composer cs: clean
  • phpstan: 33 errors, same as the base, none in touched files

🤖 Generated with Claude Code

jakejackson1 and others added 2 commits September 17, 2026 11:40
… ZHTM then ZHH for Macao (#201)

OtlTags::language() read the language subtag, then the language and
region. Nothing mapped a script subtag and bare zh had no key, so zh,
zh-Hans and zh-Hant laid text out with the script's DFLT entry, and a
script that disagreed with its region took the region's tag: zh-Hans-HK
got ZHH, zh-Hant-CN got ZHS. Macao got ZHT.

Chinese now follows HarfBuzz 14.3.1's hb_ot_tags_from_complex_language():
Traditional in Hong Kong or Macao keeps the region's tag, then Hans is
ZHS and Hant is ZHT whatever the region, then a hk, mo or tw subtag
gives ZHH, ZHTM then ZHH, or ZHT, and anything else is ZHS. language()
tries each candidate against the script in turn, so its return value and
Otl.php are unchanged.

zh on its own now selects ZHS where a font offers it. No font in
tests/data/ttf or packages/ has a Chinese language system other than the
synthetic test fonts, and no golden master or snapshot sets lang="zh".

NotoSansTC-MacaoLangSys-Synthetic is #191's font with 四 under a fourth
language system, ZHTM. For every case in ChineseLangSysTest mPDF draws
the glyph hb-shape --language draws.

Test font: NotoSansTC-MacaoLangSys-Synthetic (Noto Sans TC 2.004, OFL
1.1), built in fontTools 4.59.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… one place (#201)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 force-pushed the fix/191-chinese-region-langsys branch from b38b6fd to 4c81cc3 Compare September 17, 2026 01:47
@jakejackson1
jakejackson1 force-pushed the fix/201-chinese-script-subtags branch from 035d522 to b23641e Compare September 17, 2026 01:47
@jakejackson1

Copy link
Copy Markdown
Member Author

Rebased: #190 was squash-merged into gravitypdf (f9b3c0e), so this stack still carried its pre-squash commits. This branch is rebased onto its rebased parent with --onto; no conflicts and the base is unchanged. Suite, snapshots and cs pass on the new head, with no fixture movement.

@jakejackson1
jakejackson1 merged commit 0b88657 into fix/191-chinese-region-langsys Sep 17, 2026
27 checks passed
@jakejackson1

Copy link
Copy Markdown
Member Author

This PR merged into a base branch that had already been squash-merged, so its change never reached gravitypdf. It's carried to gravitypdf by #220.

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.

1 participant