Druckdaten: Hintergrund-PDF (VDP) & eingebettete eigene Schriften

- Renderer auf FPDI (setasign/fpdi) umgestellt: Kunden-PDF wird als
  Seitenhintergrund importiert, nur dynamische Felder werden überlagert
  (Variable Data Printing); bei Hintergrund keine eigenen Schnittmarken
- Eigene Schriften (TTF/OTF) per TCPDF_FONTS::addTTFfont eingebettet,
  fontFamily pro Element; DejaVu-TTF im PHP-Image
- CardTemplate: backgroundPath + fonts; Renderer color() unterstützt {hex}
- CardAssetUploadController: Upload/Delete Hintergrund-PDF + Schrift-Upload,
  Speicher in var/storage/cards/{companyId} (außerhalb Webroot)
- Editor-GET liefert hasBackground + fonts
- Migration robust gegen MariaDB json_valid-CHECK (nullable -> '[]' -> NOT NULL)
- Konzept §13 ergänzt

Verifiziert: Kunden-Hintergrund + dynamische Felder + eingebettete Serifenschrift
+ QR; /FontFile im PDF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 17:23:41 +02:00
parent 1a035d6c61
commit b52d696cc5
10 changed files with 377 additions and 9 deletions

View File

@ -109,5 +109,10 @@ mit Beschnitt/Endformat/Sicherheits-Markierung, Elemente per Drag&Drop
Farbe/Datenbindung), Vorder-/Rückseite, Live-Vorschau mit echten Daten, Farbe/Datenbindung), Vorder-/Rückseite, Live-Vorschau mit echten Daten,
Speichern + PDF-Vorschau. Backend: `GET|PUT /api/companies/{id}/card-template`. Speichern + PDF-Vorschau. Backend: `GET|PUT /api/companies/{id}/card-template`.
Nächster Schritt: PDF/X-1a-Finishing (Ghostscript) + Font-Embedding, **Hintergrund-PDF (Variable Data Printing) + eigene Schriften:** Kunde kann
Sammel-PDF/Druckbogen, dann Wallet-Pässe (§12). Siehe `docs/KONZEPT.md` §9. ein fertig gestaltetes Karten-PDF hochladen; die Plattform legt nur die
dynamischen Felder + QR darüber. Eigene Schriften (TTF/OTF) werden eingebettet.
Endpunkte: `POST /api/companies/{id}/card-template/background` und `.../font`.
Nächster Schritt: Editor-UI für Hintergrund-/Font-Upload, PDF/X-1a-Finishing
(Ghostscript), Sammel-PDF/Druckbogen, dann Wallet-Pässe (§12). Siehe `docs/KONZEPT.md` §9.

View File

@ -17,6 +17,7 @@
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^6.0", "phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"setasign/fpdi": "^2.6",
"symfony/asset": "7.4.*", "symfony/asset": "7.4.*",
"symfony/console": "7.4.*", "symfony/console": "7.4.*",
"symfony/dotenv": "7.4.*", "symfony/dotenv": "7.4.*",

