vcard4reseller/backend/src/Service/CardPdfRenderer.php
Thomas Peterson 67e4353c8d Skalierbarkeit: Druck-Assets in S3-Object-Storage (Flysystem)
Macht die App-Nodes zustandslos (horizontal skalierbar): Hintergrund-PDFs
und Schriften liegen nicht mehr lokal, sondern im S3-kompatiblen Object
Storage (Flysystem + async-aws). In der DB stehen Storage-Keys.

- flysystem-bundle + async-aws (Storage "card_assets"), env-getrieben
  (S3_ENDPOINT/REGION/BUCKET/KEY/SECRET/PATH_STYLE) → lokal MinIO, prod Hetzner OS
- CardAssetUploadController: Upload/Read/Delete über Storage; GET streamt PDF
- CardPdfRenderer: liest Hintergrund (FPDI StreamReader) & Schriften (Temp-Datei) aus S3
- docker-compose: minio + minio-init (Bucket) + zweiter App-Node php2 (Profil scale-test)
- app:render-card Command für den Cross-Node-Nachweis

Verifiziert: Upload über Node 1 → identisches PDF-Render (51897 B, mit
Hintergrund) auf Node 2, der nur DB + Object Storage liest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:56:51 +02:00

282 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Service;
use App\Entity\CardTemplate;
use App\Entity\Employee;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Writer\PngWriter;
use League\Flysystem\FilesystemOperator;
use setasign\Fpdi\PdfParser\StreamReader;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Rendert eine druckfertige Visitenkarte (CMYK, Beschnitt + Schnittmarken,
* Vorder-/Rückseite) aus einer CardTemplate + Mitarbeiterprofil via TCPDF.
* Koordinaten der Vorlage sind in mm im Trim-Raum (0,0 = Endformat-Ecke).
*/
final class CardPdfRenderer
{
private const MARK_LEN = 4.0; // mm Länge der Schnittmarken
/** @var array<string, string> family → eingebetteter TCPDF-Fontname */
private array $fontMap = [];
public function __construct(
private readonly UrlGeneratorInterface $urls,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
public function render(Employee $employee, CardTemplate $template): string
{
$branding = $this->branding($employee);
$bleed = $template->getBleedMm();
$bgKey = $template->getBackgroundPath();
$hasBg = $bgKey && $this->cardAssets->fileExists($bgKey);
$bgReader = $hasBg ? StreamReader::createByString($this->cardAssets->read($bgKey)) : null;
// Mit Hintergrund-PDF: Seite = Endformat+Beschnitt, keine eigenen Schnittmarken
// (der Kunde liefert Beschnitt/Marken selbst). Sonst Rand für Schnittmarken.
$margin = $hasBg ? $bleed : $bleed + self::MARK_LEN;
$pw = $template->getWidthMm() + 2 * $margin;
$ph = $template->getHeightMm() + 2 * $margin;
$pdf = new \setasign\Fpdi\Tcpdf\Fpdi('L', 'mm', [$pw, $ph], true, 'UTF-8', false);
$pdf->SetCreator('vcard4reseller');
$pdf->SetTitle(trim($employee->getFirstName().' '.$employee->getLastName()));
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->SetAutoPageBreak(false);
$pdf->SetMargins(0, 0, 0);
$pdf->setCellPaddings(0, 0, 0, 0);
$pdf->setCellMargins(0, 0, 0, 0);
$this->fontMap = $this->registerFonts($template);
$bgPages = $hasBg ? $pdf->setSourceFile($bgReader) : 0;
foreach ([$template->getFront(), $template->getBack()] as $i => $elements) {
$pdf->AddPage('L', [$pw, $ph]);
if ($hasBg && ($i + 1) <= $bgPages) {
$imported = $pdf->importPage($i + 1);
$pdf->useTemplate($imported, 0, 0, $pw, $ph);
}
foreach ($elements as $el) {
$this->renderElement($pdf, $el, $employee, $branding, $margin);
}
if (!$hasBg) {
$this->cropMarks($pdf, $template->getWidthMm(), $template->getHeightMm(), $bleed, $margin);
}
}
return $pdf->Output('card.pdf', 'S');
}
/**
* Bettet eigene Schriften (TTF/OTF) ein und liefert Map family → TCPDF-Fontname.
*
* @return array<string, string>
*/
private function registerFonts(CardTemplate $template): array
{
$map = [];
foreach ($template->getFonts() as $f) {
$key = $f['path'] ?? '';
$family = $f['family'] ?? '';
if ('' === $family || '' === $key || !$this->cardAssets->fileExists($key)) {
continue;
}
// TCPDF braucht eine echte Datei → Schrift aus dem Storage in eine Temp-Datei
$tmp = tempnam(sys_get_temp_dir(), 'fnt');
file_put_contents($tmp, $this->cardAssets->read($key));
try {
$map[$family] = \TCPDF_FONTS::addTTFfont($tmp, 'TrueTypeUnicode', '', 32);
} catch (\Throwable) {
// nicht konvertierbar → Fallback auf Core-Font
} finally {
@unlink($tmp);
}
}
return $map;
}
/** @param array<string, mixed> $el */
private function renderElement(\TCPDF $pdf, array $el, Employee $e, array $branding, float $o): void
{
$type = $el['type'] ?? 'text';
$px = $o + (float) ($el['x'] ?? 0);
$py = $o + (float) ($el['y'] ?? 0);
$w = (float) ($el['w'] ?? 0);
$h = (float) ($el['h'] ?? 0);
switch ($type) {
case 'rect':
[$c, $m, $y, $k] = $this->color($el['fill'] ?? ['ref' => 'primary'], $branding);
$pdf->SetFillColor($c, $m, $y, $k);
$pdf->Rect($px, $py, $w, $h, 'F');
break;
case 'line':
[$c, $m, $y, $k] = $this->color($el['color'] ?? ['ref' => 'text'], $branding);
$pdf->SetDrawColor($c, $m, $y, $k);
$pdf->SetLineWidth((float) ($el['lineWidth'] ?? 0.3));
$pdf->Line($px, $py, $px + $w, $py + $h);
break;
case 'qr':
$png = $this->qrPng($this->shareUrl($e));
$pdf->Image('@'.$png, $px, $py, $w, $h, 'PNG');
break;
case 'image':
$src = 'logo' === ($el['binding'] ?? null) ? ($branding['logoUrl'] ?? null) : ($el['src'] ?? null);
if ($src) {
try {
$pdf->Image($src, $px, $py, $w, $h, '', '', '', false, 300, $el['align'] ?? '');
} catch (\Throwable) {
// Logo nicht ladbar → überspringen, Karte bleibt valide
}
}
break;
case 'field':
case 'text':
$value = 'field' === $type
? $this->binding((string) ($el['binding'] ?? ''), $e)
: (string) ($el['text'] ?? '');
if ('' === trim($value)) {
return;
}
$value = ($el['prefix'] ?? '').$value;
[$c, $m, $y, $k] = $this->color($el['color'] ?? ['ref' => 'text'], $branding);
$pdf->SetTextColor($c, $m, $y, $k);
$fam = $el['fontFamily'] ?? null;
$font = $this->fontMap[$fam] ?? (in_array($fam, ['times', 'courier', 'helvetica'], true) ? $fam : 'helvetica');
$pdf->SetFont($font, !empty($el['bold']) ? 'B' : '', (float) ($el['fontSize'] ?? 9));
$pdf->MultiCell($w ?: 0, 0, $value, 0, $el['align'] ?? 'L', false, 1, $px, $py, true, 0, false, true, 0, 'T');
break;
}
}
/** Schnittmarken an den vier Endformat-Ecken (Registrierschwarz). */
private function cropMarks(\TCPDF $pdf, float $w, float $h, float $bleed, float $o): void
{
$pdf->SetDrawColor(0, 0, 0, 100);
$pdf->SetLineWidth(0.2);
$m = self::MARK_LEN;
$tl = $o; $tt = $o; $tr = $o + $w; $tb = $o + $h; // Trim-Linien
$bl = $o - $bleed; $bt = $o - $bleed; $br = $o + $w + $bleed; $bb = $o + $h + $bleed; // Bleed-Kanten
// je Ecke eine vertikale + horizontale Marke, außerhalb des Beschnitts
$pdf->Line($tl, $bt - $m, $tl, $bt); $pdf->Line($bl - $m, $tt, $bl, $tt); // oben links
$pdf->Line($tr, $bt - $m, $tr, $bt); $pdf->Line($br, $tt, $br + $m, $tt); // oben rechts
$pdf->Line($tl, $bb, $tl, $bb + $m); $pdf->Line($bl - $m, $tb, $bl, $tb); // unten links
$pdf->Line($tr, $bb, $tr, $bb + $m); $pdf->Line($br, $tb, $br + $m, $tb); // unten rechts
}
/** @return array{0:float,1:float,2:float,3:float} CMYK 0100 */
private function color(mixed $color, array $branding): array
{
if (is_array($color) && isset($color['ref'])) {
$hex = match ($color['ref']) {
'primary' => $branding['primaryColor'] ?? '#f58220',
'dark' => $branding['primaryDark'] ?? '#222222',
'text' => '#343434',
'white' => '#ffffff',
default => '#343434',
};
return $this->hexToCmyk($hex);
}
if (is_array($color) && isset($color['hex'])) {
return $this->hexToCmyk((string) $color['hex']);
}
if (is_array($color) && isset($color['c'])) {
return [(float) $color['c'], (float) $color['m'], (float) $color['y'], (float) $color['k']];
}
return [0, 0, 0, 80];
}
/** @return array{0:float,1:float,2:float,3:float} */
private function hexToCmyk(string $hex): array
{
$hex = ltrim($hex, '#');
if (6 !== strlen($hex)) {
return [0, 0, 0, 80];
}
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$k = 1 - max($r, $g, $b);
if ($k >= 1.0) {
return [0, 0, 0, 100];
}
$c = (1 - $r - $k) / (1 - $k);
$m = (1 - $g - $k) / (1 - $k);
$y = (1 - $b - $k) / (1 - $k);
return [round($c * 100), round($m * 100), round($y * 100), round($k * 100)];
}
private function binding(string $binding, Employee $e): string
{
return match ($binding) {
'firstName' => $e->getFirstName(),
'lastName' => $e->getLastName(),
'fullName' => trim($e->getFirstName().' '.$e->getLastName()),
'position' => (string) $e->getPosition(),
'department' => (string) $e->getDepartment(),
'email' => (string) $e->getEmail(),
'phone' => (string) $e->getPhone(),
'mobile' => (string) $e->getMobile(),
'company.name' => $e->getCompany()->getName(),
'profileUrl' => $this->profileUrl($e),
'shortUrl' => $this->shareUrl($e),
default => '',
};
}
/** @return array<string, string|null> */
private function branding(Employee $e): array
{
$b = $e->getCompany()->getBrandingConfig();
return is_array($b) ? $b : [];
}
private function qrPng(string $data): string
{
return (new Builder(
writer: new PngWriter(),
data: $data,
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
size: 600,
margin: 0,
))->build()->getString();
}
private function shareUrl(Employee $e): string
{
if (null !== $e->getShortCode()) {
return $this->urls->generate('short_link', ['code' => $e->getShortCode()], UrlGeneratorInterface::ABSOLUTE_URL);
}
return $this->profileUrl($e);
}
private function profileUrl(Employee $e): string
{
return $this->urls->generate('public_profile', [
'companySlug' => $e->getCompany()->getSlug(),
'slug' => $e->getSlug(),
], UrlGeneratorInterface::ABSOLUTE_URL);
}
}