abgeschlossene Seiten-Content-Streams */ private array $pages = []; 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->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->renderPublicationDetails($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; } private function renderPublicationDetails(PressRelease $pressRelease, ?Company $company): void { $this->sectionHeading('Angaben zur Veröffentlichung'); $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([ ['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'], ])); $lastIndex = count($rows) - 1; foreach ($rows as $index => [$label, $value]) { $this->metaRow($label, $value, $index < $lastIndex); } } 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->htmlToParagraphs($pressRelease->text) as $paragraph) { if ($paragraph['heading']) { $this->ensureSpace(48); $this->y -= 4; $this->writeParagraph($paragraph['text'], 11, 15, 'F2', self::NAVY); $this->y -= 3; continue; } $this->writeParagraph($paragraph['text'], 10, 14.5); $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->htmlToParagraphs($boilerplate) as $paragraph) { $this->writeParagraph($paragraph['text'], 9.5, 13.5, 'F1', self::SLATE); $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 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). * * @return list */ private function htmlToParagraphs(?string $html): array { $headingMarker = "\x01"; $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); $paragraphs = []; foreach (preg_split('/\n{2,}/u', $text) ?: [] as $paragraph) { $paragraph = trim((string) preg_replace('/\s*\n\s*/u', "\n", $paragraph)); if ($paragraph === '' || $paragraph === $headingMarker) { continue; } $paragraphs[] = [ 'heading' => str_starts_with($paragraph, $headingMarker), 'text' => ltrim($paragraph, $headingMarker), ]; } return $paragraphs; } // ------------------------------------------------------------ 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; } } 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::CONTENT_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); } /** * @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', ]; $objects = [ "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", ]; $firstPageObjectNumber = 3 + count($fonts); $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++; } 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[] = "{$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; } }