74
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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "07ec9373fd92a37b6046b19dfd065ec1", "content-hash": "76223bb8137f9fc4c551962833c3a836",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@ -3403,6 +3403,78 @@
}, },
"time": "2024-09-11T13:17:53+00:00" "time": "2024-09-11T13:17:53+00:00"
}, },
{
"name": "setasign/fpdi",
"version": "v2.6.7",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "388c51e69982a3fc16698710b763e8107a49f510"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/388c51e69982a3fc16698710b763e8107a49f510",
"reference": "388c51e69982a3fc16698710b763e8107a49f510",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"php": ">=7.2 <=8.5.99999"
},
"conflict": {
"setasign/tfpdf": "<1.31"
},
"require-dev": {
"phpunit/phpunit": "^8.5.52",
"setasign/fpdf": "~1.8.6",
"setasign/tfpdf": "~1.33",
"squizlabs/php_codesniffer": "^3.5",
"tecnickcom/tcpdf": "^6.8"
},
"suggest": {
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
},
"type": "library",
"autoload": {
"psr-4": {
"setasign\\Fpdi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Slabon",
"email": "jan.slabon@setasign.com",
"homepage": "https://www.setasign.com"
},
{
"name": "Maximilian Kresse",
"email": "maximilian.kresse@setasign.com",
"homepage": "https://www.setasign.com"
}
],
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
"homepage": "https://www.setasign.com/fpdi",
"keywords": [
"fpdf",
"fpdi",
"pdf"
],
"support": {
"issues": "https://github.com/Setasign/FPDI/issues",
"source": "https://github.com/Setasign/FPDI/tree/v2.6.7"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
"type": "tidelift"
}
],
"time": "2026-05-13T10:16:22+00:00"
},
{ {
"name": "symfony/asset", "name": "symfony/asset",
"version": "v7.4.8", "version": "v7.4.8",

View File

@ -0,0 +1,34 @@
<?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 Version20260531150051 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// Hintergrund-PDF-Pfad + eigene Schriften. fonts wird nullable hinzugefügt und
// für bestehende Zeilen mit '[]' befüllt (sonst verletzt '' den JSON-CHECK), dann NOT NULL.
$this->addSql('ALTER TABLE card_template ADD background_path VARCHAR(255) DEFAULT NULL, ADD fonts JSON DEFAULT NULL');
$this->addSql("UPDATE card_template SET fonts = '[]' WHERE fonts IS NULL");
$this->addSql('ALTER TABLE card_template MODIFY fonts JSON NOT NULL');
}
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 background_path, DROP fonts');
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Controller;
use App\Entity\CardTemplate;
use App\Entity\Company;
use App\Repository\CardTemplateRepository;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Lädt Druck-Assets einer Firma hoch: Hintergrund-PDF (Variable Data Printing)
* und eigene Schriften (eingebettet ins PDF). Dateien liegen außerhalb des Webroots.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardAssetUploadController
{
public function __construct(
private readonly CardTemplateRepository $templates,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
#[Autowire('%kernel.project_dir%/var/storage/cards')]
private readonly string $storageDir,
) {
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_upload', methods: ['POST'])]
public function uploadBackground(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$file = $this->file($request);
if (!in_array(strtolower((string) $file->getClientOriginalExtension()), ['pdf'], true)) {
throw new BadRequestHttpException('Nur PDF erlaubt.');
}
$template = $this->getOrCreate($company);
$path = $this->store($file, $company->getId(), 'background', 'pdf');
$template->setBackgroundPath($path);
$this->em->persist($template);
$this->em->flush();
return new JsonResponse(['backgroundPath' => $path, 'fileName' => $file->getClientOriginalName()], 201);
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_delete', methods: ['DELETE'])]
public function deleteBackground(string $id): JsonResponse
{
$company = $this->company($id);
$template = $this->templates->findCardForCompany($company);
if ($template) {
if ($template->getBackgroundPath() && is_file($template->getBackgroundPath())) {
@unlink($template->getBackgroundPath());
}
$template->setBackgroundPath(null);
$this->em->flush();
}
return new JsonResponse(['backgroundPath' => null]);
}
#[Route('/api/companies/{id}/card-template/font', name: 'card_font_upload', methods: ['POST'])]
public function uploadFont(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$file = $this->file($request);
$ext = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($ext, ['ttf', 'otf'], true)) {
throw new BadRequestHttpException('Nur TTF/OTF erlaubt.');
}
$family = trim((string) $request->request->get('family')) ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$template = $this->getOrCreate($company);
$path = $this->store($file, $company->getId(), 'font', $ext);
$template->addFont($family, $path);
$this->em->flush();
return new JsonResponse(['fonts' => $template->getFonts()], 201);
}
private function file(Request $request): UploadedFile
{
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Keine Datei (Feld "file") übermittelt.');
}
return $file;
}
private function store(UploadedFile $file, Uuid $companyId, string $prefix, string $ext): string
{
$dir = $this->storageDir.'/'.$companyId->toRfc4122();
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$name = $prefix.'-'.bin2hex(random_bytes(4)).'.'.$ext;
$file->move($dir, $name);
return $dir.'/'.$name;
}
private function getOrCreate(Company $company): CardTemplate
{
return $this->templates->findCardForCompany($company)
?? (new CardTemplate())->setCompany($company);
}
private function company(string $id): Company
{
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $company;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $company;
}
}

View File

@ -95,6 +95,8 @@ final class CardTemplateEditorController
'safeMm' => $t->getSafeMm(), 'safeMm' => $t->getSafeMm(),
'front' => $t->getFront(), 'front' => $t->getFront(),
'back' => $t->getBack(), 'back' => $t->getBack(),
'hasBackground' => null !== $t->getBackgroundPath(),
'fonts' => array_map(fn ($f) => $f['family'] ?? '', $t->getFonts()),
]; ];
} }
} }

View File

