presseportale/app/Services/PressRelease/ProofPdfRenderer.php
Kevin Adametz e783adc54e technik: Statische Analyse wieder aussagekräftig machen
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>
2026-09-01 11:07:51 +00:00

1476 lines
48 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}