diff --git a/composer.json b/composer.json index 44d1bb1cf..a735d680a 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "ext-iconv": "to use filters |reverse, |substring", "ext-mbstring": "to use filters like lower, upper, capitalize, ...", "ext-fileinfo": "to use filter |datastream", + "ext-intl": "to use Latte\\Engine::setLocale()", "nette/utils": "to use filter |webalize", "nette/php-generator": "to use tag {templatePrint}" }, diff --git a/src/Latte/Engine.php b/src/Latte/Engine.php index 6f0b3d737..00f33d4f1 100644 --- a/src/Latte/Engine.php +++ b/src/Latte/Engine.php @@ -50,6 +50,7 @@ class Engine private bool $sandboxed = false; private ?string $phpBinary = null; private ?string $cacheKey; + private ?string $locale = null; public function __construct() @@ -565,6 +566,25 @@ public function isStrictParsing(): bool } + /** + * Sets locale for date and number formatting. See PHP intl extension. + */ + public function setLocale(?string $locale): static + { + if ($locale && !extension_loaded('intl')) { + throw new RuntimeException("Locate requires the 'intl' extension to be installed."); + } + $this->locale = $locale; + return $this; + } + + + public function getLocale(): ?string + { + return $this->locale; + } + + public function setLoader(Loader $loader): static { $this->loader = $loader; diff --git a/src/Latte/Essential/CoreExtension.php b/src/Latte/Essential/CoreExtension.php index e081d5d2d..04a56c589 100644 --- a/src/Latte/Essential/CoreExtension.php +++ b/src/Latte/Essential/CoreExtension.php @@ -40,6 +40,12 @@ public function beforeCompile(Latte\Engine $engine): void } + public function beforeRender(Runtime\Template $template): void + { + $this->filters->locale = $template->getEngine()->getLocale(); + } + + public function getTags(): array { return [ @@ -142,7 +148,7 @@ public function getFilters(): array 'lower' => extension_loaded('mbstring') ? [$this->filters, 'lower'] : fn() => throw new RuntimeException('Filter |lower requires mbstring extension.'), - 'number' => 'number_format', + 'number' => [$this->filters, 'number'], 'padLeft' => [$this->filters, 'padLeft'], 'padRight' => [$this->filters, 'padRight'], 'query' => [$this->filters, 'query'], diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index 66d4cc5dc..b51f3bae3 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -23,6 +23,9 @@ */ final class Filters { + public ?string $locale = null; + + /** * Converts HTML to plain text. */ @@ -166,16 +169,13 @@ public static function repeat(FilterInfo $info, $s, int $count): string /** * Date/time formatting. */ - public static function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string + public function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string { + $format ??= Latte\Runtime\Filters::$dateFormat; if ($time == null) { // intentionally == return null; - } - - $format ??= Latte\Runtime\Filters::$dateFormat; - if ($time instanceof \DateInterval) { + } elseif ($time instanceof \DateInterval) { return $time->format($format); - } elseif (is_numeric($time)) { $time = (new \DateTime)->setTimestamp((int) $time); } elseif (!$time instanceof \DateTimeInterface) { @@ -186,8 +186,23 @@ public static function date(string|int|\DateTimeInterface|\DateInterval|null $ti if (PHP_VERSION_ID >= 80100) { trigger_error("Function strftime() used by filter |date is deprecated since PHP 8.1, use format without % characters like 'Y-m-d'.", E_USER_DEPRECATED); } - return @strftime($format, $time->format('U') + 0); + + } elseif (preg_match('#^(\+(short|medium|long|full))?(\+time(\+sec)?)?$#', '+' . $format, $m)) { + $formatter = new \IntlDateFormatter( + $this->getLocale('date'), + match ($m[2]) { + 'short' => \IntlDateFormatter::SHORT, + 'medium' => \IntlDateFormatter::MEDIUM, + 'long' => \IntlDateFormatter::LONG, + 'full' => \IntlDateFormatter::FULL, + '' => \IntlDateFormatter::NONE, + }, + isset($m[3]) ? (isset($m[4]) ? \IntlDateFormatter::MEDIUM : \IntlDateFormatter::SHORT) : \IntlDateFormatter::NONE, + ); + $res = $formatter->format($time); + $res = preg_replace('~(\d\.) ~', "\$1\u{a0}", $res); + return $res; } return $time->format($format); @@ -197,7 +212,7 @@ public static function date(string|int|\DateTimeInterface|\DateInterval|null $ti /** * Converts to human-readable file size. */ - public static function bytes(float $bytes, int $precision = 2): string + public function bytes(float $bytes, int $precision = 2): string { $bytes = round($bytes); $units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; @@ -209,7 +224,15 @@ public static function bytes(float $bytes, int $precision = 2): string $bytes /= 1024; } - return round($bytes, $precision) . ' ' . $unit; + if ($this->locale === null) { + $bytes = (string) round($bytes, $precision); + } else { + $formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL); + $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $precision); + $bytes = $formatter->format($bytes); + } + + return $bytes . ' ' . $unit; } @@ -455,7 +478,7 @@ public static function batch(iterable $list, int $length, $rest = null): \Genera * @param iterable $data * @return iterable */ - public static function sort( + public function sort( iterable $data, ?\Closure $comparison = null, string|int|\Closure|null $by = null, @@ -469,7 +492,16 @@ public static function sort( $by = $byKey === true ? null : $byKey; } - $comparison ??= fn($a, $b) => $a <=> $b; + if ($comparison) { + } elseif ($this->locale === null) { + $comparison = fn($a, $b) => $a <=> $b; + } else { + $collator = new \Collator($this->locale); + $comparison = fn($a, $b) => is_string($a) && is_string($b) + ? $collator->compare($a, $b) + : $a <=> $b; + } + $comparison = match (true) { $by === null => $comparison, $by instanceof \Closure => fn($a, $b) => $comparison($by($a), $by($b)), @@ -650,4 +682,33 @@ public static function random(string|array $values): mixed ? $values[array_rand($values, 1)] : null; } + + + /** + * Formats a number with grouped thousands and optionally decimal digits according to locale. + */ + public function number( + float $number, + int $decimals = 0, + string $decimalSeparator = '.', + string $thousandsSeparator = ',', + ): string + { + if ($this->locale === null || func_num_args() > 2) { + return number_format($number, $decimals, $decimalSeparator, $thousandsSeparator); + } + + $formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL); + $formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $decimals); + return $formatter->format($number); + } + + + private function getLocale(string $name): string + { + if ($this->locale === null) { + throw new Latte\RuntimeException("Filter |$name requires the locale to be set using Engine::setLocale()"); + } + return $this->locale; + } } diff --git a/tests/filters/bytes.phpt b/tests/filters/bytes.phpt index fb40acc0c..df059c3c2 100644 --- a/tests/filters/bytes.phpt +++ b/tests/filters/bytes.phpt @@ -12,10 +12,20 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -Assert::same('0 B', Filters::bytes(0.1)); +test('no locale', function () { + $filters = new Filters; + Assert::same('0 B', $filters->bytes(0.1)); + Assert::same('-1.03 GB', $filters->bytes(-1024 * 1024 * 1050)); + Assert::same('8881.78 PB', $filters->bytes(1e19)); +}); -Assert::same('-1.03 GB', Filters::bytes(-1024 * 1024 * 1050)); +test('with locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; -Assert::same('8881.78 PB', Filters::bytes(1e19)); + Assert::same('0 B', $filters->bytes(0.1)); + Assert::same('-1,03 GB', $filters->bytes(-1024 * 1024 * 1050)); + Assert::same('8 881,78 PB', $filters->bytes(1e19)); +}); diff --git a/tests/filters/date.phpt b/tests/filters/date.phpt index b72abf48d..9000a029d 100644 --- a/tests/filters/date.phpt +++ b/tests/filters/date.phpt @@ -12,32 +12,47 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -setlocale(LC_TIME, 'C'); - - -Assert::null(Filters::date(null)); - - -Assert::same("23.\u{a0}1.\u{a0}1978", Filters::date(254_400_000)); - - -Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date('1978-05-05')); - - -Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date(new DateTime('1978-05-05'))); - - -Assert::same('1978-01-23', Filters::date(254_400_000, 'Y-m-d')); - - -Assert::same('1212-09-26', Filters::date('1212-09-26', 'Y-m-d')); - - -Assert::same('1212-09-26', Filters::date(new DateTimeImmutable('1212-09-26'), 'Y-m-d')); - - -Assert::same('30:10:10', Filters::date(new DateInterval('PT30H10M10S'), '%H:%I:%S')); - - -date_default_timezone_set('America/Los_Angeles'); -Assert::same('07:09', Filters::date(1_408_284_571, 'H:i')); +test('no locale', function () { + $filters = new Filters; + + Assert::null($filters->date(null)); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date('1978-05-05')); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date(new DateTime('1978-05-05'))); + Assert::same('1978-01-23', $filters->date(254_400_000, 'Y-m-d')); + Assert::same('1212-09-26', $filters->date('1212-09-26', 'Y-m-d')); + Assert::same('1212-09-26', $filters->date(new DateTimeImmutable('1212-09-26'), 'Y-m-d')); + + // timestamp + date_default_timezone_set('America/Los_Angeles'); + Assert::same("23.\u{a0}1.\u{a0}1978", $filters->date(254_400_000)); + Assert::same('07:09', $filters->date(1_408_284_571, 'H:i')); +}); + + +test('date interval', function () { + $filters = new Filters; + + Assert::same('30:10:10', $filters->date(new DateInterval('PT30H10M10S'), '%H:%I:%S')); +}); + + +test('local date/time', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + + // date format + Assert::null($filters->date(null, 'medium')); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date('1978-05-05', 'medium')); + Assert::same('05.05.78', $filters->date(new DateTime('1978-05-05'), 'short')); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date(new DateTime('1978-05-05'), 'medium')); + Assert::same("5.\u{a0}května 1978", $filters->date(new DateTime('1978-05-05'), 'long')); + Assert::same("pátek 5.\u{a0}května 1978", $filters->date(new DateTime('1978-05-05'), 'full')); + + // time format + Assert::same('12:13', $filters->date(new DateTime('12:13:14'), 'time')); + Assert::same('12:13:14', $filters->date(new DateTime('12:13:14'), 'time+sec')); + + // combined + Assert::same('05.05.78 12:13', $filters->date(new DateTime('1978-05-05 12:13:14'), 'short+time')); + Assert::same('05.05.78 12:13:14', $filters->date(new DateTime('1978-05-05 12:13:14'), 'short+time+sec')); +}); diff --git a/tests/filters/number.phpt b/tests/filters/number.phpt new file mode 100644 index 000000000..4c231662a --- /dev/null +++ b/tests/filters/number.phpt @@ -0,0 +1,59 @@ +number(0)); + Assert::same('0.00', $filters->number(0, 2)); + Assert::same('1,234', $filters->number(1234)); + Assert::same('123.46', $filters->number(123.456, 2)); + Assert::same('123.457', $filters->number(123.4567, 3)); + Assert::same('1 234.56', $filters->number(1234.56, 2, '.', ' ')); + Assert::same('1.234,56', $filters->number(1234.56, 2, ',', '.')); + Assert::same('-1,234', $filters->number(-1234)); + Assert::same('-1,234.57', $filters->number(-1234.5678, 2)); + Assert::same('nan', $filters->number(NAN, 2)); + + // negative decimals means rounding + Assert::same('100', $filters->number(123.456, -2)); +}); + + +test('with locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + + Assert::same('0', $filters->number(0)); + Assert::same('0,00', $filters->number(0, 2)); + Assert::same('1 234', $filters->number(1234)); + Assert::same('123,46', $filters->number(123.456, 2)); + Assert::same('123,457', $filters->number(123.4567, 3)); + Assert::same('-1 234', $filters->number(-1234)); + Assert::same('-1 234,57', $filters->number(-1234.5678, 2)); + Assert::same('NaN', $filters->number(NAN, 2)); + + // negative decimals is invalid, prints all digits + Assert::same('0', $filters->number(0.0, -2)); + Assert::same('123,456', $filters->number(123.456, -2)); +}); + + +test('disabled locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + + Assert::same('1 234.56', $filters->number(1234.56, 2, '.', ' ')); + Assert::same('1.234,56', $filters->number(1234.56, 2, ',', '.')); +}); diff --git a/tests/filters/sort.phpt b/tests/filters/sort.phpt index 7d957aa1a..c60ee9260 100644 --- a/tests/filters/sort.phpt +++ b/tests/filters/sort.phpt @@ -31,13 +31,14 @@ function exportIterator(Traversable $iterator): array test('array', function () { - Assert::same([1 => 11, 0 => 22, 33], Filters::sort([22, 11, 33])); - Assert::same([], Filters::sort([])); + $filters = new Filters; + Assert::same([1 => 11, 0 => 22, 33], $filters->sort([22, 11, 33])); + Assert::same([], $filters->sort([])); }); test('iterator', function () { - $sorted = Filters::sort(iterator()); + $sorted = (new Filters)->sort(iterator()); Assert::same(3, count($sorted)); Assert::equal( @@ -52,7 +53,7 @@ test('iterator', function () { test('re-iteration', function () { - $sorted = Filters::sort(iterator()); + $sorted = (new Filters)->sort(iterator()); $res = [ [['a' => 55], ['k' => 22]], [['a' => 77], ['k' => 33]], @@ -72,7 +73,7 @@ test('re-iteration', function () { test('user comparison + array', function () { Assert::same( [2 => 33, 0 => 22, 1 => 11], - Filters::sort([22, 11, 33], fn($a, $b) => $b <=> $a) + (new Filters)->sort([22, 11, 33], fn($a, $b) => $b <=> $a) ); }); @@ -84,17 +85,18 @@ test('user comparison + iterator', function () { [['a' => 77], ['k' => 33]], [['a' => 55], ['k' => 22]], ], - exportIterator(Filters::sort(iterator(), fn($a, $b) => $b <=> $a)), + exportIterator((new Filters)->sort(iterator(), fn($a, $b) => $b <=> $a)), ); }); test('array + by', function () { + $filters = new Filters; Assert::equal( [1 => (object) ['k' => 11], 0 => ['k' => 22], ['k' => 33]], - Filters::sort([['k' => 22], (object) ['k' => 11], ['k' => 33]], by: 'k'), + $filters->sort([['k' => 22], (object) ['k' => 11], ['k' => 33]], by: 'k'), ); - Assert::same([], Filters::sort([], by: 'k')); + Assert::same([], $filters->sort([], by: 'k')); }); @@ -105,7 +107,7 @@ test('iterator + by', function () { [['a' => 55], ['k' => 22]], [['a' => 77], ['k' => 33]], ], - exportIterator(Filters::sort(iterator(), by: 'k')), + exportIterator((new Filters)->sort(iterator(), by: 'k')), ); }); @@ -113,7 +115,7 @@ test('iterator + by', function () { test('callback + array + by', function () { Assert::same( [1 => 11, 0 => 22, 33], - Filters::sort([22, 11, 33], by: fn($a) => $a * 11) + (new Filters)->sort([22, 11, 33], by: fn($a) => $a * 11) ); }); @@ -125,14 +127,15 @@ test('callback + iterator + by', function () { [['a' => 55], ['k' => 22]], [['a' => 66], (object) ['k' => 11]], ], - exportIterator(Filters::sort(iterator(), by: fn($a) => -((array) $a)['k'])), + exportIterator((new Filters)->sort(iterator(), by: fn($a) => -((array) $a)['k'])), ); }); test('array + byKey', function () { - Assert::same([1 => 11, 0 => 22, 33], Filters::sort([22, 11, 33])); - Assert::same([], Filters::sort([], byKey: true)); + $filters = new Filters; + Assert::same([1 => 11, 0 => 22, 33], $filters->sort([22, 11, 33])); + Assert::same([], $filters->sort([], byKey: true)); }); @@ -143,7 +146,7 @@ test('iterator + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 77], ['k' => 33]], ], - exportIterator(Filters::sort(iterator(), byKey: true)), + exportIterator((new Filters)->sort(iterator(), byKey: true)), ); }); @@ -151,7 +154,7 @@ test('iterator + byKey', function () { test('user comparison + array + byKey', function () { Assert::same( [2 => 33, 1 => 11, 0 => 22], - Filters::sort([22, 11, 33], fn($a, $b) => $b <=> $a, byKey: true), + (new Filters)->sort([22, 11, 33], fn($a, $b) => $b <=> $a, byKey: true), ); }); @@ -163,7 +166,7 @@ test('user comparison + iterator + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 55], ['k' => 22]], ], - exportIterator(Filters::sort(iterator(), fn($a, $b) => $b <=> $a, byKey: true)), + exportIterator((new Filters)->sort(iterator(), fn($a, $b) => $b <=> $a, byKey: true)), ); }); @@ -175,7 +178,7 @@ test('iterator + by + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 77], ['k' => 33]], ], - exportIterator(Filters::sort(iterator(), byKey: 'a')), + exportIterator((new Filters)->sort(iterator(), byKey: 'a')), ); }); @@ -187,6 +190,13 @@ test('callback + iterator + by + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 55], ['k' => 22]], ], - exportIterator(Filters::sort(iterator(), byKey: fn($a) => -((array) $a)['a'])), + exportIterator((new Filters)->sort(iterator(), byKey: fn($a) => -((array) $a)['a'])), ); }); + + +test('locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + Assert::same([22, 2 => 'a', 1 => 'c', 4 => 'd', 3 => 'ch'], $filters->sort([22, 'c', 'a', 'ch', 'd'])); +});