@ -48,6 +48,14 @@ class CardTemplate implements ResellerOwnedInterface
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $back = []; private array $back = [];
/** Pfad zum hochgeladenen Hintergrund-PDF des Kunden (Seite 1 = Vorder-, 2 = Rückseite). */
#[ORM\Column(length: 255, nullable: true)]
private ?string $backgroundPath = null;
/** @var array<int, array{family: string, path: string}> Eingebettete eigene Schriften */
#[ORM\Column(type: 'json')]
private array $fonts = [];
#[ORM\ManyToOne(targetEntity: Company::class)] #[ORM\ManyToOne(targetEntity: Company::class)]
private ?Company $company = null; private ?Company $company = null;
@ -165,6 +173,41 @@ class CardTemplate implements ResellerOwnedInterface
return $this; return $this;
} }
public function getBackgroundPath(): ?string
{
return $this->backgroundPath;
}
public function setBackgroundPath(?string $backgroundPath): self
{
$this->backgroundPath = $backgroundPath;
return $this;
}
/** @return array<int, array{family: string, path: string}> */
public function getFonts(): array
{
return $this->fonts;
}
/** @param array<int, array{family: string, path: string}> $fonts */
public function setFonts(array $fonts): self
{
$this->fonts = $fonts;
return $this;
}
public function addFont(string $family, string $path): self
{
// bestehende gleicher Familie ersetzen
$this->fonts = array_values(array_filter($this->fonts, fn ($f) => ($f['family'] ?? null) !== $family));
$this->fonts[] = ['family' => $family, 'path' => $path];
return $this;
}
public function getCompany(): ?Company public function getCompany(): ?Company
{ {
return $this->company; return $this->company;

View File

@ -18,6 +18,9 @@ final class CardPdfRenderer
{ {
private const MARK_LEN = 4.0; // mm Länge der Schnittmarken 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) public function __construct(private readonly UrlGeneratorInterface $urls)
{ {
} }
@ -26,11 +29,17 @@ final class CardPdfRenderer
{ {
$branding = $this->branding($employee); $branding = $this->branding($employee);
$bleed = $template->getBleedMm(); $bleed = $template->getBleedMm();
$margin = $bleed + self::MARK_LEN; // Medienrand bis Endformat
$pw = $template->getWidthMm() + 2 * $margin; // Seiten-/MediaBox-Breite $bgPath = $template->getBackgroundPath();
$hasBg = $bgPath && is_file($bgPath);
// 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; $ph = $template->getHeightMm() + 2 * $margin;
$pdf = new \TCPDF('L', 'mm', [$pw, $ph], true, 'UTF-8', false); $pdf = new \setasign\Fpdi\Tcpdf\Fpdi('L', 'mm', [$pw, $ph], true, 'UTF-8', false);
$pdf->SetCreator('vcard4reseller'); $pdf->SetCreator('vcard4reseller');
$pdf->SetTitle(trim($employee->getFirstName().' '.$employee->getLastName())); $pdf->SetTitle(trim($employee->getFirstName().' '.$employee->getLastName()));
$pdf->setPrintHeader(false); $pdf->setPrintHeader(false);
@ -40,17 +49,50 @@ final class CardPdfRenderer
$pdf->setCellPaddings(0, 0, 0, 0); $pdf->setCellPaddings(0, 0, 0, 0);
$pdf->setCellMargins(0, 0, 0, 0); $pdf->setCellMargins(0, 0, 0, 0);
foreach ([$template->getFront(), $template->getBack()] as $elements) { $this->fontMap = $this->registerFonts($template);
$bgPages = $hasBg ? $pdf->setSourceFile($bgPath) : 0;
foreach ([$template->getFront(), $template->getBack()] as $i => $elements) {
$pdf->AddPage('L', [$pw, $ph]); $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) { foreach ($elements as $el) {
$this->renderElement($pdf, $el, $employee, $branding, $margin); $this->renderElement($pdf, $el, $employee, $branding, $margin);
} }
if (!$hasBg) {
$this->cropMarks($pdf, $template->getWidthMm(), $template->getHeightMm(), $bleed, $margin); $this->cropMarks($pdf, $template->getWidthMm(), $template->getHeightMm(), $bleed, $margin);
} }
}
return $pdf->Output('card.pdf', 'S'); 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) {
$path = $f['path'] ?? '';
$family = $f['family'] ?? '';
if ('' !== $family && is_file($path)) {
try {
$map[$family] = \TCPDF_FONTS::addTTFfont($path, 'TrueTypeUnicode', '', 32);
} catch (\Throwable) {
// nicht konvertierbar → Fallback auf Core-Font
}
}
}
return $map;
}
/** @param array<string, mixed> $el */ /** @param array<string, mixed> $el */
private function renderElement(\TCPDF $pdf, array $el, Employee $e, array $branding, float $o): void private function renderElement(\TCPDF $pdf, array $el, Employee $e, array $branding, float $o): void
{ {
@ -101,7 +143,9 @@ final class CardPdfRenderer
$value = ($el['prefix'] ?? '').$value; $value = ($el['prefix'] ?? '').$value;
[$c, $m, $y, $k] = $this->color($el['color'] ?? ['ref' => 'text'], $branding); [$c, $m, $y, $k] = $this->color($el['color'] ?? ['ref' => 'text'], $branding);
$pdf->SetTextColor($c, $m, $y, $k); $pdf->SetTextColor($c, $m, $y, $k);
$pdf->SetFont('helvetica', !empty($el['bold']) ? 'B' : '', (float) ($el['fontSize'] ?? 9)); $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'); $pdf->MultiCell($w ?: 0, 0, $value, 0, $el['align'] ?? 'L', false, 1, $px, $py, true, 0, false, true, 0, 'T');
break; break;
} }

View File

@ -3,6 +3,7 @@ FROM php:8.4-fpm-bookworm
# System-Abhängigkeiten für die PHP-Extensions # System-Abhängigkeiten für die PHP-Extensions
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
git unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \ git unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \
fonts-dejavu-core \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" intl pdo_mysql zip gd opcache \ && docker-php-ext-install -j"$(nproc)" intl pdo_mysql zip gd opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/*

View File

@ -363,3 +363,31 @@ CardTemplate (JSON) + Employee + Branding
Gleiches `CardTemplate`-Modell mit `type = letterhead` und Format **A4**; der Renderer Gleiches `CardTemplate`-Modell mit `type = letterhead` und Format **A4**; der Renderer
ist format-agnostisch (Maße + Elementliste). Bindings identisch + ggf. Adressfeld/Faltmarken. ist format-agnostisch (Maße + Elementliste). Bindings identisch + ggf. Adressfeld/Faltmarken.
### Hintergrund-PDF (Variable Data Printing) & eigene Schriften
**Hintergrund-PDF:** Der Kunde gestaltet die Karte vollständig in seinem DTP-Programm
(InDesign/Illustrator: Logo, Farbflächen, Hintergrund, statische Texte) und exportiert
ein druckfertiges PDF (CMYK, mit Beschnitt). Dieses wird hochgeladen und dient als
**Hintergrund**; die Plattform legt darüber nur noch die **dynamischen Felder**
(Name, Position, Kontakt, QR). Klassisches *Variable Data Printing*.
- Technik: **FPDI** (`setasign/fpdi`) importiert die Seiten des Kunden-PDFs; der
Renderer (FPDI-Tcpdf) platziert sie als Seitenhintergrund (`useTemplate`) und
zeichnet die dynamischen Elemente darüber.
- Erwartung: Hintergrund-PDF hat die Maße Endformat + Beschnitt (`width+2·bleed` ×
`height+2·bleed`). Seite 1 = Vorderseite, optional Seite 2 = Rückseite.
- Bei Hintergrund-PDF: **keine** eigenen Schnittmarken/Beschnitt-Logik (der Kunde
liefert das); Element-Koordinaten weiterhin im Trim-Raum, Offset = `bleedMm`.
- Die Vorlage enthält dann nur noch dynamische Elemente (Felder/QR), die statischen
entfallen.
**Eigene Schriften:** TTF/OTF werden hochgeladen und per `TCPDF_FONTS::addTTFfont()`
**eingebettet** (Subset). `CardTemplate` führt eine Schriften-Liste (`family → Pfad`);
Elemente referenzieren eine `fontFamily` (Custom oder Core helvetica/times/courier).
Font-Embedding ist zugleich Voraussetzung für striktes **PDF/X**.
**Uploads/Datenmodell:** `CardTemplate.backgroundPath` (Pfad zum Kunden-PDF, nullable),
`CardTemplate.fonts` (`[{family, path}]`). Dateien liegen außerhalb des Webroots unter
`var/storage/cards/{companyId}/`. Upload-Endpunkte:
`POST /api/companies/{id}/card-template/background` (PDF) und `.../font` (TTF/OTF).