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 */ 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 $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 */ 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); } }