billing: Veröffentlichungsnachweis mit Titelbild, Bildrechten und Rich-Text
Der Nachweis-PDF bindet nun das Titelbild der PM kompakt oben ein – links das Bild mit den beim Upload erfassten Bildrechten (Copyright, Urheber, Lizenz, Quelle, KI-Kennzeichnung), rechts daneben die Angaben zur Veröffentlichung als Tabelle. Der Volltext übernimmt Fett-/Kursiv- Formatierung (F2/F3/F4), und Fortsetzungsseiten beginnen den Inhalt mit Abstand unterhalb des Seitenrands statt direkt am Seitenanfang. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
07d89a0e6a
commit
a1302994c5
2 changed files with 851 additions and 46 deletions
|
|
@ -2,13 +2,19 @@
|
|||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use GdImage;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3.
|
||||
|
|
@ -33,6 +39,13 @@ class ProofPdfRenderer
|
|||
|
||||
private const CONTENT_TOP = 796;
|
||||
|
||||
/**
|
||||
* Oberkante des Inhalts auf Fortsetzungsseiten (ab Seite 2). Bewusst tiefer
|
||||
* als der Seitenrand, damit der Text nicht direkt unter dem schmalen
|
||||
* Fortsetzungs-Band klebt, sondern mit Luft nach unten einrückt.
|
||||
*/
|
||||
private const CONTINUATION_TOP = 760;
|
||||
|
||||
private const BOTTOM = 92;
|
||||
|
||||
/** Pressekonto-Hub-Navy (#1A2540) — Markenfarbe für Headings/Flächen. */
|
||||
|
|
@ -58,6 +71,9 @@ class ProofPdfRenderer
|
|||
/** @var list<string> abgeschlossene Seiten-Content-Streams */
|
||||
private array $pages = [];
|
||||
|
||||
/** @var list<array{alias: string, jpeg: string, width: int, height: int}> eingebettete Bild-XObjects */
|
||||
private array $images = [];
|
||||
|
||||
private string $stream = '';
|
||||
|
||||
private float $y = self::CONTENT_TOP;
|
||||
|
|
@ -86,6 +102,7 @@ class ProofPdfRenderer
|
|||
public function render(PressRelease $pressRelease): string
|
||||
{
|
||||
$this->pages = [];
|
||||
$this->images = [];
|
||||
$this->stream = '';
|
||||
$this->y = self::CONTENT_TOP;
|
||||
|
||||
|
|
@ -100,7 +117,7 @@ class ProofPdfRenderer
|
|||
|
||||
$this->renderDocumentHead($pressRelease);
|
||||
$this->renderConfirmation($pressRelease);
|
||||
$this->renderPublicationDetails($pressRelease, $company);
|
||||
$this->renderOverview($pressRelease, $company);
|
||||
$this->renderFullContent($pressRelease);
|
||||
$this->renderBoilerplate($pressRelease, $company);
|
||||
$this->renderPressContacts($contacts);
|
||||
|
|
@ -180,23 +197,177 @@ class ProofPdfRenderer
|
|||
$this->y = $top - $height;
|
||||
}
|
||||
|
||||
private function renderPublicationDetails(PressRelease $pressRelease, ?Company $company): void
|
||||
/**
|
||||
* Kopfbereich des Nachweises: Titelbild links (kompakt) und die Angaben zur
|
||||
* Veröffentlichung als Tabelle rechts daneben. Ohne echtes Bild fällt der
|
||||
* Abschnitt auf die klassische, seitenbreite Detailtabelle zurück.
|
||||
*/
|
||||
private function renderOverview(PressRelease $pressRelease, ?Company $company): void
|
||||
{
|
||||
$rows = $this->publicationRows($pressRelease, $company);
|
||||
$image = $this->coverImage($pressRelease);
|
||||
$embed = $image ? $this->loadCoverImage($image) : null;
|
||||
|
||||
if ($embed === null || $image === null) {
|
||||
$this->sectionHeading('Angaben zur Veröffentlichung');
|
||||
|
||||
$lastIndex = count($rows) - 1;
|
||||
|
||||
foreach ($rows as $index => [$label, $value]) {
|
||||
$this->metaRow($label, $value, $index < $lastIndex);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$jpeg, $pxWidth, $pxHeight] = $embed;
|
||||
|
||||
$imageColumnWidth = 200.0;
|
||||
$columnGap = 26.0;
|
||||
$rightX = self::LEFT + $imageColumnWidth + $columnGap;
|
||||
$rightWidth = self::RIGHT - $rightX;
|
||||
|
||||
$scale = min($imageColumnWidth / $pxWidth, 190.0 / $pxHeight);
|
||||
$drawWidth = $pxWidth * $scale;
|
||||
$drawHeight = $pxHeight * $scale;
|
||||
|
||||
// Rechtliche Bildangaben aus dem Upload (Copyright, Urheber, Lizenz …).
|
||||
$creditLines = $this->imageCreditLines($image);
|
||||
|
||||
$captionHeight = 0.0;
|
||||
if ($creditLines !== []) {
|
||||
$captionHeight += 10;
|
||||
foreach ($creditLines as $line) {
|
||||
$captionHeight += max(1, count($this->wrap($line, $imageColumnWidth, 7.5))) * 9.5 + 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Höhe beider Spalten vorab bestimmen, damit der ganze Block zusammen
|
||||
// (mit Überschrift) ggf. auf eine neue Seite rutscht.
|
||||
$detailsHeight = 0.0;
|
||||
foreach ($rows as [, $value]) {
|
||||
$lineCount = max(1, count($this->wrap($value, $rightWidth, 9)));
|
||||
$detailsHeight += 11 + $lineCount * 12 + 7;
|
||||
}
|
||||
|
||||
$blockHeight = max($drawHeight + $captionHeight, $detailsHeight);
|
||||
|
||||
$this->ensureSpace(52 + $blockHeight + 12);
|
||||
$this->sectionHeading('Angaben zur Veröffentlichung');
|
||||
|
||||
$top = $this->y;
|
||||
|
||||
// Titelbild links, mit dezentem Rahmen.
|
||||
$imageBottom = $top - $drawHeight;
|
||||
$alias = $this->registerImage($jpeg, $pxWidth, $pxHeight);
|
||||
$this->drawImage($alias, self::LEFT, $imageBottom, $drawWidth, $drawHeight);
|
||||
$this->stream .= $this->strokeRectOp(self::LEFT, $imageBottom, $drawWidth, $drawHeight, 0.5, self::RULE);
|
||||
|
||||
// Bildnachweis/Lizenz unter dem Bild.
|
||||
$leftBottom = $imageBottom;
|
||||
if ($creditLines !== []) {
|
||||
$captionY = $imageBottom - 12;
|
||||
|
||||
foreach ($creditLines as $line) {
|
||||
foreach ($this->wrap($line, $imageColumnWidth, 7.5) as $wrapped) {
|
||||
if ($wrapped !== '') {
|
||||
$this->text(self::LEFT, $captionY, $wrapped, 7.5, 'F1', self::GRAY);
|
||||
}
|
||||
|
||||
$captionY -= 9.5;
|
||||
}
|
||||
|
||||
$captionY -= 2;
|
||||
}
|
||||
|
||||
$leftBottom = $captionY;
|
||||
}
|
||||
|
||||
// Angaben rechts daneben als kompakte Label/Wert-Tabelle.
|
||||
$rightY = $top - 1;
|
||||
|
||||
foreach ($rows as [$label, $value]) {
|
||||
$this->text($rightX, $rightY, mb_strtoupper($label), 7.5, 'F2', self::GRAY);
|
||||
$rightY -= 11;
|
||||
|
||||
foreach ($this->wrap($value, $rightWidth, 9) as $line) {
|
||||
if ($line !== '') {
|
||||
$this->text($rightX, $rightY, $line, 9, 'F1', self::INK);
|
||||
}
|
||||
|
||||
$rightY -= 12;
|
||||
}
|
||||
|
||||
$rightY -= 7;
|
||||
}
|
||||
|
||||
$this->y = min($leftBottom, $rightY) - 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechtliche Bildangaben aus dem Upload, wie sie unter dem Titelbild
|
||||
* dokumentiert werden: Bildnachweis/Copyright, Urheber, Lizenz(-detail),
|
||||
* Lizenz-/Quellen-URL sowie die KI-Kennzeichnung.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function imageCreditLines(PressReleaseImage $image): array
|
||||
{
|
||||
$lines = [];
|
||||
|
||||
if (filled($image->copyright)) {
|
||||
$lines[] = 'Bildnachweis: '.$image->copyright;
|
||||
}
|
||||
|
||||
if (filled($image->author)
|
||||
&& (blank($image->copyright) || ! str_contains((string) $image->copyright, (string) $image->author))) {
|
||||
$lines[] = 'Urheber: '.$image->author;
|
||||
}
|
||||
|
||||
if ($image->license_type instanceof ImageLicenseType) {
|
||||
$detail = filled($image->license_detail) ? ' ('.$image->license_detail.')' : '';
|
||||
$lines[] = 'Lizenz: '.$image->license_type->label().$detail;
|
||||
}
|
||||
|
||||
if (filled($image->license_url)) {
|
||||
$lines[] = 'Lizenz-URL: '.$image->license_url;
|
||||
}
|
||||
|
||||
if (filled($image->source_url)) {
|
||||
$lines[] = 'Quelle: '.$image->source_url;
|
||||
}
|
||||
|
||||
$aiLabel = $image->ai_declaration instanceof AiImageDeclaration
|
||||
? $image->ai_declaration->publicLabel()
|
||||
: null;
|
||||
|
||||
if ($aiLabel !== null || $image->is_ai_generated) {
|
||||
$lines[] = 'KI-Kennzeichnung: '.($aiLabel ?? 'KI-generiert');
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angaben zur Veröffentlichung als Label/Wert-Paare. Bei Portal::Both
|
||||
* dokumentiert der Nachweis beide öffentlichen URLs.
|
||||
*
|
||||
* @return list<array{0: string, 1: string}>
|
||||
*/
|
||||
private function publicationRows(PressRelease $pressRelease, ?Company $company): array
|
||||
{
|
||||
$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([
|
||||
return array_values(array_filter([
|
||||
['Pressemitteilung', 'PM #'.$pressRelease->id],
|
||||
['Titel', (string) $pressRelease->title],
|
||||
$company?->name ? ['Firma', (string) $company->name] : null,
|
||||
|
|
@ -208,12 +379,6 @@ class ProofPdfRenderer
|
|||
$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
|
||||
|
|
@ -229,17 +394,20 @@ class ProofPdfRenderer
|
|||
|
||||
$this->y -= 8;
|
||||
|
||||
foreach ($this->htmlToParagraphs($pressRelease->text) as $paragraph) {
|
||||
if ($paragraph['heading']) {
|
||||
foreach ($this->htmlToRichBlocks($pressRelease->text) as $block) {
|
||||
if ($block['type'] === 'heading') {
|
||||
$this->ensureSpace(48);
|
||||
$this->y -= 4;
|
||||
$this->writeParagraph($paragraph['text'], 11, 15, 'F2', self::NAVY);
|
||||
$this->writeParagraph($this->atomsToText($block['atoms']), 11, 15, 'F2', self::NAVY);
|
||||
$this->y -= 3;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->writeParagraph($paragraph['text'], 10, 14.5);
|
||||
$atoms = $block['type'] === 'listitem' ? $this->bulletAtoms($block['atoms']) : $block['atoms'];
|
||||
$indent = $block['type'] === 'listitem' ? 14.0 : 0.0;
|
||||
|
||||
$this->writeRichAtoms($atoms, 10, 14.5, self::INK, $indent);
|
||||
$this->y -= 6;
|
||||
}
|
||||
}
|
||||
|
|
@ -254,8 +422,18 @@ class ProofPdfRenderer
|
|||
|
||||
$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);
|
||||
foreach ($this->htmlToRichBlocks($boilerplate) as $block) {
|
||||
if ($block['type'] === 'heading') {
|
||||
$this->writeParagraph($this->atomsToText($block['atoms']), 10, 14, 'F2', self::NAVY);
|
||||
$this->y -= 4;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$atoms = $block['type'] === 'listitem' ? $this->bulletAtoms($block['atoms']) : $block['atoms'];
|
||||
$indent = $block['type'] === 'listitem' ? 14.0 : 0.0;
|
||||
|
||||
$this->writeRichAtoms($atoms, 9.5, 13.5, self::SLATE, $indent);
|
||||
$this->y -= 5;
|
||||
}
|
||||
}
|
||||
|
|
@ -450,45 +628,381 @@ class ProofPdfRenderer
|
|||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Wandelt den (sanitisierten) HTML-Inhalt der PM in strukturierte Blöcke
|
||||
* mit formatierten Textläufen (Atomen). Block-Elemente werden zu Absätzen,
|
||||
* Überschriften (h1–h6) und Listenpunkten; <strong>/<b> und <em>/<i> setzen
|
||||
* die Fett-/Kursiv-Auszeichnung, <br> einen Zeilenumbruch. Legacy-Plaintext
|
||||
* (ohne Markup) wird an Doppelumbrüchen in Absätze getrennt.
|
||||
*
|
||||
* @return list<array{heading: bool, text: string}>
|
||||
* @return list<array{type: string, atoms: list<array<string, mixed>>}>
|
||||
*/
|
||||
private function htmlToParagraphs(?string $html): array
|
||||
private function htmlToRichBlocks(?string $html): array
|
||||
{
|
||||
$headingMarker = "\x01";
|
||||
$html = trim((string) $html);
|
||||
|
||||
$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);
|
||||
if ($html === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$paragraphs = [];
|
||||
if (! str_contains($html, '<')) {
|
||||
$blocks = [];
|
||||
|
||||
foreach (preg_split('/\n{2,}/u', $text) ?: [] as $paragraph) {
|
||||
$paragraph = trim((string) preg_replace('/\s*\n\s*/u', "\n", $paragraph));
|
||||
foreach (preg_split('/\n{2,}/u', str_replace(["\r\n", "\r"], "\n", $html)) ?: [] as $paragraph) {
|
||||
$paragraph = trim($paragraph);
|
||||
|
||||
if ($paragraph === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$atoms = $this->atomsFromRuns([['text' => $paragraph, 'bold' => false, 'italic' => false, 'break' => false]]);
|
||||
|
||||
if ($atoms !== []) {
|
||||
$blocks[] = ['type' => 'paragraph', 'atoms' => $atoms];
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
$document = new \DOMDocument;
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document->loadHTML(
|
||||
'<?xml encoding="UTF-8"?><div>'.$html.'</div>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
$root = null;
|
||||
|
||||
foreach ($document->childNodes as $node) {
|
||||
if ($node instanceof \DOMElement) {
|
||||
$root = $node;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $root instanceof \DOMElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$blocks = [];
|
||||
$this->walkBlocks($root, $blocks);
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{type: string, atoms: list<array<string, mixed>>}> $blocks
|
||||
*/
|
||||
private function walkBlocks(\DOMNode $container, array &$blocks): void
|
||||
{
|
||||
foreach ($container->childNodes as $node) {
|
||||
if ($node instanceof \DOMText) {
|
||||
if (trim($node->textContent) !== '') {
|
||||
$this->appendBlock($blocks, 'paragraph', $this->gatherRuns($node, false, false));
|
||||
}
|
||||
|
||||
if ($paragraph === '' || $paragraph === $headingMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paragraphs[] = [
|
||||
'heading' => str_starts_with($paragraph, $headingMarker),
|
||||
'text' => ltrim($paragraph, $headingMarker),
|
||||
];
|
||||
if (! $node instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = strtolower($node->tagName);
|
||||
|
||||
if (in_array($tag, ['ul', 'ol'], true)) {
|
||||
foreach ($node->childNodes as $item) {
|
||||
if ($item instanceof \DOMElement && strtolower($item->tagName) === 'li') {
|
||||
$this->appendBlock($blocks, 'listitem', $this->gatherRuns($item, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^h[1-6]$/', $tag) === 1) {
|
||||
$this->appendBlock($blocks, 'heading', $this->gatherRuns($node, false, false));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($tag, ['p', 'blockquote', 'div', 'figure', 'figcaption', 'section', 'article'], true)) {
|
||||
if ($this->hasBlockChild($node)) {
|
||||
$this->walkBlocks($node, $blocks);
|
||||
} else {
|
||||
$this->appendBlock($blocks, 'paragraph', $this->gatherRuns($node, false, false));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline-Element direkt unter dem Container (strong/em/a/span …).
|
||||
$this->appendBlock($blocks, 'paragraph', $this->gatherRuns($node, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{type: string, atoms: list<array<string, mixed>>}> $blocks
|
||||
* @param list<array<string, mixed>> $runs
|
||||
*/
|
||||
private function appendBlock(array &$blocks, string $type, array $runs): void
|
||||
{
|
||||
$atoms = $this->atomsFromRuns($runs);
|
||||
|
||||
if ($atoms !== []) {
|
||||
$blocks[] = ['type' => $type, 'atoms' => $atoms];
|
||||
}
|
||||
}
|
||||
|
||||
private function hasBlockChild(\DOMNode $node): bool
|
||||
{
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child instanceof \DOMElement
|
||||
&& in_array(strtolower($child->tagName), ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'div', 'section', 'article', 'figure'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return $paragraphs;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt die Textläufe eines Knotens rekursiv und schleppt dabei den
|
||||
* Fett-/Kursiv-Zustand mit. <br> erzeugt einen expliziten Umbruch.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function gatherRuns(\DOMNode $node, bool $bold, bool $italic): array
|
||||
{
|
||||
$runs = [];
|
||||
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child instanceof \DOMText) {
|
||||
$runs[] = ['text' => $child->textContent, 'bold' => $bold, 'italic' => $italic, 'break' => false];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $child instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = strtolower($child->tagName);
|
||||
|
||||
if ($tag === 'br') {
|
||||
$runs[] = ['text' => '', 'bold' => $bold, 'italic' => $italic, 'break' => true];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$childBold = $bold || in_array($tag, ['strong', 'b'], true);
|
||||
$childItalic = $italic || in_array($tag, ['em', 'i'], true);
|
||||
|
||||
$runs = array_merge($runs, $this->gatherRuns($child, $childBold, $childItalic));
|
||||
}
|
||||
|
||||
return $runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert Textläufe zu Wort-Atomen: Whitespace wird zusammengefasst,
|
||||
* <br> zu Umbruch-Atomen. `glue` markiert Wörter, die ohne Leerzeichen an
|
||||
* das vorherige anschließen (Formatwechsel mitten im Wort).
|
||||
*
|
||||
* @param list<array<string, mixed>> $runs
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function atomsFromRuns(array $runs): array
|
||||
{
|
||||
$atoms = [];
|
||||
$glue = false;
|
||||
|
||||
foreach ($runs as $run) {
|
||||
if (! empty($run['break'])) {
|
||||
$atoms[] = ['break' => true];
|
||||
$glue = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = str_replace(["\r\n", "\r", "\n"], ' ', (string) $run['text']);
|
||||
$parts = preg_split('/(\s+)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s+$/u', $part) === 1) {
|
||||
$glue = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$atoms[] = [
|
||||
'text' => $part,
|
||||
'bold' => (bool) $run['bold'],
|
||||
'italic' => (bool) $run['italic'],
|
||||
'glue' => $glue,
|
||||
'break' => false,
|
||||
];
|
||||
$glue = true;
|
||||
}
|
||||
}
|
||||
|
||||
while ($atoms !== [] && ! empty($atoms[0]['break'])) {
|
||||
array_shift($atoms);
|
||||
}
|
||||
|
||||
while ($atoms !== [] && ! empty($atoms[array_key_last($atoms)]['break'])) {
|
||||
array_pop($atoms);
|
||||
}
|
||||
|
||||
return $atoms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flacht Atome zu Plaintext ab (für Überschriften, die einheitlich gesetzt
|
||||
* werden).
|
||||
*
|
||||
* @param list<array<string, mixed>> $atoms
|
||||
*/
|
||||
private function atomsToText(array $atoms): string
|
||||
{
|
||||
$text = '';
|
||||
|
||||
foreach ($atoms as $atom) {
|
||||
if (! empty($atom['break'])) {
|
||||
$text .= "\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$text .= ($text === '' || ! empty($atom['glue']) || str_ends_with($text, "\n") ? '' : ' ').$atom['text'];
|
||||
}
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt einem Listenpunkt ein Aufzählungszeichen voran.
|
||||
*
|
||||
* @param list<array<string, mixed>> $atoms
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function bulletAtoms(array $atoms): array
|
||||
{
|
||||
return array_merge(
|
||||
[['text' => '•', 'bold' => false, 'italic' => false, 'glue' => false, 'break' => false]],
|
||||
$atoms,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt das Titelbild der PM: bevorzugt das als Vorschau markierte,
|
||||
* sonst das erste. Null, wenn kein echtes Bild vorliegt.
|
||||
*/
|
||||
private function coverImage(PressRelease $pressRelease): ?PressReleaseImage
|
||||
{
|
||||
$image = $pressRelease->relationLoaded('images')
|
||||
? ($pressRelease->images->firstWhere('is_preview', true) ?? $pressRelease->images->first())
|
||||
: $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
return $image instanceof PressReleaseImage ? $image : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Bilddatei, skaliert sie auf eine druckfreundliche Kantenlänge und
|
||||
* liefert sie als baseline-JPEG (DeviceRGB, für DCTDecode) samt Pixelmaßen.
|
||||
* Rückgabe null, wenn GD oder die Datei fehlen.
|
||||
*
|
||||
* @return array{0: string, 1: int, 2: int}|null [jpegBytes, widthPx, heightPx]
|
||||
*/
|
||||
private function loadCoverImage(PressReleaseImage $image): ?array
|
||||
{
|
||||
if (! function_exists('imagecreatefromstring')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = $this->readImageBytes($image);
|
||||
|
||||
if ($bytes === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($bytes);
|
||||
|
||||
if (! $source instanceof GdImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
|
||||
if ($width < 1 || $height < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Obergrenze der Kantenlänge, damit das PDF trotz Foto schlank bleibt.
|
||||
$maxPx = 1400;
|
||||
$scale = min(1.0, $maxPx / $width, $maxPx / $height);
|
||||
$targetWidth = max(1, (int) round($width * $scale));
|
||||
$targetHeight = max(1, (int) round($height * $scale));
|
||||
|
||||
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
// Transparenz (PNG/WebP) auf Weiß ausflachen — DCTDecode kennt keinen Alphakanal.
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $white);
|
||||
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
||||
|
||||
ob_start();
|
||||
$ok = imagejpeg($canvas, null, 82);
|
||||
$jpeg = (string) ob_get_clean();
|
||||
imagedestroy($canvas);
|
||||
|
||||
if (! $ok || $jpeg === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$jpeg, $targetWidth, $targetHeight];
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Bilddatei über die passende Disk ein und probiert dabei eine
|
||||
* druckfreundliche Varianten-Kette (large → cover → medium → Original).
|
||||
*/
|
||||
private function readImageBytes(PressReleaseImage $image): ?string
|
||||
{
|
||||
$disk = $image->disk ?: 'local';
|
||||
|
||||
foreach (['large', 'cover', 'medium', null] as $variant) {
|
||||
$path = $image->pathForVariant($variant);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
$bytes = Storage::disk($disk)->get($path);
|
||||
|
||||
if (is_string($bytes) && $bytes !== '') {
|
||||
return $bytes;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Nächste Variante versuchen.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Layout-Primitive
|
||||
|
|
@ -546,6 +1060,142 @@ class ProofPdfRenderer
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt formatierten Fließtext (fett/kursiv) aus Wort-Atomen. Der Umbruch
|
||||
* erfolgt wortweise; gleich formatierte Wörter einer Zeile werden zu einem
|
||||
* Textbefehl zusammengefasst (kompaktes PDF, korrekte Wortabstände).
|
||||
*
|
||||
* @param list<array<string, mixed>> $atoms
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function writeRichAtoms(array $atoms, float $size, float $lineHeight, array $color, float $indent = 0.0): void
|
||||
{
|
||||
$left = self::LEFT + $indent;
|
||||
$maxWidth = self::RIGHT - $left;
|
||||
|
||||
foreach ($this->layoutLines($atoms, $maxWidth, $size) as $line) {
|
||||
$this->ensureSpace($lineHeight);
|
||||
|
||||
$x = $left;
|
||||
$count = count($line);
|
||||
$index = 0;
|
||||
|
||||
while ($index < $count) {
|
||||
$segment = $line[$index];
|
||||
$x += $segment['space'];
|
||||
|
||||
$font = $this->richFont($segment['bold'], $segment['italic']);
|
||||
$chunk = $segment['text'];
|
||||
$chunkWidth = $this->estimateWidth($segment['text'], $size);
|
||||
|
||||
$next = $index + 1;
|
||||
while ($next < $count && $this->richFont($line[$next]['bold'], $line[$next]['italic']) === $font) {
|
||||
$following = $line[$next];
|
||||
$chunk .= ($following['space'] > 0 ? ' ' : '').$following['text'];
|
||||
$chunkWidth += $following['space'] + $this->estimateWidth($following['text'], $size);
|
||||
$next++;
|
||||
}
|
||||
|
||||
if ($chunk !== '') {
|
||||
$this->text($x, $this->y, $chunk, $size, $font, $color);
|
||||
}
|
||||
|
||||
$x += $chunkWidth;
|
||||
$index = $next;
|
||||
}
|
||||
|
||||
$this->y -= $lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bricht Wort-Atome zeilenweise um. Jede Zeile ist eine Liste von Segmenten
|
||||
* mit vorangestellter Leerraum-Breite (0 bei Glue/Zeilenanfang).
|
||||
*
|
||||
* @param list<array<string, mixed>> $atoms
|
||||
* @return list<list<array<string, mixed>>>
|
||||
*/
|
||||
private function layoutLines(array $atoms, float $maxWidth, float $size): array
|
||||
{
|
||||
$spaceWidth = $this->estimateWidth(' ', $size);
|
||||
$lines = [];
|
||||
$current = [];
|
||||
$width = 0.0;
|
||||
|
||||
foreach ($atoms as $atom) {
|
||||
if (! empty($atom['break'])) {
|
||||
$lines[] = $current;
|
||||
$current = [];
|
||||
$width = 0.0;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$word = (string) $atom['text'];
|
||||
$wordWidth = $this->estimateWidth($word, $size);
|
||||
|
||||
// Überlanges Einzelwort (z. B. URL) hart umbrechen.
|
||||
if ($wordWidth > $maxWidth) {
|
||||
if ($current !== []) {
|
||||
$lines[] = $current;
|
||||
$current = [];
|
||||
$width = 0.0;
|
||||
}
|
||||
|
||||
foreach ($this->splitLongWord($word, $maxWidth, $size) as $chunkIndex => $chunk) {
|
||||
if ($chunkIndex > 0) {
|
||||
$lines[] = $current;
|
||||
$current = [];
|
||||
$width = 0.0;
|
||||
}
|
||||
|
||||
$current[] = ['text' => $chunk, 'bold' => (bool) $atom['bold'], 'italic' => (bool) $atom['italic'], 'space' => 0.0];
|
||||
$width += $this->estimateWidth($chunk, $size);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$space = ($current === [] || ! empty($atom['glue'])) ? 0.0 : $spaceWidth;
|
||||
|
||||
if ($current !== [] && empty($atom['glue']) && $width + $space + $wordWidth > $maxWidth) {
|
||||
$lines[] = $current;
|
||||
$current = [];
|
||||
$width = 0.0;
|
||||
$space = 0.0;
|
||||
}
|
||||
|
||||
$current[] = ['text' => $word, 'bold' => (bool) $atom['bold'], 'italic' => (bool) $atom['italic'], 'space' => $space];
|
||||
$width += $space + $wordWidth;
|
||||
}
|
||||
|
||||
if ($current !== []) {
|
||||
$lines[] = $current;
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function splitLongWord(string $word, float $maxWidth, float $size): array
|
||||
{
|
||||
$maxCharacters = max(4, (int) floor($maxWidth / ($size * 0.5)));
|
||||
|
||||
return mb_str_split($word, $maxCharacters) ?: [$word];
|
||||
}
|
||||
|
||||
private function richFont(bool $bold, bool $italic): string
|
||||
{
|
||||
return match (true) {
|
||||
$bold && $italic => 'F4',
|
||||
$bold => 'F2',
|
||||
$italic => 'F3',
|
||||
default => 'F1',
|
||||
};
|
||||
}
|
||||
|
||||
private function ensureSpace(float $needed): void
|
||||
{
|
||||
if ($this->y - $needed < self::BOTTOM) {
|
||||
|
|
@ -557,7 +1207,7 @@ class ProofPdfRenderer
|
|||
{
|
||||
$this->pages[] = $this->stream;
|
||||
$this->stream = '';
|
||||
$this->y = self::CONTENT_TOP;
|
||||
$this->y = self::CONTINUATION_TOP;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -584,6 +1234,58 @@ class ProofPdfRenderer
|
|||
$this->stream .= $this->lineOp($x1, $y1, $x2, $y2, $width, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert ein JPEG als Bild-XObject und liefert dessen Ressourcen-Alias
|
||||
* (Im1, Im2 …), der beim PDF-Aufbau in die Seiten-Resources geschrieben wird.
|
||||
*/
|
||||
private function registerImage(string $jpeg, int $width, int $height): string
|
||||
{
|
||||
$alias = 'Im'.(count($this->images) + 1);
|
||||
|
||||
$this->images[] = [
|
||||
'alias' => $alias,
|
||||
'jpeg' => $jpeg,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
|
||||
return $alias;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet ein registriertes Bild-XObject an Position (x, y) in der
|
||||
* gewünschten Zielgröße (die Bildmatrix skaliert das 1×1-Einheitsquadrat).
|
||||
*/
|
||||
private function drawImage(string $alias, float $x, float $y, float $width, float $height): void
|
||||
{
|
||||
$this->stream .= sprintf(
|
||||
"q %.2F 0 0 %.2F %.2F %.2F cm /%s Do Q\n",
|
||||
$width,
|
||||
$height,
|
||||
$x,
|
||||
$y,
|
||||
$alias,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function strokeRectOp(float $x, float $y, float $width, float $height, float $lineWidth, array $color): string
|
||||
{
|
||||
return sprintf(
|
||||
"%.3F %.3F %.3F RG %.2F w %.2F %.2F %.2F %.2F re S\n",
|
||||
$color[0],
|
||||
$color[1],
|
||||
$color[2],
|
||||
$lineWidth,
|
||||
$x,
|
||||
$y,
|
||||
$width,
|
||||
$height,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
|
|
@ -708,13 +1410,14 @@ class ProofPdfRenderer
|
|||
'F1' => 'Helvetica',
|
||||
'F2' => 'Helvetica-Bold',
|
||||
'F3' => 'Helvetica-Oblique',
|
||||
'F4' => 'Helvetica-BoldOblique',
|
||||
];
|
||||
|
||||
$objects = [
|
||||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||
];
|
||||
|
||||
$firstPageObjectNumber = 3 + count($fonts);
|
||||
$firstPageObjectNumber = 3 + count($fonts) + count($this->images);
|
||||
$kids = [];
|
||||
foreach (array_keys($pages) as $index) {
|
||||
$kids[] = ($firstPageObjectNumber + $index * 2).' 0 R';
|
||||
|
|
@ -730,9 +1433,21 @@ class ProofPdfRenderer
|
|||
$number++;
|
||||
}
|
||||
|
||||
$xobjectRefs = [];
|
||||
foreach ($this->images as $image) {
|
||||
$objects[] = "{$number} 0 obj\n<< /Type /XObject /Subtype /Image /Width {$image['width']} /Height {$image['height']} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ".strlen($image['jpeg'])." >>\nstream\n".$image['jpeg']."\nendstream\nendobj\n";
|
||||
$xobjectRefs[] = "/{$image['alias']} {$number} 0 R";
|
||||
$number++;
|
||||
}
|
||||
|
||||
$resources = '/Font << '.implode(' ', $fontRefs).' >>';
|
||||
if ($xobjectRefs !== []) {
|
||||
$resources .= ' /XObject << '.implode(' ', $xobjectRefs).' >>';
|
||||
}
|
||||
|
||||
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[] = "{$number} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << {$resources} >> /Contents {$contentNumber} 0 R >>\nendobj\n";
|
||||
$objects[] = "{$contentNumber} 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n";
|
||||
$number += 2;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
|
|
@ -16,6 +17,7 @@ use App\Services\Billing\ExtraPmPurchaseService;
|
|||
use App\Services\Billing\ProofPdfService;
|
||||
use App\Services\PressRelease\ProofPdfRenderer;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
|
@ -145,6 +147,94 @@ test('a long release flows over multiple numbered pages', function () {
|
|||
expect($pdf)->toContain('Fortsetzung');
|
||||
});
|
||||
|
||||
test('continuation pages start their content below the page top', function () {
|
||||
$pr = PressRelease::factory()->published()->create([
|
||||
'title' => 'Lange Meldung',
|
||||
'text' => implode('', array_map(
|
||||
static fn (int $i): string => '<p>Absatz '.$i.': '.str_repeat('Dies ist ein ausfuehrlicher Beispielsatz. ', 12).'</p>',
|
||||
range(1, 12),
|
||||
)),
|
||||
]);
|
||||
|
||||
$pdf = app(ProofPdfRenderer::class)->render($pr);
|
||||
|
||||
// Content-Streams in Dokumentreihenfolge extrahieren (Seite 1, 2, …).
|
||||
preg_match_all('/stream\n(.*?)endstream/s', $pdf, $matches);
|
||||
expect(count($matches[1]))->toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Jede Fortsetzungsseite (ab Seite 2) darf ihren Inhalt nicht direkt am
|
||||
// Seitenrand beginnen — die erste Textposition liegt spürbar tiefer.
|
||||
foreach (array_slice($matches[1], 1) as $continuationStream) {
|
||||
preg_match('/(-?\d+\.\d+) (-?\d+\.\d+) Td/', $continuationStream, $firstTd);
|
||||
|
||||
expect((float) $firstTd[2])->toBeLessThanOrEqual(760.0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a release with a title image embeds it as an image xobject', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$canvas = imagecreatetruecolor(600, 400);
|
||||
imagefilledrectangle($canvas, 0, 0, 600, 400, imagecolorallocate($canvas, 30, 60, 120));
|
||||
ob_start();
|
||||
imagejpeg($canvas);
|
||||
$jpeg = (string) ob_get_clean();
|
||||
imagedestroy($canvas);
|
||||
|
||||
$pr = PressRelease::factory()->published()->create(['title' => 'Meldung mit Bild']);
|
||||
|
||||
$path = 'press-releases/'.$pr->id.'/images/foto.jpg';
|
||||
Storage::disk('local')->put($path, $jpeg);
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'copyright' => 'Foto: Jane Doe',
|
||||
'author' => 'Jane Doe',
|
||||
'license_type' => ImageLicenseType::Own->value,
|
||||
'source_url' => 'https://example.com/bildquelle',
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$pdf = app(ProofPdfRenderer::class)->render($pr);
|
||||
|
||||
expect($pdf)
|
||||
->toContain('/Subtype /Image')
|
||||
->toContain('/Filter /DCTDecode')
|
||||
->toContain('/XObject <<')
|
||||
->toContain(' Do Q')
|
||||
// Rechtliche Bildangaben aus dem Upload unter dem Bild.
|
||||
->toContain('Bildnachweis: Foto: Jane Doe')
|
||||
->toContain('Lizenz: Eigene Aufnahme')
|
||||
->toContain('Quelle: https://example.com/bildquelle');
|
||||
});
|
||||
|
||||
test('a release without a title image embeds no image xobject', function () {
|
||||
$pr = PressRelease::factory()->published()->create(['title' => 'Meldung ohne Bild']);
|
||||
|
||||
$pdf = app(ProofPdfRenderer::class)->render($pr);
|
||||
|
||||
expect($pdf)->not->toContain('/Subtype /Image');
|
||||
});
|
||||
|
||||
test('rich text keeps bold and italic formatting', function () {
|
||||
$pr = PressRelease::factory()->published()->create([
|
||||
'title' => 'Formatierte Meldung',
|
||||
'text' => '<p>Ein <strong>fetterText</strong> und <em>kursiverText</em> sowie <strong><em>beidesText</em></strong> hier.</p>',
|
||||
]);
|
||||
|
||||
$pdf = app(ProofPdfRenderer::class)->render($pr);
|
||||
|
||||
// Fett → Helvetica-Bold (F2), kursiv → Helvetica-Oblique (F3),
|
||||
// fett+kursiv → Helvetica-BoldOblique (F4).
|
||||
expect($pdf)
|
||||
->toMatch('#/F2 [0-9.]+ Tf[^)]*\(fetterText\)#')
|
||||
->toMatch('#/F3 [0-9.]+ Tf[^)]*\(kursiverText\)#')
|
||||
->toMatch('#/F4 [0-9.]+ Tf[^)]*\(beidesText\)#')
|
||||
->toContain('/BaseFont /Helvetica-BoldOblique');
|
||||
});
|
||||
|
||||
test('the rendered proof documents the real public url of the release', function () {
|
||||
$pr = PressRelease::factory()->published()->create([
|
||||
'portal' => Portal::Presseecho,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue