Skip to content

Commit 8d92896

Browse files
committed
added support for locale, affects |date, |number, |bytes and |sort filters
1 parent 930973e commit 8d92896

File tree

8 files changed

+244
-62
lines changed

8 files changed

+244
-62
lines changed

composer.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"ext-iconv": "to use filters |reverse, |substring",
3131
"ext-mbstring": "to use filters like lower, upper, capitalize, ...",
3232
"ext-fileinfo": "to use filter |datastream",
33+
"ext-intl": "to use Latte\\Engine::setLocale()",
3334
"nette/utils": "to use filter |webalize",
3435
"nette/php-generator": "to use tag {templatePrint}"
3536
},

src/Latte/Engine.php

+20
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class Engine
5050
private bool $sandboxed = false;
5151
private ?string $phpBinary = null;
5252
private ?string $cacheKey;
53+
private ?string $locale = null;
5354

5455

5556
public function __construct()
@@ -565,6 +566,25 @@ public function isStrictParsing(): bool
565566
}
566567

567568

569+
/**
570+
* Sets locale for date and number formatting. See PHP intl extension.
571+
*/
572+
public function setLocale(?string $locale): static
573+
{
574+
if ($locale && !extension_loaded('intl')) {
575+
throw new RuntimeException("Locate requires the 'intl' extension to be installed.");
576+
}
577+
$this->locale = $locale;
578+
return $this;
579+
}
580+
581+
582+
public function getLocale(): ?string
583+
{
584+
return $this->locale;
585+
}
586+
587+
568588
public function setLoader(Loader $loader): static
569589
{
570590
$this->loader = $loader;

src/Latte/Essential/CoreExtension.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ public function beforeCompile(Latte\Engine $engine): void
4040
}
4141

4242

43+
public function beforeRender(Runtime\Template $template): void
44+
{
45+
$this->filters->locale = $template->getEngine()->getLocale();
46+
}
47+
48+
4349
public function getTags(): array
4450
{
4551
return [
@@ -142,7 +148,7 @@ public function getFilters(): array
142148
'lower' => extension_loaded('mbstring')
143149
? [$this->filters, 'lower']
144150
: fn() => throw new RuntimeException('Filter |lower requires mbstring extension.'),
145-
'number' => 'number_format',
151+
'number' => [$this->filters, 'number'],
146152
'padLeft' => [$this->filters, 'padLeft'],
147153
'padRight' => [$this->filters, 'padRight'],
148154
'query' => [$this->filters, 'query'],

src/Latte/Essential/Filters.php

+72-11
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
*/
2424
final class Filters
2525
{
26+
public ?string $locale = null;
27+
28+
2629
/**
2730
* Converts HTML to plain text.
2831
*/
@@ -166,16 +169,13 @@ public static function repeat(FilterInfo $info, $s, int $count): string
166169
/**
167170
* Date/time formatting.
168171
*/
169-
public static function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string
172+
public function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string
170173
{
174+
$format ??= Latte\Runtime\Filters::$dateFormat;
171175
if ($time == null) { // intentionally ==
172176
return null;
173-
}
174-
175-
$format ??= Latte\Runtime\Filters::$dateFormat;
176-
if ($time instanceof \DateInterval) {
177+
} elseif ($time instanceof \DateInterval) {
177178
return $time->format($format);
178-
179179
} elseif (is_numeric($time)) {
180180
$time = (new \DateTime)->setTimestamp((int) $time);
181181
} elseif (!$time instanceof \DateTimeInterface) {
@@ -186,8 +186,23 @@ public static function date(string|int|\DateTimeInterface|\DateInterval|null $ti
186186
if (PHP_VERSION_ID >= 80100) {
187187
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);
188188
}
189-
190189
return @strftime($format, $time->format('U') + 0);
190+
191+
} elseif (preg_match('#^(\+(short|medium|long|full))?(\+time(\+sec)?)?$#', '+' . $format, $m)) {
192+
$formatter = new \IntlDateFormatter(
193+
$this->getLocale('date'),
194+
match ($m[2]) {
195+
'short' => \IntlDateFormatter::SHORT,
196+
'medium' => \IntlDateFormatter::MEDIUM,
197+
'long' => \IntlDateFormatter::LONG,
198+
'full' => \IntlDateFormatter::FULL,
199+
'' => \IntlDateFormatter::NONE,
200+
},
201+
isset($m[3]) ? (isset($m[4]) ? \IntlDateFormatter::MEDIUM : \IntlDateFormatter::SHORT) : \IntlDateFormatter::NONE,
202+
);
203+
$res = $formatter->format($time);
204+
$res = preg_replace('~(\d\.) ~', "\$1\u{a0}", $res);
205+
return $res;
191206
}
192207

193208
return $time->format($format);
@@ -197,7 +212,7 @@ public static function date(string|int|\DateTimeInterface|\DateInterval|null $ti
197212
/**
198213
* Converts to human-readable file size.
199214
*/
200-
public static function bytes(float $bytes, int $precision = 2): string
215+
public function bytes(float $bytes, int $precision = 2): string
201216
{
202217
$bytes = round($bytes);
203218
$units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
@@ -209,7 +224,15 @@ public static function bytes(float $bytes, int $precision = 2): string
209224
$bytes /= 1024;
210225
}
211226

212-
return round($bytes, $precision) . ' ' . $unit;
227+
if ($this->locale === null) {
228+
$bytes = (string) round($bytes, $precision);
229+
} else {
230+
$formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL);
231+
$formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $precision);
232+
$bytes = $formatter->format($bytes);
233+
}
234+
235+
return $bytes . ' ' . $unit;
213236
}
214237

215238

@@ -455,7 +478,7 @@ public static function batch(iterable $list, int $length, $rest = null): \Genera
455478
* @param iterable<K, V> $data
456479
* @return iterable<K, V>
457480
*/
458-
public static function sort(
481+
public function sort(
459482
iterable $data,
460483
?\Closure $comparison = null,
461484
string|int|\Closure|null $by = null,
@@ -469,7 +492,16 @@ public static function sort(
469492
$by = $byKey === true ? null : $byKey;
470493
}
471494

472-
$comparison ??= fn($a, $b) => $a <=> $b;
495+
if ($comparison) {
496+
} elseif ($this->locale === null) {
497+
$comparison = fn($a, $b) => $a <=> $b;
498+
} else {
499+
$collator = new \Collator($this->locale);
500+
$comparison = fn($a, $b) => is_string($a) && is_string($b)
501+
? $collator->compare($a, $b)
502+
: $a <=> $b;
503+
}
504+
473505
$comparison = match (true) {
474506
$by === null => $comparison,
475507
$by instanceof \Closure => fn($a, $b) => $comparison($by($a), $by($b)),
@@ -650,4 +682,33 @@ public static function random(string|array $values): mixed
650682
? $values[array_rand($values, 1)]
651683
: null;
652684
}
685+
686+
687+
/**
688+
* Formats a number with grouped thousands and optionally decimal digits according to locale.
689+
*/
690+
public function number(
691+
float $number,
692+
int $decimals = 0,
693+
string $decimalSeparator = '.',
694+
string $thousandsSeparator = ',',
695+
): string
696+
{
697+
if ($this->locale === null || func_num_args() > 2) {
698+
return number_format($number, $decimals, $decimalSeparator, $thousandsSeparator);
699+
}
700+
701+
$formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL);
702+
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $decimals);
703+
return $formatter->format($number);
704+
}
705+
706+
707+
private function getLocale(string $name): string
708+
{
709+
if ($this->locale === null) {
710+
throw new Latte\RuntimeException("Filter |$name requires the locale to be set using Engine::setLocale()");
711+
}
712+
return $this->locale;
713+
}
653714
}

tests/filters/bytes.phpt

+13-3
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@ use Tester\Assert;
1212
require __DIR__ . '/../bootstrap.php';
1313

1414

15-
Assert::same('0 B', Filters::bytes(0.1));
15+
test('no locale', function () {
16+
$filters = new Filters;
1617

18+
Assert::same('0 B', $filters->bytes(0.1));
19+
Assert::same('-1.03 GB', $filters->bytes(-1024 * 1024 * 1050));
20+
Assert::same('8881.78 PB', $filters->bytes(1e19));
21+
});
1722

18-
Assert::same('-1.03 GB', Filters::bytes(-1024 * 1024 * 1050));
1923

24+
test('with locale', function () {
25+
$filters = new Filters;
26+
$filters->locale = 'cs_CZ';
2027

21-
Assert::same('8881.78 PB', Filters::bytes(1e19));
28+
Assert::same('0 B', $filters->bytes(0.1));
29+
Assert::same('-1,03 GB', $filters->bytes(-1024 * 1024 * 1050));
30+
Assert::same('8 881,78 PB', $filters->bytes(1e19));
31+
});

tests/filters/date.phpt

+44-29
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,47 @@ use Tester\Assert;
1212
require __DIR__ . '/../bootstrap.php';
1313

1414

15-
setlocale(LC_TIME, 'C');
16-
17-
18-
Assert::null(Filters::date(null));
19-
20-
21-
Assert::same("23.\u{a0}1.\u{a0}1978", Filters::date(254_400_000));
22-
23-
24-
Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date('1978-05-05'));
25-
26-
27-
Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date(new DateTime('1978-05-05')));
28-
29-
30-
Assert::same('1978-01-23', Filters::date(254_400_000, 'Y-m-d'));
31-
32-
33-
Assert::same('1212-09-26', Filters::date('1212-09-26', 'Y-m-d'));
34-
35-
36-
Assert::same('1212-09-26', Filters::date(new DateTimeImmutable('1212-09-26'), 'Y-m-d'));
37-
38-
39-
Assert::same('30:10:10', Filters::date(new DateInterval('PT30H10M10S'), '%H:%I:%S'));
40-
41-
42-
date_default_timezone_set('America/Los_Angeles');
43-
Assert::same('07:09', Filters::date(1_408_284_571, 'H:i'));
15+
test('no locale', function () {
16+
$filters = new Filters;
17+
18+
Assert::null($filters->date(null));
19+
Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date('1978-05-05'));
20+
Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date(new DateTime('1978-05-05')));
21+
Assert::same('1978-01-23', $filters->date(254_400_000, 'Y-m-d'));
22+
Assert::same('1212-09-26', $filters->date('1212-09-26', 'Y-m-d'));
23+
Assert::same('1212-09-26', $filters->date(new DateTimeImmutable('1212-09-26'), 'Y-m-d'));
24+
25+
// timestamp
26+
date_default_timezone_set('America/Los_Angeles');
27+
Assert::same("23.\u{a0}1.\u{a0}1978", $filters->date(254_400_000));
28+
Assert::same('07:09', $filters->date(1_408_284_571, 'H:i'));
29+
});
30+
31+
32+
test('date interval', function () {
33+
$filters = new Filters;
34+
35+
Assert::same('30:10:10', $filters->date(new DateInterval('PT30H10M10S'), '%H:%I:%S'));
36+
});
37+
38+
39+
test('local date/time', function () {
40+
$filters = new Filters;
41+
$filters->locale = 'cs_CZ';
42+
43+
// date format
44+
Assert::null($filters->date(null, 'medium'));
45+
Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date('1978-05-05', 'medium'));
46+
Assert::same('05.05.78', $filters->date(new DateTime('1978-05-05'), 'short'));
47+
Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date(new DateTime('1978-05-05'), 'medium'));
48+
Assert::same("5.\u{a0}května 1978", $filters->date(new DateTime('1978-05-05'), 'long'));
49+
Assert::same("pátek 5.\u{a0}května 1978", $filters->date(new DateTime('1978-05-05'), 'full'));
50+
51+
// time format
52+
Assert::same('12:13', $filters->date(new DateTime('12:13:14'), 'time'));
53+
Assert::same('12:13:14', $filters->date(new DateTime('12:13:14'), 'time+sec'));
54+
55+
// combined
56+
Assert::same('05.05.78 12:13', $filters->date(new DateTime('1978-05-05 12:13:14'), 'short+time'));
57+
Assert::same('05.05.78 12:13:14', $filters->date(new DateTime('1978-05-05 12:13:14'), 'short+time+sec'));
58+
});

