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:
parent
ebaf509a2f
commit
408b37a5ea
@ -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.
|
||||
|
||||
@ -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
79
backend/composer.lock
generated
@ -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",
|
||||
|
||||
33
backend/migrations/Version20260531092327.php
Normal file
33
backend/migrations/Version20260531092327.php
Normal 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');
|
||||
}
|
||||
}
|
||||
65
backend/src/Controller/CardPdfController.php
Normal file
65
backend/src/Controller/CardPdfController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
189
backend/src/Entity/CardTemplate.php
Normal file
189
backend/src/Entity/CardTemplate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
backend/src/Repository/CardTemplateRepository.php
Normal file
25
backend/src/Repository/CardTemplateRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
221
backend/src/Service/CardPdfRenderer.php
Normal file
221
backend/src/Service/CardPdfRenderer.php
Normal 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 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<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);
|
||||
}
|
||||
}
|
||||
45
backend/src/Service/CardTemplateFactory.php
Normal file
45
backend/src/Service/CardTemplateFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user