presseportale/app/Services/PressRelease/ProofPdfRenderer.php
Kevin Adametz 69411b4c87 Proof-PDF + Extra-PM-Verkauf ueber die Credit-Wallet (Decision-Update 2.1/2.3)
- 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>
2026-06-17 14:28:08 +00:00

232 lines
7.8 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\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;
}
}