"" [slug] * Team-Key aus data/matchcenter.json (z. B. erste-mannschaft, damen) — * bestimmt, wessen Tabelle auf der Live-Seite „Tabelle" erscheint. * * Erwarteter Ordnerinhalt: * /vorwort.* optional, Fallback-Bild — nur genutzt, solange für diese * Ausgabe noch kein Vorwort-Text hinterlegt ist (siehe unten) * /rueckseite.* optional * /spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz) * fürs Cover — wird unverändert kopiert, nie über GD neu kodiert * /verfasser.* optional, Porträtfoto für die Vorwort-Signatur * * KEINE Anzeigen im Ordner (seit 31.07.2026): die kommen aus dem dauerhaften * Bestand data/anzeigen.json, gepflegt mit bin/anzeige-add.php. Sponsorenmotive * ändern sich selten, das Heft erscheint oft — vorher lag jede Anzeige als Kopie * unter jeder Ausgabe und ihr Alt-Text musste jedes Mal neu geschrieben werden. * * Feste Vorlage (Interview-Beschluss 29.07.2026, Anzeigen 31.07.2026 überarbeitet): * Der Inhalt ist eine Liste von BLÖCKEN, die Anzeigen werden gleichmäßig in die * Lücken dazwischen verteilt — nie mehr als zwei hintereinander (vorher vier * Blöcke à fünf, das las sich wie „Heft zu Ende"). Anzeigenfrei bleiben: nach dem * Cover, im Aufschlag Vorwort+Interview, zwischen den Tabellenseiten und zwischen * Impressum und Rückseite. Die Doppelseiten-Regel (erste Tabelle auf gerader * Seite) rechnet das Skript selbst und verschiebt dafür eine Anzeige. * Das Cover wird automatisch generiert (app/components/stadionzeitung-cover.php). * * Neue Ausgaben entstehen als ENTWURF (status: entwurf): unsichtbar in * Archiv/Sitemap, Live-Seiten rechnen bei jedem Aufruf frisch. Erst * bin/stadionzeitung-publish.php friert Tabellen/News/Ergebnisse/Vorschau als * snapshot ein und schaltet die Ausgabe sichtbar. Fürs gefaltete A5-Heft * (Druckfassung ?druck=1, Broschüren-Reihenfolge) sollte die Seitenzahl ein * Vielfaches von 4 sein — das Skript meldet die Lücke, aufgefüllt wird beim * Erzeugen gemeinsam (weiße Füllseiten in der Druckfassung sind der Notnagel). * * Vorwort-Text ist KEIN Skript-Input: er wird (wie data/events.json/news.json) im * Chat gepflegt — Felix schickt den Text, Claude trägt ihn manuell ins `vorwort`-Feld * der Ausgabe in data/stadionzeitung.json ein (heading/paragraphs/closing/signee_name/ * signee_role). Damit ein erneuter Skript-Lauf (z. B. um eine Anzeige zu korrigieren) * dieses Feld nicht wegwirft, liest das Skript den bisherigen Wert vor dem Ersetzen * aus und schreibt ihn unverändert zurück. Ist ein `vorwort`-Feld vorhanden, rendert * die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des * Fallback-Bilds `vorwort.*`. * * Interview (`interview`-Feld) und die generische Aufruf-Seite (`aufruf`-Feld, * z. B. Sportfest-Helferaufruf) sind wie das Vorwort chat-gepflegt: das Skript * trägt sie über erneute Läufe weiter und plant die Live-Seite nur ein, wenn * das Feld existiert. Reihenfolge im anzeigenfreien Aufschlag: Vorwort → * Interview → Aufruf. Für die Aufruf-Seite erzeugt das Skript zusätzlich den * QR (qr-aufruf.png im Ausgaben-Ordner) aus `aufruf.link`. * * Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte * Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen. */ if (PHP_SAPI !== 'cli') { exit(1); } require dirname(__DIR__) . '/app/bootstrap.php'; $args = array_values(array_filter($argv, static fn (string $a): bool => $a !== '-v')); $verbose = in_array('-v', $argv, true); array_shift($args); // Skriptname if (count($args) < 4) { fwrite(STDERR, "Nutzung: php bin/stadionzeitung-add.php \"\" [slug]\n"); exit(1); } [$sourceDir, $opponent, $date, $teamKey] = $args; $slugArg = $args[4] ?? ''; $log = static function (string $msg, string $level = 'INFO') use ($verbose): void { log_write('stadionzeitung', $level, $msg); if ($verbose || $level === 'ERROR' || $level === 'WARN') { echo '[' . date('c') . "] {$level} {$msg}\n"; } }; $fail = static function (string $msg) use ($log): never { $log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR'); exit(1); }; // --- Eingaben validieren --- if (!is_dir($sourceDir)) { $fail("Ordner nicht gefunden: {$sourceDir}"); } $parsedDate = DateTimeImmutable::createFromFormat('Y-m-d', $date); if ($parsedDate === false || $parsedDate->format('Y-m-d') !== $date) { $fail("Ungültiges Datum (erwartet YYYY-MM-DD): {$date}"); } $opponent = trim($opponent); if ($opponent === '') { $fail('Gegner darf nicht leer sein.'); } $mc = json_load('matchcenter'); $knownTeamKeys = array_column($mc['teams'] ?? [], 'key'); if (!in_array($teamKey, $knownTeamKeys, true)) { $fail("Unbekannter team-key '{$teamKey}'. Bekannt: " . implode(', ', $knownTeamKeys)); } /** * Kleine, lokale Slugify-Funktion (nur dieser Aufrufer) — Umlaute * transliterieren, Kleinschreibung, alles außer a-z0-9 zu Bindestrichen. */ $slugify = static function (string $text): string { $text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']); $text = strtolower($text); $text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? ''; return trim($text, '-'); }; $slug = $slugArg !== '' ? $slugify($slugArg) : $date . '-' . $slugify($opponent); if ($slug === '') { $fail('Konnte keinen gültigen Slug bilden.'); } $exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP'; $findOne = static function (string $baseName) use ($sourceDir, $exts): ?string { $matches = glob(rtrim($sourceDir, '/') . "/{$baseName}.{{$exts}}", GLOB_BRACE) ?: []; return $matches[0] ?? null; }; $findAll = static function (string $subdir) use ($sourceDir, $exts): array { $dir = rtrim($sourceDir, '/') . "/{$subdir}"; if (!is_dir($dir)) { return []; } $files = glob("{$dir}/*.{{$exts}}", GLOB_BRACE) ?: []; natsort($files); return array_values($files); }; $vorwort = $findOne('vorwort'); $rueckseite = $findOne('rueckseite'); $spieler = $findOne('spieler'); $verfasser = $findOne('verfasser'); // Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften // Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch // vorhandener anzeigen/-Unterordner wäre also wirkungslos — dann besser sagen, // wo sie jetzt hingehören, als ihn stillschweigend zu ignorieren. if ($findAll('anzeigen') !== []) { $log('Der Ordner enthält noch anzeigen/ — die werden NICHT mehr von hier gelesen. ' . 'Anzeigen liegen im Bestand: php bin/anzeige-add.php ', 'WARN'); } if ($rueckseite === null) { $log('Keine rueckseite.* im Ordner — das Heft endet mit dem Impressum.', 'WARN'); } // --- Lock gegen parallele Läufe (billige Absicherung, kein echtes Cron-Risiko hier) --- if (!is_dir(STORAGE_PATH . '/cache')) { mkdir(STORAGE_PATH . '/cache', 0755, true); } $lock = fopen(STORAGE_PATH . '/cache/stadionzeitung.lock', 'c'); if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { $log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN'); exit(1); } // --- Bisherigen Eintrag lesen (falls vorhanden) — trägt u. a. das chat-gepflegte // vorwort-Feld über einen erneuten Lauf hinweg weiter, siehe Kommentar oben. --- $cacheFile = DATA_PATH . '/stadionzeitung.json'; $existing = is_file($cacheFile) ? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []]) : ['issues' => []]; $oldIssue = null; foreach ($existing['issues'] ?? [] as $entry) { if (($entry['slug'] ?? '') === $slug) { $oldIssue = $entry; break; } } $oldVorwort = $oldIssue['vorwort'] ?? null; // Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso. $oldInterview = $oldIssue['interview'] ?? null; // Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt. $oldAufruf = $oldIssue['aufruf'] ?? null; // --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen --- $destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}"; if (!is_dir($destDir)) { mkdir($destDir, 0755, true); } else { // Der Ordner wird KOMPLETT geleert, nicht nach Mustern aufgeräumt: alles // darin entsteht in diesem Lauf neu (Vorwort- und Rückseiten-Bild, Spieler, // Verfasser, QR). Ein Muster-Aufräumen hinterlässt sonst Waisen, sobald sich // Dateinamen ändern — genau das ist passiert: neben den `page-NN`-Resten des // alten Positions-Schemas lagen auch die `anzeige-*`-Kopien, die jetzt im // Bestand (public/assets/img/anzeigen/) leben. Die geteilten CTA-QR-Codes // liegen eine Ebene höher und sind davon nicht betroffen. foreach (glob("{$destDir}/*") ?: [] as $old) { if (is_file($old)) { unlink($old); } } } $widths = [480, 960, 1440]; // --- Anzeigen kommen aus dem DAUERHAFTEN Bestand (data/anzeigen.json), nicht // mehr aus dem Ablieferordner: Sponsorenmotive ändern sich selten, das Heft // erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und // im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die // Bilder liegen schon. --- $anzeigenSeiten = array_values(array_map( static function (array $a): array { $sponsor = trim((string) ($a['sponsor'] ?? '')); return [ 'type' => 'image', 'base' => (string) $a['base'], 'widths' => $a['widths'] ?? [], // Alt-Text aus dem Sponsorennamen abgeleitet, damit er nur an EINER // Stelle gepflegt wird. `alt` im Bestand überschreibt ihn (z. B. // „Eigenanzeige: …"). 'alt' => (string) ($a['alt'] ?? ('Anzeige: ' . $sponsor)), ]; }, array_filter( json_load('anzeigen')['anzeigen'] ?? [], // `bis` gegen das DATUM DER AUSGABE prüfen, nicht gegen heute: ein Heft // zum Spiel am 1. August darf für ein Fest am 6. September werben, ein // später angelegtes Heft nicht mehr. Damit retirieren zeitgebundene // Eigenanzeigen (Sportfest-Plakat, Programm) von selbst, statt bis zum // nächsten Handgriff im Heft zu bleiben. static fn (array $a): bool => ($a['aktiv'] ?? true) && !empty($a['base']) && (empty($a['bis']) || (string) $a['bis'] >= $date) ) )); // --- Seiten-Bausteine: Live-Seiten brauchen kein Bild, die zwei verbliebenen // Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE // Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich // bei jeder Umverteilung. --- $live = static function (string $page, ?string $team = null): array { $entry = ['type' => 'live', 'page' => $page]; if ($team !== null) { $entry['team'] = $team; } return $entry; }; $bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array { try { $variants = resize_image_variants($src, "{$destDir}/{$name}", $widths, 82); } catch (RuntimeException $e) { throw new RuntimeException("Seite '{$name}' ({$src}) fehlgeschlagen: " . $e->getMessage()); } $generated = array_values(array_unique(array_column($variants, 'width'))); sort($generated); return [ 'type' => 'image', 'base' => "img/stadionzeitung/{$slug}/{$name}", 'widths' => $generated, 'alt' => $alt, ]; }; /* * Inhalt als Liste von BLÖCKEN, Anzeigen kommen anschließend in die Lücken * dazwischen. Ein Block ist eine Folge von Seiten, die zusammenbleiben MUSS; * `anzeigen_danach` sagt, ob nach dem Block eine Lücke für Anzeigen entsteht. * * Drei Stellen sind bewusst anzeigenfrei: * - nach dem Cover: der Leser schlägt auf Inhalt auf, nicht auf Werbung, * - innerhalb des Aufschlags Vorwort+Interview (deshalb EIN Block), * - innerhalb der Tabellen (deshalb EIN Block): die Tabellenseiten müssen im * gefalteten Heft nebeneinander liegen, eine Anzeige dazwischen zerstört * genau die Doppelseite, die die Paritätsrechnung unten herstellt, * - zwischen Impressum und Rückseite: die Rückseite ist die Heft-Außenseite. */ $bloecke = []; try { $bloecke[] = ['name' => 'cover', 'seiten' => [$live('cover')], 'anzeigen_danach' => false]; $aufschlag = []; if ($oldVorwort !== null) { $aufschlag[] = $live('vorwort'); } elseif ($vorwort !== null) { $aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung'); } // Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes // Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu. if ($oldInterview !== null) { $aufschlag[] = $live('interview'); } // Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien // Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller // Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld). if ($oldAufruf !== null) { $aufschlag[] = $live('aufruf'); } if ($aufschlag !== []) { $bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true]; } $bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true]; // Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json // (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die // SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch. $tabellen = []; foreach ($mc['teams'] ?? [] as $team) { if (!empty($team['key'])) { $tabellen[] = $live('tabelle', (string) $team['key']); } } if ($tabellen !== []) { $bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true]; } foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) { $bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true]; } $bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false]; if ($rueckseite !== null) { $bloecke[] = [ 'name' => 'rueckseite', 'seiten' => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen {$opponent}")], 'anzeigen_danach' => false, ]; } } catch (RuntimeException $e) { $fail($e->getMessage()); } // --- Anzeigen gleichmäßig auf die Lücken verteilen --- $luecken = []; foreach ($bloecke as $i => $b) { if ($b['anzeigen_danach']) { $luecken[] = $i; } } $luckCount = count($luecken); $adCount = count($anzeigenSeiten); if ($luckCount === 0) { $fail('Keine Lücke für Anzeigen — die Vorlage hat keine Inhaltsblöcke.'); } // Grundmenge plus Restverteilung. Der Rest wird GESTREUT (nicht vorne gebündelt), // sonst stünden alle Doppel-Lücken am Heftanfang. $basis = intdiv($adCount, $luckCount); $rest = $adCount % $luckCount; $verteilung = []; for ($g = 0; $g < $luckCount; $g++) { $verteilung[$g] = $basis + (intdiv(($g + 1) * $rest, $luckCount) - intdiv($g * $rest, $luckCount)); } if ($adCount > 2 * $luckCount) { $log(sprintf( '%d Anzeigen auf %d Lücken — mehr als zwei hintereinander ist damit unvermeidbar (höchstens %d). Weniger Anzeigen oder eine Inhaltsseite mehr.', $adCount, $luckCount, max($verteilung) ), 'WARN'); } /** 1-basierte Seitenzahl, auf der ein Block beginnt (null, wenn er fehlt). */ $blockSeite = static function (string $name) use ($bloecke, $verteilung): ?int { $seite = 0; $g = 0; foreach ($bloecke as $b) { if ($b['name'] === $name) { return $seite + 1; } $seite += count($b['seiten']); if ($b['anzeigen_danach']) { $seite += $verteilung[$g]; $g++; } } return null; }; /* * Doppelseiten-Regel: aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander, * die GERADE Seite links. Die erste Tabellenseite muss deshalb auf einer geraden * Seitenzahl liegen, damit die Tabellen im gefalteten Heft eine Doppelseite bilden. * Nur Anzeigen in Lücken VOR dem Tabellenblock verschieben dessen Parität — eine * Anzeige zwischen einer solchen Lücke und einer dahinter zu tauschen kippt sie * also, ohne die Gesamtseitenzahl zu ändern. Früher war das Handarbeit * („notfalls eine Anzeige verschieben"), jetzt rechnet es das Skript. */ $tabellenIndex = null; foreach ($bloecke as $i => $b) { if ($b['name'] === 'tabellen') { $tabellenIndex = $i; break; } } if ($tabellenIndex !== null && ($seite = $blockSeite('tabellen')) !== null && $seite % 2 === 1) { $vor = []; $nach = []; foreach ($luecken as $g => $blockIdx) { if ($blockIdx < $tabellenIndex) { $vor[] = $g; } else { $nach[] = $g; } } $getauscht = false; // Erst versuchen: eine Anzeige nach vorne holen (Lücke vorne darf auf 2). foreach ($vor as $g) { if ($verteilung[$g] >= 2) { continue; } foreach (array_reverse($nach) as $h) { if ($verteilung[$h] > 0) { $verteilung[$g]++; $verteilung[$h]--; $getauscht = true; $log("Doppelseiten-Regel: eine Anzeige aus Lücke {$h} nach Lücke {$g} verschoben, damit die Tabellen nebeneinander liegen."); break 2; } } } // Sonst umgekehrt: eine Anzeige nach hinten abgeben. if (!$getauscht) { foreach ($vor as $g) { if ($verteilung[$g] < 1) { continue; } foreach ($nach as $h) { if ($verteilung[$h] < 2) { $verteilung[$g]--; $verteilung[$h]++; $getauscht = true; $log("Doppelseiten-Regel: eine Anzeige aus Lücke {$g} nach Lücke {$h} verschoben, damit die Tabellen nebeneinander liegen."); break 2; } } } } if (!$getauscht) { $log('Doppelseiten-Regel NICHT herstellbar (keine Anzeige verschiebbar) — die Tabellen liegen im Heft nicht nebeneinander.', 'WARN'); } } // --- Zusammensetzen: Block, dann die Anzeigen seiner Lücke --- $images = []; $g = 0; $zeiger = 0; foreach ($bloecke as $b) { foreach ($b['seiten'] as $seiteEintrag) { $images[] = $seiteEintrag; } if ($b['anzeigen_danach']) { for ($k = 0; $k < $verteilung[$g]; $k++) { $images[] = $anzeigenSeiten[$zeiger++]; } $g++; } } $total = count($images); // Layout einmal kompakt protokollieren (statt einer Zeile pro Seite) — daran // sieht man auf einen Blick, ob die Verteilung stimmt. $layout = implode(' ', array_map( static fn (array $e): string => ($e['type'] === 'live' ? strtoupper(substr($e['page'], 0, 3)) : '·'), $images )); $log("Layout ({$total} Seiten, · = Anzeige): {$layout}"); $log(sprintf( '%d Anzeigen auf %d Lücken verteilt (%s), längster Block: %d.', $adCount, $luckCount, implode('/', $verteilung), max($verteilung ?: [0]) )); // Fürs gefaltete Heft (Sattelheftung) muss die Seitenzahl durch 4 teilbar // sein — die Druckfassung füllt fehlende Seiten weiß auf, aber sauberer ist // eine gemeinsame Lösung beim Erzeugen (weitere Anzeige, Füllseite mit // Inhalt). Deshalb hier nur eine deutliche Meldung, kein Abbruch. if ($total % 4 !== 0) { $log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN'); } // --- Spielerfoto fürs Cover: zuschneiden UND verkleinern. --- // Hier stand früher „unverändert kopieren, Felix liefert bereits ein web-taugliches // Foto" — die Annahme stimmte nicht. Das Hutschdorf-Foto hatte 2584x5093 Pixel und // 12 MB; iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt // (hier gut 53 MB), und auf einer Ausgabe liegen rund 86 Bilder. Ergebnis: das Cover // blieb am iPhone leer, während Chrome am Desktop es zeigte (31.07.2026). // Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa // 238x470 CSS-Pixel — die 1000er Variante deckt Druck und dreifache Pixeldichte ab. // resize_image_variants() erhält bei $alpha = true die Freistellung (PNG statt JPG). // Optional, kein Fehlschlag bei Fehlen. $playerImage = null; if ($spieler !== null) { $ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION)); $istPng = $ext === 'png'; // PNG: erst auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel // leerem Rand aus dem Export-Tool), dann verkleinern. Der Zuschnitt geht in eine // Zwischendatei, damit das riesige Original nicht im Ausgaben-Ordner landet. $quelle = $spieler; $tmpCrop = "{$destDir}/spieler-zuschnitt.png"; if ($istPng && crop_transparent_png_to_content($spieler, $tmpCrop)) { $quelle = $tmpCrop; } try { $varianten = resize_image_variants($quelle, "{$destDir}/spieler", [320, 640, 1000], 82, $istPng); $playerImage = [ 'base' => "img/stadionzeitung/{$slug}/spieler", 'widths' => array_map(static fn (array $v): int => $v['width'], $varianten), 'ext' => $istPng ? 'png' : 'jpg', 'alt' => '', ]; $groesse = array_sum(array_map(static fn (array $v): int => $v['bytes'], $varianten)); $log(sprintf( 'Spielerfoto übernommen: %d Varianten%s, zusammen %.1f MB (Original %.1f MB).', count($varianten), $istPng ? ' mit Transparenz, auf Inhalt zugeschnitten' : '', $groesse / 1048576, filesize($spieler) / 1048576 )); } catch (Throwable $e) { $log("Spielerfoto konnte nicht verarbeitet werden: {$spieler}", 'WARN'); } if (is_file($tmpCrop)) { unlink($tmpCrop); } } // --- QR-Code zur digitalen Ausgabe fürs Cover: einmalig, Ziel-URL ändert sich nie. // club_url() statt abs_url(): der Code landet auf Papier und muss auf die echte // Domain zeigen, auch wenn er hier lokal erzeugt wird (vorher stand // http://localhost:8000 darin). // QR ist "nice to have" — schlägt die Erzeugung fehl, läuft die Ausgabe ohne weiter. --- $qrImage = null; $qrDest = "{$destDir}/qr.png"; if (generate_qr_png(club_url('stadionzeitung/' . $slug), $qrDest)) { $qrImage = "img/stadionzeitung/{$slug}/qr.png"; $log('QR-Code erzeugt.'); } else { $log('QR-Code konnte nicht erzeugt werden — Cover rendert ohne QR.', 'WARN'); } // Geteilte QRs der CTA-Füße (Kontakt → /mitmachen, News → /news, Termine → // /termine, Vorschau → /matchcenter, für alle Ausgaben gleich) — ebenfalls über // club_url(), damit sie unabhängig von der Umgebung stimmen und nach dem // Produktivgang nicht neu erzeugt werden müssen. foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'historie', 'sportheimbuchung', 'partner-werden'] as $ctaTarget) { if (generate_qr_png(club_url($ctaTarget), PUBLIC_PATH . "/assets/img/stadionzeitung/qr-{$ctaTarget}.png")) { $log("QR-Code für /{$ctaTarget} erzeugt."); } else { $log("QR-Code für /{$ctaTarget} konnte nicht erzeugt werden — die Seite rendert ohne QR.", 'WARN'); } } // Aufruf-Seite: QR auf das interne Ziel (z. B. /veranstaltungen/sportfest-2026) // aus dem chat-gepflegten `aufruf.link`-Feld. Anders als die geteilten CTA-QRs // ist das Ziel ausgabenspezifisch, deshalb liegt der Code im Ausgaben-Ordner // (qr-aufruf.png) — der wird bei jedem Lauf ohnehin neu befüllt. club_url() // wie überall, damit der gedruckte Code auf die echte Domain zeigt. $aufrufLink = trim((string) ($oldAufruf['link'] ?? '')); if ($aufrufLink !== '') { if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) { $log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt."); } else { $log('QR-Code für die Aufruf-Seite konnte nicht erzeugt werden — die Seite rendert ohne QR.', 'WARN'); } } // Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die // einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code, // keine Einbindung, Regel 1 bleibt unberührt). $instagramUrl = (string) (json_load('club')['social']['instagram'] ?? ''); if ($instagramUrl !== '' && generate_qr_png($instagramUrl, PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')) { $log('QR-Code fürs Instagram-Profil erzeugt.'); } else { $log('QR-Code fürs Instagram-Profil konnte nicht erzeugt werden — Momente-Seite rendert ohne QR.', 'WARN'); } // --- Termine-Snapshot für die Live-Seite "termine": die 5 zum Spieltag // nächsten Termine, EINMALIG eingefroren (termine_build_lists($date)) — // nicht bei jedem Seitenaufruf neu berechnet. Eine Ausgabe ist eine gedruckte // Publikation: wer sie Monate später liest, soll weiter sehen, was am // Spieltag anstand, nicht was von heute aus gesehen ansteht. Das heutige // Spiel selbst (das Cover zeigt es schon) fliegt raus — sonst listet // "Kommende Termine" die eigene Ausgabe. --- $termineUpcoming = array_filter( termine_build_lists($date)['upcoming'], static fn (array $item): bool => ($item['date'] ?? '') !== $date || ($item['type'] ?? '') !== 'spiel' ); $termineSnapshot = array_slice(array_values($termineUpcoming), 0, 5); $log(count($termineSnapshot) . ' Termine für die Live-Seite eingefroren (Stand ' . $date . ').'); // --- Porträtfoto für die Vorwort-Signatur: normales Foto (kein Alpha), daher // über resize_image_variants() wie eine Bild-Seite — kleine Breiten reichen, // es wird nur als kleiner Kreis neben der Signatur gezeigt. Optional. --- $verfasserPhoto = null; if ($verfasser !== null) { $verfasserDest = "{$destDir}/verfasser"; try { $variants = resize_image_variants($verfasser, $verfasserDest, [120, 240], 82); $generatedWidths = array_values(array_unique(array_column($variants, 'width'))); sort($generatedWidths); $verfasserPhoto = [ 'base' => "img/stadionzeitung/{$slug}/verfasser", 'widths' => $generatedWidths, 'alt' => '', ]; $log('Verfasserfoto übernommen.'); } catch (RuntimeException $e) { $log("Verfasserfoto konnte nicht verarbeitet werden: {$e->getMessage()}", 'WARN'); } } // --- data/stadionzeitung.json atomar schreiben (bestehenden Slug ersetzen) --- $issues = array_values(array_filter( $existing['issues'] ?? [], static fn (array $issue): bool => ($issue['slug'] ?? '') !== $slug )); // Immer als Entwurf — auch beim erneuten Lauf über eine schon veröffentlichte // Ausgabe: der Lauf ersetzt die Seiten, also muss der Stand neu geprüft und // neu veröffentlicht werden (der alte snapshot passt nicht mehr und fliegt mit). if ($oldIssue !== null && ($oldIssue['status'] ?? '') === 'veroeffentlicht') { $log("Ausgabe '{$slug}' war veröffentlicht — steht jetzt wieder auf Entwurf, nach Prüfung neu veröffentlichen (bin/stadionzeitung-publish.php).", 'WARN'); } $issues[] = [ 'slug' => $slug, 'opponent' => $opponent, 'date' => $date, 'team_key' => $teamKey, 'status' => 'entwurf', 'pages' => $total, 'player_image' => $playerImage, 'qr_image' => $qrImage, 'vorwort' => $oldVorwort, 'interview' => $oldInterview, 'aufruf' => $oldAufruf, 'verfasser_photo' => $verfasserPhoto, 'termine_snapshot' => $termineSnapshot, 'images' => $images, ]; usort($issues, static fn (array $a, array $b): int => strcmp($b['date'], $a['date'])); $payload = json_encode(['issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $tmp = $cacheFile . '.tmp'; file_put_contents($tmp, $payload); rename($tmp, $cacheFile); $log("OK: Ausgabe '{$slug}' ({$opponent}, {$date}, Team {$teamKey}) mit {$total} Seiten als Entwurf gespeichert."); echo "Fertig (ENTWURF): /stadionzeitung/{$slug} ({$total} Seiten, davon {$adCount} Anzeigen aus dem Bestand)\n"; echo 'Anzeigen verteilt: ' . implode('/', $verteilung) . ' je Lücke, längster Block ' . max($verteilung ?: [0]) . ".\n"; echo "Veröffentlichen nach Prüfung: php bin/stadionzeitung-publish.php {$slug}\n";