presseportale/app/Services/PressRelease/ProofPdfRenderer.php
Kevin Adametz a1302994c5 billing: Veröffentlichungsnachweis mit Titelbild, Bildrechten und Rich-Text
Der Nachweis-PDF bindet nun das Titelbild der PM kompakt oben ein – links
das Bild mit den beim Upload erfassten Bildrechten (Copyright, Urheber,
Lizenz, Quelle, KI-Kennzeichnung), rechts daneben die Angaben zur
Veröffentlichung als Tabelle. Der Volltext übernimmt Fett-/Kursiv-
Formatierung (F2/F3/F4), und Fortsetzungsseiten beginnen den Inhalt mit
Abstand unterhalb des Seitenrands statt direkt am Seitenanfang.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 12:05:46 +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 || $image === 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;
}
}