From a1302994c57c0108f9bce1943c769cb4f481e6a9 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 16 Jul 2026 12:05:46 +0000 Subject: [PATCH] =?UTF-8?q?billing:=20Ver=C3=B6ffentlichungsnachweis=20mit?= =?UTF-8?q?=20Titelbild,=20Bildrechten=20und=20Rich-Text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../PressRelease/ProofPdfRenderer.php | 807 +++++++++++++++++- tests/Feature/ProofPdfAndExtraPmTest.php | 90 ++ 2 files changed, 851 insertions(+), 46 deletions(-) diff --git a/app/Services/PressRelease/ProofPdfRenderer.php b/app/Services/PressRelease/ProofPdfRenderer.php index e9b388e2..37f2054f 100644 --- a/app/Services/PressRelease/ProofPdfRenderer.php +++ b/app/Services/PressRelease/ProofPdfRenderer.php @@ -2,13 +2,19 @@ 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. @@ -33,6 +39,13 @@ class ProofPdfRenderer 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. */ @@ -58,6 +71,9 @@ class ProofPdfRenderer /** @var list abgeschlossene Seiten-Content-Streams */ private array $pages = []; + /** @var list eingebettete Bild-XObjects */ + private array $images = []; + private string $stream = ''; private float $y = self::CONTENT_TOP; @@ -86,6 +102,7 @@ class ProofPdfRenderer public function render(PressRelease $pressRelease): string { $this->pages = []; + $this->images = []; $this->stream = ''; $this->y = self::CONTENT_TOP; @@ -100,7 +117,7 @@ class ProofPdfRenderer $this->renderDocumentHead($pressRelease); $this->renderConfirmation($pressRelease); - $this->renderPublicationDetails($pressRelease, $company); + $this->renderOverview($pressRelease, $company); $this->renderFullContent($pressRelease); $this->renderBoilerplate($pressRelease, $company); $this->renderPressContacts($contacts); @@ -180,23 +197,177 @@ class ProofPdfRenderer $this->y = $top - $height; } - private function renderPublicationDetails(PressRelease $pressRelease, ?Company $company): void + /** + * 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 + */ + 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'; - // Bei Portal::Both dokumentiert der Nachweis beide öffentlichen URLs. $links = $pressRelease->publicPortalLinks(); $urlRows = array_map(static fn (array $link): array => [ count($links) > 1 ? 'URL '.$link['label'] : 'Öffentliche URL', $link['url'], ], $links); - $rows = array_values(array_filter([ + return array_values(array_filter([ ['Pressemitteilung', 'PM #'.$pressRelease->id], ['Titel', (string) $pressRelease->title], $company?->name ? ['Firma', (string) $company->name] : null, @@ -208,12 +379,6 @@ class ProofPdfRenderer $this->languageLabel($pressRelease->language) ? ['Sprache', (string) $this->languageLabel($pressRelease->language)] : null, ['Nachweis erstellt am', now()->format('d.m.Y').', '.now()->format('H:i').' Uhr'], ])); - - $lastIndex = count($rows) - 1; - - foreach ($rows as $index => [$label, $value]) { - $this->metaRow($label, $value, $index < $lastIndex); - } } private function renderFullContent(PressRelease $pressRelease): void @@ -229,17 +394,20 @@ class ProofPdfRenderer $this->y -= 8; - foreach ($this->htmlToParagraphs($pressRelease->text) as $paragraph) { - if ($paragraph['heading']) { + foreach ($this->htmlToRichBlocks($pressRelease->text) as $block) { + if ($block['type'] === 'heading') { $this->ensureSpace(48); $this->y -= 4; - $this->writeParagraph($paragraph['text'], 11, 15, 'F2', self::NAVY); + $this->writeParagraph($this->atomsToText($block['atoms']), 11, 15, 'F2', self::NAVY); $this->y -= 3; continue; } - $this->writeParagraph($paragraph['text'], 10, 14.5); + $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; } } @@ -254,8 +422,18 @@ class ProofPdfRenderer $this->sectionHeading($company?->name ? 'Über '.$company->name : 'Über das Unternehmen'); - foreach ($this->htmlToParagraphs($boilerplate) as $paragraph) { - $this->writeParagraph($paragraph['text'], 9.5, 13.5, 'F1', self::SLATE); + 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; } } @@ -450,45 +628,381 @@ class ProofPdfRenderer } /** - * Wandelt den (sanitisierten) HTML-Inhalt der PM in Plaintext-Absätze: - * Block-Elemente werden zu Absatztrennern,
zu Zeilenumbrüchen, - * Listenpunkte erhalten ein Aufzählungszeichen. Zwischenüberschriften - * (h1–h6) werden markiert, damit sie im PDF hervorgehoben gesetzt werden - * können. Legacy-Plaintext läuft unverändert durch die Absatz-Trennung - * (Doppelumbruch). + * 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 + * @return list>}> */ - private function htmlToParagraphs(?string $html): array + private function htmlToRichBlocks(?string $html): array { - $headingMarker = "\x01"; + $html = trim((string) $html); - $text = (string) $html; - $text = (string) preg_replace('//i', "\n", $text); - $text = (string) preg_replace('/]*>/i', '• ', $text); - $text = (string) preg_replace('/]*>/i', "\n\n".$headingMarker, $text); - $text = (string) preg_replace('/<\/(p|h[1-6]|li|ul|ol|blockquote|div)>/i', "\n\n", $text); - $text = strip_tags($text); - $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $text = str_replace(["\r\n", "\r"], "\n", $text); - $text = (string) preg_replace('/[ \t]+/u', ' ', $text); + if ($html === '') { + return []; + } - $paragraphs = []; + if (! str_contains($html, '<')) { + $blocks = []; - foreach (preg_split('/\n{2,}/u', $text) ?: [] as $paragraph) { - $paragraph = trim((string) preg_replace('/\s*\n\s*/u', "\n", $paragraph)); + 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)); + } - if ($paragraph === '' || $paragraph === $headingMarker) { continue; } - $paragraphs[] = [ - 'heading' => str_starts_with($paragraph, $headingMarker), - 'text' => ltrim($paragraph, $headingMarker), - ]; + 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 $paragraphs; + 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 @@ -546,6 +1060,142 @@ class ProofPdfRenderer } } + /** + * 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) { @@ -557,7 +1207,7 @@ class ProofPdfRenderer { $this->pages[] = $this->stream; $this->stream = ''; - $this->y = self::CONTENT_TOP; + $this->y = self::CONTINUATION_TOP; } /** @@ -584,6 +1234,58 @@ class ProofPdfRenderer $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 */ @@ -708,13 +1410,14 @@ class ProofPdfRenderer '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); + $firstPageObjectNumber = 3 + count($fonts) + count($this->images); $kids = []; foreach (array_keys($pages) as $index) { $kids[] = ($firstPageObjectNumber + $index * 2).' 0 R'; @@ -730,9 +1433,21 @@ class ProofPdfRenderer $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 << /Font << '.implode(' ', $fontRefs)." >> >> /Contents {$contentNumber} 0 R >>\nendobj\n"; + $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; } diff --git a/tests/Feature/ProofPdfAndExtraPmTest.php b/tests/Feature/ProofPdfAndExtraPmTest.php index e1e8d24b..52ce3466 100644 --- a/tests/Feature/ProofPdfAndExtraPmTest.php +++ b/tests/Feature/ProofPdfAndExtraPmTest.php @@ -1,5 +1,6 @@ seed(RolesAndPermissionsSeeder::class); @@ -145,6 +147,94 @@ test('a long release flows over multiple numbered pages', function () { expect($pdf)->toContain('Fortsetzung'); }); +test('continuation pages start their content below the page top', function () { + $pr = PressRelease::factory()->published()->create([ + 'title' => 'Lange Meldung', + 'text' => implode('', array_map( + static fn (int $i): string => '

Absatz '.$i.': '.str_repeat('Dies ist ein ausfuehrlicher Beispielsatz. ', 12).'

', + range(1, 12), + )), + ]); + + $pdf = app(ProofPdfRenderer::class)->render($pr); + + // Content-Streams in Dokumentreihenfolge extrahieren (Seite 1, 2, …). + preg_match_all('/stream\n(.*?)endstream/s', $pdf, $matches); + expect(count($matches[1]))->toBeGreaterThanOrEqual(2); + + // Jede Fortsetzungsseite (ab Seite 2) darf ihren Inhalt nicht direkt am + // Seitenrand beginnen — die erste Textposition liegt spürbar tiefer. + foreach (array_slice($matches[1], 1) as $continuationStream) { + preg_match('/(-?\d+\.\d+) (-?\d+\.\d+) Td/', $continuationStream, $firstTd); + + expect((float) $firstTd[2])->toBeLessThanOrEqual(760.0); + } +}); + +test('a release with a title image embeds it as an image xobject', function () { + Storage::fake('local'); + + $canvas = imagecreatetruecolor(600, 400); + imagefilledrectangle($canvas, 0, 0, 600, 400, imagecolorallocate($canvas, 30, 60, 120)); + ob_start(); + imagejpeg($canvas); + $jpeg = (string) ob_get_clean(); + imagedestroy($canvas); + + $pr = PressRelease::factory()->published()->create(['title' => 'Meldung mit Bild']); + + $path = 'press-releases/'.$pr->id.'/images/foto.jpg'; + Storage::disk('local')->put($path, $jpeg); + + $pr->images()->create([ + 'disk' => 'local', + 'path' => $path, + 'copyright' => 'Foto: Jane Doe', + 'author' => 'Jane Doe', + 'license_type' => ImageLicenseType::Own->value, + 'source_url' => 'https://example.com/bildquelle', + 'is_preview' => true, + 'sort_order' => 1, + ]); + + $pdf = app(ProofPdfRenderer::class)->render($pr); + + expect($pdf) + ->toContain('/Subtype /Image') + ->toContain('/Filter /DCTDecode') + ->toContain('/XObject <<') + ->toContain(' Do Q') + // Rechtliche Bildangaben aus dem Upload unter dem Bild. + ->toContain('Bildnachweis: Foto: Jane Doe') + ->toContain('Lizenz: Eigene Aufnahme') + ->toContain('Quelle: https://example.com/bildquelle'); +}); + +test('a release without a title image embeds no image xobject', function () { + $pr = PressRelease::factory()->published()->create(['title' => 'Meldung ohne Bild']); + + $pdf = app(ProofPdfRenderer::class)->render($pr); + + expect($pdf)->not->toContain('/Subtype /Image'); +}); + +test('rich text keeps bold and italic formatting', function () { + $pr = PressRelease::factory()->published()->create([ + 'title' => 'Formatierte Meldung', + 'text' => '

Ein fetterText und kursiverText sowie beidesText hier.

', + ]); + + $pdf = app(ProofPdfRenderer::class)->render($pr); + + // Fett → Helvetica-Bold (F2), kursiv → Helvetica-Oblique (F3), + // fett+kursiv → Helvetica-BoldOblique (F4). + expect($pdf) + ->toMatch('#/F2 [0-9.]+ Tf[^)]*\(fetterText\)#') + ->toMatch('#/F3 [0-9.]+ Tf[^)]*\(kursiverText\)#') + ->toMatch('#/F4 [0-9.]+ Tf[^)]*\(beidesText\)#') + ->toContain('/BaseFont /Helvetica-BoldOblique'); +}); + test('the rendered proof documents the real public url of the release', function () { $pr = PressRelease::factory()->published()->create([ 'portal' => Portal::Presseecho,