tests/filters/number.phpt

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/**
4+
* Test: Latte\Essential\Filters::number()
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Latte\Essential\Filters;
10+
use Tester\Assert;
11+
12+
require __DIR__ . '/../bootstrap.php';
13+
14+
15+
test('no locale', function () {
16+
$filters = new Filters;
17+
18+
Assert::same('0', $filters->number(0));
19+
Assert::same('0.00', $filters->number(0, 2));
20+
Assert::same('1,234', $filters->number(1234));
21+
Assert::same('123.46', $filters->number(123.456, 2));
22+
Assert::same('123.457', $filters->number(123.4567, 3));
23+
Assert::same('1 234.56', $filters->number(1234.56, 2, '.', ' '));
24+
Assert::same('1.234,56', $filters->number(1234.56, 2, ',', '.'));
25+
Assert::same('-1,234', $filters->number(-1234));
26+
Assert::same('-1,234.57', $filters->number(-1234.5678, 2));
27+
Assert::same('nan', $filters->number(NAN, 2));
28+
29+
// negative decimals means rounding
30+
Assert::same('100', $filters->number(123.456, -2));
31+
});
32+
33+
34+
test('with locale', function () {
35+
$filters = new Filters;
36+
$filters->locale = 'cs_CZ';
37+
38+
Assert::same('0', $filters->number(0));
39+
Assert::same('0,00', $filters->number(0, 2));
40+
Assert::same('1 234', $filters->number(1234));
41+
Assert::same('123,46', $filters->number(123.456, 2));
42+
Assert::same('123,457', $filters->number(123.4567, 3));
43+
Assert::same('-1 234', $filters->number(-1234));
44+
Assert::same('-1 234,57', $filters->number(-1234.5678, 2));
45+
Assert::same('NaN', $filters->number(NAN, 2));
46+
47+
// negative decimals is invalid, prints all digits
48+
Assert::same('0', $filters->number(0.0, -2));
49+
Assert::same('123,456', $filters->number(123.456, -2));
50+
});
51+
52+
53+
test('disabled locale', function () {
54+
$filters = new Filters;
55+
$filters->locale = 'cs_CZ';
56+
57+
Assert::same('1 234.56', $filters->number(1234.56, 2, '.', ' '));
58+
Assert::same('1.234,56', $filters->number(1234.56, 2, ',', '.'));
59+
});

0 commit comments

Comments
 (0)