diff --git a/README.md b/README.md index 4a234b9..761247a 100644 --- a/README.md +++ b/README.md @@ -98,5 +98,10 @@ die API gebundene Screens: Start: `cd frontend && npm run dev` → http://localhost:5173 (Login z. B. reseller@demo.de / reseller). -Nächster Schritt: Wallet-Pässe (Konzept §12, Google zuerst), E-Mail-Signaturen, -Druckdaten. Siehe `docs/KONZEPT.md` §9. +**Druckdaten (Kerngeschäft, in Arbeit):** druckfertige Visitenkarten als PDF +(CMYK, 85×55mm + 2mm Beschnitt + Schnittmarken, Vorder-/Rückseite) — Endpunkt +`GET /api/employees/{id}/card.pdf`. Layout via `CardTemplate` (Standardvorlage +greift Firmen-Branding + QR ab). Siehe `docs/KONZEPT.md` §13. + +Nächster Schritt: visueller Karten-Editor, PDF/X-1a-Finishing (Ghostscript), +dann Wallet-Pässe (§12). Siehe `docs/KONZEPT.md` §9. diff --git a/backend/composer.json b/backend/composer.json index 9bfd89b..64f1ac9 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -32,7 +32,8 @@ "symfony/twig-bundle": "7.4.*", "symfony/uid": "7.4.*", "symfony/validator": "7.4.*", - "symfony/yaml": "7.4.*" + "symfony/yaml": "7.4.*", + "tecnickcom/tcpdf": "^6.11" }, "config": { "allow-plugins": { diff --git a/backend/composer.lock b/backend/composer.lock index 4dd38c1..99f3ebe 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d338a65321dc56060e7c17bb67662ca5", + "content-hash": "07ec9373fd92a37b6046b19dfd065ec1", "packages": [ { "name": "api-platform/doctrine-common", @@ -7831,6 +7831,83 @@ ], "time": "2026-05-25T06:06:12+00:00" }, + { + "name": "tecnickcom/tcpdf", + "version": "6.11.3", + "source": { + "type": "git", + "url": "https://github.com/tecnickcom/TCPDF.git", + "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/b18f6119161019916c5bb07cb8da5205ae5c1b63", + "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "php": ">=7.1.0" + }, + "suggest": { + "ext-gd": "Enables additional image handling in some workflows.", + "ext-imagick": "Enables additional image format support when available.", + "ext-zlib": "Recommended for compressed streams and related features.", + "tecnickcom/tc-lib-pdf": "Modern replacement for TCPDF for new projects." + }, + "type": "library", + "autoload": { + "classmap": [ + "config", + "include", + "tcpdf.php", + "tcpdf_barcodes_1d.php", + "tcpdf_barcodes_2d.php", + "include/tcpdf_colors.php", + "include/tcpdf_filters.php", + "include/tcpdf_font_data.php", + "include/tcpdf_fonts.php", + "include/tcpdf_images.php", + "include/tcpdf_static.php", + "include/barcodes/datamatrix.php", + "include/barcodes/pdf417.php", + "include/barcodes/qrcode.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Nicola Asuni", + "email": "info@tecnick.com", + "role": "lead" + } + ], + "description": "Deprecated legacy PDF engine for PHP. For new projects use tecnickcom/tc-lib-pdf.", + "homepage": "https://tcpdf.org", + "keywords": [ + "PDFD32000-2008", + "TCPDF", + "barcodes", + "datamatrix", + "pdf", + "pdf417", + "qrcode" + ], + "support": { + "issues": "https://github.com/tecnickcom/TCPDF/issues", + "source": "https://github.com/tecnickcom/TCPDF" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", + "type": "paypal" + } + ], + "time": "2026-04-21T17:00:18+00:00" + }, { "name": "twig/twig", "version": "v3.27.1", diff --git a/backend/migrations/Version20260531092327.php b/backend/migrations/Version20260531092327.php new file mode 100644 index 0000000..92169bf --- /dev/null +++ b/backend/migrations/Version20260531092327.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE card_template (id BINARY(16) NOT NULL, name VARCHAR(120) NOT NULL, type VARCHAR(20) NOT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, front JSON NOT NULL, back JSON NOT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) DEFAULT NULL, INDEX IDX_2E51D100979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE card_template ADD CONSTRAINT FK_2E51D100979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D100979B1AD6'); + $this->addSql('DROP TABLE card_template'); + } +} diff --git a/backend/src/Controller/CardPdfController.php b/backend/src/Controller/CardPdfController.php new file mode 100644 index 0000000..cc15a0c --- /dev/null +++ b/backend/src/Controller/CardPdfController.php @@ -0,0 +1,65 @@ +employees->find(Uuid::fromString($id)); + if (!$employee instanceof Employee) { + throw new NotFoundHttpException('Mitarbeiter nicht gefunden.'); + } + $this->assertAccess($employee); + + $template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default(); + $pdf = $this->renderer->render($employee, $template); + + return new Response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => sprintf('inline; filename="visitenkarte-%s.pdf"', $employee->getSlug()), + ]); + } + + private function assertAccess(Employee $employee): void + { + if ($this->tenant->isPlatformAdmin()) { + return; + } + $reseller = $this->tenant->getReseller(); + if (null === $reseller || $employee->getReseller()?->getId()->equals($reseller->getId()) !== true) { + throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zum eigenen Mandanten.'); + } + $own = $this->tenant->getCompany(); + if (null !== $own && !$employee->getCompany()->getId()->equals($own->getId())) { + throw new AccessDeniedHttpException('Nur Mitarbeiter der eigenen Firma.'); + } + } +} diff --git a/backend/src/Entity/CardTemplate.php b/backend/src/Entity/CardTemplate.php new file mode 100644 index 0000000..dcc2538 --- /dev/null +++ b/backend/src/Entity/CardTemplate.php @@ -0,0 +1,189 @@ +> */ + #[ORM\Column(type: 'json')] + private array $front = []; + + /** @var array> */ + #[ORM\Column(type: 'json')] + private array $back = []; + + #[ORM\ManyToOne(targetEntity: Company::class)] + private ?Company $company = null; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + public function __construct() + { + $this->id = Uuid::v7(); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + public function getWidthMm(): float + { + return $this->widthMm; + } + + public function setWidthMm(float $widthMm): self + { + $this->widthMm = $widthMm; + + return $this; + } + + public function getHeightMm(): float + { + return $this->heightMm; + } + + public function setHeightMm(float $heightMm): self + { + $this->heightMm = $heightMm; + + return $this; + } + + public function getBleedMm(): float + { + return $this->bleedMm; + } + + public function setBleedMm(float $bleedMm): self + { + $this->bleedMm = $bleedMm; + + return $this; + } + + public function getSafeMm(): float + { + return $this->safeMm; + } + + public function setSafeMm(float $safeMm): self + { + $this->safeMm = $safeMm; + + return $this; + } + + /** @return array> */ + public function getFront(): array + { + return $this->front; + } + + /** @param array> $front */ + public function setFront(array $front): self + { + $this->front = $front; + + return $this; + } + + /** @return array> */ + public function getBack(): array + { + return $this->back; + } + + /** @param array> $back */ + public function setBack(array $back): self + { + $this->back = $back; + + return $this; + } + + public function getCompany(): ?Company + { + return $this->company; + } + + public function setCompany(?Company $company): self + { + $this->company = $company; + + return $this; + } + + public function getReseller(): ?Reseller + { + return $this->company?->getReseller(); + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/backend/src/Repository/CardTemplateRepository.php b/backend/src/Repository/CardTemplateRepository.php new file mode 100644 index 0000000..d8ee8ec --- /dev/null +++ b/backend/src/Repository/CardTemplateRepository.php @@ -0,0 +1,25 @@ + + */ +class CardTemplateRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CardTemplate::class); + } + + /** Vorlage einer Firma (Karten-Typ), falls vorhanden. */ + public function findCardForCompany(Company $company): ?CardTemplate + { + return $this->findOneBy(['company' => $company, 'type' => CardTemplate::TYPE_CARD]); + } +} diff --git a/backend/src/Service/CardPdfRenderer.php b/backend/src/Service/CardPdfRenderer.php new file mode 100644 index 0000000..1ab1ac0 --- /dev/null +++ b/backend/src/Service/CardPdfRenderer.php @@ -0,0 +1,221 @@ +branding($employee); + $bleed = $template->getBleedMm(); + $margin = $bleed + self::MARK_LEN; // Medienrand bis Endformat + $pw = $template->getWidthMm() + 2 * $margin; // Seiten-/MediaBox-Breite + $ph = $template->getHeightMm() + 2 * $margin; + + $pdf = new \TCPDF('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); + + foreach ([$template->getFront(), $template->getBack()] as $elements) { + $pdf->AddPage('L', [$pw, $ph]); + foreach ($elements as $el) { + $this->renderElement($pdf, $el, $employee, $branding, $margin); + } + $this->cropMarks($pdf, $template->getWidthMm(), $template->getHeightMm(), $bleed, $margin); + } + + return $pdf->Output('card.pdf', 'S'); + } + + /** @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); + $pdf->SetFont('helvetica', !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['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); + } +} diff --git a/backend/src/Service/CardTemplateFactory.php b/backend/src/Service/CardTemplateFactory.php new file mode 100644 index 0000000..40f75a2 --- /dev/null +++ b/backend/src/Service/CardTemplateFactory.php @@ -0,0 +1,45 @@ +setName('Standard'); + $w = $t->getWidthMm(); // 85 + $h = $t->getHeightMm(); // 55 + $b = $t->getBleedMm(); // 2 + + $t->setFront([ + ['type' => 'image', 'binding' => 'logo', 'x' => 6, 'y' => 6, 'w' => 36, 'h' => 11, 'align' => 'L'], + ['type' => 'field', 'binding' => 'fullName', 'x' => 6, 'y' => 24, 'w' => $w - 12, 'fontSize' => 13, 'bold' => true, 'color' => ['ref' => 'dark']], + ['type' => 'field', 'binding' => 'position', 'x' => 6, 'y' => 30.5, 'w' => $w - 12, 'fontSize' => 8.5, 'bold' => true, 'color' => ['ref' => 'primary']], + ['type' => 'line', 'x' => 6, 'y' => 36, 'w' => 26, 'lineWidth' => 0.5, 'color' => ['ref' => 'primary']], + ['type' => 'field', 'binding' => 'phone', 'prefix' => 'T ', 'x' => 6, 'y' => 39.5, 'w' => $w - 12, 'fontSize' => 7.5, 'color' => ['ref' => 'text']], + ['type' => 'field', 'binding' => 'mobile', 'prefix' => 'M ', 'x' => 6, 'y' => 43.5, 'w' => $w - 12, 'fontSize' => 7.5, 'color' => ['ref' => 'text']], + ['type' => 'field', 'binding' => 'email', 'x' => 6, 'y' => 47.5, 'w' => $w - 12, 'fontSize' => 7.5, 'color' => ['ref' => 'text']], + ]); + + $t->setBack([ + // Vollflächiger Hintergrund inkl. Beschnitt (negative Trim-Koordinaten) + ['type' => 'rect', 'x' => -$b, 'y' => -$b, 'w' => $w + 2 * $b, 'h' => $h + 2 * $b, 'fill' => ['ref' => 'primary']], + ['type' => 'field', 'binding' => 'company.name', 'x' => 0, 'y' => 9, 'w' => $w, 'align' => 'C', 'fontSize' => 12, 'bold' => true, 'color' => ['ref' => 'white']], + // Weißes Panel als Kontrast für den QR-Code + ['type' => 'rect', 'x' => $w / 2 - 12, 'y' => 18, 'w' => 24, 'h' => 24, 'fill' => ['ref' => 'white']], + ['type' => 'qr', 'x' => $w / 2 - 10, 'y' => 20, 'w' => 20, 'h' => 20], + ['type' => 'text', 'text' => 'Scan für digitales Profil', 'x' => 0, 'y' => 45, 'w' => $w, 'align' => 'C', 'fontSize' => 6, 'color' => ['ref' => 'white']], + ]); + + return $t; + } +} diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index 3c737a3..969f4c2 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -299,3 +299,67 @@ Apple Pass Type ID ist an *einen* Apple-Account gebunden. Optionen: **(a)** ein - Apple Developer Account + Pass Type ID + Zertifikat (`.p12`) — für Apple Wallet. - Google-Cloud-Projekt + Wallet-API + Service-Account — für Google Wallet. + +--- + +## 13. Druckdaten: Visitenkarten-PDF (Kerngeschäft) + +Reseller drucken für ihre Firmenkunden Visitenkarten und brauchen **druckfertige PDFs**. Das Layout variiert **pro Firma**. Später kommt **Briefpapier** (gleiches System, anderes Format) dazu. + +### Festgelegte Entscheidungen (2026-05-31) + +- **Farbraum/Qualität:** CMYK, Ziel **PDF/X-1a**, eingebettete Fonts. +- **Format:** 85×55 mm, **2 mm Beschnitt** (→ 89×59 mm Dokument), **Schnittmarken**, Sicherheitsabstand ~4 mm. +- **Seiten:** Vorder- **und** Rückseite (2 PDF-Seiten). +- **Ausgabe:** ein PDF pro Mitarbeiter (Druckbogen/Ausschießen später). +- **Layout-Definition:** **visueller Editor** im Dashboard → strukturierte Element-Definition (JSON), nicht HTML. + +### Datenmodell + +**`CardTemplate`** — gehört zu einer Company (`company = null` ⇒ globale Standardvorlage). +Felder: `name`, `widthMm` (85), `heightMm` (55), `bleedMm` (2), `safeMm` (4), +`front` (Element[]), `back` (Element[]), `type` (`card` | später `letterhead`), `company_id`. + +**Element** (JSON): `type` (`field` | `text` | `image` | `qr` | `rect` | `line`), +`xMm`, `yMm`, `wMm`, `hMm`, `rotation`, +Typografie (`fontFamily`, `fontSizePt`, `bold`, `italic`, `align`, `lineHeight`), +`color` (CMYK `{c,m,y,k}` 0–100), `fill` (CMYK), +`binding` (bei `field`: Mitarbeiter-Feld), `text` (bei `text`: statisch), `src` (bei `image`). + +**Bindings** (Datenquelle = Mitarbeiterprofil): `firstName`, `lastName`, `fullName`, +`position`, `department`, `email`, `phone`, `mobile`, `company.name`, `profileUrl`, +`shortUrl`; Spezial: `qr` (Kurz-URL), `logo` (Firmen-Logo aus brandingConfig). + +### Rendering-Pipeline + +``` +CardTemplate (JSON) + Employee + Branding + │ + ▼ TCPDF (Koordinaten in mm, CMYK, Fonts eingebettet) + 89×59 mm Seite (Trim 85×55 + 2 mm Bleed), Schnittmarken, V/R = 2 Seiten + │ + ▼ (Finishing) Ghostscript → PDF/X-1a:2001 mit CMYK-Output-Intent (ICC, z. B. ISO Coated v2) + druckfertiges PDF +``` + +- **TCPDF**: exakte mm-Platzierung pro Element, `setColor`/`setTextColor` mit CMYK, + `cropMark()` an den Trim-Ecken, Bilder/QR als eingebettete Grafik, Fonts eingebettet. +- **PDF/X-1a-Finishing** (Ghostscript) als nachgelagerter Schritt — braucht `ghostscript` + + ICC-Profil im Container. Bis dahin: valides CMYK-PDF mit Bleed + Schnittmarken. + +### Endpunkte + +- `GET /api/employees/{id}/card.pdf` — Einzelkarte (auth, Reseller/Firma; mandantengeprüft). +- später: Sammel-PDF / ausgeschossener Bogen je Firma; Anbindung an `PrintOrder`. + +### Visueller Editor (SPA) + +- Canvas im mm-Maßstab mit sichtbarem **Beschnitt-, Trim- und Sicherheitsrahmen**. +- Elemente per Drag&Drop; Eigenschaften-Panel (Position/Größe/Font/Farbe/Datenbindung). +- Tabs **Vorder-/Rückseite**; Live-Vorschau mit echten oder Beispiel-Daten. +- Speichern als `CardTemplate` (pro Firma); Default-Vorlage als Startpunkt. + +### Briefpapier (später) + +Gleiches `CardTemplate`-Modell mit `type = letterhead` und Format **A4**; der Renderer +ist format-agnostisch (Maße + Elementliste). Bindings identisch + ggf. Adressfeld/Faltmarken.