157 lines
6.1 KiB
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] ?? '';
|
|
}
|
|
}
|