abgeschlossene Seiten-Content-Streams */ private array $pages = []; /** @var list 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 $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 */ 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 */ 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 $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 */ 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; / und / setzen * die Fett-/Kursiv-Auszeichnung,
einen Zeilenumbruch. Legacy-Plaintext * (ohne Markup) wird an Doppelumbrüchen in Absätze getrennt. * * @return list>}> */ 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( '
'.$html.'
', 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>}> $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>}> $blocks * @param list> $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.
erzeugt einen expliziten Umbruch. * * @return list> */ 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, *
zu Umbruch-Atomen. `glue` markiert Wörter, die ohne Leerzeichen an * das vorherige anschließen (Formatwechsel mitten im Wort). * * @param list> $runs * @return list> */ 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> $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> $atoms * @return list> */ 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> $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> $atoms * @return list>> */ 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 */ 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 */ 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 $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; } }