Druckdaten: druckfertige Visitenkarten-PDF (CMYK, Beschnitt, V/R)

- CardTemplate-Entität (Layout pro Firma; company=null = globale Vorlage)
- CardTemplateFactory: Standardlayout, greift Firmen-Branding + QR ab
- CardPdfRenderer (TCPDF): 85x55mm + 2mm Beschnitt, Schnittmarken, CMYK,
  Vorder-/Rückseite, mm-genaue Element-Platzierung, eingebetteter QR
- GET /api/employees/{id}/card.pdf (Auth + Mandantenprüfung)
- Konzept §13 (Druckdaten) ergänzt

Verifiziert: 2 Seiten, CMYK-Farbraum, Schnittmarken, Branding durchgängig.
Offen: PDF/X-1a-Finishing (Ghostscript), Font-Embedding, visueller Editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 11:54:02 +02:00
parent ebaf509a2f
commit 408b37a5ea
10 changed files with 729 additions and 4 deletions

View File

@ -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.

View File

@ -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": {

79
backend/composer.lock generated
View File

@ -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",

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531092327 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Repository\CardTemplateRepository;
use App\Repository\EmployeeRepository;
use App\Security\TenantContext;
use App\Service\CardPdfRenderer;
use App\Service\CardTemplateFactory;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Erzeugt die druckfertige Visitenkarte (PDF) für einen Mitarbeiter.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardPdfController
{
public function __construct(
private readonly EmployeeRepository $employees,
private readonly CardTemplateRepository $templates,
private readonly CardTemplateFactory $factory,
private readonly CardPdfRenderer $renderer,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/employees/{id}/card.pdf', name: 'employee_card_pdf', methods: ['GET'])]
public function __invoke(string $id): Response
{
$employee = $this->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.');
}
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Entity;
use App\Repository\CardTemplateRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Layout-Vorlage für druckbare Ausgaben (Visitenkarte; später Briefpapier).
* company = null globale Standardvorlage. Front/Back sind Listen von
* Element-Definitionen (siehe KONZEPT §13), die der CardPdfRenderer interpretiert.
*/
#[ORM\Entity(repositoryClass: CardTemplateRepository::class)]
class CardTemplate implements ResellerOwnedInterface
{
public const TYPE_CARD = 'card';
public const TYPE_LETTERHEAD = 'letterhead';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 120)]
private string $name = 'Standard';
#[ORM\Column(length: 20)]
private string $type = self::TYPE_CARD;
#[ORM\Column(type: 'float')]
private float $widthMm = 85.0;
#[ORM\Column(type: 'float')]
private float $heightMm = 55.0;
#[ORM\Column(type: 'float')]
private float $bleedMm = 2.0;
#[ORM\Column(type: 'float')]
private float $safeMm = 4.0;
/** @var array<int, array<string, mixed>> */
#[ORM\Column(type: 'json')]
private array $front = [];
/** @var array<int, array<string, mixed>> */
#[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<int, array<string, mixed>> */
public function getFront(): array
{
return $this->front;
}
/** @param array<int, array<string, mixed>> $front */
public function setFront(array $front): self
{
$this->front = $front;
return $this;
}
/** @return array<int, array<string, mixed>> */
public function getBack(): array
{
return $this->back;
}
/** @param array<int, array<string, mixed>> $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;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Repository;
use App\Entity\CardTemplate;
use App\Entity\Company;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CardTemplate>
*/
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]);
}
}

View File

@ -0,0 +1,221 @@
<?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 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
public function __construct(private readonly UrlGeneratorInterface $urls)
{
}
public function render(Employee $employee, CardTemplate $template): string
{
$branding = $this->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<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);
$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 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['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);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Service;
use App\Entity\CardTemplate;
/**
* Liefert eine sinnvolle Standard-Visitenkarte (85×55), solange eine Firma
* noch keine eigene Vorlage im Editor angelegt hat. Koordinaten in mm im
* Trim-Raum (0,0 = obere linke Ecke der Endformat-Fläche). Farben semantisch
* (primary/dark/text/white) der Renderer löst sie aus dem Branding auf.
*/
final class CardTemplateFactory
{
public function default(): CardTemplate
{
$t = new CardTemplate();
$t->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;
}
}

View File

@ -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}` 0100), `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.