presseportale/app/Services/PressRelease/ProofPdfRenderer.php
Kevin Adametz 72e6a3c210 Backend-WIP: Kontakt-Queue, Page-Cache, Validierungs-Feedback, i18n
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>
2026-07-09 15:38:11 +00:00

761 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}