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:
parent
1a035d6c61
commit
b52d696cc5
@ -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.
|
||||||
|
|||||||
@ -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
74
backend/composer.lock
generated
@ -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",
|
||||||
|
|||||||
34
backend/migrations/Version20260531150051.php
Normal file
34
backend/migrations/Version20260531150051.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
138
backend/src/Controller/CardAssetUploadController.php
Normal file
138
backend/src/Controller/CardAssetUploadController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
$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');
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/*
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user