Files
mareike/app/Providers/DocumentTemplateRenderProvider.php

157 lines
6.1 KiB
PHP

<?php
namespace App\Providers;
use App\Models\DocumentAsset;
use App\Models\DocumentTemplate;
use Illuminate\Support\Facades\Log;
/**
* Setzt ein Dokument aus den in der Datenbank gepflegten Vorlagenblöcken zusammen.
*
* Ablauf: `layout` bildet das Seitengerüst und enthält `{block:...}`-Platzhalter; darin werden die
* einzelnen Blöcke eingesetzt, anschließend Bilder und Werte. Das Ergebnis wandert in ein dünnes
* Blade-Gerüst, das selbst kein Layout enthält.
*
* WICHTIG: Vorlageninhalt kommt aus der Datenbank und wird von einem Admin-Formular befüllt. Er wird
* ausschließlich per `strtr()` bzw. `preg_replace_callback()` ersetzt und NIEMALS durch `Blade::render()`
* oder `eval` geschickt -- sonst wäre das Formular ein Weg zur Codeausführung auf dem Server.
*/
class DocumentTemplateRenderProvider
{
/** @var array<string, string> */
private array $blocks;
/**
* @param array<string, string> $overrides Blockinhalte, die den gespeicherten Stand ersetzen -- für die
* Vorschau in der Vorlagen-Verwaltung, damit ungespeicherte
* Änderungen sichtbar werden.
*/
public function __construct(private readonly string $documentType, array $overrides = [])
{
$stored = DocumentTemplate::forType($this->documentType)
->map(fn(DocumentTemplate $block): string => (string) $block->content)
->all();
$this->blocks = array_merge($stored, array_map(strval(...), $overrides));
}
/**
* @param array<string, string> $tokens Platzhalter ohne geschweifte Klammern, z.B. ['invoice_number' => '...']
*/
public function render(array $tokens): string
{
$html = $this->blockContent(DocumentTemplate::BLOCK_LAYOUT);
$html = $this->insertBlocks($html);
$html = $this->insertAssets($html);
$html = $this->applyConditionals($html, $tokens);
$html = $this->insertTokens($html, $tokens);
$style = $this->stripBareDataUris($this->insertTokens(
$this->insertAssets($this->blockContent(DocumentTemplate::BLOCK_STYLE)),
$tokens
));
return view('pdfs.document', [
'title' => $tokens['document_title'] ?? '',
'style' => $style,
'content' => $html,
])->render();
}
/**
* Entfernt Data-URIs, die nicht in `url(...)` bzw. Anführungszeichen stehen.
*
* dompdf lagert Data-URIs im CSS nur dann in interne Blobs aus, wenn ihnen `(`, `"` oder `'`
* vorausgeht (siehe Stylesheet::_parse_css). Ein bar im CSS stehendes Data-URI bleibt als Rohtext
* liegen, und die anschließende Ruleset-Regex `[^{]*{[^}]*}` scannt für jede Startposition durch
* das gesamte Base64 -- quadratische Laufzeit. Bei einem eingebetteten Logo sind das Minuten, und
* unter PHP-FPM stirbt der Worker am Zeitlimit: die Anfrage endet in einem 502.
*
* Gültiges CSS ist so ein Data-URI ohnehin nicht. Es wird deshalb entfernt und weitergerendert,
* statt das Dokument scheitern zu lassen -- wer eine Rechnung braucht, hat die Vorlage nicht
* kaputt gemacht. Die Warnung im Log sorgt dafür, dass der Zustand trotzdem auffällt.
*/
private function stripBareDataUris(string $css): string
{
// Das Semikolon gehört zum Data-URI selbst ("data:image/png;base64,..."), darf hier also nicht
// begrenzen -- sonst bliebe der Base64-Rumpf stehen und der Parser bremst weiter.
$cleaned = preg_replace('/(?<![("\'])data:[^\s}\)\'"]+/', '', $css);
if ($cleaned !== $css) {
Log::warning('Dokumentvorlage: Data-URI ausserhalb von url("...") im CSS entfernt.', [
'document_type' => $this->documentType,
'block' => DocumentTemplate::BLOCK_STYLE,
]);
}
return $cleaned;
}
/** Ersetzt `{block:name}` durch den jeweiligen Blockinhalt. Leere oder fehlende Blöcke fallen weg. */
private function insertBlocks(string $html): string
{
return preg_replace_callback(
'/\{block:([a-z0-9_-]+)}/i',
fn(array $match): string => $this->blockContent($match[1]),
$html
);
}
/** Ersetzt `{asset:name}` durch die Data-URI des Bildes. Unbekannte Namen werden zu einem Leerstring. */
private function insertAssets(string $html): string
{
if (!str_contains($html, '{asset:')) {
return $html;
}
$assets = DocumentAsset::all()->keyBy('name');
return preg_replace_callback(
'/\{asset:([a-z0-9_-]+)}/i',
static fn(array $match): string => $assets->get($match[1])?->toDataUri() ?? '',
$html
);
}
/**
* Wertet `{if:token}...{/if:token}` aus: der Abschnitt bleibt nur stehen, wenn der Platzhalter einen
* Wert hat. Ohne das stünden auf der Rechnung leere Zeilen, hängende Trenner und Beschriftungen ohne
* Wert ("Steuernummer: "), sobald ein optionales Feld beim Tenant nicht gepflegt ist.
*
* Verschachtelte Bedingungen werden nicht unterstützt -- für die Vorlagen hier reicht eine Ebene.
*
* @param array<string, string> $tokens
*/
private function applyConditionals(string $html, array $tokens): string
{
return preg_replace_callback(
'/\{if:([a-z0-9_]+)}(.*?)\{\/if:\1}/is',
static fn(array $match): string => trim((string) ($tokens[$match[1]] ?? '')) === '' ? '' : $match[2],
$html
);
}
/**
* Ersetzt die Wert-Platzhalter. Unbekannte Platzhalter bleiben unangetastet stehen -- das macht beim
* Pflegen der Vorlage sofort sichtbar, dass ein Name nicht stimmt.
*
* @param array<string, string> $tokens
*/
private function insertTokens(string $html, array $tokens): string
{
$replacements = [];
foreach ($tokens as $name => $value) {
$replacements['{' . $name . '}'] = (string) $value;
}
return strtr($html, $replacements);
}
private function blockContent(string $block): string
{
return $this->blocks[$block] ?? '';
}
}