phpstan lief mit 390 Meldungen außerhalb der Baseline, der größte Teil davon Falschmeldungen. Ursache: Larastan löste keinen einzigen Cast auf, weil alle Models ihre Casts über die casts()-METHODE deklarieren (Laravel-11-Stil). Laravel führt die erst zur Laufzeit mit $casts zusammen, Larastan instanziiert Models aber ohne Konstruktor — $pressRelease->status galt deshalb als string und jeder Enum-Vergleich als "immer falsch". Dafür gibt es den Schalter parseModelCastsMethod, dessen Larastan-Default false ist. Zweite Sammelursache waren fehlende Generics an den Relations-Methoden: Ohne sie landete phpstan auf Illuminate\Database\Eloquent\Model und meldete jede Modell-Eigenschaft und jeden eigenen Scope als unbekannt. Alle 102 Relationen tragen jetzt ihr @return <Related, $this>; das verbessert nebenbei die Vervollständigung in der IDE. Zusammen fällt die Zahl von 390 auf 169. Die neu erzeugte Baseline umfasst 166 Einträge, überwiegend Closure-Parameter, die als blankes Builder typisiert sind, sowie zwei nachgeprüfte Falschmeldungs-Gruppen: die Concerns unter app/Livewire werden ausschließlich aus Volt-Komponenten eingebunden, die phpstan nicht analysiert, und Cashier typisiert findBillable() gegen den TRAIT Billable, was phpstan nicht mit User vereinbaren kann — die Laufzeitprüfung dort ist richtig und bleibt. Was die Analyse an echten toten Zweigen zutage gefördert hat, ist mit entfernt: eine doppelte Null-Prüfung im Nachweis-PDF, Prüfungen gegen NOT-NULL-Spalten in der Rechnungsstellung und im Vorschau-Controller, eine immer wahre Bedingung im Logo-Pfad und in RetireAgencyCompanyType, ein überflüssiges ?? [] auf einer Symfony-Methode, die immer ein Array liefert, sowie der unerreichbare null-Arm in publicPortalLinks(). MigrateLegacyMedia deklariert seine Medientypen jetzt als Literal-Union, womit das match ohne default nachweislich vollständig ist statt nur faktisch. Damit das nicht wieder abdriftet — die Baseline stand seit Mai unverändert — läuft die Analyse ab jetzt als eigener Schritt in der Test-Pipeline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1476 lines
48 KiB
PHP
1476 lines
48 KiB
PHP
<?php
|
||
|
||
namespace App\Services\PressRelease;
|
||
|
||
use App\Enums\AiImageDeclaration;
|
||
use App\Enums\ImageLicenseType;
|
||
use App\Enums\Portal;
|
||
use App\Models\Company;
|
||
use App\Models\Contact;
|
||
use App\Models\PressRelease;
|
||
use App\Models\PressReleaseImage;
|
||
use GdImage;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3.
|
||
*
|
||
* Der Nachweis dokumentiert die Veröffentlichung vollständig: Bestätigungstext,
|
||
* alle Veröffentlichungsdaten (Portal, Datum, öffentliche URLs, Kategorie …),
|
||
* die Pressemitteilung im kompletten Wortlaut, Boilerplate, Pressekontakte und
|
||
* Herausgeber-Angaben — mehrseitig mit Seitennummerierung und Fußzeile, im
|
||
* Pressekonto-Branding (Navy/Bronze). On-demand aus vorhandenen PM-Daten —
|
||
* keine KI, kein externer Renderer (gleicher schlanker PDF-Ansatz wie der
|
||
* Legacy-Rechnungs-Renderer).
|
||
*/
|
||
class ProofPdfRenderer
|
||
{
|
||
private const PAGE_WIDTH = 595;
|
||
|
||
private const PAGE_HEIGHT = 842;
|
||
|
||
private const LEFT = 60;
|
||
|
||
private const RIGHT = 535;
|
||
|
||
private const CONTENT_TOP = 796;
|
||
|
||
/**
|
||
* Oberkante des Inhalts auf Fortsetzungsseiten (ab Seite 2). Bewusst tiefer
|
||
* als der Seitenrand, damit der Text nicht direkt unter dem schmalen
|
||
* Fortsetzungs-Band klebt, sondern mit Luft nach unten einrückt.
|
||
*/
|
||
private const CONTINUATION_TOP = 760;
|
||
|
||
private const BOTTOM = 92;
|
||
|
||
/** Pressekonto-Hub-Navy (#1A2540) — Markenfarbe für Headings/Flächen. */
|
||
private const NAVY = [0.102, 0.145, 0.251];
|
||
|
||
/** Pressekonto-Bronze (#B07A3A) — Akzentfarbe für Linien/Details. */
|
||
private const BRONZE = [0.690, 0.478, 0.227];
|
||
|
||
private const INK = [0.129, 0.145, 0.180];
|
||
|
||
private const SLATE = [0.302, 0.333, 0.384];
|
||
|
||
private const GRAY = [0.478, 0.502, 0.549];
|
||
|
||
private const HEADER_MUTED = [0.663, 0.702, 0.800];
|
||
|
||
private const BOX_BG = [0.958, 0.962, 0.973];
|
||
|
||
private const RULE = [0.855, 0.867, 0.898];
|
||
|
||
private const WHITE = [1.0, 1.0, 1.0];
|
||
|
||
/** @var list<string> abgeschlossene Seiten-Content-Streams */
|
||
private array $pages = [];
|
||
|
||
/** @var list<array{alias: string, jpeg: string, width: int, height: int}> eingebettete Bild-XObjects */
|
||
private array $images = [];
|
||
|
||
private string $stream = '';
|
||
|
||
private float $y = self::CONTENT_TOP;
|
||
|
||
public function downloadResponse(PressRelease $pressRelease): Response
|
||
{
|
||
$pdf = $this->render($pressRelease);
|
||
|
||
return response()->streamDownload(
|
||
static function () use ($pdf): void {
|
||
echo $pdf;
|
||
},
|
||
$this->filename($pressRelease),
|
||
[
|
||
'Content-Type' => 'application/pdf',
|
||
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||
],
|
||
);
|
||
}
|
||
|
||
public function filename(PressRelease $pressRelease): string
|
||
{
|
||
return 'Veroeffentlichungsnachweis-PM-'.$pressRelease->id.'.pdf';
|
||
}
|
||
|
||
public function render(PressRelease $pressRelease): string
|
||
{
|
||
$this->pages = [];
|
||
$this->images = [];
|
||
$this->stream = '';
|
||
$this->y = self::CONTENT_TOP;
|
||
|
||
$company = $pressRelease->company;
|
||
|
||
/** @var Collection<int, Contact> $contacts */
|
||
$contacts = $pressRelease->contacts()
|
||
->withoutGlobalScopes()
|
||
->orderBy('last_name')
|
||
->orderBy('first_name')
|
||
->get();
|
||
|
||
$this->renderDocumentHead($pressRelease);
|
||
$this->renderConfirmation($pressRelease);
|
||
$this->renderOverview($pressRelease, $company);
|
||
$this->renderFullContent($pressRelease);
|
||
$this->renderBoilerplate($pressRelease, $company);
|
||
$this->renderPressContacts($contacts);
|
||
$this->renderPublisher($company);
|
||
$this->renderClosingNote();
|
||
|
||
$this->pages[] = $this->stream;
|
||
|
||
return $this->buildPdf($this->withPageChrome($pressRelease));
|
||
}
|
||
|
||
// ------------------------------------------------------------ Abschnitte
|
||
|
||
private function renderDocumentHead(PressRelease $pressRelease): void
|
||
{
|
||
$bandBottom = 744.0;
|
||
|
||
$this->rect(0, $bandBottom, self::PAGE_WIDTH, self::PAGE_HEIGHT - $bandBottom, self::NAVY);
|
||
$this->rect(0, $bandBottom - 4, self::PAGE_WIDTH, 4, self::BRONZE);
|
||
|
||
$this->text(self::LEFT, 810, 'pressekonto', 10.5, 'F2', self::HEADER_MUTED);
|
||
$this->text(self::LEFT, 778, 'Veröffentlichungsnachweis', 21, 'F2', self::WHITE);
|
||
$this->text(self::LEFT, 759, $this->portalLabel($pressRelease), 10.5, 'F1', self::HEADER_MUTED);
|
||
|
||
$this->y = 704;
|
||
}
|
||
|
||
private function renderConfirmation(PressRelease $pressRelease): void
|
||
{
|
||
$publishedAt = $pressRelease->published_at;
|
||
$dateLabel = $publishedAt?->format('d.m.Y') ?? 'n/a';
|
||
$timeLabel = $publishedAt?->format('H:i');
|
||
|
||
$statement = sprintf(
|
||
'Hiermit wird bestätigt, dass die nachfolgend dokumentierte Pressemitteilung am %s%s auf %s veröffentlicht wurde.',
|
||
$dateLabel,
|
||
$timeLabel ? " um {$timeLabel} Uhr" : '',
|
||
$this->portalLabel($pressRelease),
|
||
);
|
||
|
||
$padding = 16.0;
|
||
$textWidth = self::RIGHT - self::LEFT - 2 * $padding;
|
||
$statementLines = $this->wrap($statement, $textWidth, 10.5);
|
||
$noteLines = $pressRelease->depublished_at
|
||
? $this->wrap(
|
||
'Hinweis: Die Veröffentlichung wurde am '.$pressRelease->depublished_at->format('d.m.Y').' beendet (offline genommen).',
|
||
$textWidth,
|
||
9,
|
||
)
|
||
: [];
|
||
|
||
$height = 2 * $padding + count($statementLines) * 15.5
|
||
+ ($noteLines !== [] ? 8 + count($noteLines) * 12.5 : 0);
|
||
|
||
$this->ensureSpace($height);
|
||
$top = $this->y;
|
||
|
||
$this->rect(self::LEFT, $top - $height, self::RIGHT - self::LEFT, $height, self::BOX_BG);
|
||
$this->rect(self::LEFT, $top - $height, 3, $height, self::BRONZE);
|
||
|
||
$baseline = $top - $padding - 10;
|
||
|
||
foreach ($statementLines as $line) {
|
||
$this->text(self::LEFT + $padding, $baseline, $line, 10.5, 'F1', self::INK);
|
||
$baseline -= 15.5;
|
||
}
|
||
|
||
if ($noteLines !== []) {
|
||
$baseline -= 4;
|
||
|
||
foreach ($noteLines as $line) {
|
||
$this->text(self::LEFT + $padding, $baseline, $line, 9, 'F1', self::GRAY);
|
||
$baseline -= 12.5;
|
||
}
|
||
}
|
||
|
||
$this->y = $top - $height;
|
||
}
|
||
|
||
/**
|
||
* Kopfbereich des Nachweises: Titelbild links (kompakt) und die Angaben zur
|
||
* Veröffentlichung als Tabelle rechts daneben. Ohne echtes Bild fällt der
|
||
* Abschnitt auf die klassische, seitenbreite Detailtabelle zurück.
|
||
*/
|
||
private function renderOverview(PressRelease $pressRelease, ?Company $company): void
|
||
{
|
||
$rows = $this->publicationRows($pressRelease, $company);
|
||
$image = $this->coverImage($pressRelease);
|
||
$embed = $image ? $this->loadCoverImage($image) : null;
|
||
|
||
if ($embed === null) {
|
||
$this->sectionHeading('Angaben zur Veröffentlichung');
|
||
|
||
$lastIndex = count($rows) - 1;
|
||
|
||
foreach ($rows as $index => [$label, $value]) {
|
||
$this->metaRow($label, $value, $index < $lastIndex);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
[$jpeg, $pxWidth, $pxHeight] = $embed;
|
||
|
||
$imageColumnWidth = 200.0;
|
||
$columnGap = 26.0;
|
||
$rightX = self::LEFT + $imageColumnWidth + $columnGap;
|
||
$rightWidth = self::RIGHT - $rightX;
|
||
|
||
$scale = min($imageColumnWidth / $pxWidth, 190.0 / $pxHeight);
|
||
$drawWidth = $pxWidth * $scale;
|
||
$drawHeight = $pxHeight * $scale;
|
||
|
||
// Rechtliche Bildangaben aus dem Upload (Copyright, Urheber, Lizenz …).
|
||
$creditLines = $this->imageCreditLines($image);
|
||
|
||
$captionHeight = 0.0;
|
||
if ($creditLines !== []) {
|
||
$captionHeight += 10;
|
||
foreach ($creditLines as $line) {
|
||
$captionHeight += max(1, count($this->wrap($line, $imageColumnWidth, 7.5))) * 9.5 + 2;
|
||
}
|
||
}
|
||
|
||
// Höhe beider Spalten vorab bestimmen, damit der ganze Block zusammen
|
||
// (mit Überschrift) ggf. auf eine neue Seite rutscht.
|
||
$detailsHeight = 0.0;
|
||
foreach ($rows as [, $value]) {
|
||
$lineCount = max(1, count($this->wrap($value, $rightWidth, 9)));
|
||
$detailsHeight += 11 + $lineCount * 12 + 7;
|
||
}
|
||
|
||
$blockHeight = max($drawHeight + $captionHeight, $detailsHeight);
|
||
|
||
$this->ensureSpace(52 + $blockHeight + 12);
|
||
$this->sectionHeading('Angaben zur Veröffentlichung');
|
||
|
||
$top = $this->y;
|
||
|
||
// Titelbild links, mit dezentem Rahmen.
|
||
$imageBottom = $top - $drawHeight;
|
||
$alias = $this->registerImage($jpeg, $pxWidth, $pxHeight);
|
||
$this->drawImage($alias, self::LEFT, $imageBottom, $drawWidth, $drawHeight);
|
||
$this->stream .= $this->strokeRectOp(self::LEFT, $imageBottom, $drawWidth, $drawHeight, 0.5, self::RULE);
|
||
|
||
// Bildnachweis/Lizenz unter dem Bild.
|
||
$leftBottom = $imageBottom;
|
||
if ($creditLines !== []) {
|
||
$captionY = $imageBottom - 12;
|
||
|
||
foreach ($creditLines as $line) {
|
||
foreach ($this->wrap($line, $imageColumnWidth, 7.5) as $wrapped) {
|
||
if ($wrapped !== '') {
|
||
$this->text(self::LEFT, $captionY, $wrapped, 7.5, 'F1', self::GRAY);
|
||
}
|
||
|
||
$captionY -= 9.5;
|
||
}
|
||
|
||
$captionY -= 2;
|
||
}
|
||
|
||
$leftBottom = $captionY;
|
||
}
|
||
|
||
// Angaben rechts daneben als kompakte Label/Wert-Tabelle.
|
||
$rightY = $top - 1;
|
||
|
||
foreach ($rows as [$label, $value]) {
|
||
$this->text($rightX, $rightY, mb_strtoupper($label), 7.5, 'F2', self::GRAY);
|
||
$rightY -= 11;
|
||
|
||
foreach ($this->wrap($value, $rightWidth, 9) as $line) {
|
||
if ($line !== '') {
|
||
$this->text($rightX, $rightY, $line, 9, 'F1', self::INK);
|
||
}
|
||
|
||
$rightY -= 12;
|
||
}
|
||
|
||
$rightY -= 7;
|
||
}
|
||
|
||
$this->y = min($leftBottom, $rightY) - 4;
|
||
}
|
||
|
||
/**
|
||
* Rechtliche Bildangaben aus dem Upload, wie sie unter dem Titelbild
|
||
* dokumentiert werden: Bildnachweis/Copyright, Urheber, Lizenz(-detail),
|
||
* Lizenz-/Quellen-URL sowie die KI-Kennzeichnung.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function imageCreditLines(PressReleaseImage $image): array
|
||
{
|
||
$lines = [];
|
||
|
||
if (filled($image->copyright)) {
|
||
$lines[] = 'Bildnachweis: '.$image->copyright;
|
||
}
|
||
|
||
if (filled($image->author)
|
||
&& (blank($image->copyright) || ! str_contains((string) $image->copyright, (string) $image->author))) {
|
||
$lines[] = 'Urheber: '.$image->author;
|
||
}
|
||
|
||
if ($image->license_type instanceof ImageLicenseType) {
|
||
$detail = filled($image->license_detail) ? ' ('.$image->license_detail.')' : '';
|
||
$lines[] = 'Lizenz: '.$image->license_type->label().$detail;
|
||
}
|
||
|
||
if (filled($image->license_url)) {
|
||
$lines[] = 'Lizenz-URL: '.$image->license_url;
|
||
}
|
||
|
||
if (filled($image->source_url)) {
|
||
$lines[] = 'Quelle: '.$image->source_url;
|
||
}
|
||
|
||
$aiLabel = $image->ai_declaration instanceof AiImageDeclaration
|
||
? $image->ai_declaration->publicLabel()
|
||
: null;
|
||
|
||
if ($aiLabel !== null || $image->is_ai_generated) {
|
||
$lines[] = 'KI-Kennzeichnung: '.($aiLabel ?? 'KI-generiert');
|
||
}
|
||
|
||
return $lines;
|
||
}
|
||
|
||
/**
|
||
* Angaben zur Veröffentlichung als Label/Wert-Paare. Bei Portal::Both
|
||
* dokumentiert der Nachweis beide öffentlichen URLs.
|
||
*
|
||
* @return list<array{0: string, 1: string}>
|
||
*/
|
||
private function publicationRows(PressRelease $pressRelease, ?Company $company): array
|
||
{
|
||
$publishedAt = $pressRelease->published_at;
|
||
$publishedLabel = $publishedAt
|
||
? $publishedAt->format('d.m.Y').', '.$publishedAt->format('H:i').' Uhr'
|
||
: 'n/a';
|
||
|
||
$links = $pressRelease->publicPortalLinks();
|
||
$urlRows = array_map(static fn (array $link): array => [
|
||
count($links) > 1 ? 'URL '.$link['label'] : 'Öffentliche URL',
|
||
$link['url'],
|
||
], $links);
|
||
|
||
return array_values(array_filter([
|
||
['Pressemitteilung', 'PM #'.$pressRelease->id],
|
||
['Titel', (string) $pressRelease->title],
|
||
$company?->name ? ['Firma', (string) $company->name] : null,
|
||
['Portal', $this->portalLabel($pressRelease)],
|
||
['Veröffentlicht am', $publishedLabel],
|
||
$pressRelease->depublished_at ? ['Offline seit', $pressRelease->depublished_at->format('d.m.Y')] : null,
|
||
...$urlRows,
|
||
$this->categoryName($pressRelease) ? ['Kategorie', (string) $this->categoryName($pressRelease)] : null,
|
||
$this->languageLabel($pressRelease->language) ? ['Sprache', (string) $this->languageLabel($pressRelease->language)] : null,
|
||
['Nachweis erstellt am', now()->format('d.m.Y').', '.now()->format('H:i').' Uhr'],
|
||
]));
|
||
}
|
||
|
||
private function renderFullContent(PressRelease $pressRelease): void
|
||
{
|
||
$this->sectionHeading('Pressemitteilung im vollständigen Wortlaut');
|
||
|
||
$this->writeParagraph((string) $pressRelease->title, 14.5, 19, 'F2', self::NAVY);
|
||
|
||
if (filled($pressRelease->subtitle)) {
|
||
$this->y -= 2;
|
||
$this->writeParagraph((string) $pressRelease->subtitle, 11, 15, 'F3', self::GRAY);
|
||
}
|
||
|
||
$this->y -= 8;
|
||
|
||
foreach ($this->htmlToRichBlocks($pressRelease->text) as $block) {
|
||
if ($block['type'] === 'heading') {
|
||
$this->ensureSpace(48);
|
||
$this->y -= 4;
|
||
$this->writeParagraph($this->atomsToText($block['atoms']), 11, 15, 'F2', self::NAVY);
|
||
$this->y -= 3;
|
||
|
||
continue;
|
||
}
|
||
|
||
$atoms = $block['type'] === 'listitem' ? $this->bulletAtoms($block['atoms']) : $block['atoms'];
|
||
$indent = $block['type'] === 'listitem' ? 14.0 : 0.0;
|
||
|
||
$this->writeRichAtoms($atoms, 10, 14.5, self::INK, $indent);
|
||
$this->y -= 6;
|
||
}
|
||
}
|
||
|
||
private function renderBoilerplate(PressRelease $pressRelease, ?Company $company): void
|
||
{
|
||
$boilerplate = trim((string) ($pressRelease->boilerplate_override ?: $company?->boilerplate));
|
||
|
||
if ($boilerplate === '') {
|
||
return;
|
||
}
|
||
|
||
$this->sectionHeading($company?->name ? 'Über '.$company->name : 'Über das Unternehmen');
|
||
|
||
foreach ($this->htmlToRichBlocks($boilerplate) as $block) {
|
||
if ($block['type'] === 'heading') {
|
||
$this->writeParagraph($this->atomsToText($block['atoms']), 10, 14, 'F2', self::NAVY);
|
||
$this->y -= 4;
|
||
|
||
continue;
|
||
}
|
||
|
||
$atoms = $block['type'] === 'listitem' ? $this->bulletAtoms($block['atoms']) : $block['atoms'];
|
||
$indent = $block['type'] === 'listitem' ? 14.0 : 0.0;
|
||
|
||
$this->writeRichAtoms($atoms, 9.5, 13.5, self::SLATE, $indent);
|
||
$this->y -= 5;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, Contact> $contacts
|
||
*/
|
||
private function renderPressContacts(Collection $contacts): void
|
||
{
|
||
if ($contacts->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$this->sectionHeading($contacts->count() > 1 ? 'Pressekontakte' : 'Pressekontakt');
|
||
|
||
foreach ($contacts as $contact) {
|
||
$details = array_values(array_filter([
|
||
filled($contact->phone) ? 'Telefon: '.$contact->phone : null,
|
||
filled($contact->fax) ? 'Fax: '.$contact->fax : null,
|
||
filled($contact->email) ? 'E-Mail: '.$contact->email : null,
|
||
]));
|
||
|
||
$padding = 12.0;
|
||
$height = $padding + 9
|
||
+ (filled($contact->responsibility) ? 13.5 : 0)
|
||
+ count($details) * 13
|
||
+ 4 + $padding;
|
||
|
||
$this->ensureSpace($height + 10);
|
||
$top = $this->y;
|
||
|
||
$this->rect(self::LEFT, $top - $height, self::RIGHT - self::LEFT, $height, self::BOX_BG);
|
||
$this->rect(self::LEFT, $top - $height, 3, $height, self::NAVY);
|
||
|
||
$baseline = $top - $padding - 8;
|
||
$this->text(self::LEFT + $padding + 4, $baseline, $contact->displayName(), 10.5, 'F2', self::NAVY);
|
||
|
||
if (filled($contact->responsibility)) {
|
||
$baseline -= 13.5;
|
||
$this->text(self::LEFT + $padding + 4, $baseline, (string) $contact->responsibility, 9, 'F3', self::GRAY);
|
||
}
|
||
|
||
foreach ($details as $detail) {
|
||
$baseline -= 13;
|
||
$this->text(self::LEFT + $padding + 4, $baseline, $detail, 9.5, 'F1', self::INK);
|
||
}
|
||
|
||
$this->y = $top - $height - 10;
|
||
}
|
||
}
|
||
|
||
private function renderPublisher(?Company $company): void
|
||
{
|
||
if (! $company) {
|
||
return;
|
||
}
|
||
|
||
$this->sectionHeading('Herausgeber');
|
||
|
||
$this->ensureSpace(40);
|
||
$this->text(self::LEFT, $this->y, (string) $company->name, 10.5, 'F2', self::NAVY);
|
||
$this->y -= 15;
|
||
|
||
if (filled($company->address)) {
|
||
$this->writeParagraph((string) $company->address, 9.5, 13);
|
||
}
|
||
|
||
if ($countryName = $this->countryName($company->country_code)) {
|
||
$this->ensureSpace(13);
|
||
$this->text(self::LEFT, $this->y, $countryName, 9.5);
|
||
$this->y -= 13;
|
||
}
|
||
|
||
$details = array_filter([
|
||
filled($company->phone) ? 'Telefon: '.$company->phone : null,
|
||
filled($company->fax) ? 'Fax: '.$company->fax : null,
|
||
filled($company->email) ? 'E-Mail: '.$company->email : null,
|
||
filled($company->website) ? 'Web: '.$company->website : null,
|
||
]);
|
||
|
||
foreach ($details as $detail) {
|
||
$this->ensureSpace(13);
|
||
$this->text(self::LEFT, $this->y, $detail, 9.5);
|
||
$this->y -= 13;
|
||
}
|
||
}
|
||
|
||
private function renderClosingNote(): void
|
||
{
|
||
$this->y -= 18;
|
||
$this->ensureSpace(40);
|
||
$this->line(self::LEFT, $this->y + 6, self::RIGHT, $this->y + 6, 0.5, self::RULE);
|
||
$this->y -= 6;
|
||
$this->writeParagraph(
|
||
'Dieser Veröffentlichungsnachweis wurde maschinell über pressekonto erstellt und ist ohne Unterschrift gültig. '
|
||
.'Die inhaltliche Verantwortung für die Pressemitteilung liegt beim Herausgeber.',
|
||
8.5,
|
||
12,
|
||
'F1',
|
||
self::GRAY,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fußzeile (Trennlinie, Dokument-Kennung, Seite X von Y) auf jeder Seite
|
||
* sowie ein schmales Fortsetzungs-Band ab Seite 2 — erst nach dem Rendern
|
||
* möglich, weil die Gesamtseitenzahl vorher nicht feststeht.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function withPageChrome(PressRelease $pressRelease): array
|
||
{
|
||
$total = count($this->pages);
|
||
$generated = now()->format('d.m.Y, H:i');
|
||
|
||
return array_map(function (string $stream, int $index) use ($total, $pressRelease, $generated): string {
|
||
$pageNumber = $index + 1;
|
||
|
||
$chrome = $this->lineOp(self::LEFT, 66, self::RIGHT, 66, 0.6, self::RULE);
|
||
$chrome .= $this->textOp(
|
||
self::LEFT,
|
||
53,
|
||
"Veröffentlichungsnachweis PM #{$pressRelease->id} · erstellt am {$generated} Uhr über pressekonto",
|
||
8,
|
||
'F1',
|
||
self::GRAY,
|
||
);
|
||
|
||
$pageLabel = "Seite {$pageNumber} von {$total}";
|
||
$chrome .= $this->textOp(self::RIGHT - $this->estimateWidth($pageLabel, 8), 53, $pageLabel, 8, 'F1', self::GRAY);
|
||
|
||
if ($pageNumber > 1) {
|
||
$chrome .= $this->rectOp(0, self::PAGE_HEIGHT - 6, self::PAGE_WIDTH, 6, self::NAVY);
|
||
$chrome .= $this->textOp(
|
||
self::LEFT,
|
||
818,
|
||
"Veröffentlichungsnachweis PM #{$pressRelease->id} – Fortsetzung",
|
||
8,
|
||
'F1',
|
||
self::GRAY,
|
||
);
|
||
}
|
||
|
||
return $stream.$chrome;
|
||
}, $this->pages, array_keys($this->pages));
|
||
}
|
||
|
||
// ------------------------------------------------------------ Datenaufbereitung
|
||
|
||
private function portalLabel(PressRelease $pressRelease): string
|
||
{
|
||
if (! $pressRelease->portal instanceof Portal) {
|
||
return (string) $pressRelease->portal;
|
||
}
|
||
|
||
return $pressRelease->portal === Portal::Both
|
||
? 'Presseecho und Businessportal24'
|
||
: $pressRelease->portal->label();
|
||
}
|
||
|
||
private function categoryName(PressRelease $pressRelease): ?string
|
||
{
|
||
if (! $pressRelease->category) {
|
||
return null;
|
||
}
|
||
|
||
$locale = in_array($pressRelease->language, ['de', 'en'], true) ? $pressRelease->language : 'de';
|
||
|
||
return $pressRelease->category->translations()->where('locale', $locale)->value('name')
|
||
?? $pressRelease->category->translations()->value('name');
|
||
}
|
||
|
||
private function languageLabel(?string $language): ?string
|
||
{
|
||
return match ($language) {
|
||
'de' => 'Deutsch',
|
||
'en' => 'Englisch',
|
||
null, '' => null,
|
||
default => strtoupper($language),
|
||
};
|
||
}
|
||
|
||
private function countryName(?string $countryCode): ?string
|
||
{
|
||
return match (strtoupper((string) $countryCode)) {
|
||
'DE' => 'Deutschland',
|
||
'AT' => 'Österreich',
|
||
'CH' => 'Schweiz',
|
||
'' => null,
|
||
default => strtoupper((string) $countryCode),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Wandelt den (sanitisierten) HTML-Inhalt der PM in strukturierte Blöcke
|
||
* mit formatierten Textläufen (Atomen). Block-Elemente werden zu Absätzen,
|
||
* Überschriften (h1–h6) und Listenpunkten; <strong>/<b> und <em>/<i> setzen
|
||
* die Fett-/Kursiv-Auszeichnung, <br> einen Zeilenumbruch. Legacy-Plaintext
|
||
* (ohne Markup) wird an Doppelumbrüchen in Absätze getrennt.
|
||
*
|
||
* @return list<array{type: string, atoms: list<array<string, mixed>>}>
|
||
*/
|
||
private function htmlToRichBlocks(?string $html): array
|
||
{
|
||
$html = trim((string) $html);
|
||
|
||
if ($html === '') {
|
||
return [];
|
||
}
|
||
|
||
if (! str_contains($html, '<')) {
|
||
$blocks = [];
|
||
|
||
foreach (preg_split('/\n{2,}/u', str_replace(["\r\n", "\r"], "\n", $html)) ?: [] as $paragraph) {
|
||
$paragraph = trim($paragraph);
|
||
|
||
if ($paragraph === '') {
|
||
continue;
|
||
}
|
||
|
||
$atoms = $this->atomsFromRuns([['text' => $paragraph, 'bold' => false, 'italic' => false, 'break' => false]]);
|
||
|
||
if ($atoms !== []) {
|
||
$blocks[] = ['type' => 'paragraph', 'atoms' => $atoms];
|
||
}
|
||
}
|
||
|
||
return $blocks;
|
||
}
|
||
|
||
$document = new \DOMDocument;
|
||
$previous = libxml_use_internal_errors(true);
|
||
$document->loadHTML(
|
||
'<?xml encoding="UTF-8"?><div>'.$html.'</div>',
|
||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
|
||
);
|
||
libxml_clear_errors();
|
||
libxml_use_internal_errors($previous);
|
||
|
||
$root = null;
|
||
|
||
foreach ($document->childNodes as $node) {
|
||
if ($node instanceof \DOMElement) {
|
||
$root = $node;
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (! $root instanceof \DOMElement) {
|
||
return [];
|
||
}
|
||
|
||
$blocks = [];
|
||
$this->walkBlocks($root, $blocks);
|
||
|
||
return $blocks;
|
||
}
|
||
|
||
/**
|
||
* @param list<array{type: string, atoms: list<array<string, mixed>>}> $blocks
|
||
*/
|
||
private function walkBlocks(\DOMNode $container, array &$blocks): void
|
||
{
|
||
foreach ($container->childNodes as $node) {
|
||
if ($node instanceof \DOMText) {
|
||
if (trim($node->textContent) !== '') {
|
||
$this->appendBlock($blocks, 'paragraph', $this->gatherRuns($node, false, false));
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (! $node instanceof \DOMElement) {
|
||
continue;
|
||
}
|
||
|
||
$tag = strtolower($node->tagName);
|
||
|
||
if (in_array($tag, ['ul', 'ol'], true)) {
|
||
foreach ($node->childNodes as $item) {
|
||
if ($item instanceof \DOMElement && strtolower($item->tagName) === 'li') {
|
||
$this->appendBlock($blocks, 'listitem', $this->gatherRuns($item, false, false));
|
||
}
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (preg_match('/^h[1-6]$/', $tag) === 1) {
|
||
$this->appendBlock($blocks, 'heading', $this->gatherRuns($node, false, false));
|
||
|
||
continue;
|
||
}
|
||
|
||
if (in_array($tag, ['p', 'blockquote', 'div', 'figure', 'figcaption', 'section', 'article'], true)) {
|
||
if ($this->hasBlockChild($node)) {
|
||
$this->walkBlocks($node, $blocks);
|
||
} else {
|
||
$this->appendBlock($blocks, 'paragraph', $this->gatherRuns($node, false, false));
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
// Inline-Element direkt unter dem Container (strong/em/a/span …).
|
||
$this->appendBlock($blocks, 'paragraph', $this->gatherRuns($node, false, false));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param list<array{type: string, atoms: list<array<string, mixed>>}> $blocks
|
||
* @param list<array<string, mixed>> $runs
|
||
*/
|
||
private function appendBlock(array &$blocks, string $type, array $runs): void
|
||
{
|
||
$atoms = $this->atomsFromRuns($runs);
|
||
|
||
if ($atoms !== []) {
|
||
$blocks[] = ['type' => $type, 'atoms' => $atoms];
|
||
}
|
||
}
|
||
|
||
private function hasBlockChild(\DOMNode $node): bool
|
||
{
|
||
foreach ($node->childNodes as $child) {
|
||
if ($child instanceof \DOMElement
|
||
&& in_array(strtolower($child->tagName), ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'div', 'section', 'article', 'figure'], true)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Sammelt die Textläufe eines Knotens rekursiv und schleppt dabei den
|
||
* Fett-/Kursiv-Zustand mit. <br> erzeugt einen expliziten Umbruch.
|
||
*
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private function gatherRuns(\DOMNode $node, bool $bold, bool $italic): array
|
||
{
|
||
$runs = [];
|
||
|
||
foreach ($node->childNodes as $child) {
|
||
if ($child instanceof \DOMText) {
|
||
$runs[] = ['text' => $child->textContent, 'bold' => $bold, 'italic' => $italic, 'break' => false];
|
||
|
||
continue;
|
||
}
|
||
|
||
if (! $child instanceof \DOMElement) {
|
||
continue;
|
||
}
|
||
|
||
$tag = strtolower($child->tagName);
|
||
|
||
if ($tag === 'br') {
|
||
$runs[] = ['text' => '', 'bold' => $bold, 'italic' => $italic, 'break' => true];
|
||
|
||
continue;
|
||
}
|
||
|
||
$childBold = $bold || in_array($tag, ['strong', 'b'], true);
|
||
$childItalic = $italic || in_array($tag, ['em', 'i'], true);
|
||
|
||
$runs = array_merge($runs, $this->gatherRuns($child, $childBold, $childItalic));
|
||
}
|
||
|
||
return $runs;
|
||
}
|
||
|
||
/**
|
||
* Normalisiert Textläufe zu Wort-Atomen: Whitespace wird zusammengefasst,
|
||
* <br> zu Umbruch-Atomen. `glue` markiert Wörter, die ohne Leerzeichen an
|
||
* das vorherige anschließen (Formatwechsel mitten im Wort).
|
||
*
|
||
* @param list<array<string, mixed>> $runs
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private function atomsFromRuns(array $runs): array
|
||
{
|
||
$atoms = [];
|
||
$glue = false;
|
||
|
||
foreach ($runs as $run) {
|
||
if (! empty($run['break'])) {
|
||
$atoms[] = ['break' => true];
|
||
$glue = false;
|
||
|
||
continue;
|
||
}
|
||
|
||
$text = str_replace(["\r\n", "\r", "\n"], ' ', (string) $run['text']);
|
||
$parts = preg_split('/(\s+)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
|
||
|
||
foreach ($parts as $part) {
|
||
if ($part === '') {
|
||
continue;
|
||
}
|
||
|
||
if (preg_match('/^\s+$/u', $part) === 1) {
|
||
$glue = false;
|
||
|
||
continue;
|
||
}
|
||
|
||
$atoms[] = [
|
||
'text' => $part,
|
||
'bold' => (bool) $run['bold'],
|
||
'italic' => (bool) $run['italic'],
|
||
'glue' => $glue,
|
||
'break' => false,
|
||
];
|
||
$glue = true;
|
||
}
|
||
}
|
||
|
||
while ($atoms !== [] && ! empty($atoms[0]['break'])) {
|
||
array_shift($atoms);
|
||
}
|
||
|
||
while ($atoms !== [] && ! empty($atoms[array_key_last($atoms)]['break'])) {
|
||
array_pop($atoms);
|
||
}
|
||
|
||
return $atoms;
|
||
}
|
||
|
||
/**
|
||
* Flacht Atome zu Plaintext ab (für Überschriften, die einheitlich gesetzt
|
||
* werden).
|
||
*
|
||
* @param list<array<string, mixed>> $atoms
|
||
*/
|
||
private function atomsToText(array $atoms): string
|
||
{
|
||
$text = '';
|
||
|
||
foreach ($atoms as $atom) {
|
||
if (! empty($atom['break'])) {
|
||
$text .= "\n";
|
||
|
||
continue;
|
||
}
|
||
|
||
$text .= ($text === '' || ! empty($atom['glue']) || str_ends_with($text, "\n") ? '' : ' ').$atom['text'];
|
||
}
|
||
|
||
return trim($text);
|
||
}
|
||
|
||
/**
|
||
* Stellt einem Listenpunkt ein Aufzählungszeichen voran.
|
||
*
|
||
* @param list<array<string, mixed>> $atoms
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private function bulletAtoms(array $atoms): array
|
||
{
|
||
return array_merge(
|
||
[['text' => '•', 'bold' => false, 'italic' => false, 'glue' => false, 'break' => false]],
|
||
$atoms,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ermittelt das Titelbild der PM: bevorzugt das als Vorschau markierte,
|
||
* sonst das erste. Null, wenn kein echtes Bild vorliegt.
|
||
*/
|
||
private function coverImage(PressRelease $pressRelease): ?PressReleaseImage
|
||
{
|
||
$image = $pressRelease->relationLoaded('images')
|
||
? ($pressRelease->images->firstWhere('is_preview', true) ?? $pressRelease->images->first())
|
||
: $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||
|
||
return $image instanceof PressReleaseImage ? $image : null;
|
||
}
|
||
|
||
/**
|
||
* Lädt die Bilddatei, skaliert sie auf eine druckfreundliche Kantenlänge und
|
||
* liefert sie als baseline-JPEG (DeviceRGB, für DCTDecode) samt Pixelmaßen.
|
||
* Rückgabe null, wenn GD oder die Datei fehlen.
|
||
*
|
||
* @return array{0: string, 1: int, 2: int}|null [jpegBytes, widthPx, heightPx]
|
||
*/
|
||
private function loadCoverImage(PressReleaseImage $image): ?array
|
||
{
|
||
if (! function_exists('imagecreatefromstring')) {
|
||
return null;
|
||
}
|
||
|
||
$bytes = $this->readImageBytes($image);
|
||
|
||
if ($bytes === null) {
|
||
return null;
|
||
}
|
||
|
||
$source = @imagecreatefromstring($bytes);
|
||
|
||
if (! $source instanceof GdImage) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
$width = imagesx($source);
|
||
$height = imagesy($source);
|
||
|
||
if ($width < 1 || $height < 1) {
|
||
return null;
|
||
}
|
||
|
||
// Obergrenze der Kantenlänge, damit das PDF trotz Foto schlank bleibt.
|
||
$maxPx = 1400;
|
||
$scale = min(1.0, $maxPx / $width, $maxPx / $height);
|
||
$targetWidth = max(1, (int) round($width * $scale));
|
||
$targetHeight = max(1, (int) round($height * $scale));
|
||
|
||
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
||
// Transparenz (PNG/WebP) auf Weiß ausflachen — DCTDecode kennt keinen Alphakanal.
|
||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $white);
|
||
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
||
|
||
ob_start();
|
||
$ok = imagejpeg($canvas, null, 82);
|
||
$jpeg = (string) ob_get_clean();
|
||
imagedestroy($canvas);
|
||
|
||
if (! $ok || $jpeg === '') {
|
||
return null;
|
||
}
|
||
|
||
return [$jpeg, $targetWidth, $targetHeight];
|
||
} finally {
|
||
imagedestroy($source);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Liest die Bilddatei über die passende Disk ein und probiert dabei eine
|
||
* druckfreundliche Varianten-Kette (large → cover → medium → Original).
|
||
*/
|
||
private function readImageBytes(PressReleaseImage $image): ?string
|
||
{
|
||
$disk = $image->disk ?: 'local';
|
||
|
||
foreach (['large', 'cover', 'medium', null] as $variant) {
|
||
$path = $image->pathForVariant($variant);
|
||
|
||
if (! is_string($path) || $path === '') {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
if (Storage::disk($disk)->exists($path)) {
|
||
$bytes = Storage::disk($disk)->get($path);
|
||
|
||
if (is_string($bytes) && $bytes !== '') {
|
||
return $bytes;
|
||
}
|
||
}
|
||
} catch (Throwable) {
|
||
// Nächste Variante versuchen.
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ------------------------------------------------------------ Layout-Primitive
|
||
|
||
private function sectionHeading(string $title): void
|
||
{
|
||
$this->ensureSpace(74);
|
||
$this->y -= 22;
|
||
$this->text(self::LEFT, $this->y, mb_strtoupper($title), 9, 'F2', self::NAVY);
|
||
$this->y -= 9;
|
||
$this->rect(self::LEFT, $this->y, 26, 2, self::BRONZE);
|
||
$this->line(self::LEFT + 34, $this->y + 1, self::RIGHT, $this->y + 1, 0.5, self::RULE);
|
||
$this->y -= 18;
|
||
}
|
||
|
||
private function metaRow(string $label, string $value, bool $withSeparator): void
|
||
{
|
||
$valueLines = $this->wrap($value, self::RIGHT - self::LEFT - 140, 9.5);
|
||
$this->ensureSpace(max(1, count($valueLines)) * 13 + 9);
|
||
|
||
$this->text(self::LEFT, $this->y, $label, 9, 'F1', self::GRAY);
|
||
|
||
if ($valueLines === []) {
|
||
$this->y -= 13;
|
||
}
|
||
|
||
foreach ($valueLines as $line) {
|
||
if ($line !== '') {
|
||
$this->text(self::LEFT + 140, $this->y, $line, 9.5, 'F1', self::INK);
|
||
}
|
||
|
||
$this->y -= 13;
|
||
}
|
||
|
||
$this->y -= 6;
|
||
|
||
if ($withSeparator) {
|
||
$this->line(self::LEFT, $this->y + 10, self::RIGHT, $this->y + 10, 0.4, self::RULE);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function writeParagraph(string $text, float $size, float $lineHeight, string $font = 'F1', array $color = self::INK): void
|
||
{
|
||
foreach ($this->wrap($text, self::RIGHT - self::LEFT, $size) as $line) {
|
||
$this->ensureSpace($lineHeight);
|
||
|
||
if ($line !== '') {
|
||
$this->text(self::LEFT, $this->y, $line, $size, $font, $color);
|
||
}
|
||
|
||
$this->y -= $lineHeight;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setzt formatierten Fließtext (fett/kursiv) aus Wort-Atomen. Der Umbruch
|
||
* erfolgt wortweise; gleich formatierte Wörter einer Zeile werden zu einem
|
||
* Textbefehl zusammengefasst (kompaktes PDF, korrekte Wortabstände).
|
||
*
|
||
* @param list<array<string, mixed>> $atoms
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function writeRichAtoms(array $atoms, float $size, float $lineHeight, array $color, float $indent = 0.0): void
|
||
{
|
||
$left = self::LEFT + $indent;
|
||
$maxWidth = self::RIGHT - $left;
|
||
|
||
foreach ($this->layoutLines($atoms, $maxWidth, $size) as $line) {
|
||
$this->ensureSpace($lineHeight);
|
||
|
||
$x = $left;
|
||
$count = count($line);
|
||
$index = 0;
|
||
|
||
while ($index < $count) {
|
||
$segment = $line[$index];
|
||
$x += $segment['space'];
|
||
|
||
$font = $this->richFont($segment['bold'], $segment['italic']);
|
||
$chunk = $segment['text'];
|
||
$chunkWidth = $this->estimateWidth($segment['text'], $size);
|
||
|
||
$next = $index + 1;
|
||
while ($next < $count && $this->richFont($line[$next]['bold'], $line[$next]['italic']) === $font) {
|
||
$following = $line[$next];
|
||
$chunk .= ($following['space'] > 0 ? ' ' : '').$following['text'];
|
||
$chunkWidth += $following['space'] + $this->estimateWidth($following['text'], $size);
|
||
$next++;
|
||
}
|
||
|
||
if ($chunk !== '') {
|
||
$this->text($x, $this->y, $chunk, $size, $font, $color);
|
||
}
|
||
|
||
$x += $chunkWidth;
|
||
$index = $next;
|
||
}
|
||
|
||
$this->y -= $lineHeight;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bricht Wort-Atome zeilenweise um. Jede Zeile ist eine Liste von Segmenten
|
||
* mit vorangestellter Leerraum-Breite (0 bei Glue/Zeilenanfang).
|
||
*
|
||
* @param list<array<string, mixed>> $atoms
|
||
* @return list<list<array<string, mixed>>>
|
||
*/
|
||
private function layoutLines(array $atoms, float $maxWidth, float $size): array
|
||
{
|
||
$spaceWidth = $this->estimateWidth(' ', $size);
|
||
$lines = [];
|
||
$current = [];
|
||
$width = 0.0;
|
||
|
||
foreach ($atoms as $atom) {
|
||
if (! empty($atom['break'])) {
|
||
$lines[] = $current;
|
||
$current = [];
|
||
$width = 0.0;
|
||
|
||
continue;
|
||
}
|
||
|
||
$word = (string) $atom['text'];
|
||
$wordWidth = $this->estimateWidth($word, $size);
|
||
|
||
// Überlanges Einzelwort (z. B. URL) hart umbrechen.
|
||
if ($wordWidth > $maxWidth) {
|
||
if ($current !== []) {
|
||
$lines[] = $current;
|
||
$current = [];
|
||
$width = 0.0;
|
||
}
|
||
|
||
foreach ($this->splitLongWord($word, $maxWidth, $size) as $chunkIndex => $chunk) {
|
||
if ($chunkIndex > 0) {
|
||
$lines[] = $current;
|
||
$current = [];
|
||
$width = 0.0;
|
||
}
|
||
|
||
$current[] = ['text' => $chunk, 'bold' => (bool) $atom['bold'], 'italic' => (bool) $atom['italic'], 'space' => 0.0];
|
||
$width += $this->estimateWidth($chunk, $size);
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
$space = ($current === [] || ! empty($atom['glue'])) ? 0.0 : $spaceWidth;
|
||
|
||
if ($current !== [] && empty($atom['glue']) && $width + $space + $wordWidth > $maxWidth) {
|
||
$lines[] = $current;
|
||
$current = [];
|
||
$width = 0.0;
|
||
$space = 0.0;
|
||
}
|
||
|
||
$current[] = ['text' => $word, 'bold' => (bool) $atom['bold'], 'italic' => (bool) $atom['italic'], 'space' => $space];
|
||
$width += $space + $wordWidth;
|
||
}
|
||
|
||
if ($current !== []) {
|
||
$lines[] = $current;
|
||
}
|
||
|
||
return $lines;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function splitLongWord(string $word, float $maxWidth, float $size): array
|
||
{
|
||
$maxCharacters = max(4, (int) floor($maxWidth / ($size * 0.5)));
|
||
|
||
return mb_str_split($word, $maxCharacters) ?: [$word];
|
||
}
|
||
|
||
private function richFont(bool $bold, bool $italic): string
|
||
{
|
||
return match (true) {
|
||
$bold && $italic => 'F4',
|
||
$bold => 'F2',
|
||
$italic => 'F3',
|
||
default => 'F1',
|
||
};
|
||
}
|
||
|
||
private function ensureSpace(float $needed): void
|
||
{
|
||
if ($this->y - $needed < self::BOTTOM) {
|
||
$this->newPage();
|
||
}
|
||
}
|
||
|
||
private function newPage(): void
|
||
{
|
||
$this->pages[] = $this->stream;
|
||
$this->stream = '';
|
||
$this->y = self::CONTINUATION_TOP;
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function text(float $x, float $y, string $text, float $size = 9, string $font = 'F1', array $color = self::INK): void
|
||
{
|
||
$this->stream .= $this->textOp($x, $y, $text, $size, $font, $color);
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function rect(float $x, float $y, float $width, float $height, array $color): void
|
||
{
|
||
$this->stream .= $this->rectOp($x, $y, $width, $height, $color);
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function line(float $x1, float $y1, float $x2, float $y2, float $width, array $color): void
|
||
{
|
||
$this->stream .= $this->lineOp($x1, $y1, $x2, $y2, $width, $color);
|
||
}
|
||
|
||
/**
|
||
* Registriert ein JPEG als Bild-XObject und liefert dessen Ressourcen-Alias
|
||
* (Im1, Im2 …), der beim PDF-Aufbau in die Seiten-Resources geschrieben wird.
|
||
*/
|
||
private function registerImage(string $jpeg, int $width, int $height): string
|
||
{
|
||
$alias = 'Im'.(count($this->images) + 1);
|
||
|
||
$this->images[] = [
|
||
'alias' => $alias,
|
||
'jpeg' => $jpeg,
|
||
'width' => $width,
|
||
'height' => $height,
|
||
];
|
||
|
||
return $alias;
|
||
}
|
||
|
||
/**
|
||
* Zeichnet ein registriertes Bild-XObject an Position (x, y) in der
|
||
* gewünschten Zielgröße (die Bildmatrix skaliert das 1×1-Einheitsquadrat).
|
||
*/
|
||
private function drawImage(string $alias, float $x, float $y, float $width, float $height): void
|
||
{
|
||
$this->stream .= sprintf(
|
||
"q %.2F 0 0 %.2F %.2F %.2F cm /%s Do Q\n",
|
||
$width,
|
||
$height,
|
||
$x,
|
||
$y,
|
||
$alias,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function strokeRectOp(float $x, float $y, float $width, float $height, float $lineWidth, array $color): string
|
||
{
|
||
return sprintf(
|
||
"%.3F %.3F %.3F RG %.2F w %.2F %.2F %.2F %.2F re S\n",
|
||
$color[0],
|
||
$color[1],
|
||
$color[2],
|
||
$lineWidth,
|
||
$x,
|
||
$y,
|
||
$width,
|
||
$height,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function textOp(float $x, float $y, string $text, float $size, string $font = 'F1', array $color = self::INK): string
|
||
{
|
||
return sprintf(
|
||
"BT /%s %.2F Tf %.3F %.3F %.3F rg %.2F %.2F Td (%s) Tj ET\n",
|
||
$font,
|
||
$size,
|
||
$color[0],
|
||
$color[1],
|
||
$color[2],
|
||
$x,
|
||
$y,
|
||
$this->escapePdfText($text),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function rectOp(float $x, float $y, float $width, float $height, array $color): string
|
||
{
|
||
return sprintf(
|
||
"%.3F %.3F %.3F rg %.2F %.2F %.2F %.2F re f\n",
|
||
$color[0],
|
||
$color[1],
|
||
$color[2],
|
||
$x,
|
||
$y,
|
||
$width,
|
||
$height,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array{0: float, 1: float, 2: float} $color
|
||
*/
|
||
private function lineOp(float $x1, float $y1, float $x2, float $y2, float $width, array $color): string
|
||
{
|
||
return sprintf(
|
||
"%.3F %.3F %.3F RG %.2F w %.2F %.2F m %.2F %.2F l S\n",
|
||
$color[0],
|
||
$color[1],
|
||
$color[2],
|
||
$width,
|
||
$x1,
|
||
$y1,
|
||
$x2,
|
||
$y2,
|
||
);
|
||
}
|
||
|
||
private function estimateWidth(string $text, float $size): float
|
||
{
|
||
return Str::length($text) * $size * 0.5;
|
||
}
|
||
|
||
private function escapePdfText(string $text): string
|
||
{
|
||
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
|
||
|
||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function wrap(string $text, float $width, float $size): array
|
||
{
|
||
$lines = [];
|
||
$maxCharacters = max(8, (int) floor($width / ($size * 0.5)));
|
||
|
||
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $paragraph) {
|
||
if (trim($paragraph) === '') {
|
||
$lines[] = '';
|
||
|
||
continue;
|
||
}
|
||
|
||
$words = [];
|
||
foreach (preg_split('/\s+/u', trim($paragraph)) ?: [] as $word) {
|
||
// Überlange Wörter (v. a. URLs) hart umbrechen statt überlaufen.
|
||
if (Str::length($word) > $maxCharacters) {
|
||
array_push($words, ...mb_str_split($word, $maxCharacters));
|
||
|
||
continue;
|
||
}
|
||
|
||
$words[] = $word;
|
||
}
|
||
|
||
$line = '';
|
||
foreach ($words as $word) {
|
||
$candidate = $line === '' ? $word : $line.' '.$word;
|
||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||
$lines[] = $line;
|
||
$line = $word;
|
||
|
||
continue;
|
||
}
|
||
|
||
$line = $candidate;
|
||
}
|
||
|
||
if ($line !== '') {
|
||
$lines[] = $line;
|
||
}
|
||
}
|
||
|
||
return $lines;
|
||
}
|
||
|
||
// ------------------------------------------------------------ PDF-Aufbau
|
||
|
||
/**
|
||
* @param list<string> $pages
|
||
*/
|
||
private function buildPdf(array $pages): string
|
||
{
|
||
$fonts = [
|
||
'F1' => 'Helvetica',
|
||
'F2' => 'Helvetica-Bold',
|
||
'F3' => 'Helvetica-Oblique',
|
||
'F4' => 'Helvetica-BoldOblique',
|
||
];
|
||
|
||
$objects = [
|
||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||
];
|
||
|
||
$firstPageObjectNumber = 3 + count($fonts) + count($this->images);
|
||
$kids = [];
|
||
foreach (array_keys($pages) as $index) {
|
||
$kids[] = ($firstPageObjectNumber + $index * 2).' 0 R';
|
||
}
|
||
|
||
$objects[] = "2 0 obj\n<< /Type /Pages /Kids [".implode(' ', $kids).'] /Count '.count($pages)." >>\nendobj\n";
|
||
|
||
$fontRefs = [];
|
||
$number = 3;
|
||
foreach ($fonts as $alias => $baseFont) {
|
||
$objects[] = "{$number} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /{$baseFont} /Encoding /WinAnsiEncoding >>\nendobj\n";
|
||
$fontRefs[] = "/{$alias} {$number} 0 R";
|
||
$number++;
|
||
}
|
||
|
||
$xobjectRefs = [];
|
||
foreach ($this->images as $image) {
|
||
$objects[] = "{$number} 0 obj\n<< /Type /XObject /Subtype /Image /Width {$image['width']} /Height {$image['height']} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ".strlen($image['jpeg'])." >>\nstream\n".$image['jpeg']."\nendstream\nendobj\n";
|
||
$xobjectRefs[] = "/{$image['alias']} {$number} 0 R";
|
||
$number++;
|
||
}
|
||
|
||
$resources = '/Font << '.implode(' ', $fontRefs).' >>';
|
||
if ($xobjectRefs !== []) {
|
||
$resources .= ' /XObject << '.implode(' ', $xobjectRefs).' >>';
|
||
}
|
||
|
||
foreach ($pages as $content) {
|
||
$contentNumber = $number + 1;
|
||
$objects[] = "{$number} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << {$resources} >> /Contents {$contentNumber} 0 R >>\nendobj\n";
|
||
$objects[] = "{$contentNumber} 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n";
|
||
$number += 2;
|
||
}
|
||
|
||
$pdf = "%PDF-1.4\n";
|
||
$offsets = [0];
|
||
|
||
foreach ($objects as $object) {
|
||
$offsets[] = strlen($pdf);
|
||
$pdf .= $object;
|
||
}
|
||
|
||
$xrefOffset = strlen($pdf);
|
||
$pdf .= "xref\n0 ".(count($objects) + 1)."\n";
|
||
$pdf .= "0000000000 65535 f \n";
|
||
|
||
foreach (array_slice($offsets, 1) as $offset) {
|
||
$pdf .= sprintf("%010d 00000 n \n", $offset);
|
||
}
|
||
|
||
$pdf .= "trailer\n<< /Size ".(count($objects) + 1)." /Root 1 0 R >>\n";
|
||
$pdf .= "startxref\n{$xrefOffset}\n%%EOF\n";
|
||
|
||
return $pdf;
|
||
}
|
||
}
|