Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.98% covered (success)
96.98%
385 / 397
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
CounterStyleRegistry
96.98% covered (success)
96.98%
385 / 397
68.75% covered (warning)
68.75%
11 / 16
85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 definition
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 representation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 marker
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 build
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
13
 buildFallback
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 generate
90.24% covered (success)
90.24%
37 / 41
0.00% covered (danger)
0.00%
0 / 1
19.34
 additive
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
12.54
 cjkIdeographic
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
14
 elidesLeadingOne
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 predefined
100.00% covered (success)
100.00%
240 / 240
100.00% covered (success)
100.00%
1 / 1
7
 digits
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 armenianTuples
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 georgianTuples
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 hebrewTuples
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Layout;
6
7/**
8 * CSS Counter Styles 3 — the counter-style catalogue and the §2 "generate
9 * a counter representation" algorithm.
10 *
11 * One registry holds the predefined styles of §6 / §7 plus any author
12 * `@counter-style` rules layered on top. Two entry points, matching the two
13 * ways a representation is consumed:
14 *
15 *  - {@see representation()} — what `counter()` / `counters()` substitute:
16 *    the symbols plus the negative sign, and nothing else;
17 *  - {@see marker()} — what a `::marker` shows: `prefix`, the
18 *    representation, then `suffix`.
19 *
20 * Getting that split wrong is visible: every CSS 2.1 list reftest writes its
21 * expected marker as literal `1. ` text, so the suffix is part of the
22 * rendered geometry, while `content: counter(n)` must not carry it.
23 */
24final class CounterStyleRegistry
25{
26    /** Guards `fallback` loops — `a` falling back to `b` falling back to `a`. */
27    private const int MAX_FALLBACK_DEPTH = 16;
28
29    /** @var array<string, CounterStyleDefinition> */
30    private array $styles;
31
32    /** @var array<string, CounterStyleDefinition>|null */
33    private static ?array $predefinedCache = null;
34
35    /**
36     * @param array<string, CounterStyleDefinition> $authorStyles author
37     *        `@counter-style` rules, keyed by lower-cased name. They
38     *        override same-named predefined styles (CSS Counter Styles 3
39     *        §4 — an author rule may redefine a predefined style).
40     */
41    public function __construct(array $authorStyles = [])
42    {
43        $this->styles = $authorStyles + self::predefined();
44    }
45
46    public function has(string $name): bool
47    {
48        return isset($this->styles[strtolower($name)]);
49    }
50
51    public function definition(string $name): ?CounterStyleDefinition
52    {
53        return $this->styles[strtolower($name)] ?? null;
54    }
55
56    /**
57     * The counter representation for `$value` — symbols plus negative sign,
58     * without `prefix` / `suffix`. This is what `counter()` produces.
59     */
60    public function representation(int $value, string $name): string
61    {
62        return $this->build($value, strtolower($name), 0) ?? (string) $value;
63    }
64
65    /**
66     * The full marker string: `prefix` + representation + `suffix`.
67     *
68     * The affixes come from the style that was ASKED for, even when the
69     * representation itself came from a fallback — CSS Counter Styles 3 §2
70     * only defers the symbol generation.
71     */
72    public function marker(int $value, string $name): string
73    {
74        $key = strtolower($name);
75        $def = $this->styles[$key] ?? null;
76        if ($def === null) {
77            return (string) $value . '. ';
78        }
79        return $def->prefix . $this->representation($value, $key) . $def->suffix;
80    }
81
82    /**
83     * CSS Counter Styles 3 §2 — generate a counter representation, or null
84     * when neither this style nor its fallback chain can represent `$value`.
85     */
86    private function build(int $value, string $name, int $depth): ?string
87    {
88        if ($depth > self::MAX_FALLBACK_DEPTH) {
89            return null;
90        }
91        $def = $this->styles[$name] ?? null;
92        if ($def === null) {
93            return null;
94        }
95        [$min, $max] = $def->effectiveRange();
96        if ($value < $min || $value > $max) {
97            return $this->buildFallback($value, $def, $depth);
98        }
99        $negative = $value < 0 && $def->usesNegativeSign();
100        $core = $this->generate($def, $negative ? -$value : $value);
101        if ($core === null) {
102            return $this->buildFallback($value, $def, $depth);
103        }
104        // §4.6 `pad` — the length compared against is the length of the
105        // representation INCLUDING the negative sign, and the pad symbols
106        // go between the sign and the symbols (`-*I`, never `*-I`).
107        $length = mb_strlen($core, 'UTF-8');
108        if ($negative) {
109            $length += mb_strlen($def->negativePrefix, 'UTF-8')
110                + mb_strlen($def->negativeSuffix, 'UTF-8');
111        }
112        if ($def->padSymbol !== '') {
113            $padWidth = mb_strlen($def->padSymbol, 'UTF-8');
114            while ($length < $def->padLength && $padWidth > 0) {
115                $core = $def->padSymbol . $core;
116                $length += $padWidth;
117            }
118        }
119        return $negative
120            ? $def->negativePrefix . $core . $def->negativeSuffix
121            : $core;
122    }
123
124    private function buildFallback(int $value, CounterStyleDefinition $def, int $depth): ?string
125    {
126        if ($def->fallback === null) {
127            return null;
128        }
129        return $this->build($value, strtolower($def->fallback), $depth + 1);
130    }
131
132    /**
133     * CSS Counter Styles 3 §3 — the five counter systems, applied to a
134     * value already made non-negative where the system requires it.
135     * Returns null when the system cannot represent the value at all,
136     * which sends the caller to the fallback.
137     */
138    private function generate(CounterStyleDefinition $def, int $value): ?string
139    {
140        $symbols = $def->symbols;
141        $count = count($symbols);
142        switch ($def->system) {
143            case CounterStyleDefinition::CYCLIC:
144                if ($count === 0) {
145                    return null;
146                }
147                // Wrap in both directions: `(-1 - 1) % 3` is -2 in PHP.
148                return $symbols[((($value - 1) % $count) + $count) % $count];
149
150            case CounterStyleDefinition::FIXED:
151                $index = $value - $def->fixedFirst;
152                return ($index >= 0 && $index < $count) ? $symbols[$index] : null;
153
154            case CounterStyleDefinition::SYMBOLIC:
155                if ($count === 0 || $value < 1) {
156                    return null;
157                }
158                return str_repeat(
159                    $symbols[($value - 1) % $count],
160                    (int) ceil($value / $count),
161                );
162
163            case CounterStyleDefinition::ALPHABETIC:
164                if ($count < 2 || $value < 1) {
165                    return null;
166                }
167                $out = '';
168                while ($value > 0) {
169                    $value--;
170                    $out = $symbols[$value % $count] . $out;
171                    $value = intdiv($value, $count);
172                }
173                return $out;
174
175            case CounterStyleDefinition::NUMERIC:
176                if ($count < 2) {
177                    return null;
178                }
179                if ($value === 0) {
180                    return $symbols[0];
181                }
182                $out = '';
183                while ($value > 0) {
184                    $out = $symbols[$value % $count] . $out;
185                    $value = intdiv($value, $count);
186                }
187                return $out;
188
189            case CounterStyleDefinition::ADDITIVE:
190                return self::additive($def->additiveSymbols, $value);
191
192            case CounterStyleDefinition::CJK_IDEOGRAPHIC:
193                return self::cjkIdeographic($def, $value);
194        }
195        return null;
196    }
197
198    /**
199     * CSS Counter Styles 3 §3.6 — the additive system. Greedily consume the
200     * weight-descending tuple list; a non-zero remainder means the value is
201     * not representable and the style must fall back.
202     *
203     * @param list<array{0: int, 1: string}> $tuples
204     */
205    private static function additive(array $tuples, int $value): ?string
206    {
207        if ($tuples === [] || $value < 0) {
208            return null;
209        }
210        if ($value === 0) {
211            foreach ($tuples as [$weight, $symbol]) {
212                if ($weight === 0) {
213                    return $symbol;
214                }
215            }
216            return null;
217        }
218        $out = '';
219        foreach ($tuples as [$weight, $symbol]) {
220            if ($weight <= 0 || $weight > $value) {
221                continue;
222            }
223            $repeat = intdiv($value, $weight);
224            $out .= str_repeat($symbol, $repeat);
225            $value -= $repeat * $weight;
226            if ($value === 0) {
227                return $out;
228            }
229        }
230        return null;
231    }
232
233    /**
234     * CSS Counter Styles 3 §7.1 — the East Asian "longhand" numerals, for
235     * values 0-9999 (the range every one of these styles declares).
236     *
237     * Walks the four decimal places from thousands down, emitting
238     * `digit + place marker`, with two per-family adjustments:
239     *
240     *  - a leading `1` before a place marker is dropped by the informal
241     *    styles (十 rather than 壹拾), never by the formal ones;
242     *  - Chinese inserts the zero digit wherever an interior place was
243     *    skipped (一千零五), which is the reason this cannot be the
244     *    `additive` system the spec presents it as.
245     *
246     * Trailing zero places emit nothing at all: 1800 is 一千八百.
247     */
248    private static function cjkIdeographic(CounterStyleDefinition $def, int $value): ?string
249    {
250        $digits = $def->symbols;
251        $multipliers = $def->cjkMultipliers;
252        if (count($digits) < 10 || count($multipliers) < 3 || $value < 0 || $value > 9999) {
253            return null;
254        }
255        if ($value === 0) {
256            return $digits[0];
257        }
258        $out = '';
259        $emitted = false;
260        $skipped = false;
261        for ($place = 3; $place >= 0; $place--) {
262            $digit = intdiv($value, 10 ** $place) % 10;
263            if ($digit === 0) {
264                // Only an INTERIOR zero can call for a filler; a run of
265                // leading zeros is not part of the number at all, and a
266                // trailing one is simply absent.
267                $skipped = $emitted;
268                continue;
269            }
270            if ($skipped && $def->cjkZeroFiller) {
271                $out .= $digits[0];
272            }
273            $skipped = false;
274            if ($place === 0 || $digit !== 1 || !self::elidesLeadingOne($def, $place, $emitted)) {
275                $out .= $digits[$digit];
276            }
277            if ($place > 0) {
278                $out .= $multipliers[$place - 1];
279            }
280            $emitted = true;
281        }
282        return $out;
283    }
284
285    private static function elidesLeadingOne(
286        CounterStyleDefinition $def,
287        int $place,
288        bool $emitted,
289    ): bool {
290        return match ($def->cjkElideOne) {
291            CounterStyleDefinition::ELIDE_ALL => true,
292            CounterStyleDefinition::ELIDE_LEADING_TENS => $place === 1 && !$emitted,
293            default => false,
294        };
295    }
296
297    // -----------------------------------------------------------------
298    // Predefined styles — CSS Counter Styles 3 §6 and §7.
299    // -----------------------------------------------------------------
300
301    /** @return array<string, CounterStyleDefinition> */
302    public static function predefined(): array
303    {
304        if (self::$predefinedCache !== null) {
305            return self::$predefinedCache;
306        }
307        $s = [];
308
309        // §6.1 Symbolic. The bullet families cycle a single symbol and use a
310        // bare space suffix rather than the default `". "`.
311        foreach ([
312            'disc' => "\u{2022}",
313            'circle' => "\u{25E6}",
314            'square' => "\u{25AA}",
315            'disclosure-open' => "\u{25BC}",
316            'disclosure-closed' => "\u{25B6}",
317        ] as $name => $glyph) {
318            $s[$name] = new CounterStyleDefinition(
319                system: CounterStyleDefinition::CYCLIC,
320                symbols: [$glyph],
321                suffix: ' ',
322            );
323        }
324
325        // §6.2 Numeric — positional systems over a script's decimal digits.
326        $s['decimal'] = new CounterStyleDefinition(
327            system: CounterStyleDefinition::NUMERIC,
328            symbols: self::digits(0x0030),
329        );
330        $s['decimal-leading-zero'] = new CounterStyleDefinition(
331            system: CounterStyleDefinition::NUMERIC,
332            symbols: self::digits(0x0030),
333            padLength: 2,
334            padSymbol: '0',
335        );
336        foreach ([
337            'arabic-indic' => 0x0660,
338            'bengali' => 0x09E6,
339            'devanagari' => 0x0966,
340            'gujarati' => 0x0AE6,
341            'gurmukhi' => 0x0A66,
342            'kannada' => 0x0CE6,
343            'khmer' => 0x17E0,
344            'lao' => 0x0ED0,
345            'malayalam' => 0x0D66,
346            'mongolian' => 0x1810,
347            'myanmar' => 0x1040,
348            'oriya' => 0x0B66,
349            'persian' => 0x06F0,
350            'tamil' => 0x0BE6,
351            'telugu' => 0x0C66,
352            'thai' => 0x0E50,
353            'tibetan' => 0x0F20,
354        ] as $name => $zero) {
355            $s[$name] = new CounterStyleDefinition(
356                system: CounterStyleDefinition::NUMERIC,
357                symbols: self::digits($zero),
358            );
359        }
360        // `cambodian` is defined as `system: extends khmer`.
361        $s['cambodian'] = $s['khmer'];
362        $s['cjk-decimal'] = new CounterStyleDefinition(
363            system: CounterStyleDefinition::NUMERIC,
364            symbols: [
365                "\u{3007}", "\u{4E00}", "\u{4E8C}", "\u{4E09}", "\u{56DB}",
366                "\u{4E94}", "\u{516D}", "\u{4E03}", "\u{516B}", "\u{4E5D}",
367            ],
368            suffix: "\u{3001}",
369            rangeMin: 0,
370        );
371
372        // §6.2 Numeric, additive spellings.
373        $roman = [
374            [1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
375            [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
376            [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
377        ];
378        $s['upper-roman'] = new CounterStyleDefinition(
379            system: CounterStyleDefinition::ADDITIVE,
380            additiveSymbols: $roman,
381            rangeMin: 1,
382            rangeMax: 3999,
383        );
384        $s['lower-roman'] = new CounterStyleDefinition(
385            system: CounterStyleDefinition::ADDITIVE,
386            additiveSymbols: array_map(
387                static fn(array $t): array => [$t[0], strtolower($t[1])],
388                $roman,
389            ),
390            rangeMin: 1,
391            rangeMax: 3999,
392        );
393        $armenian = self::armenianTuples();
394        $s['upper-armenian'] = new CounterStyleDefinition(
395            system: CounterStyleDefinition::ADDITIVE,
396            additiveSymbols: $armenian,
397            rangeMin: 1,
398            rangeMax: 9999,
399        );
400        $s['armenian'] = $s['upper-armenian'];
401        $s['lower-armenian'] = new CounterStyleDefinition(
402            system: CounterStyleDefinition::ADDITIVE,
403            additiveSymbols: array_map(
404                static fn(array $t): array => [$t[0], mb_strtolower($t[1], 'UTF-8')],
405                $armenian,
406            ),
407            rangeMin: 1,
408            rangeMax: 9999,
409        );
410        $s['georgian'] = new CounterStyleDefinition(
411            system: CounterStyleDefinition::ADDITIVE,
412            additiveSymbols: self::georgianTuples(),
413            rangeMin: 1,
414            rangeMax: 19999,
415        );
416        $s['hebrew'] = new CounterStyleDefinition(
417            system: CounterStyleDefinition::ADDITIVE,
418            additiveSymbols: self::hebrewTuples(),
419            rangeMin: 1,
420            rangeMax: 10999,
421        );
422
423        // §6.3 Alphabetic — bijective numbering over a symbol list.
424        $s['lower-alpha'] = new CounterStyleDefinition(
425            system: CounterStyleDefinition::ALPHABETIC,
426            symbols: range('a', 'z'),
427        );
428        $s['lower-latin'] = $s['lower-alpha'];
429        $s['upper-alpha'] = new CounterStyleDefinition(
430            system: CounterStyleDefinition::ALPHABETIC,
431            symbols: range('A', 'Z'),
432        );
433        $s['upper-latin'] = $s['upper-alpha'];
434        $s['lower-greek'] = new CounterStyleDefinition(
435            system: CounterStyleDefinition::ALPHABETIC,
436            symbols: [
437                'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ',
438                'ν', 'ξ', 'ο', 'π', 'ρ', 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
439            ],
440        );
441        foreach ([
442            'hiragana' => [
443                'あ','い','う','え','お','か','き','く','け','こ',
444                'さ','し','す','せ','そ','た','ち','つ','て','と',
445                'な','に','ぬ','ね','の','は','ひ','ふ','へ','ほ',
446                'ま','み','む','め','も','や','ゆ','よ','ら','り',
447                'る','れ','ろ','わ','ゐ','ゑ','を','ん',
448            ],
449            'hiragana-iroha' => [
450                'い','ろ','は','に','ほ','へ','と','ち','り','ぬ',
451                'る','を','わ','か','よ','た','れ','そ','つ','ね',
452                'な','ら','む','う','ゐ','の','お','く','や','ま',
453                'け','ふ','こ','え','て','あ','さ','き','ゆ','め',
454                'み','し','ゑ','ひ','も','せ','す',
455            ],
456            'katakana' => [
457                'ア','イ','ウ','エ','オ','カ','キ','ク','ケ','コ',
458                'サ','シ','ス','セ','ソ','タ','チ','ツ','テ','ト',
459                'ナ','ニ','ヌ','ネ','ノ','ハ','ヒ','フ','ヘ','ホ',
460                'マ','ミ','ム','メ','モ','ヤ','ユ','ヨ','ラ','リ',
461                'ル','レ','ロ','ワ','ヰ','ヱ','ヲ','ン',
462            ],
463            'katakana-iroha' => [
464                'イ','ロ','ハ','ニ','ホ','ヘ','ト','チ','リ','ヌ',
465                'ル','ヲ','ワ','カ','ヨ','タ','レ','ソ','ツ','ネ',
466                'ナ','ラ','ム','ウ','ヰ','ノ','オ','ク','ヤ','マ',
467                'ケ','フ','コ','エ','テ','ア','サ','キ','ユ','メ',
468                'ミ','シ','ヱ','ヒ','モ','セ','ス',
469            ],
470        ] as $name => $kana) {
471            $s[$name] = new CounterStyleDefinition(
472                system: CounterStyleDefinition::ALPHABETIC,
473                symbols: $kana,
474                suffix: "\u{3001}",
475            );
476        }
477
478        // §6.5 Fixed — a finite sequence that falls back once exhausted.
479        $s['cjk-earthly-branch'] = new CounterStyleDefinition(
480            system: CounterStyleDefinition::FIXED,
481            symbols: [
482                "\u{5B50}", "\u{4E11}", "\u{5BC5}", "\u{536F}",
483                "\u{8FB0}", "\u{5DF3}", "\u{5348}", "\u{672A}",
484                "\u{7533}", "\u{9149}", "\u{620C}", "\u{4EA5}",
485            ],
486            suffix: "\u{3001}",
487        );
488        $s['cjk-heavenly-stem'] = new CounterStyleDefinition(
489            system: CounterStyleDefinition::FIXED,
490            symbols: [
491                "\u{7532}", "\u{4E59}", "\u{4E19}", "\u{4E01}", "\u{620A}",
492                "\u{5DF1}", "\u{5E9A}", "\u{8F9B}", "\u{58EC}", "\u{7678}",
493            ],
494            suffix: "\u{3001}",
495        );
496
497        // §7.1 East Asian. Every one declares `range: -9999 9999` and
498        // `fallback: cjk-decimal`, so 10000 renders as 一〇〇〇〇 rather
499        // than reaching for the 万 the optional extended range would use.
500        $ja = "\u{30DE}\u{30A4}\u{30CA}\u{30B9}";        // マイナス
501        $ko = "\u{B9C8}\u{C774}\u{B108}\u{C2A4}\u{0020}"; // 마이너스␣
502        $ideographicComma = "\u{3001}";
503        foreach ([
504            'japanese-informal' => [
505                "\u{3007}\u{4E00}\u{4E8C}\u{4E09}\u{56DB}\u{4E94}\u{516D}\u{4E03}\u{516B}\u{4E5D}",
506                "\u{5341}\u{767E}\u{5343}",
507                CounterStyleDefinition::ELIDE_ALL, false, $ideographicComma, $ja,
508            ],
509            'japanese-formal' => [
510                "\u{96F6}\u{58F1}\u{5F10}\u{53C2}\u{56DB}\u{4F0D}\u{516D}\u{4E03}\u{516B}\u{4E5D}",
511                "\u{62FE}\u{767E}\u{9621}",
512                CounterStyleDefinition::ELIDE_NONE, false, $ideographicComma, $ja,
513            ],
514            'simp-chinese-informal' => [
515                "\u{96F6}\u{4E00}\u{4E8C}\u{4E09}\u{56DB}\u{4E94}\u{516D}\u{4E03}\u{516B}\u{4E5D}",
516                "\u{5341}\u{767E}\u{5343}",
517                CounterStyleDefinition::ELIDE_LEADING_TENS, true, $ideographicComma, "\u{8D1F}",
518            ],
519            'simp-chinese-formal' => [
520                "\u{96F6}\u{58F9}\u{8D30}\u{53C1}\u{8086}\u{4F0D}\u{9646}\u{67D2}\u{634C}\u{7396}",
521                "\u{62FE}\u{4F70}\u{4EDF}",
522                CounterStyleDefinition::ELIDE_NONE, true, $ideographicComma, "\u{8D1F}",
523            ],
524            'trad-chinese-informal' => [
525                "\u{96F6}\u{4E00}\u{4E8C}\u{4E09}\u{56DB}\u{4E94}\u{516D}\u{4E03}\u{516B}\u{4E5D}",
526                "\u{5341}\u{767E}\u{5343}",
527                CounterStyleDefinition::ELIDE_LEADING_TENS, true, $ideographicComma, "\u{8CA0}",
528            ],
529            'trad-chinese-formal' => [
530                "\u{96F6}\u{58F9}\u{8CB3}\u{53C3}\u{8086}\u{4F0D}\u{9678}\u{67D2}\u{634C}\u{7396}",
531                "\u{62FE}\u{4F70}\u{4EDF}",
532                CounterStyleDefinition::ELIDE_NONE, true, $ideographicComma, "\u{8CA0}",
533            ],
534            'korean-hangul-formal' => [
535                "\u{C601}\u{C77C}\u{C774}\u{C0BC}\u{C0AC}\u{C624}\u{C721}\u{CE60}\u{D314}\u{AD6C}",
536                "\u{C2ED}\u{BC31}\u{CC9C}",
537                CounterStyleDefinition::ELIDE_NONE, false, ', ', $ko,
538            ],
539            'korean-hanja-informal' => [
540                "\u{96F6}\u{4E00}\u{4E8C}\u{4E09}\u{56DB}\u{4E94}\u{516D}\u{4E03}\u{516B}\u{4E5D}",
541                "\u{5341}\u{767E}\u{5343}",
542                CounterStyleDefinition::ELIDE_ALL, false, ', ', $ko,
543            ],
544            'korean-hanja-formal' => [
545                "\u{96F6}\u{58F9}\u{8CB3}\u{53C3}\u{56DB}\u{4E94}\u{516D}\u{4E03}\u{516B}\u{4E5D}",
546                "\u{62FE}\u{767E}\u{4EDF}",
547                CounterStyleDefinition::ELIDE_NONE, false, ', ', $ko,
548            ],
549        ] as $name => [$digitRun, $multiplierRun, $elide, $zeroFiller, $styleSuffix, $negative]) {
550            $s[$name] = new CounterStyleDefinition(
551                system: CounterStyleDefinition::CJK_IDEOGRAPHIC,
552                symbols: mb_str_split($digitRun, 1, 'UTF-8'),
553                negativePrefix: $negative,
554                suffix: $styleSuffix,
555                rangeMin: -9999,
556                rangeMax: 9999,
557                // The Chinese and Japanese styles fall back to cjk-decimal;
558                // the Korean ones declare no fallback and so take the
559                // initial `decimal` — 10000 is 一〇〇〇〇 for the former and
560                // plain `10000` for the latter.
561                fallback: str_starts_with($name, 'korean-') ? 'decimal' : 'cjk-decimal',
562                cjkMultipliers: mb_str_split($multiplierRun, 1, 'UTF-8'),
563                cjkElideOne: $elide,
564                cjkZeroFiller: $zeroFiller,
565            );
566        }
567
568        self::$predefinedCache = $s;
569        return $s;
570    }
571
572    /**
573     * The ten decimal digits of a script, given the codepoint of its ZERO.
574     * Every §6.2 "simple numeric" style is exactly this — spelling the
575     * tables out by hand is how transcription typos get shipped.
576     *
577     * @return list<string>
578     */
579    private static function digits(int $zero): array
580    {
581        $out = [];
582        for ($i = 0; $i < 10; $i++) {
583            $out[] = \IntlChar::chr($zero + $i) ?? '?';
584        }
585        return $out;
586    }
587
588    /** @return list<array{0: int, 1: string}> */
589    private static function armenianTuples(): array
590    {
591        $ones = ['Ա','Բ','Գ','Դ','Ե','Զ','Է','Ը','Թ'];
592        $tens = ['Ժ','Ի','Լ','Խ','Ծ','Կ','Հ','Ձ','Ղ'];
593        $hundreds = ['Ճ','Մ','Յ','Ն','Շ','Ո','Չ','Պ','Ջ'];
594        $thousands = ['Ռ','Ս','Վ','Տ','Ր','Ց','Ւ','Փ','Ք'];
595        $out = [];
596        foreach ([[1000, $thousands], [100, $hundreds], [10, $tens], [1, $ones]] as [$scale, $list]) {
597            for ($i = 9; $i >= 1; $i--) {
598                $out[] = [$scale * $i, $list[$i - 1]];
599            }
600        }
601        usort($out, static fn(array $a, array $b): int => $b[0] <=> $a[0]);
602        return $out;
603    }
604
605    /** @return list<array{0: int, 1: string}> */
606    private static function georgianTuples(): array
607    {
608        $ones = ['ა','ბ','გ','დ','ე','ვ','ზ','ჱ','თ'];
609        $tens = ['ი','კ','ლ','მ','ნ','ჲ','ო','პ','ჟ'];
610        $hundreds = ['რ','ს','ტ','ჳ','ფ','ქ','ღ','ყ','შ'];
611        $thousands = ['ჩ','ც','ძ','წ','ჭ','ხ','ჴ','ჯ','ჰ'];
612        $out = [[10000, 'ჵ']];
613        foreach ([[1000, $thousands], [100, $hundreds], [10, $tens], [1, $ones]] as [$scale, $list]) {
614            for ($i = 9; $i >= 1; $i--) {
615                $out[] = [$scale * $i, $list[$i - 1]];
616            }
617        }
618        usort($out, static fn(array $a, array $b): int => $b[0] <=> $a[0]);
619        return $out;
620    }
621
622    /**
623     * CSS Counter Styles 3 §6.2 `hebrew`. 15 and 16 are spelled טו / טז
624     * rather than the theophoric יה / יו, which is exactly why the style
625     * is additive with explicit 15/16 tuples instead of positional.
626     *
627     * @return list<array{0: int, 1: string}>
628     */
629    private static function hebrewTuples(): array
630    {
631        return [
632            [10000, 'י׳'], [9000, 'ט׳'], [8000, 'ח׳'], [7000, 'ז׳'],
633            [6000, 'ו׳'], [5000, 'ה׳'], [4000, 'ד׳'], [3000, 'ג׳'],
634            [2000, 'ב׳'], [1000, 'א׳'],
635            [400, 'ת'], [300, 'ש'], [200, 'ר'], [100, 'ק'],
636            [90, 'צ'], [80, 'פ'], [70, 'ע'], [60, 'ס'], [50, 'נ'],
637            [40, 'מ'], [30, 'ל'], [20, 'כ'],
638            [19, 'יט'], [18, 'יח'], [17, 'יז'], [16, 'טז'], [15, 'טו'],
639            [10, 'י'],
640            [9, 'ט'], [8, 'ח'], [7, 'ז'], [6, 'ו'], [5, 'ה'],
641            [4, 'ד'], [3, 'ג'], [2, 'ב'], [1, 'א'],
642        ];
643    }
644}