From 54c2aa280fbb1ce4f294469b5879ada73fc6beac Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 17 Sep 2026 09:25:58 +1000 Subject: [PATCH 1/2] Select ZHS or ZHT from a Chinese script subtag, ZHS for zh alone, and ZHTM then ZHH for Macao (#201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Shaper/OtlTags.php | 75 ++++++++--- tests/Mpdf/ChineseLangSysTest.php | 74 +++++++++++ tests/Mpdf/ChineseRegionLangSysTest.php | 55 -------- tests/Mpdf/Shaper/OtlTagsTest.php | 38 +++--- .../NotoSansTC-MacaoLangSys-Synthetic.json | 111 ++++++++++++++++ .../NotoSansTC-MacaoLangSys-Synthetic.txt | 17 +++ .../NotoSansTC-MacaoLangSys-Synthetic.txt | 92 +++++++++++++ .../NotoSansTC-MacaoLangSys-Synthetic.json | 123 ++++++++++++++++++ .../ttf/NotoSansTC-MacaoLangSys-Synthetic.ttf | Bin 0 -> 2064 bytes 9 files changed, 494 insertions(+), 91 deletions(-) create mode 100644 tests/Mpdf/ChineseLangSysTest.php delete mode 100644 tests/Mpdf/ChineseRegionLangSysTest.php create mode 100644 tests/data/fontcache/NotoSansTC-MacaoLangSys-Synthetic.json create mode 100644 tests/data/otldump/NotoSansTC-MacaoLangSys-Synthetic.txt create mode 100644 tests/data/shaping/NotoSansTC-MacaoLangSys-Synthetic.txt create mode 100644 tests/data/subset/NotoSansTC-MacaoLangSys-Synthetic.json create mode 100644 tests/data/ttf/NotoSansTC-MacaoLangSys-Synthetic.ttf diff --git a/src/Shaper/OtlTags.php b/src/Shaper/OtlTags.php index 1d86972aa..aaa4f48ea 100644 --- a/src/Shaper/OtlTags.php +++ b/src/Shaper/OtlTags.php @@ -77,7 +77,8 @@ public static function script(array $ScriptLang, $scripttag, $scriptblock, $shap * @param string $ietf The language of the text as an IETF tag, e.g. 'sr-Cyrl' or 'zh-Hant-HK' * @param string $available The language systems the script offers, as one string of tags * - * @return string The tag, 'DFLT' where the script offers that and not the language, or '' for neither + * @return string The first of the language's tags the script offers, 'DFLT' where it offers none of + * them and offers that, or '' for neither */ public static function language($ietf, $available) { @@ -85,35 +86,67 @@ public static function language($ietf, $available) return ''; } - $tags = $ietf ? explode('-', $ietf) : []; + $subtags = $ietf ? explode('-', strtolower($ietf)) : []; - $lang = isset($tags[0]) ? strtolower($tags[0]) : ''; - - // The region, where the second subtag is one; a third subtag always is, the second being - // the script - $country = ''; - if (isset($tags[1]) && strlen($tags[1]) == 2) { - $country = strtolower($tags[1]); + if (isset($subtags[0]) && $subtags[0] == 'zh') { + $candidates = self::chinese($subtags); + } elseif (isset($subtags[0]) && isset(Ucdn::$ot_languages[$subtags[0]])) { + $candidates = [Ucdn::$ot_languages[$subtags[0]]]; + } else { + $candidates = []; } - if (isset($tags[2]) && $tags[2]) { - $country = strtolower($tags[2]); + + foreach ($candidates as $langsys) { + if (strpos($available, $langsys) !== false) { + return $langsys; + } } - $region = $lang . '-' . $country; + return strpos($available, 'DFLT') !== false ? 'DFLT' : ''; + } - if ($lang != '' && isset(Ucdn::$ot_languages[$lang])) { - $langsys = Ucdn::$ot_languages[$lang]; - } elseif ($lang != '' && $country != '' && isset(Ucdn::$ot_languages[$region])) { - $langsys = Ucdn::$ot_languages[$region]; - } else { - $langsys = 'DFLT'; + /** + * The language systems for Chinese, in the order HarfBuzz tries them + * (hb_ot_tags_from_complex_language() in hb-ot-tag-table.hh). + * + * The script subtag outranks the region, so zh-Hans-HK is Simplified, except that Traditional + * Chinese in Hong Kong or Macao keeps its region's tag. Macao has its own ZHTM, and where a font + * lacks it takes Hong Kong's ZHH before the default. A tag with neither is Simplified. + * + * The zh- keys of Ucdn::$ot_languages are not read: they hold one tag per region, and nothing for + * the script or for zh alone. + * + * @param string[] $subtags The lower-cased subtags of a tag whose language is zh + * + * @return string[] + */ + private static function chinese(array $subtags) + { + $regions = [ + 'hk' => ['ZHH '], + 'mo' => ['ZHTM', 'ZHH '], + 'tw' => ['ZHT '], + ]; + + $script = isset($subtags[1]) ? $subtags[1] : ''; + + if ($script == 'hant' && isset($subtags[2]) && ($subtags[2] == 'hk' || $subtags[2] == 'mo')) { + return $regions[$subtags[2]]; + } + if ($script == 'hans') { + return ['ZHS ']; + } + if ($script == 'hant') { + return ['ZHT ']; } - if (strpos($available, $langsys) !== false) { - return $langsys; + foreach ($regions as $region => $langsys) { + if (in_array($region, $subtags, true)) { + return $langsys; + } } - return strpos($available, 'DFLT') !== false ? 'DFLT' : ''; + return ['ZHS ']; } /** diff --git a/tests/Mpdf/ChineseLangSysTest.php b/tests/Mpdf/ChineseLangSysTest.php new file mode 100644 index 000000000..f78f1352b --- /dev/null +++ b/tests/Mpdf/ChineseLangSysTest.php @@ -0,0 +1,74 @@ +assertSame([$expected], $this->drawn('NotoSansTC-RegionLangSys-Synthetic.ttf', $lang)); + } + + public function languages() + { + return [ + 'zh-HK' => ['zh-HK', self::ZHH], + 'zh-TW' => ['zh-TW', self::ZHT], + 'zh-CN' => ['zh-CN', self::ZHS], + 'zh' => ['zh', self::ZHS], + 'zh-Hans' => ['zh-Hans', self::ZHS], + 'zh-Hant' => ['zh-Hant', self::ZHT], + 'zh-Hans-HK' => ['zh-Hans-HK', self::ZHS], + 'zh-Hant-CN' => ['zh-Hant-CN', self::ZHT], + 'zh-MO, where the font has no ZHTM' => ['zh-MO', self::ZHH], + ]; + } + + public function testMacaoSelectsZHTMWhereTheFontOffersIt() + { + $this->assertSame([self::ZHTM], $this->drawn('NotoSansTC-MacaoLangSys-Synthetic.ttf', 'zh-MO')); + } + + private function drawn($font, $lang) + { + $key = strtolower(str_replace('-', '', basename($font, '.ttf'))); + + $mpdf = new TextRecordingMpdf([ + 'mode' => 'utf-8', + 'fontDir' => [__DIR__ . '/../data/ttf'], + 'fontdata' => [$key => [ + 'R' => $font, + 'useOTL' => 0xFF, + ]], + 'default_font' => $key, + ]); + $mpdf->WriteHTML(sprintf('

&#x%04X;

', $lang, self::BONE)); + + return array_values(unpack('N*', mb_convert_encoding($mpdf->drawnText[0], 'UTF-32BE', 'UTF-8'))); + } + +} diff --git a/tests/Mpdf/ChineseRegionLangSysTest.php b/tests/Mpdf/ChineseRegionLangSysTest.php deleted file mode 100644 index be97538a8..000000000 --- a/tests/Mpdf/ChineseRegionLangSysTest.php +++ /dev/null @@ -1,55 +0,0 @@ - ['zh-HK', self::ZHH], - 'zh-TW' => ['zh-TW', self::ZHT], - 'zh-CN' => ['zh-CN', self::ZHS], - ]; - } - - /** - * @dataProvider regions - */ - public function testTheRegionSelectsTheLanguageSystem($lang, $expected) - { - $mpdf = new TextRecordingMpdf([ - 'mode' => 'utf-8', - 'fontDir' => [__DIR__ . '/../data/ttf'], - 'fontdata' => ['notosanstcregionlangsyssynthetic' => [ - 'R' => 'NotoSansTC-RegionLangSys-Synthetic.ttf', - 'useOTL' => 0xFF, - ]], - 'default_font' => 'notosanstcregionlangsyssynthetic', - ]); - $mpdf->WriteHTML(sprintf('

&#x%04X;

', $lang, self::BONE)); - - $drawn = array_values(unpack('N*', mb_convert_encoding($mpdf->drawnText[0], 'UTF-32BE', 'UTF-8'))); - - $this->assertSame([$expected], $drawn); - } - -} diff --git a/tests/Mpdf/Shaper/OtlTagsTest.php b/tests/Mpdf/Shaper/OtlTagsTest.php index a421cb2fd..fbdc26eb2 100644 --- a/tests/Mpdf/Shaper/OtlTagsTest.php +++ b/tests/Mpdf/Shaper/OtlTagsTest.php @@ -124,36 +124,44 @@ public function languages() } /** - * @dataProvider chineseRegions + * @dataProvider chinese */ - public function testAChineseRegionSelectsItsLanguageSystem($ietf, $expected) + public function testChineseSelectsItsLanguageSystemTheWayHarfBuzzDoes($ietf, $expected) { - $this->assertSame($expected, OtlTags::language($ietf, 'DFLT ZHH ZHS ZHT ')); + $this->assertSame($expected, OtlTags::language($ietf, 'DFLT ZHH ZHS ZHT ZHTM ')); } - public function chineseRegions() + public function chinese() { return [ 'Hong Kong' => ['zh-HK', 'ZHH '], - 'Hong Kong, after the script' => ['zh-Hant-HK', 'ZHH '], 'Taiwan' => ['zh-TW', 'ZHT '], - 'Macao' => ['zh-MO', 'ZHT '], + 'Macao' => ['zh-MO', 'ZHTM'], 'China' => ['zh-CN', 'ZHS '], 'Singapore' => ['zh-SG', 'ZHS '], 'lower case' => ['zh-tw', 'ZHT '], - 'a region the table has no entry for' => ['zh-US', 'DFLT'], + 'a region with no language system of its own' => ['zh-US', 'ZHS '], + 'no script or region' => ['zh', 'ZHS '], + 'Simplified' => ['zh-Hans', 'ZHS '], + 'Traditional' => ['zh-Hant', 'ZHT '], + 'upper case' => ['ZH-HANT', 'ZHT '], + 'Simplified, in Hong Kong' => ['zh-Hans-HK', 'ZHS '], + 'Simplified, in Taiwan' => ['zh-Hans-TW', 'ZHS '], + 'Simplified, in Macao' => ['zh-Hans-MO', 'ZHS '], + 'Traditional, in China' => ['zh-Hant-CN', 'ZHT '], + 'Traditional, in Taiwan' => ['zh-Hant-TW', 'ZHT '], + 'Traditional, in Hong Kong' => ['zh-Hant-HK', 'ZHH '], + 'Traditional, in Macao' => ['zh-Hant-MO', 'ZHTM'], + 'another script, in Hong Kong' => ['zh-Latn-HK', 'ZHH '], + 'Min Nan, a retired tag' => ['zh-min-nan', 'ZHS '], ]; } - /** - * Pins #201 as it stands: without a region, Chinese has no language system, where HarfBuzz - * takes ZHS for zh and zh-Hans and ZHT for zh-Hant. - */ - public function testChineseWithoutARegionHasNoLanguageSystem() + public function testMacaoFallsBackToHongKong() { - $this->assertSame('DFLT', OtlTags::language('zh', 'DFLT ZHH ZHS ZHT ')); - $this->assertSame('DFLT', OtlTags::language('zh-Hant', 'DFLT ZHH ZHS ZHT ')); - $this->assertSame('DFLT', OtlTags::language('zh-Hans', 'DFLT ZHH ZHS ZHT ')); + $this->assertSame('ZHH ', OtlTags::language('zh-MO', 'DFLT ZHH ZHS ZHT ')); + $this->assertSame('ZHH ', OtlTags::language('zh-Hant-MO', 'DFLT ZHH ZHS ZHT ')); + $this->assertSame('DFLT', OtlTags::language('zh-MO', 'DFLT ZHS ZHT ')); } } diff --git a/tests/data/fontcache/NotoSansTC-MacaoLangSys-Synthetic.json b/tests/data/fontcache/NotoSansTC-MacaoLangSys-Synthetic.json new file mode 100644 index 000000000..58c35d481 --- /dev/null +++ b/tests/data/fontcache/NotoSansTC-MacaoLangSys-Synthetic.json @@ -0,0 +1,111 @@ +{ + "_": "Generated by composer fontcache:update. See tests/Mpdf/Fonts/ParserGoldenMaster.php.", + "fullName": "NotoSansTC-MacaoLangSys-Synthetic", + "mtx": { + "GSUBScriptLang": { + "hani": "DFLT ZHH ZHS ZHT ZHTM " + }, + "GSUBFeatures": { + "hani": { + "ZHH ": { + "locl": [ + 0 + ] + }, + "ZHS ": { + "locl": [ + 1 + ] + }, + "ZHT ": { + "locl": [ + 2 + ] + }, + "ZHTM": { + "locl": [ + 3 + ] + } + } + }, + "GSUBLookups": [ + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 152 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 166 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 180 + ], + "MarkFilteringSet": "" + }, + { + "Type": 1, + "Flag": 0, + "SubtableCount": 1, + "Subtables": [ + 194 + ], + "MarkFilteringSet": "" + } + ], + "GPOSScriptLang": [], + "GPOSFeatures": [], + "GPOSLookups": [], + "MarkGlyphSets": [], + "rtlPUAstr": "", + "haskernGPOS": false, + "hassmallcapsGSUB": false + }, + "cache": { + "GDEFdata.json": { + "GlyphClassBases": " 04E00| 04E09| 04E8C| 056DB| 09AA8", + "GlyphClassMarks": "", + "GlyphClassLigatures": "", + "GlyphClassComponents": "", + "MarkGlyphSets": [], + "MarkAttachmentType": [] + }, + "GSUB.dat": "206 bytes, sha256 15a34b758887cf89b199b406d9176b260da726c485910751dbf71edbb7515e8a", + "GSUBdata.json": [ + [ + { + "39592": 0 + } + ], + [ + { + "39592": 0 + } + ], + [ + { + "39592": 0 + } + ], + [ + { + "39592": 0 + } + ] + ] + } +} diff --git a/tests/data/otldump/NotoSansTC-MacaoLangSys-Synthetic.txt b/tests/data/otldump/NotoSansTC-MacaoLangSys-Synthetic.txt new file mode 100644 index 000000000..828995639 --- /dev/null +++ b/tests/data/otldump/NotoSansTC-MacaoLangSys-Synthetic.txt @@ -0,0 +1,17 @@ +

GDEF table

+

Glyph classes

+

Glyph class 1

+
Base glyph (single character, spacing glyph)
+
一 三 二 四 骨
+

GSUB Tables

+

GSUB Scripts & Languages

+
+
hani
ZHH : locl
ZHS : locl
ZHT : locl
ZHTM: locl
+
+
GPOS table not defined
+ +=== detail: script hani language DFLT === +

GSUB Tables

+
This font's GSUB script "hani" offers no language system "DFLT". It has: ZHH, ZHS, ZHT, ZHTM
+ +
GPOS table not defined
diff --git a/tests/data/shaping/NotoSansTC-MacaoLangSys-Synthetic.txt b/tests/data/shaping/NotoSansTC-MacaoLangSys-Synthetic.txt new file mode 100644 index 000000000..7c53de204 --- /dev/null +++ b/tests/data/shaping/NotoSansTC-MacaoLangSys-Synthetic.txt @@ -0,0 +1,92 @@ +=== latin === +0xFF 0041 0056 0041 0054 0061 0072 => 0041 0056 0041 0054 0061 0072 group=CCCCCC +0x80 0041 0056 0041 0054 0061 0072 => 0041 0056 0041 0054 0061 0072 group=CCCCCC +0xFF +kern -liga 0041 0056 0041 0054 0061 0072 => 0041 0056 0041 0054 0061 0072 group=CCCCCC +=== cyrillic === +0xFF 0416 0430 0439 => 0416 0430 0439 group=CCC +0x80 0416 0430 0439 => 0416 0430 0439 group=CCC +0xFF +kern -liga 0416 0430 0439 => 0416 0430 0439 group=CCC +=== greek === +0xFF 03B1 03B2 03C2 => 03B1 03B2 03C2 group=CCC +0x80 03B1 03B2 03C2 => 03B1 03B2 03C2 group=CCC +0xFF +kern -liga 03B1 03B2 03C2 => 03B1 03B2 03C2 group=CCC +=== hiragana === +0xFF 3042 3043 3044 => 3042 3043 3044 group=CCC +0x80 3042 3043 3044 => 3042 3043 3044 group=CCC +0xFF +kern -liga 3042 3043 3044 => 3042 3043 3044 group=CCC +=== arabic === +0xFF 0628 0640 0645 0644 0627 => 0628 0640 0645 0644 0627 group=CCCCC +0x80 0628 0640 0645 0644 0627 => 0628 0640 0645 0644 0627 group=CCCCC +0xFF +kern -liga 0628 0640 0645 0644 0627 => 0628 0640 0645 0644 0627 group=CCCCC +=== syriac === +0xFF 0710 0712 0713 0715 => 0710 0712 0713 0715 group=CCCC +0x80 0710 0712 0713 0715 => 0710 0712 0713 0715 group=CCCC +0xFF +kern -liga 0710 0712 0713 0715 => 0710 0712 0713 0715 group=CCCC +=== nko === +0xFF 07CA 07CB 07CC => 07CA 07CB 07CC group=CCC +0x80 07CA 07CB 07CC => 07CA 07CB 07CC group=CCC +0xFF +kern -liga 07CA 07CB 07CC => 07CA 07CB 07CC group=CCC +=== devanagari === +0xFF 0915 094D 0937 093F => 0915 094D 0937 093F group=CCCC +0x80 0915 094D 0937 093F => 0915 094D 0937 093F group=CCCC +0xFF +kern -liga 0915 094D 0937 093F => 0915 094D 0937 093F group=CCCC +=== bengali === +0xFF 0995 09CD 09B7 09BF => 0995 09CD 09B7 09BF group=CCCC +0x80 0995 09CD 09B7 09BF => 0995 09CD 09B7 09BF group=CCCC +0xFF +kern -liga 0995 09CD 09B7 09BF => 0995 09CD 09B7 09BF group=CCCC +=== gurmukhi === +0xFF 0A15 0A4D 0A38 0A3F => 0A15 0A4D 0A38 0A3F group=CCCC +0x80 0A15 0A4D 0A38 0A3F => 0A15 0A4D 0A38 0A3F group=CCCC +0xFF +kern -liga 0A15 0A4D 0A38 0A3F => 0A15 0A4D 0A38 0A3F group=CCCC +=== tamil === +0xFF 0B95 0BCD 0BB7 0BBF => 0B95 0BCD 0BB7 0BBF group=CCCC +0x80 0B95 0BCD 0BB7 0BBF => 0B95 0BCD 0BB7 0BBF group=CCCC +0xFF +kern -liga 0B95 0BCD 0BB7 0BBF => 0B95 0BCD 0BB7 0BBF group=CCCC +=== malayalam === +0xFF 0D15 0D4D 0D37 0D3F => 0D15 0D4D 0D37 0D3F group=CCCC +0x80 0D15 0D4D 0D37 0D3F => 0D15 0D4D 0D37 0D3F group=CCCC +0xFF +kern -liga 0D15 0D4D 0D37 0D3F => 0D15 0D4D 0D37 0D3F group=CCCC +=== sinhala === +0xFF 0D9A 0DCA 0DBB 0DBB => 0D9A 0DCA 0DBB 0DBB group=CCCC +0x80 0D9A 0DCA 0DBB 0DBB => 0D9A 0DCA 0DBB 0DBB group=CCCC +0xFF +kern -liga 0D9A 0DCA 0DBB 0DBB => 0D9A 0DCA 0DBB 0DBB group=CCCC +=== khmer === +0xFF 1780 17D2 1781 17C1 => 1780 17D2 1781 17C1 group=CCCC +0x80 1780 17D2 1781 17C1 => 1780 17D2 1781 17C1 group=CCCC +0xFF +kern -liga 1780 17D2 1781 17C1 => 1780 17D2 1781 17C1 group=CCCC +=== thai === +0xFF 0E01 0E34 0E48 0E23 => 0E01 0E34 0E48 0E23 group=CCCC +0x80 0E01 0E34 0E48 0E23 => 0E01 0E34 0E48 0E23 group=CCCC +0xFF +kern -liga 0E01 0E34 0E48 0E23 => 0E01 0E34 0E48 0E23 group=CCCC +=== lao === +0xFF 0E81 0EB4 0E8D => 0E81 0EB4 0E8D group=CCC +0x80 0E81 0EB4 0E8D => 0E81 0EB4 0E8D group=CCC +0xFF +kern -liga 0E81 0EB4 0E8D => 0E81 0EB4 0E8D group=CCC +=== myanmar === +0xFF 1000 103A 1039 1001 => 1000 103A 1039 1001 group=CCCC +0x80 1000 103A 1039 1001 => 1000 103A 1039 1001 group=CCCC +0xFF +kern -liga 1000 103A 1039 1001 => 1000 103A 1039 1001 group=CCCC +=== new tai lue === +0xFF 1980 19B0 1981 => 1980 19B0 1981 group=CCC +0x80 1980 19B0 1981 => 1980 19B0 1981 group=CCC +0xFF +kern -liga 1980 19B0 1981 => 1980 19B0 1981 group=CCC +=== cham === +0xFF AA00 AA33 AA01 => AA00 AA33 AA01 group=CCC +0x80 AA00 AA33 AA01 => AA00 AA33 AA01 group=CCC +0xFF +kern -liga AA00 AA33 AA01 => AA00 AA33 AA01 group=CCC +=== tai tham === +0xFF 1A20 1A60 1A21 => 1A20 1A60 1A21 group=CCC +0x80 1A20 1A60 1A21 => 1A20 1A60 1A21 group=CCC +0xFF +kern -liga 1A20 1A60 1A21 => 1A20 1A60 1A21 group=CCC +=== mixed scripts === +0xFF 0041 0628 0915 0042 => 0041 0628 0915 0042 group=CCCC +0x80 0041 0628 0915 0042 => 0041 0628 0915 0042 group=CCCC +0xFF +kern -liga 0041 0628 0915 0042 => 0041 0628 0915 0042 group=CCCC +=== spaced === +0xFF 0041 0020 0628 0020 0042 => 0041 0020 0628 0020 0042 group=CSCSC +0x80 0041 0020 0628 0020 0042 => 0041 0020 0628 0020 0042 group=CSCSC +0xFF +kern -liga 0041 0020 0628 0020 0042 => 0041 0020 0628 0020 0042 group=CSCSC +=== font characters 0 === +0xFF 0020 4E00 4E09 4E8C 56DB 9AA8 => 0020 4E00 4E09 4E8C 56DB 9AA8 group=SCCCCC +0x80 0020 4E00 4E09 4E8C 56DB 9AA8 => 0020 4E00 4E09 4E8C 56DB 9AA8 group=SCCCCC +0xFF +kern -liga 0020 4E00 4E09 4E8C 56DB 9AA8 => 0020 4E00 4E09 4E8C 56DB 9AA8 group=SCCCCC diff --git a/tests/data/subset/NotoSansTC-MacaoLangSys-Synthetic.json b/tests/data/subset/NotoSansTC-MacaoLangSys-Synthetic.json new file mode 100644 index 000000000..95c105180 --- /dev/null +++ b/tests/data/subset/NotoSansTC-MacaoLangSys-Synthetic.json @@ -0,0 +1,123 @@ +{ + "_": "Generated by composer subset:update. See tests/Mpdf/Fonts/SubsetGoldenMaster.php.", + "useOTL=0x00": { + "characters": 7, + "makeSubset": { + "bytes": 1828, + "sha256": "1a08d48939adc69ce292ff650076355c5052f120c731933af2ee7639286467ef", + "tables": { + "OS/2": "96 bytes, checksum 0x553CF586, sha256 4feb4a7666c967ad3bedc80ca0480d43b41a41ffe07c31028a6ef52218d38859", + "cmap": "108 bytes, checksum 0x3758A5CD, sha256 27e5bdb67154aea061c1a0b3e62f0b894a919ea72a9df76f6a575a3398b6c21f", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "616 bytes, checksum 0xF2BD808C, sha256 eb64d774f26f4e9a473759bbcdc30a0a8b61b20877fdfe44a02c0bd1355b96c6", + "head": "54 bytes, checksum 0x26853F95, sha256 1acb9bbfa64aefe9fb625673eb5b941acd732a30a7a9cdbe48b0867d476876aa", + "hhea": "36 bytes, checksum 0x087602F8, sha256 d4af68535beeb99ce192587063523bb1862795cbb69021e6a6f6837509a41164", + "hmtx": "28 bytes, checksum 0x185001B1, sha256 dcdc38d773361123ed46e2ed00be835f86453b8de0b415d657f8894a0356a4ae", + "loca": "16 bytes, checksum 0x013C0204, sha256 16b586cebec1f59e81f7212f6264e445b9d03d7549e66ab0595a3c8f8b8b5a01", + "maxp": "32 bytes, checksum 0x00100051, sha256 a90cbb1baa544bddcc9065d5a64a0fb38a90285012ee709966a26b270ec7f551", + "name": "612 bytes, checksum 0x31BF4960, sha256 23c77f4598d07365d682aa25460b20f76e80c4c511e019e04b5390477f8ebb4b", + "post": "32 bytes, checksum 0xFF860032, sha256 22dd499f08932214bde96943f3882f3619324478217095c049b68a57c6c10693" + }, + "maxUni": 65535, + "defaultWidth": 1000, + "codeToGlyph": "7 entries, sha256 33437c8ffd013e5cb18588ae09402766e4327e02b063f3258c7c8414254c0e19" + }, + "makeSubsetSIP": { + "bytes": 1804, + "sha256": "7e85dff50a5defaf99bfafaa39871f02e383726483048542d2d524839c9ad202", + "tables": { + "OS/2": "96 bytes, checksum 0x5910D1E5, sha256 9b6aabb1544d4c39e4e3ce746b7822eaef6c698888d89808bf3fe66f6a62bf7f", + "cmap": "84 bytes, checksum 0x00380090, sha256 b744253609e2a88d21149d2993bd74c60e813a46a40dbdd5fa6a10f27bd1defe", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "616 bytes, checksum 0xF2BD808C, sha256 eb64d774f26f4e9a473759bbcdc30a0a8b61b20877fdfe44a02c0bd1355b96c6", + "head": "54 bytes, checksum 0x26853F95, sha256 d3c8bad2f1b5344ce391c0bdbbaf20112ab08c2549862b0f290b099c920716cf", + "hhea": "36 bytes, checksum 0x087602F8, sha256 d4af68535beeb99ce192587063523bb1862795cbb69021e6a6f6837509a41164", + "hmtx": "28 bytes, checksum 0x185001B1, sha256 dcdc38d773361123ed46e2ed00be835f86453b8de0b415d657f8894a0356a4ae", + "loca": "16 bytes, checksum 0x013C0204, sha256 16b586cebec1f59e81f7212f6264e445b9d03d7549e66ab0595a3c8f8b8b5a01", + "maxp": "32 bytes, checksum 0x00100051, sha256 a90cbb1baa544bddcc9065d5a64a0fb38a90285012ee709966a26b270ec7f551", + "name": "612 bytes, checksum 0x31B84960, sha256 54f060d3e1c9cbea3eba51254e89baf8171d80dc6c5b4ffddcdbb053e7fc612b", + "post": "32 bytes, checksum 0xFF860032, sha256 22dd499f08932214bde96943f3882f3619324478217095c049b68a57c6c10693" + }, + "maxUniChar": 65535, + "defaultWidth": 1000 + }, + "repackageTTF": { + "bytes": 1800, + "sha256": "6691f7d9e584dad1f8b60832ec511ea8bdaf26b6e87fab8060151f58b9374998", + "tables": { + "OS/2": "96 bytes, checksum 0x553CF586, sha256 4feb4a7666c967ad3bedc80ca0480d43b41a41ffe07c31028a6ef52218d38859", + "cmap": "92 bytes, checksum 0xC9881364, sha256 733b6a16d046f7383c0562d37943c507574d58ad29e5f1215189dde306cb0f29", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "610 bytes, checksum 0x310A423E, sha256 07f8c133a7cd0ae670db6b0b91d1c28df3f22310dbe6371f44400eda2f635330", + "head": "54 bytes, checksum 0x26853F95, sha256 bfd07cb84f0abdc2d9b0567935b1128c4e0496677e5451dceb5c1bf7c35208c6", + "hhea": "36 bytes, checksum 0x087602F4, sha256 3b8df10855749dc2b94ca33deacd41aa368f250bd21bfe9561090e4dece3ed54", + "hmtx": "20 bytes, checksum 0x09490118, sha256 25146f57e5c57b537146373684d9d1a2321c5a9161766675cf932e2c89f231e0", + "loca": "16 bytes, checksum 0x013501FB, sha256 aae769be1e60c3529a0063171e29d79ca147197e2ed59c40dcbc5bbb949381ed", + "maxp": "32 bytes, checksum 0x00100051, sha256 a90cbb1baa544bddcc9065d5a64a0fb38a90285012ee709966a26b270ec7f551", + "name": "612 bytes, checksum 0x31BF4960, sha256 23c77f4598d07365d682aa25460b20f76e80c4c511e019e04b5390477f8ebb4b", + "post": "32 bytes, checksum 0xFF860032, sha256 22dd499f08932214bde96943f3882f3619324478217095c049b68a57c6c10693" + }, + "maxUni": 0 + } + }, + "useOTL=0xFF": { + "characters": 7, + "makeSubset": { + "bytes": 1828, + "sha256": "1a08d48939adc69ce292ff650076355c5052f120c731933af2ee7639286467ef", + "tables": { + "OS/2": "96 bytes, checksum 0x553CF586, sha256 4feb4a7666c967ad3bedc80ca0480d43b41a41ffe07c31028a6ef52218d38859", + "cmap": "108 bytes, checksum 0x3758A5CD, sha256 27e5bdb67154aea061c1a0b3e62f0b894a919ea72a9df76f6a575a3398b6c21f", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "616 bytes, checksum 0xF2BD808C, sha256 eb64d774f26f4e9a473759bbcdc30a0a8b61b20877fdfe44a02c0bd1355b96c6", + "head": "54 bytes, checksum 0x26853F95, sha256 1acb9bbfa64aefe9fb625673eb5b941acd732a30a7a9cdbe48b0867d476876aa", + "hhea": "36 bytes, checksum 0x087602F8, sha256 d4af68535beeb99ce192587063523bb1862795cbb69021e6a6f6837509a41164", + "hmtx": "28 bytes, checksum 0x185001B1, sha256 dcdc38d773361123ed46e2ed00be835f86453b8de0b415d657f8894a0356a4ae", + "loca": "16 bytes, checksum 0x013C0204, sha256 16b586cebec1f59e81f7212f6264e445b9d03d7549e66ab0595a3c8f8b8b5a01", + "maxp": "32 bytes, checksum 0x00100051, sha256 a90cbb1baa544bddcc9065d5a64a0fb38a90285012ee709966a26b270ec7f551", + "name": "612 bytes, checksum 0x31BF4960, sha256 23c77f4598d07365d682aa25460b20f76e80c4c511e019e04b5390477f8ebb4b", + "post": "32 bytes, checksum 0xFF860032, sha256 22dd499f08932214bde96943f3882f3619324478217095c049b68a57c6c10693" + }, + "maxUni": 65535, + "defaultWidth": 1000, + "codeToGlyph": "7 entries, sha256 33437c8ffd013e5cb18588ae09402766e4327e02b063f3258c7c8414254c0e19" + }, + "makeSubsetSIP": { + "bytes": 1804, + "sha256": "7e85dff50a5defaf99bfafaa39871f02e383726483048542d2d524839c9ad202", + "tables": { + "OS/2": "96 bytes, checksum 0x5910D1E5, sha256 9b6aabb1544d4c39e4e3ce746b7822eaef6c698888d89808bf3fe66f6a62bf7f", + "cmap": "84 bytes, checksum 0x00380090, sha256 b744253609e2a88d21149d2993bd74c60e813a46a40dbdd5fa6a10f27bd1defe", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "616 bytes, checksum 0xF2BD808C, sha256 eb64d774f26f4e9a473759bbcdc30a0a8b61b20877fdfe44a02c0bd1355b96c6", + "head": "54 bytes, checksum 0x26853F95, sha256 d3c8bad2f1b5344ce391c0bdbbaf20112ab08c2549862b0f290b099c920716cf", + "hhea": "36 bytes, checksum 0x087602F8, sha256 d4af68535beeb99ce192587063523bb1862795cbb69021e6a6f6837509a41164", + "hmtx": "28 bytes, checksum 0x185001B1, sha256 dcdc38d773361123ed46e2ed00be835f86453b8de0b415d657f8894a0356a4ae", + "loca": "16 bytes, checksum 0x013C0204, sha256 16b586cebec1f59e81f7212f6264e445b9d03d7549e66ab0595a3c8f8b8b5a01", + "maxp": "32 bytes, checksum 0x00100051, sha256 a90cbb1baa544bddcc9065d5a64a0fb38a90285012ee709966a26b270ec7f551", + "name": "612 bytes, checksum 0x31B84960, sha256 54f060d3e1c9cbea3eba51254e89baf8171d80dc6c5b4ffddcdbb053e7fc612b", + "post": "32 bytes, checksum 0xFF860032, sha256 22dd499f08932214bde96943f3882f3619324478217095c049b68a57c6c10693" + }, + "maxUniChar": 65535, + "defaultWidth": 1000 + }, + "repackageTTF": { + "bytes": 1808, + "sha256": "2112a40561ecf5ba8871bae9f44ff1d365a448e36d2990f6abb05c613cf393c5", + "tables": { + "OS/2": "96 bytes, checksum 0x553CF586, sha256 4feb4a7666c967ad3bedc80ca0480d43b41a41ffe07c31028a6ef52218d38859", + "cmap": "100 bytes, checksum 0xC9881391, sha256 1d4376603e4499f42e96a03200d95c4a71efad8458f72b5f4a3879e07784b1bc", + "gasp": "8 bytes, checksum 0x00000010, sha256 4ca731f86ad506ac0e320283dc9461926346de3c4d91d539ea7f6620d3826940", + "glyf": "610 bytes, checksum 0x310A423E, sha256 07f8c133a7cd0ae670db6b0b91d1c28df3f22310dbe6371f44400eda2f635330", + "head": "54 bytes, checksum 0x26853F95, sha256 92de6dfe6f3b4c977d8d4cd3346333f92231c0ef7f08acdd9a2e12a3530ea8e7", + "hhea": "36 bytes, checksum 0x087602F4, sha256 3b8df10855749dc2b94ca33deacd41aa368f250bd21bfe9561090e4dece3ed54", + "hmtx": "20 bytes, checksum 0x09490118, sha256 25146f57e5c57b537146373684d9d1a2321c5a9161766675cf932e2c89f231e0", + "loca": "16 bytes, checksum 0x013501FB, sha256 aae769be1e60c3529a0063171e29d79ca147197e2ed59c40dcbc5bbb949381ed", + "maxp": "32 bytes, checksum 0x00100051, sha256 a90cbb1baa544bddcc9065d5a64a0fb38a90285012ee709966a26b270ec7f551", + "name": "612 bytes, checksum 0x31BF4960, sha256 23c77f4598d07365d682aa25460b20f76e80c4c511e019e04b5390477f8ebb4b", + "post": "32 bytes, checksum 0xFF860032, sha256 22dd499f08932214bde96943f3882f3619324478217095c049b68a57c6c10693" + }, + "maxUni": 0 + } + } +} diff --git a/tests/data/ttf/NotoSansTC-MacaoLangSys-Synthetic.ttf b/tests/data/ttf/NotoSansTC-MacaoLangSys-Synthetic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d84eaa7ccc76b65925bf98db361ce7aeb2154482 GIT binary patch literal 2064 zcmb7FTWph69RHtl%Gb-bc4b{VV1sWf>k5pqw(H6^C~RZU$u5jBTo#>_bz3LfT4BzD zhLCW{Vj%GWq618fi3*7c;0vM;F8H7(3YeIfC?x0ukoY1AJRrUJJ73F9<-xD{-~Z=- zJC_n8q5?WVg0^hh(6*80lb%TLhSt8Rx36X3i(NlJUJv<7cke21-Q!@zsEx(cy5!b$Hzg*scTWqfi*8`8}++ zL0%f2m`WJiSqbFNA=}1dLt*A;53yF(Q#cV$#1Xv*cElrfGCVQt`Ko;fk?u5nBJtRs zsmu}bB0tQelI)E$<)KCWQgvoV%n4byLR@O`VV!O! zeUk|O)#Mg)S|rVsLqgN&X$u)Njm-4yrxU-hcW538c$@dbv^h)JAbD9Ew0Vz`!N#&d zvH_Ymv~<&YQcs={@yv{@gsIX;TKN)w14JbSl||@guEJ(&pnmF>4gdkL0m~IO(oqQ1 zj5;|wRJ*9l*mZRKuP4uBGSELGXMX#PU-;`n;_UL_!E8QwJo#483R+FQG)fnl2c1PQ zxkH$oKwKl`lZEI-Yq^!Na>lG#6cK7V&Vs2!Y!EoY-%4Ljf5?IfaVJ^A%h+r>m>w10 zza(?2z+lAe*?rFKMf^h|>QUznb!F6N%&uztoeaZJ1Jj=0|ai z-_>F-q=1ZXb)p?-0RBZjv(VS@s-Ot?tTwx^QTV*|0lVFyD9+`=S>bZ|`-Q(V73pyE zKT^dv+nL3^rBzor->{~wBJbx-CX2F2U8LkYO`LP1)2dgUWs1{f=692K__dV#L_7av z{Op0ja+AGSDeZafy}tCt5}V+br3HdJYOAn&-I;S@R+OPW3ssPd{IrhR<(^|oPCuXs zwMse<6)Ji~sdBlhymqgzzTVH!LY1%BrYKqy-0ukTfWyr7fxu$!fUXGZik;o!g;1)s z?I`zbPVWq{d!a(RqqC8l^<{M~L!L2R$weyV3>8#X>KzWFv)tIzbn4SSUH1nkg51`0 zFtN<$P8@7tmA|+0g>(D&v#--PZr=*0#}+R!7z8VD7BFTov3J+5ecwAhU8^s&>Q;=5 zR3A4jP%UdMYH9veAD_R#+-P1@%+A;C(+-}V2C#EXXM}`1p5qzr_~{(aakj7Ic!7I! zJ;&>)g_Y*`dGspoT{eya{kP<%AzFre;deiY2XMn8{s(#t4dvnFrj6r!i|d2 z>+l+aTtzjo#o!ZzS1-IK5ktiZZ3XUxJOqeQhh`at$6nZFrcuNghjuqQsXe|6Ng3>rqZ_64OyJcUf}{ zs0VLM6J`^pU5FwlF#?(ZWaIeZRgcqO4}aft`gz`*R%-K-I}k;@DbzQFQ=xsCgQ)Vq zK1K{LrA2!W^K%Xyl6*}WWCAoI76hp zx)RHESqe^*@pfxekPiQR Date: Thu, 17 Sep 2026 09:32:33 +1000 Subject: [PATCH 2/2] Hold the Chinese regions once, and read a Traditional tag's region in one place (#201) Co-Authored-By: Claude Opus 5 (1M context) --- src/Shaper/OtlTags.php | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Shaper/OtlTags.php b/src/Shaper/OtlTags.php index aaa4f48ea..07c8c57e4 100644 --- a/src/Shaper/OtlTags.php +++ b/src/Shaper/OtlTags.php @@ -30,6 +30,15 @@ class OtlTags 'mym2' => 'mymr', ]; + /** + * The Chinese regions with language systems of their own, in the order HarfBuzz looks for them. + */ + private static $chineseRegions = [ + 'hk' => ['ZHH '], + 'mo' => ['ZHTM', 'ZHH '], + 'tw' => ['ZHT '], + ]; + /** * The script tag a run is laid out with, out of what the table offers. * @@ -88,12 +97,11 @@ public static function language($ietf, $available) $subtags = $ietf ? explode('-', strtolower($ietf)) : []; + $candidates = []; if (isset($subtags[0]) && $subtags[0] == 'zh') { $candidates = self::chinese($subtags); } elseif (isset($subtags[0]) && isset(Ucdn::$ot_languages[$subtags[0]])) { $candidates = [Ucdn::$ot_languages[$subtags[0]]]; - } else { - $candidates = []; } foreach ($candidates as $langsys) { @@ -122,25 +130,18 @@ public static function language($ietf, $available) */ private static function chinese(array $subtags) { - $regions = [ - 'hk' => ['ZHH '], - 'mo' => ['ZHTM', 'ZHH '], - 'tw' => ['ZHT '], - ]; - $script = isset($subtags[1]) ? $subtags[1] : ''; - if ($script == 'hant' && isset($subtags[2]) && ($subtags[2] == 'hk' || $subtags[2] == 'mo')) { - return $regions[$subtags[2]]; - } if ($script == 'hans') { return ['ZHS ']; } if ($script == 'hant') { - return ['ZHT ']; + $region = isset($subtags[2]) ? $subtags[2] : ''; + + return $region == 'hk' || $region == 'mo' ? self::$chineseRegions[$region] : ['ZHT ']; } - foreach ($regions as $region => $langsys) { + foreach (self::$chineseRegions as $region => $langsys) { if (in_array($region, $subtags, true)) { return $langsys; }