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>
This commit is contained in:
Kevin Adametz 2026-07-16 12:05:46 +00:00
parent 07d89a0e6a
commit a1302994c5
2 changed files with 851 additions and 46 deletions

View file

@ -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<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;
@ -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<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';
// 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, <br> 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; <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{heading: bool, text: string}>
* @return list<array{type: string, atoms: list<array<string, mixed>>}>
*/
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('/<br\s*\/?>/i', "\n", $text);
$text = (string) preg_replace('/<li\b[^>]*>/i', '• ', $text);
$text = (string) preg_replace('/<h[1-6]\b[^>]*>/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(
'<?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));
}
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<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 $paragraphs;
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
@ -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<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) {
@ -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;
}

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\ImageLicenseType;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
@ -16,6 +17,7 @@ use App\Services\Billing\ExtraPmPurchaseService;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\ProofPdfRenderer;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
$this->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 => '<p>Absatz '.$i.': '.str_repeat('Dies ist ein ausfuehrlicher Beispielsatz. ', 12).'</p>',
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' => '<p>Ein <strong>fetterText</strong> und <em>kursiverText</em> sowie <strong><em>beidesText</em></strong> hier.</p>',
]);
$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,