Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.98% |
385 / 397 |
|
68.75% |
11 / 16 |
CRAP | |
0.00% |
0 / 1 |
| CounterStyleRegistry | |
96.98% |
385 / 397 |
|
68.75% |
11 / 16 |
85 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| has | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| definition | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| representation | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| marker | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| build | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
13 | |||
| buildFallback | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| generate | |
90.24% |
37 / 41 |
|
0.00% |
0 / 1 |
19.34 | |||
| additive | |
70.59% |
12 / 17 |
|
0.00% |
0 / 1 |
12.54 | |||
| cjkIdeographic | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
14 | |||
| elidesLeadingOne | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
5 | |||
| predefined | |
100.00% |
240 / 240 |
|
100.00% |
1 / 1 |
7 | |||
| digits | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| armenianTuples | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| georgianTuples | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| hebrewTuples | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 24 | final 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 | } |