Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
96 / 96 |
|
100.00% |
18 / 18 |
CRAP | |
100.00% |
1 / 1 |
| TemplateTwigExtension | |
100.00% |
95 / 95 |
|
100.00% |
18 / 18 |
35 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getFunctions | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
| getFilters | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| companyLogo | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| relatedTable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| renderComponent | |
100.00% |
44 / 44 |
|
100.00% |
1 / 1 |
12 | |||
| qrCode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| barcode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| bankTransferQr | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| verificationBadge | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| chartSparkline | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| translate | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| pageBreak | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| statusBadge | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| formatCurrency | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| formatDate | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| formatDateTime | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fieldLabel | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Template\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use DateTimeImmutable; |
| 12 | use Throwable; |
| 13 | use Twig\Extension\AbstractExtension; |
| 14 | use Twig\TwigFilter; |
| 15 | use Twig\TwigFunction; |
| 16 | |
| 17 | /** |
| 18 | * Custom Twig Functions and Filters for Email & PDF Templates. |
| 19 | * |
| 20 | * Provides specialized components: logos, dynamic line item tables, |
| 21 | * QR codes, barcodes, EPC/ZBP bank transfer QR, digital verification badges, |
| 22 | * lightweight SVG sparkline charts, page breaks, status badges, translations, |
| 23 | * and currency/date formatting. |
| 24 | * |
| 25 | * @package App\Core\Template\Application\Service |
| 26 | */ |
| 27 | final class TemplateTwigExtension extends AbstractExtension |
| 28 | { |
| 29 | /** |
| 30 | * Dictionary of multi-lingual document phrases for t() function. |
| 31 | * |
| 32 | * @var array<string, array<string, string>> |
| 33 | */ |
| 34 | private const array TRANSLATION_MAP = [ |
| 35 | 'invoice' => ['pl' => 'Faktura VAT', 'en' => 'Tax Invoice', 'de' => 'Rechnung'], |
| 36 | 'proforma' => ['pl' => 'Faktura Proforma', 'en' => 'Proforma Invoice', 'de' => 'Proforma-Rechnung'], |
| 37 | 'total_net' => ['pl' => 'Razem netto', 'en' => 'Total Net', 'de' => 'Gesamt Netto'], |
| 38 | 'tax_vat' => ['pl' => 'Podatek VAT', 'en' => 'VAT Amount', 'de' => 'MwSt.'], |
| 39 | 'total_gross' => ['pl' => 'Razem brutto', 'en' => 'Total Gross', 'de' => 'Gesamt Brutto'], |
| 40 | 'due_date' => ['pl' => 'Termin płatności', 'en' => 'Payment Due Date', 'de' => 'Fälligkeitsdatum'], |
| 41 | 'issue_date' => ['pl' => 'Data wystawienia', 'en' => 'Date of Issue', 'de' => 'Ausstellungsdatum'], |
| 42 | 'bank_account' => ['pl' => 'Konto bankowe', 'en' => 'Bank Account', 'de' => 'Bankverbindung'], |
| 43 | 'seller' => ['pl' => 'Sprzedawca', 'en' => 'Seller', 'de' => 'Verkäufer'], |
| 44 | 'buyer' => ['pl' => 'Nabywca', 'en' => 'Buyer', 'de' => 'Käufer'], |
| 45 | 'nip' => ['pl' => 'NIP', 'en' => 'Tax ID', 'de' => 'USt-IdNr.'], |
| 46 | 'document_no' => ['pl' => 'Numer dokumentu', 'en' => 'Document No.', 'de' => 'Dokumentennummer'], |
| 47 | 'page' => ['pl' => 'Strona', 'en' => 'Page', 'de' => 'Seite'], |
| 48 | 'verified_doc' => [ |
| 49 | 'pl' => 'Dokument Zweryfikowany Cyfrowo', |
| 50 | 'en' => 'Digitally Verified Document', |
| 51 | 'de' => 'Digital Verifiziertes Dokument', |
| 52 | ], |
| 53 | ]; |
| 54 | |
| 55 | private TemplateVisualComponentRenderer $renderer; |
| 56 | |
| 57 | /** |
| 58 | * TemplateTwigExtension constructor. |
| 59 | * |
| 60 | * @param TemplateVisualComponentRenderer|null $renderer Optional visual renderer. |
| 61 | */ |
| 62 | public function __construct(?TemplateVisualComponentRenderer $renderer = null) |
| 63 | { |
| 64 | $this->renderer = $renderer ?? new TemplateVisualComponentRenderer([$this, 'formatCurrency']); |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Returns custom functions available in templates. |
| 69 | * |
| 70 | * @return array<int, TwigFunction> List of Twig functions. |
| 71 | */ |
| 72 | public function getFunctions(): array |
| 73 | { |
| 74 | return [ |
| 75 | new TwigFunction('company_logo', [$this, 'companyLogo'], ['is_safe' => ['html']]), |
| 76 | new TwigFunction('related_table', [$this, 'relatedTable'], ['is_safe' => ['html']]), |
| 77 | new TwigFunction('render_component', [$this, 'renderComponent'], ['is_safe' => ['html']]), |
| 78 | new TwigFunction('qr_code', [$this, 'qrCode'], ['is_safe' => ['html']]), |
| 79 | new TwigFunction('barcode', [$this, 'barcode'], ['is_safe' => ['html']]), |
| 80 | new TwigFunction('bank_transfer_qr', [$this, 'bankTransferQr'], ['is_safe' => ['html']]), |
| 81 | new TwigFunction('verification_badge', [$this, 'verificationBadge'], ['is_safe' => ['html']]), |
| 82 | new TwigFunction('chart_sparkline', [$this, 'chartSparkline'], ['is_safe' => ['html']]), |
| 83 | new TwigFunction('page_break', [$this, 'pageBreak'], ['is_safe' => ['html']]), |
| 84 | new TwigFunction('status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]), |
| 85 | new TwigFunction('field_label', [$this, 'fieldLabel'], ['is_safe' => ['html']]), |
| 86 | new TwigFunction('t', [$this, 'translate'], ['is_safe' => ['html']]), |
| 87 | ]; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Returns custom filters available in templates. |
| 92 | * |
| 93 | * @return array<int, TwigFilter> List of Twig filters. |
| 94 | */ |
| 95 | public function getFilters(): array |
| 96 | { |
| 97 | return [ |
| 98 | new TwigFilter('format_currency', [$this, 'formatCurrency']), |
| 99 | new TwigFilter('format_date', [$this, 'formatDate']), |
| 100 | new TwigFilter('format_datetime', [$this, 'formatDateTime']), |
| 101 | new TwigFilter('t', [$this, 'translate']), |
| 102 | ]; |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * @see TemplateVisualComponentRenderer::companyLogo() |
| 107 | */ |
| 108 | public function companyLogo(int $maxWidth = 180, int $maxHeight = 60, string $alt = 'Company Logo'): string |
| 109 | { |
| 110 | return $this->renderer->companyLogo($maxWidth, $maxHeight, $alt); |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * @see TemplateVisualComponentRenderer::relatedTable() |
| 115 | */ |
| 116 | public function relatedTable(string $relationName, array $columns = [], array $options = []): string |
| 117 | { |
| 118 | return $this->renderer->relatedTable($relationName, $columns, $options); |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Unified visual component rendering gateway for Twig templates. |
| 123 | * |
| 124 | * @param string $name Component name (e.g. 'related_table', 'company_logo'). |
| 125 | * @param array<string, mixed> $options Component options. |
| 126 | * @return string Rendered component HTML. |
| 127 | */ |
| 128 | public function renderComponent(string $name, array $options = []): string |
| 129 | { |
| 130 | if ($name === 'related_table') { |
| 131 | $relationName = (string)($options['relation'] ?? 'items'); |
| 132 | $columns = (array)($options['columns'] ?? [ |
| 133 | 'name', 'quantity', 'unit_price', 'net_amount', 'tax_percent', 'tax_amount', 'gross_amount', |
| 134 | ]); |
| 135 | return $this->relatedTable($relationName, $columns, $options); |
| 136 | } |
| 137 | |
| 138 | return match ($name) { |
| 139 | 'company_logo' => $this->companyLogo( |
| 140 | (int)($options['max_width'] ?? 180), |
| 141 | (int)($options['max_height'] ?? 60), |
| 142 | (string)($options['alt'] ?? 'Company Logo') |
| 143 | ), |
| 144 | 'qr_code' => $this->qrCode( |
| 145 | (string)($options['data'] ?? ''), |
| 146 | (int)($options['size'] ?? 120) |
| 147 | ), |
| 148 | 'barcode' => $this->barcode( |
| 149 | (string)($options['data'] ?? ''), |
| 150 | (string)($options['type'] ?? 'C128') |
| 151 | ), |
| 152 | 'bank_transfer_qr' => $this->bankTransferQr( |
| 153 | (string)($options['account'] ?? ''), |
| 154 | (string)($options['recipient'] ?? ''), |
| 155 | $options['amount'] ?? 0, |
| 156 | (string)($options['title'] ?? ''), |
| 157 | (string)($options['currency'] ?? 'PLN') |
| 158 | ), |
| 159 | 'verification_badge' => $this->verificationBadge( |
| 160 | (string)($options['hash'] ?? ''), |
| 161 | isset($options['base_url']) ? (string)$options['base_url'] : null |
| 162 | ), |
| 163 | 'chart_sparkline' => $this->chartSparkline( |
| 164 | (array)($options['values'] ?? []), |
| 165 | (string)($options['type'] ?? 'bar'), |
| 166 | (int)($options['width'] ?? 120), |
| 167 | (int)($options['height'] ?? 32), |
| 168 | (string)($options['color'] ?? '#2563eb') |
| 169 | ), |
| 170 | 'page_break' => $this->pageBreak(), |
| 171 | 'status_badge' => $this->statusBadge( |
| 172 | (string)($options['status'] ?? ''), |
| 173 | (string)($options['label'] ?? '') |
| 174 | ), |
| 175 | default => '', |
| 176 | }; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * @see TemplateVisualComponentRenderer::qrCode() |
| 181 | */ |
| 182 | public function qrCode(string $data, int $size = 120): string |
| 183 | { |
| 184 | return $this->renderer->qrCode($data, $size); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * @see TemplateVisualComponentRenderer::barcode() |
| 189 | */ |
| 190 | public function barcode(string $code, string $type = 'C128'): string |
| 191 | { |
| 192 | return $this->renderer->barcode($code, $type); |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * @see TemplateVisualComponentRenderer::bankTransferQr() |
| 197 | */ |
| 198 | public function bankTransferQr( |
| 199 | string $account, |
| 200 | string $recipient, |
| 201 | float|int|string $amount, |
| 202 | string $title, |
| 203 | string $currency = 'PLN' |
| 204 | ): string { |
| 205 | return $this->renderer->bankTransferQr($account, $recipient, $amount, $title, $currency); |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * @see TemplateVisualComponentRenderer::verificationBadge() |
| 210 | */ |
| 211 | public function verificationBadge(string $hash, ?string $baseUrl = null): string |
| 212 | { |
| 213 | return $this->renderer->verificationBadge($hash, $baseUrl); |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * @see TemplateVisualComponentRenderer::chartSparkline() |
| 218 | */ |
| 219 | public function chartSparkline( |
| 220 | array $values, |
| 221 | string $type = 'bar', |
| 222 | int $width = 120, |
| 223 | int $height = 32, |
| 224 | string $color = '#2563eb' |
| 225 | ): string { |
| 226 | return $this->renderer->chartSparkline($values, $type, $width, $height, $color); |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * Translates document phrase key into requested language with parameter binding. |
| 231 | * |
| 232 | * @param string $key Translation phrase key. |
| 233 | * @param string $locale Target 2-letter language code (pl, en, de). |
| 234 | * @param array<string, mixed> $replacements Key-value replacement parameters. |
| 235 | * @return string Localized translated string. |
| 236 | */ |
| 237 | public function translate(string $key, string $locale = 'pl', array $replacements = []): string |
| 238 | { |
| 239 | $lang = strtolower(trim($locale)); |
| 240 | $text = self::TRANSLATION_MAP[$key][$lang] |
| 241 | ?? self::TRANSLATION_MAP[$key]['en'] |
| 242 | ?? self::TRANSLATION_MAP[$key]['pl'] |
| 243 | ?? $key; |
| 244 | |
| 245 | foreach ($replacements as $param => $value) { |
| 246 | $text = str_replace(':' . $param, (string)$value, $text); |
| 247 | $text = str_replace('{' . $param . '}', (string)$value, $text); |
| 248 | } |
| 249 | |
| 250 | return $text; |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * @see TemplateVisualComponentRenderer::pageBreak() |
| 255 | */ |
| 256 | public function pageBreak(): string |
| 257 | { |
| 258 | return $this->renderer->pageBreak(); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * @see TemplateVisualComponentRenderer::statusBadge() |
| 263 | */ |
| 264 | public function statusBadge(string|int|null $status, string $label = ''): string |
| 265 | { |
| 266 | return $this->renderer->statusBadge($status, $label); |
| 267 | } |
| 268 | |
| 269 | /** |
| 270 | * Formats decimal/integer numbers into standard currency presentation. |
| 271 | * |
| 272 | * @param float|int|string|null $amount Raw currency number. |
| 273 | * @param string $currency Currency code (e.g. PLN, EUR, USD). |
| 274 | * @return string Formatted currency string. |
| 275 | */ |
| 276 | public function formatCurrency(float|int|string|null $amount, string $currency = 'PLN'): string |
| 277 | { |
| 278 | $val = is_numeric($amount) ? (float)$amount : 0.0; |
| 279 | return number_format($val, 2, ',', ' ') . ' ' . $currency; |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Parses and formats datetime string. |
| 284 | * |
| 285 | * @param string|null $date Date or datetime string. |
| 286 | * @param string $format Desired output date format. |
| 287 | * @return string Formatted date string or empty string. |
| 288 | */ |
| 289 | public function formatDate(?string $date, string $format = 'Y-m-d'): string |
| 290 | { |
| 291 | if ($date === null || trim($date) === '') { |
| 292 | return ''; |
| 293 | } |
| 294 | |
| 295 | try { |
| 296 | $dt = new DateTimeImmutable($date); |
| 297 | return $dt->format($format); |
| 298 | } catch (Throwable) { |
| 299 | return $date; |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | /** |
| 304 | * Parses and formats timestamp into date and time string. |
| 305 | * |
| 306 | * @param string|null $date Date or datetime string. |
| 307 | * @param string $format Desired output datetime format. |
| 308 | * @return string Formatted datetime string or empty string. |
| 309 | */ |
| 310 | public function formatDateTime(?string $date, string $format = 'Y-m-d H:i'): string |
| 311 | { |
| 312 | return $this->formatDate($date, $format); |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Formats and resolves a field label element with translation map and fallback support. |
| 317 | * |
| 318 | * @param string $fieldKey Technical field key or custom label. |
| 319 | * @param string|null $fallback Optional fallback label if primary translation is absent. |
| 320 | * @param string $lang Language code (default: 'pl'). |
| 321 | * @return string Escaped field label string. |
| 322 | */ |
| 323 | public function fieldLabel(string $fieldKey, ?string $fallback = null, string $lang = 'pl'): string |
| 324 | { |
| 325 | $resolved = self::TRANSLATION_MAP[$fieldKey][$lang] |
| 326 | ?? (trim((string)($fallback ?? '')) !== '' ? (string)$fallback : $fieldKey); |
| 327 | |
| 328 | return htmlspecialchars($resolved, ENT_QUOTES, 'UTF-8'); |
| 329 | } |
| 330 | } |