- ProofPdfService: Veroeffentlichungsnachweis 3 Credits pauschal, einmal pro
PM (Zweitdownload kostenfrei); ProofPdfRenderer erzeugt das PDF on-demand
aus vorhandenen PM-Daten (kein externer Renderer); GET-Download-Endpoint
/admin/me/press-releases/{id}/nachweis hinter downloadProof-Policy + Kauf-Gate
- ExtraPmPurchaseService: tier-gestaffelter Nachkauf (19/15/12/10/8) aus der
Wallet; verbucht als bezahlter SinglePurchase(ExtraPm) und greift damit in
die bestehende Kontingent-/Slot-Mechanik. InsufficientCreditsException
liefert das Mini-Checkout-Signal (required/available/shortfall)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
7.8 KiB
PHP
232 lines
7.8 KiB
PHP
<?php
|
||
|
||
namespace App\Services\PressRelease;
|
||
|
||
use App\Enums\Portal;
|
||
use App\Models\PressRelease;
|
||
use Illuminate\Support\Str;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
/**
|
||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3
|
||
* („PM XY wurde am … auf … veröffentlicht" inkl. URL, Datum, Vorschau).
|
||
* 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 = 70;
|
||
|
||
private const RIGHT = 525;
|
||
|
||
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
|
||
{
|
||
$content = '';
|
||
$portalLabel = $pressRelease->portal instanceof Portal
|
||
? $pressRelease->portal->label()
|
||
: (string) $pressRelease->portal;
|
||
$publishedAt = $pressRelease->published_at?->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE);
|
||
$dateLabel = $publishedAt?->format('d.m.Y') ?? 'n/a';
|
||
$timeLabel = $publishedAt?->format('H:i') ?? null;
|
||
|
||
$content .= $this->text(self::LEFT, 790, 'Veröffentlichungsnachweis', 22, 'F2');
|
||
$content .= $this->line(self::LEFT, 778, self::RIGHT, 778, 0.7);
|
||
$content .= $this->text(self::LEFT, 760, $portalLabel, 11, 'F2');
|
||
|
||
$y = 712.0;
|
||
$headline = $timeLabel
|
||
? "Diese Pressemitteilung wurde am {$dateLabel} um {$timeLabel} Uhr auf {$portalLabel} veröffentlicht."
|
||
: "Diese Pressemitteilung wurde am {$dateLabel} auf {$portalLabel} veröffentlicht.";
|
||
$y = $this->wrappedText($content, self::LEFT, $y, $headline, 11, self::RIGHT - self::LEFT, 16);
|
||
|
||
$y -= 24;
|
||
$content .= $this->text(self::LEFT, $y, 'Titel', 9, 'F2');
|
||
$y = $this->wrappedText($content, self::LEFT, $y - 15, (string) $pressRelease->title, 13, self::RIGHT - self::LEFT, 17, 'F2');
|
||
|
||
$y -= 18;
|
||
foreach ($this->metaRows($pressRelease, $portalLabel, $dateLabel) as [$label, $value]) {
|
||
$content .= $this->text(self::LEFT, $y, $label, 9, 'F2');
|
||
$this->wrappedText($content, self::LEFT + 110, $y, $value, 9, self::RIGHT - self::LEFT - 110, 12);
|
||
$y -= 20;
|
||
}
|
||
|
||
$y -= 10;
|
||
$content .= $this->text(self::LEFT, $y, 'Vorschau', 9, 'F2');
|
||
$this->wrappedText($content, self::LEFT, $y - 15, $this->previewText($pressRelease), 9, self::RIGHT - self::LEFT, 12);
|
||
|
||
$content .= $this->line(self::LEFT, 70, self::RIGHT, 70, 0.5);
|
||
$content .= $this->text(self::LEFT, 56, 'Erstellt über pressekonto – maschinell erzeugter Nachweis.', 8);
|
||
|
||
return $this->buildPdf($content);
|
||
}
|
||
|
||
/**
|
||
* @return list<array{0: string, 1: string}>
|
||
*/
|
||
private function metaRows(PressRelease $pressRelease, string $portalLabel, string $dateLabel): array
|
||
{
|
||
return array_values(array_filter([
|
||
['Portal', $portalLabel],
|
||
['Veröffentlicht', $dateLabel],
|
||
['URL', $this->publicUrl($pressRelease)],
|
||
$pressRelease->category?->name ? ['Kategorie', (string) $pressRelease->category->name] : null,
|
||
]));
|
||
}
|
||
|
||
private function publicUrl(PressRelease $pressRelease): string
|
||
{
|
||
$base = match ($pressRelease->portal) {
|
||
Portal::Presseecho => config('domains.domain_presseecho_url'),
|
||
Portal::Businessportal24 => config('domains.domain_businessportal_url'),
|
||
default => config('app.url'),
|
||
};
|
||
|
||
return rtrim((string) $base, '/').'/'.ltrim((string) $pressRelease->slug, '/');
|
||
}
|
||
|
||
private function previewText(PressRelease $pressRelease): string
|
||
{
|
||
$plain = trim(html_entity_decode(strip_tags((string) $pressRelease->text)));
|
||
|
||
return Str::limit($plain, 600);
|
||
}
|
||
|
||
private function buildPdf(string $content): string
|
||
{
|
||
$objects = [
|
||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
|
||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
|
||
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
|
||
];
|
||
|
||
$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;
|
||
}
|
||
|
||
private function escapePdfText(string $text): string
|
||
{
|
||
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
|
||
|
||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||
}
|
||
|
||
private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1'): string
|
||
{
|
||
return sprintf(
|
||
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
|
||
$font,
|
||
$size,
|
||
$x,
|
||
$y,
|
||
$this->escapePdfText($text),
|
||
);
|
||
}
|
||
|
||
private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
|
||
{
|
||
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
|
||
}
|
||
|
||
private function wrappedText(
|
||
string &$content,
|
||
float $x,
|
||
float $y,
|
||
string $text,
|
||
int $size,
|
||
float $width,
|
||
float $lineHeight = 12,
|
||
string $font = 'F1',
|
||
): float {
|
||
foreach ($this->wrap($text, $width, $size) as $line) {
|
||
if ($line !== '') {
|
||
$content .= $this->text($x, $y, $line, $size, $font);
|
||
}
|
||
|
||
$y -= $lineHeight;
|
||
}
|
||
|
||
return $y;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function wrap(string $text, float $width, int $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;
|
||
}
|
||
|
||
$line = '';
|
||
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
|
||
$candidate = trim($line.' '.$word);
|
||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||
$lines[] = $line;
|
||
$line = $word;
|
||
|
||
continue;
|
||
}
|
||
|
||
$line = $candidate;
|
||
}
|
||
|
||
if ($line !== '') {
|
||
$lines[] = $line;
|
||
}
|
||
}
|
||
|
||
return $lines;
|
||
}
|
||
}
|