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>
282 lines
11 KiB
PHP
282 lines
11 KiB
PHP
<?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 0–100 */
|
||
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);
|
||
}
|
||
}
|