Gebuendelt, da ueber gemeinsame lang-/Service-Dateien verschraenkt: - Kontakt-Nachrichten: contact_messages-Tabelle + Model/Enums/Factory, Admin-Queue (livewire/admin/contact-messages), ContactController persistiert das Anliegen zusaetzlich zur Brand-Mail - Page-Data-Cache (Services/Web/*PageData) + web-archive-Indizes auf press_releases - Validierungs-Feedback + Company-Portal-Restriction (Feature-Tests) - i18n-Auslagerung lang/de|en (auth, passwords, validation, static) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
761 lines
25 KiB
PHP
761 lines
25 KiB
PHP
<?php
|
||
|
||
namespace App\Services\PressRelease;
|
||
|
||
use App\Enums\Portal;
|
||
use App\Models\Company;
|
||
use App\Models\Contact;
|
||
use App\Models\PressRelease;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Str;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
/**
|
||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3.
|
||
*
|
||
* Der Nachweis dokumentiert die Veröffentlichung vollständig: Bestätigungstext,
|
||
* alle Veröffentlichungsdaten (Portal, Datum, öffentliche URLs, Kategorie …),
|
||
* die Pressemitteilung im kompletten Wortlaut, Boilerplate, Pressekontakte und
|
||
* Herausgeber-Angaben — mehrseitig mit Seitennummerierung und Fußzeile, im
|
||
* Pressekonto-Branding (Navy/Bronze). On-demand aus vorhandenen PM-Daten —
|
||
* keine KI, kein externer Renderer (gleicher schlanker PDF-Ansatz wie der
|
||
* Legacy-Rechnungs-Renderer).
|
||
*/
|
||
class ProofPdfRenderer
|
||
{
|
||
private const PAGE_WIDTH = 595;
|
||
|
||
private const PAGE_HEIGHT = 842;
|
||
|
||
private const LEFT = 60;
|
||
|
||
private const RIGHT = 535;
|
||
|
||
private const CONTENT_TOP = 796;
|
||
|
||
private const BOTTOM = 92;
|
||
|
||
/** Pressekonto-Hub-Navy (#1A2540) — Markenfarbe für Headings/Flächen. */
|
||
private const NAVY = [0.102, 0.145, 0.251];
|
||
|
||
/** Pressekonto-Bronze (#B07A3A) — Akzentfarbe für Linien/Details. */
|
||
private const BRONZE = [0.690, 0.478, 0.227];
|
||
|
||
private const INK = [0.129, 0.145, 0.180];
|
||
|
||
private const SLATE = [0.302, 0.333, 0.384];
|
||
|
||
private const GRAY = [0.478, 0.502, 0.549];
|
||
|
||
private const HEADER_MUTED = [0.663, 0.702, 0.800];
|
||
|
||
private const BOX_BG = [0.958, 0.962, 0.973];
|
||
|
||
private const RULE = [0.855, 0.867, 0.898];
|
||
|
||
private const WHITE = [1.0, 1.0, 1.0];
|
||
|
||
/** @var list<string> abgeschlossene Seiten-Content-Streams */
|
||
private array $pages = [];
|
||
|
||
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<int, Contact> $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<int, Contact> $contacts
|
||
*/
|
||
private function renderPressContacts(Collection $contacts): void
|
||
{
|
||
if ($contacts->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$this->sectionHeading($contacts->count() > 1 ? 'Pressekontakte' : 'Pressekontakt');
|
||
|
||
foreach ($contacts as $contact) {
|
||
$details = array_values(array_filter([
|
||
filled($contact->phone) ? 'Telefon: '.$contact->phone : null,
|
||
filled($contact->fax) ? 'Fax: '.$contact->fax : null,
|
||
filled($contact->email) ? 'E-Mail: '.$contact->email : null,
|
||
]));
|
||
|
||
$padding = 12.0;
|
||
$height = $padding + 9
|
||
+ (filled($contact->responsibility) ? 13.5 : 0)
|
||
+ count($details) * 13
|
||
+ 4 + $padding;
|
||
|
||
$this->ensureSpace($height + 10);
|
||
$top = $this->y;
|
||
|
||
$this->rect(self::LEFT, $top - $height, self::RIGHT - self::LEFT, $height, self::BOX_BG);
|
||
$this->rect(self::LEFT, $top - $height, 3, $height, self::NAVY);
|
||
|
||
$baseline = $top - $padding - 8;
|
||
$this->text(self::LEFT + $padding + 4, $baseline, $contact->displayName(), 10.5, 'F2', self::NAVY);
|
||
|
||
if (filled($contact->responsibility)) {
|
||
$baseline -= 13.5;
|
||
$this->text(self::LEFT + $padding + 4, $baseline, (string) $contact->responsibility, 9, 'F3', self::GRAY);
|
||
}
|
||
|
||
foreach ($details as $detail) {
|
||
$baseline -= 13;
|
||
$this->text(self::LEFT + $padding + 4, $baseline, $detail, 9.5, 'F1', self::INK);
|
||
}
|
||
|
||
$this->y = $top - $height - 10;
|
||
}
|
||
}
|
||
|
||
private function renderPublisher(?Company $company): void
|
||
{
|
||
if (! $company) {
|
||
return;
|
||
}
|
||
|
||
$this->sectionHeading('Herausgeber');
|
||
|
||
$this->ensureSpace(40);
|
||
$this->text(self::LEFT, $this->y, (string) $company->name, 10.5, 'F2', self::NAVY);
|
||
$this->y -= 15;
|
||
|
||
if (filled($company->address)) {
|
||
$this->writeParagraph((string) $company->address, 9.5, 13);
|
||
}
|
||
|
||
if ($countryName = $this->countryName($company->country_code)) {
|
||
$this->ensureSpace(13);
|
||
$this->text(self::LEFT, $this->y, $countryName, 9.5);
|
||
$this->y -= 13;
|
||
}
|
||
|
||
$details = array_filter([
|
||
filled($company->phone) ? 'Telefon: '.$company->phone : null,
|
||
filled($company->fax) ? 'Fax: '.$company->fax : null,
|
||
filled($company->email) ? 'E-Mail: '.$company->email : null,
|
||
filled($company->website) ? 'Web: '.$company->website : null,
|
||
]);
|
||
|
||
foreach ($details as $detail) {
|
||
$this->ensureSpace(13);
|
||
$this->text(self::LEFT, $this->y, $detail, 9.5);
|
||
$this->y -= 13;
|
||
}
|
||
}
|
||
|
||
private function renderClosingNote(): void
|
||
{
|
||
$this->y -= 18;
|
||
$this->ensureSpace(40);
|
||
$this->line(self::LEFT, $this->y + 6, self::RIGHT, $this->y + 6, 0.5, self::RULE);
|
||
$this->y -= 6;
|
||
$this->writeParagraph(
|
||
'Dieser Veröffentlichungsnachweis wurde maschinell über pressekonto erstellt und ist ohne Unterschrift gültig. '
|
||
.'Die inhaltliche Verantwortung für die Pressemitteilung liegt beim Herausgeber.',
|
||
8.5,
|
||
12,
|
||
'F1',
|
||
self::GRAY,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fußzeile (Trennlinie, Dokument-Kennung, Seite X von Y) auf jeder Seite
|
||
* sowie ein schmales Fortsetzungs-Band ab Seite 2 — erst nach dem Rendern
|
||
* möglich, weil die Gesamtseitenzahl vorher nicht feststeht.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function withPageChrome(PressRelease $pressRelease): array
|
||
{
|
||
$total = count($this->pages);
|
||
$generated = now()->format('d.m.Y, H:i');
|
||
|
||
return array_map(function (string $stream, int $index) use ($total, $pressRelease, $generated): string {
|
||
$pageNumber = $index + 1;
|
||
|
||
$chrome = $this->lineOp(self::LEFT, 66, self::RIGHT, 66, 0.6, self::RULE);
|
||
$chrome .= $this->textOp(
|
||
self::LEFT,
|
||
53,
|
||
"Veröffentlichungsnachweis PM #{$pressRelease->id} · erstellt am {$generated} Uhr über pressekonto",
|
||
8,
|
||
'F1',
|
||
self::GRAY,
|
||
);
|
||
|
||
$pageLabel = "Seite {$pageNumber} von {$total}";
|
||
$chrome .= $this->textOp(self::RIGHT - $this->estimateWidth($pageLabel, 8), 53, $pageLabel, 8, 'F1', self::GRAY);
|
||
|
||
if ($pageNumber > 1) {
|
||
$chrome .= $this->rectOp(0, self::PAGE_HEIGHT - 6, self::PAGE_WIDTH, 6, self::NAVY);
|
||
$chrome .= $this->textOp(
|
||
self::LEFT,
|
||
818,
|
||
"Veröffentlichungsnachweis PM #{$pressRelease->id} – Fortsetzung",
|
||
8,
|
||
'F1',
|
||
self::GRAY,
|
||
);
|
||
}
|
||
|
||
return $stream.$chrome;
|
||
}, $this->pages, array_keys($this->pages));
|
||
}
|
||
|
||
// ------------------------------------------------------------ Datenaufbereitung
|
||
|
||
private function portalLabel(PressRelease $pressRelease): string
|
||
{
|
||
if (! $pressRelease->portal instanceof Portal) {
|
||
return (string) $pressRelease->portal;
|
||
}
|
||
|
||
return $pressRelease->portal === Portal::Both
|
||
? 'Presseecho und Businessportal24'
|
||
: $pressRelease->portal->label();
|
||
}
|
||
|
||
private function categoryName(PressRelease $pressRelease): ?string
|
||
{
|
||
if (! $pressRelease->category) {
|
||
return null;
|
||
}
|
||
|
||
$locale = in_array($pressRelease->language, ['de', 'en'], true) ? $pressRelease->language : 'de';
|
||
|
||
return $pressRelease->category->translations()->where('locale', $locale)->value('name')
|
||
?? $pressRelease->category->translations()->value('name');
|
||
}
|
||
|
||
private function languageLabel(?string $language): ?string
|
||
{
|
||
return match ($language) {
|
||
'de' => 'Deutsch',
|
||
'en' => 'Englisch',
|
||
null, '' => null,
|
||
default => strtoupper($language),
|
||
};
|
||
}
|
||
|
||
private function countryName(?string $countryCode): ?string
|
||
{
|
||
return match (strtoupper((string) $countryCode)) {
|
||
'DE' => 'Deutschland',
|
||
'AT' => 'Österreich',
|
||
'CH' => 'Schweiz',
|
||
'' => null,
|
||
default => strtoupper((string) $countryCode),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Wandelt den (sanitisierten) HTML-Inhalt der PM in 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).
|
||
*
|
||
* @return list<array{heading: bool, text: string}>
|
||
*/
|
||
private function htmlToParagraphs(?string $html): array
|
||
{
|
||
$headingMarker = "\x01";
|
||
|
||
$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);
|
||
|
||
$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<string>
|
||
*/
|
||
private function wrap(string $text, float $width, float $size): array
|
||
{
|
||
$lines = [];
|
||
$maxCharacters = max(8, (int) floor($width / ($size * 0.5)));
|
||
|
||
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $paragraph) {
|
||
if (trim($paragraph) === '') {
|
||
$lines[] = '';
|
||
|
||
continue;
|
||
}
|
||
|
||
$words = [];
|
||
foreach (preg_split('/\s+/u', trim($paragraph)) ?: [] as $word) {
|
||
// Überlange Wörter (v. a. URLs) hart umbrechen statt überlaufen.
|
||
if (Str::length($word) > $maxCharacters) {
|
||
array_push($words, ...mb_str_split($word, $maxCharacters));
|
||
|
||
continue;
|
||
}
|
||
|
||
$words[] = $word;
|
||
}
|
||
|
||
$line = '';
|
||
foreach ($words as $word) {
|
||
$candidate = $line === '' ? $word : $line.' '.$word;
|
||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||
$lines[] = $line;
|
||
$line = $word;
|
||
|
||
continue;
|
||
}
|
||
|
||
$line = $candidate;
|
||
}
|
||
|
||
if ($line !== '') {
|
||
$lines[] = $line;
|
||
}
|
||
}
|
||
|
||
return $lines;
|
||
}
|
||
|
||
// ------------------------------------------------------------ PDF-Aufbau
|
||
|
||
/**
|
||
* @param list<string> $pages
|
||
*/
|
||
private function buildPdf(array $pages): string
|
||
{
|
||
$fonts = [
|
||
'F1' => 'Helvetica',
|
||
'F2' => 'Helvetica-Bold',
|
||
'F3' => 'Helvetica-Oblique',
|
||
];
|
||
|
||
$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;
|
||
}
|
||
}
|