From b52d696cc5a74246fa11bfa3390fcecf2acaa8d6 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Sun, 31 May 2026 17:23:41 +0200 Subject: [PATCH] Druckdaten: Hintergrund-PDF (VDP) & eingebettete eigene Schriften MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 9 +- backend/composer.json | 1 + backend/composer.lock | 74 +++++++++- backend/migrations/Version20260531150051.php | 34 +++++ .../Controller/CardAssetUploadController.php | 138 ++++++++++++++++++ .../CardTemplateEditorController.php | 2 + backend/src/Entity/CardTemplate.php | 43 ++++++ backend/src/Service/CardPdfRenderer.php | 56 ++++++- docker/php/Dockerfile | 1 + docs/KONZEPT.md | 28 ++++ 10 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/Version20260531150051.php create mode 100644 backend/src/Controller/CardAssetUploadController.php diff --git a/README.md b/README.md index 6922254..210cf11 100644 --- a/README.md +++ b/README.md @@ -109,5 +109,10 @@ mit Beschnitt/Endformat/Sicherheits-Markierung, Elemente per Drag&Drop Farbe/Datenbindung), Vorder-/Rückseite, Live-Vorschau mit echten Daten, Speichern + PDF-Vorschau. Backend: `GET|PUT /api/companies/{id}/card-template`. -Nächster Schritt: PDF/X-1a-Finishing (Ghostscript) + Font-Embedding, -Sammel-PDF/Druckbogen, dann Wallet-Pässe (§12). Siehe `docs/KONZEPT.md` §9. +**Hintergrund-PDF (Variable Data Printing) + eigene Schriften:** Kunde kann +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. diff --git a/backend/composer.json b/backend/composer.json index 64f1ac9..6146832 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -17,6 +17,7 @@ "nelmio/cors-bundle": "^2.6", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpdoc-parser": "^2.3", + "setasign/fpdi": "^2.6", "symfony/asset": "7.4.*", "symfony/console": "7.4.*", "symfony/dotenv": "7.4.*", diff --git a/backend/composer.lock b/backend/composer.lock index 99f3ebe..04066ec 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": "07ec9373fd92a37b6046b19dfd065ec1", + "content-hash": "76223bb8137f9fc4c551962833c3a836", "packages": [ { "name": "api-platform/doctrine-common", @@ -3403,6 +3403,78 @@ }, "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", "version": "v7.4.8", diff --git a/backend/migrations/Version20260531150051.php b/backend/migrations/Version20260531150051.php new file mode 100644 index 0000000..0986c72 --- /dev/null +++ b/backend/migrations/Version20260531150051.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/backend/src/Controller/CardAssetUploadController.php b/backend/src/Controller/CardAssetUploadController.php new file mode 100644 index 0000000..af08fa3 --- /dev/null +++ b/backend/src/Controller/CardAssetUploadController.php @@ -0,0 +1,138 @@ +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; + } +} diff --git a/backend/src/Controller/CardTemplateEditorController.php b/backend/src/Controller/CardTemplateEditorController.php index c17558e..f3f673c 100644 --- a/backend/src/Controller/CardTemplateEditorController.php +++ b/backend/src/Controller/CardTemplateEditorController.php @@ -95,6 +95,8 @@ final class CardTemplateEditorController 'safeMm' => $t->getSafeMm(), 'front' => $t->getFront(), 'back' => $t->getBack(), + 'hasBackground' => null !== $t->getBackgroundPath(), + 'fonts' => array_map(fn ($f) => $f['family'] ?? '', $t->getFonts()), ]; } } diff --git a/backend/src/Entity/CardTemplate.php b/backend/src/Entity/CardTemplate.php index dcc2538..b60afcb 100644 --- a/backend/src/Entity/CardTemplate.php +++ b/backend/src/Entity/CardTemplate.php @@ -48,6 +48,14 @@ class CardTemplate implements ResellerOwnedInterface #[ORM\Column(type: 'json')] 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 Eingebettete eigene Schriften */ + #[ORM\Column(type: 'json')] + private array $fonts = []; + #[ORM\ManyToOne(targetEntity: Company::class)] private ?Company $company = null; @@ -165,6 +173,41 @@ class CardTemplate implements ResellerOwnedInterface return $this; } + public function getBackgroundPath(): ?string + { + return $this->backgroundPath; + } + + public function setBackgroundPath(?string $backgroundPath): self + { + $this->backgroundPath = $backgroundPath; + + return $this; + } + + /** @return array */ + public function getFonts(): array + { + return $this->fonts; + } + + /** @param array $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 { return $this->company; diff --git a/backend/src/Service/CardPdfRenderer.php b/backend/src/Service/CardPdfRenderer.php index cb369d6..6b1bc6e 100644 --- a/backend/src/Service/CardPdfRenderer.php +++ b/backend/src/Service/CardPdfRenderer.php @@ -18,6 +18,9 @@ final class CardPdfRenderer { private const MARK_LEN = 4.0; // mm Länge der Schnittmarken + /** @var array family → eingebetteter TCPDF-Fontname */ + private array $fontMap = []; + public function __construct(private readonly UrlGeneratorInterface $urls) { } @@ -26,11 +29,17 @@ final class CardPdfRenderer { $branding = $this->branding($employee); $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; - $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->SetTitle(trim($employee->getFirstName().' '.$employee->getLastName())); $pdf->setPrintHeader(false); @@ -40,17 +49,50 @@ final class CardPdfRenderer $pdf->setCellPaddings(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]); + if ($hasBg && ($i + 1) <= $bgPages) { + $imported = $pdf->importPage($i + 1); + $pdf->useTemplate($imported, 0, 0, $pw, $ph); + } foreach ($elements as $el) { $this->renderElement($pdf, $el, $employee, $branding, $margin); } - $this->cropMarks($pdf, $template->getWidthMm(), $template->getHeightMm(), $bleed, $margin); + if (!$hasBg) { + $this->cropMarks($pdf, $template->getWidthMm(), $template->getHeightMm(), $bleed, $margin); + } } return $pdf->Output('card.pdf', 'S'); } + /** + * Bettet eigene Schriften (TTF/OTF) ein und liefert Map family → TCPDF-Fontname. + * + * @return array + */ + private function registerFonts(CardTemplate $template): array + { + $map = []; + foreach ($template->getFonts() as $f) { + $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 $el */ 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; [$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)); + $fam = $el['fontFamily'] ?? null; + $font = $this->fontMap[$fam] ?? (in_array($fam, ['times', 'courier', 'helvetica'], true) ? $fam : 'helvetica'); + $pdf->SetFont($font, !empty($el['bold']) ? 'B' : '', (float) ($el['fontSize'] ?? 9)); $pdf->MultiCell($w ?: 0, 0, $value, 0, $el['align'] ?? 'L', false, 1, $px, $py, true, 0, false, true, 0, 'T'); break; } diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 710c09c..11d3331 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -3,6 +3,7 @@ FROM php:8.4-fpm-bookworm # System-Abhängigkeiten für die PHP-Extensions RUN apt-get update && apt-get install -y --no-install-recommends \ 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-install -j"$(nproc)" intl pdo_mysql zip gd opcache \ && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index 969f4c2..f73146b 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -363,3 +363,31 @@ CardTemplate (JSON) + Employee + Branding Gleiches `CardTemplate`-Modell mit `type = letterhead` und Format **A4**; der Renderer 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).