1: <?php
2: namespace TIC\TwigBundle\Extension;
3:
4: use TIC\TwigBundle\Base\TICTwigExtension as BaseExtension;
5: use TIC\CoreBundle\Util\ConvertHelper;
6: use TIC\CoreBundle\Util\StringHelper;
7:
8: use Symfony\Contracts\Translation\TranslatorInterface;
9: use Twig\TwigFilter;
10: use Twig\TwigFunction;
11: use Twig\Environment;
12:
13: /**
14: * Filtres et fonctions twig de formattage et conversions.
15: * https://symfony.com/doc/current/templating/twig_extension.html
16: */
17: class FormatExtension extends BaseExtension
18: {
19:
20: public function getFilters(): array
21: {
22: return [
23: new TwigFilter('boolval', [$this, 'boolvalFilter'], ['is_safe'=>['html']] ),
24: new TwigFilter('boolean', [$this, 'booleanFilter'], ['is_safe'=>['html']] ),
25: new TwigFilter('counter', [$this, 'counterFilter'], ),
26: new TwigFilter('jsvalue', [$this, 'jsvalueFilter'], ['is_safe'=>['html']] ),
27: new TwigFilter('stringify', [$this, 'stringifyFilter'], ['is_safe'=>['html']] ),
28: new TwigFilter('slugify', [$this, 'slugifyFilter'] ),
29: new TwigFilter('asciify', [$this, 'asciifyFilter'] ),
30: new TwigFilter('obfuscate', [$this, 'obfuscateFilter'], ['is_safe'=>['html']] ),
31: new TwigFilter('pathEncode',[$this, 'pathEncodeFilter'] ),
32: new TwigFilter('linkurl', [$this, 'linkurlFilter'], ['is_safe'=>['html']] ),
33: new TwigFilter('mailto', [$this, 'mailtoFilter'], ['is_safe'=>['html']] ),
34: new TwigFilter('phone', [$this, 'phoneFilter'], ['is_safe'=>['html']] ),
35: new TwigFilter('price', [$this, 'priceFilter'], ['is_safe'=>['html']] ),
36: new TwigFilter('euro', [$this, 'euroFilter'], ['is_safe'=>['html']] ),
37: new TwigFilter('hsize', [$this, 'hsizeFilter'], ['is_safe'=>['html']] ),
38: new TwigFilter('color', [$this, 'colorFilter'], ['is_safe'=>['html']] ),
39: new TwigFilter('civ', [$this, 'civFilter'], ['is_safe'=>['html']] ),
40: new TwigFilter('roles', [$this, 'rolesFilter'], ['is_safe'=>['html']] ),
41: # new TwigFilter('stars', [$this, 'starsFilter'], ['is_safe'=>['html'], 'needs_environment'=>true] ),
42: new TwigFilter('icon', [$this, 'iconFilter'], ['is_safe'=>['html']] ),
43: new TwigFilter('lnum', [$this, 'lnumFilter'] ),
44: new TwigFilter('numStep', [$this, 'numStepFilter'] ),
45: new TwigFilter('labelize', [$this, 'labelizeFilter'], ['is_safe'=>['html']] ),
46: new TwigFilter('ereplace', [$this, 'eReplaceFilter'], ['is_safe'=>['html']] ),
47: new TwigFilter('transList', [$this, 'transListFilter'] ),
48: ];
49: }
50:
51: public function getFunctions(): array
52: {
53: return [
54: # new TwigFunction('example', [$this, 'exampleFunction'] ),
55: ];
56: }
57:
58: public function __construct(TranslatorInterface $translator = null)
59: {
60: $this->translator = $translator;
61: }
62:
63:
64: /**
65: *
66: */
67: public function boolvalFilter($value, $true_val, $false_val = "")
68: {
69: if (! \is_bool($value)) return $value;
70: return ($value) ? $true_val : $false_val;
71: }
72:
73: /**
74: * Retourne le libellé d'un booléen (ou null) correspondant à la variable (token traduisible).
75: *
76: * @param mixed $value Variable à interpréter pour retourner un état booléen (true/false/null)
77: * @param string $default Libellé à retourner si aucune correspondance booléenne trouvée pour $value
78: * @param bool $trans Recherche d'une traduction pour le token $value (si c'est une chaine)
79: * @return string Libellé d'un booléen (token traduisible ou déjà traduit)
80: */
81: public function booleanFilter($value, string $default = '-', bool $trans = false): string
82: {
83: $bool = $this->getBool($value, $default);
84: if ($bool === null) $text = self::$strings['null'];
85: elseif ($bool === true) $text = self::$strings['true'];
86: elseif ($bool === false) $text = self::$strings['false'];
87: return ($trans) ? $this->trans($text) : $text;
88: }
89:
90: /**
91: * Retourne une quantité à partir d'une variable de type quelconque (intval, floatval, count...).
92: * @TODO: ajouter des options pour la présentation des nombres (number_format)
93: *
94: * @param mixed $value Valeur dont une quantité est à déterminer
95: * @return int|float Valeur numérique (ou null si indéterminé)
96: */
97: public function counterFilter($value)
98: {
99: return ConvertHelper::counter($value);
100: }
101:
102: /**
103: * Transformation d'une variable PHP quelque soit son type pour son affichage en Javascript (avec échappements).
104: */
105: public function jsvalueFilter($value): string
106: {
107: switch (true) {
108: case \is_null($value) : return 'null';
109: case \is_bool($value) : return ($value) ? 'true' : 'false';
110: case \is_string($value) : return '"' . StringHelper::jsescape($value) . '"';
111: case \is_array($value) : return json_encode($value);
112: case \is_object($value) : return json_encode($value);
113: default : return $value;
114: }
115: }
116:
117: /**
118: * Transformation d'une variable PHP quelque soit son type dans sa forme correspondante en chaine de caractères.
119: */
120: public function stringifyFilter($value)
121: {
122: return ConvertHelper::stringify($value);
123: }
124:
125: /**
126: * Retourne une chaine ASCII en convertissant les caractères étendus.
127: *
128: * @param string $text Texte en UTF-8 à transformer
129: * @return string Chaine équivalente avec les caractères 7 bits
130: */
131: public function asciifyFilter(string $text): string
132: {
133: return StringHelper::asciify($text);
134: }
135:
136: /**
137: * Retourne une chaine canonisée à partir d'une chaine donnée.
138: *
139: * @param string $text Texte en UTF-8 à slugifier
140: * @return string Slug avec uniquement les caractères [a-z], [0-9], '.', '_' et '-'
141: */
142: public function slugifyFilter(string $text): string
143: {
144: return StringHelper::slugify($text);
145: }
146:
147: /**
148: * Transformation d'une chaine de caractère en code Javascript son camouflage dans une page HTML (utile pour emails).
149: */
150: public function obfuscateFilter($string, $delta = null)
151: {
152: if ($delta === null) $delta = \rand(2, 22);
153: $value = ''; foreach (\str_split($string) as $c) $value.= \chr(\ord($c) + $delta);
154: return \sprintf('<script type="text/javascript">document.write(atob("%s").replace(/./g, function(c){ return String.fromCharCode(c.charCodeAt(0) - %d); }))</script>',
155: \base64_encode($value), $delta
156: );
157: }
158:
159: /**
160: * Encodage pour échappements de chaques parties du chemin d'une URL (en conservant les '/').
161: */
162: public function pathEncodeFilter($url)
163: {
164: if (! \is_string($url)) return $url;
165: return \implode('/', \array_map('rawurlencode', \explode('/', $url)));
166: }
167:
168: /**
169: * Affichage d'un lien HTML avec son libellé cliquable pour une URL.
170: */
171: public function linkurlFilter(?string $url, ?string $label = null): ?string
172: {
173: if (null === $label) $label = $url;
174: if (empty($url)) return $label;
175: return \sprintf('<a href="%s" target="_blank">%s</a>', \htmlspecialchars($url), \htmlspecialchars($label));
176: }
177:
178: /**
179: * Affichage d'un lien HTML avec son libellé cliquable pour une (ou plusieurs) adresse(s) email.
180: *
181: * @param string|array $emails Adresse(s) email(s) à afficher (séparateur virgule sur chaine)
182: * @param string|bool $label Texte à afficher sur le lien, null pour laisser la valeur source
183: * ou booléen pour valeur formattée (true=complet / false=adresse seul)
184: * @param string $glue Séparateur pour la concaténation des adresses retournées (json_encode si null)
185: * @return string Chaine HTML avec la (ou les) adresse(s) avec lien mailto: (si adresse reconnue)
186: */
187: public function mailtoFilter(mixed $emails, mixed $label = false, ?string $glue = ', '): ?string
188: {
189: if ($emails === null) return null;
190: if (! \is_array($emails)) $emails = \explode(',', $emails);
191:
192: $html = array();
193: foreach ($emails as $email) {
194: if ($email === null) continue;
195: $email = \trim($email);
196: if ($email === '') continue;
197:
198: if (\preg_match('/^(.*[\s<])?([^@<>\s"\']+@[^@<>\s\'"]+)[^@]*$/', $email, $match)) {
199: $addr = $match[2];
200: $full = \trim($match[1], " \n\r\t\v\x00\"<>");
201: $full = \strlen($full) ? \sprintf('"%s" <%s>', $full, $addr) : $addr;
202: $text = \is_string($label) ? $label : $email;
203: if ($label === true) $text = $full;
204: elseif ($label === false) $text = $addr;
205: $html[] = \sprintf('<span class="mailto %4$s"><a href="mailto:%1$s" title="%2$s">%3$s</a></span>',
206: \htmlspecialchars($full),
207: \htmlspecialchars($this->trans('widget.mailto.link_title') . $addr),
208: \htmlspecialchars($text),
209: ($text === $addr) ? "mailaddr" : "mailtext"
210: );
211: } else $html[] = \htmlspecialchars($email);
212: }
213: return ($glue === null) ? \json_encode($html) : \implode($glue, $html);
214: }
215:
216: /**
217: * Formattage d'un numéro de téléphone pour affichage.
218: *
219: * @param mixed $value Variable à formatter comme numéro de téléphone (liste possible)
220: * @param string $default Libellé à retourner si la valeur est vide
221: * @param bool $html Utilisation d'entités HTML dans le formatage (activé par défaut !)
222: * @param mixed $inter Préfixe international à ajouter (aucun par défaut, "+33" si true)
223: * @return string Numéro(s) de téléphone formattés (séparés par "\n" si plusieurs)
224: */
225: public function phoneFilter($value, string $default='&mdash;', bool $html = true, $inter = ''): string
226: {
227: if ($inter === true) $inter = "+33";
228: if (empty($html) && ($default == '&mdash;')) $default = '-';
229:
230: $phones = array();
231: $values = \is_array($value) ? $value : array($value);
232: foreach ($values as $value) {
233: $phone = \trim($value);
234:
235: if (empty($phone)) $phone = $default;
236: elseif (\preg_match('/^(\(?\d?\)?\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d{2,8})$/', $phone, $m)) {
237: // (0)1 23 45 67 89 01-23-45-67-89 1 23 45 67 89 0123456789 ...
238: $phone = \trim(\sprintf('%s %02d %02d %02d %02d %02d', $inter, $m[1], $m[2], $m[3], $m[4], $m[5]));
239: }
240: elseif (\preg_match('/^(\+\d{1,3})[\s\.\-]+(\(?\d?\)?\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d{2,8})$/', $phone, $m)) {
241: // +33 (0)1 23 45 67 89 +33-01-23-45-67-89 +33 1 23 45 67 89 ...
242: $phone = \trim(\sprintf('%s %02d %02d %02d %02d %02d', ($inter===false)?"":$m[1], $m[2], $m[3], $m[4], $m[5], $m[6]));
243: }
244: elseif (\preg_match('/^(\+\d{1,3})[\s\.\-]*(\(?\d\)?\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d\d)[\s\.\-]*(\d{2,8})$/', $phone, $m)) {
245: // +330123456789 ...
246: $phone = \trim(\sprintf('%s %02d %02d %02d %02d %02d', ($inter===false)?"":$m[1], $m[2], $m[3], $m[4], $m[5], $m[6]));
247: }
248: $phones[] = ($html) ? \str_replace(' ', '&nbsp;', $phone) : $phone;
249: }
250: return \implode("\n", $phones);
251: }
252:
253: /**
254: * Formattage d'un prix pour affichage
255: */
256: public function priceFilter($number, $default = null, string $devise = '&nbsp;&euro;', string $space ='&nbsp;'): ?string
257: {
258: # $locale = \setlocale(LC_MONETARY, 'fr_FR');
259: # return \money_format('%.2n', $number);
260: if (\is_string($number)) $number = \floatval(\strtr($number, ',', '.'));
261: if (($default !== null) && (\abs($number) < 0.001)) return $default;
262: // NOTE: number_format applique un PHP_ROUND_HALF_UP si besoin
263: return \number_format($number, 2, ',', $space) . $devise;
264: }
265:
266: /**
267: * Formattage d'un prix pour affichage en euros.
268: */
269: public function euroFilter($number, $default = null)
270: {
271: return $this->priceFilter($number, $default, ' €', '');
272: }
273:
274: /**
275: * Formattage d'un quantité d'octets pour affichage "pour humain" (avec l'unité la plus proche).
276: *
277: * @param integer $bytes Valeur en octets
278: * @param array $units Liste des unités (liste par défaut si non array)
279: * @param integer $prec Précision (nombre de décimales)
280: * @param integer $base Calculs en base 2 ou en base 10
281: * @return string
282: */
283: public function hsizeFilter($bytes, $units = null, int $prec = 1, int $base = 2)
284: {
285: return ConvertHelper::hsize($bytes, $units, $prec, $base);
286: }
287:
288: /**
289: * Formattage d'un aperçu en HTML d'une couleur (avec ou sans son code).
290: */
291: public function colorFilter($color, $with_label = false)
292: {
293: if (empty($color)) return '';
294: if ($with_label === true) $with_label = 'right';
295: $html = \sprintf('<span class="tic-color" style="background-color: %s;">&nbsp;', $color);
296: switch (\trim(\strtolower($with_label))) {
297: case 'left' : $html = \htmlspecialchars($color) . '</span>&nbsp;' . $html . '</span>'; break;
298: case 'right' : $html.= '</span>&nbsp;' . \htmlspecialchars($color); break;
299: case 'inside' : $html.= \htmlspecialchars($color) . '&nbsp;</span>'; break;
300: default : $html.= '</span>'; break;
301: }
302: return $html;
303: }
304:
305: /**
306: * Formattage d'une civilité dans sa version traduite à partir de son code interne.
307: */
308: public function civFilter($value, $abbr = false, $trans = true)
309: {
310: if ($value === null || $value === '') return '';
311: if (\strpos($value, '.') !== FALSE) $value = \str_replace('.', '', $value);
312: $text = \sprintf('ext.%s.%s', ($abbr)?'civ':'civilite', \strtolower($value));
313: return ($trans) ? $this->trans($text) : $text;
314: }
315:
316: /**
317: * Formattage du (ou des) rôle(s) d'un utilisateur en utilisant les traductions.
318: */
319: public function rolesFilter($roles, $join = false, $trans = true)
320: {
321: if ($roles === null || empty($roles)) return ($join === false) ? array() : "";
322: if (! \is_array($roles)) $roles = \explode(',', $roles);
323: \sort($roles);
324:
325: $labels = array();
326: foreach ($roles as $role) {
327: if ($role === 'ROLE_USER') continue;
328: $label = 'app.user.' . \strtolower(\str_replace('ROLE_', 'roles.', $role));
329: $labels[] = ($trans) ? $this->trans($label) : $label;
330: }
331:
332: if ($join === false) return $labels;
333: if ($join === true) $join = ", ";
334: return \implode($join, $labels);
335: }
336:
337: /**
338: *
339: public function starsFilter(\Twig_Environment $env, $value, $symbol = null)
340: {
341: if (empty($symbol)) {
342: $asset = $env->getFunction('asset')->getCallable();
343: $symbol = \call_user_func($asset, 'bundles/ticcore/images/bootstrap-star-rating/star.png');
344: }
345: $html = '<sup class="tic_stars" title="' . $value . '">';
346: for ($n = 0; $n < $value; $n++) {
347: $html.= \sprintf('<img src="%s" alt="*" />', $symbol);
348: }
349: $html.= '</sup>';
350: return $html;
351: }
352: */
353:
354: /**
355: * Génération d'un tag HTML pour afficher un icone FontAwesome ou Bootstrap (avec quelques alias utiles).
356: *
357: * @param string $name Classe Bootstrap ou FontAwesome (ex 'fas-user', 'fab-user', 'envelope', 'ban-circle'...)
358: * @param string $state Ajout d'une classe de type 'text-<state>' (ex: primary, info, success, warning, danger)
359: */
360: public function iconFilter(string $name, string $state=''): string
361: {
362: return $this->getIcon($name, $state='');
363: }
364:
365: /**
366: * Formattage d'un nombre (selon les règles d'affichage françaises).
367: */
368: public function lnumFilter($number, $precision = null)
369: {
370: return \number_format((float)$number, $precision, ',', ' ');
371: # return \sprintf('%.0'.$precision.'f', $number);
372: }
373:
374: /**
375: * Validation/transformation d'un nombre pour respecter un "pas" (multiplieur).
376: */
377: public function numStepFilter($number, $step = 5, $default = '')
378: {
379: if (! \is_numeric($number)) return $default;
380: return \intval(\round($number / $step) * $step);
381: }
382:
383: /**
384: * Génération automatique d'un libellé (token de traduction) à partir d'un champ de formulaire.
385: */
386: public function labelizeFilter($view_or_data, $type = 'label')
387: {
388: $data = $view_or_data;
389: if (\is_object($view_or_data)) {
390: $data = array(
391: 'path' => $view_or_data->parent->vars['id'],
392: 'name' => $view_or_data->vars['name'],
393: );
394: if (isset($view_or_data->parent->vars['label_path'])) {
395: $data['path'] = $view_or_data->parent->vars['label_path'];
396: }
397: if (isset($view_or_data->parent->parent->parent->parent->vars)
398: && $view_or_data->parent->parent->parent->vars['name'] == 'translations') {
399: $data['path'] = $view_or_data->parent->parent->parent->parent->vars['id'];
400: }
401: }
402: $path = \preg_replace('/__([a-zA-Z\-]+)\d\d\d__/', '__$1__', $data['path']);
403: $parent = \strtr($path . '$', array(
404: '__name__' => '',
405: # '_form_' => '.',
406: '_form_' => '.' . $type . '-',
407: # '_form$' => '.',
408: '_form$' => '.' . $type . '.',
409: '$' => '.',
410: '_' => '.',
411: ));
412: # return preg_replace('/\.\d*\./', '.', $parent) . $type . '.' $data['name'];
413: if (! \preg_match('/\.' . $type . '[\.\-]/', $parent)) $parent.= $type . ".";
414: return \preg_replace('/\.\d*\./', '.', $parent) . $data['name'];
415: }
416:
417: /**
418: * Application d'une RegExp de substitution.
419: */
420: public function eReplaceFilter(string $subject, string $pattern, string $replacement): string
421: {
422: return \preg_replace($pattern, $replacement, $subject);
423: }
424:
425: /**
426: * Application du filtre de traduction (trans) sur une liste de valeurs.
427: */
428: public function transListFilter(array $values): array
429: {
430: \array_walk($values, function(&$text) { $text = $this->trans($text); });
431: return $values;
432: }
433:
434: }
435: