Files

343 lines
13 KiB
PHP
Raw Permalink Normal View History

<?php
declare(strict_types=1);
/**
* Alle Download-Dateien des Vereinslogos aus den Master-Dateien erzeugen (nur CLI).
*
* php bin/logo-build.php [-v]
*
* Quelle: storage/logo-quellen/<datei>.svg — die Master-Dateien. `aktuell.svg` ist
* das heutige Logo (identisch zu public/assets/img/logo.svg, das bleibt das
* Website-Asset und wird hier nicht angefasst). Alte Vereinslogos kommen als
* weitere Dateien dazu und werden unten in $OUTPUTS eingetragen.
*
* Ziel: public/assets/downloads/logo/ — je Ausgabe SVG, PNG (transparent, drei
* Breiten), JPG (weißer Grund, zwei Breiten) und PDF, dazu ein ZIP über alles.
* Der Ordner wird bei jedem Lauf GELEERT und neu gefüllt; nur so lässt sich eine
* Variante auch wieder entfernen.
*
* WARUM CHROME UND NICHT bin/img-resize.php: GD kennt kein SVG, und Inkscape,
* rsvg-convert oder ImageMagick sind hier nicht vorhanden (und wären auf dem
* Shared Hosting auch nicht zu haben). Headless Chrome kann beides, was wir
* brauchen: transparent rastern und ein echtes VEKTOR-PDF drucken (geprüft:
* das PDF enthält Kurven-Operatoren, kein eingebettetes Pixelbild).
*
* Das Skript läuft EINMALIG LOKAL, das Ergebnis wird committet. Nichts davon
* läuft auf dem Server, es gibt keinen Cron und keine Laufzeit-Abhängigkeit.
*
* Die Dateinamen sind absichtlich ausgeschrieben: sie landen im Download-Ordner
* fremder Leute, wo „logo-2.png“ niemandem hilft.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
Instagram: Scraper raus, Beitraege einzeln ueber oEmbed aufnehmen bin/instagram-sync.php lieferte seit dem 03.09.2026 bei jedem Lauf "Keine Posts ermittelbar". Von einem privaten Anschluss gegengeprueft: feed/user und web_profile_info antworten mit 401 require_login, das Profil-HTML traegt keine Beitragsdaten mehr, auch /embed/ liefert nur noch eine App-Huelle. Es lag also nicht an der Server-IP, und der Scraper ist geloescht, damit ihn niemand wiederbelebt. Graph API (Meta-Konto noetig) und kommerzielle Dienste wurden verworfen. Neu: bin/instagram-add.php. --profil liest die Beitragsliste ueber headless Chrome aus dem gerenderten Profil-DOM (im HTML stehen die Codes nicht) und holt zu jedem neuen Beitrag Bild, Caption und Datum ueber Instagrams oEmbed-Endpunkt; das Datum steckt in der media_id. Daneben einzelne URLs, --liste, --entfernen und --bild als Notfall. Der Shortcode ist Primaerschluessel und damit die komplette Dedupe-Logik. Die Struktur von data/instagram.json bleibt exakt gleich, deshalb war an Startseite, News-Feed und Stadionzeitung nichts zu aendern. Kein Cron mehr: der Job faellt aus public/cron.php, auf dem Webspace gibt es kein Chrome. Die Chrome-Suche liegt jetzt gemeinsam in bin/chrome-lib.php, genutzt von bin/logo-build.php und bin/instagram-add.php. data/instagram.json ist ab jetzt versioniert. Solange sie ignoriert war, lief sie gegen die versionierten Bilder auseinander: lokal lagen 10 Bilder zu 9 Eintraegen, drei Beitraege fehlten im Feed, zwei Eintraege hatten kein Bild. Repariert, jetzt 16 Eintraege zu 16 Bildern. bin/preflight.php prueft das in beide Richtungen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
2026-09-17 11:23:12 +02:00
require_once __DIR__ . '/chrome-lib.php';
$verbose = in_array('-v', $argv, true);
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('logo', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log($msg, 'ERROR');
exit(1);
};
// --- Was erzeugt wird ---------------------------------------------------------
// Schlüssel = Dateiname-Suffix UND Schlüssel in data/logo.json (bin/preflight.php
// prüft, dass beide Listen zusammenpassen). `ersetzen` färbt die Master-SVG um,
// indem die Farbklassen im <defs>-Block ausgetauscht werden — die CorelDRAW-Datei
// legt alle Farben genau dort ab (.fil0/.fil3 schwarz, .fil1 weiß, .fil2 rot).
$OUTPUTS = [
'farbig' => [
'quelle' => 'aktuell.svg',
'ersetzen' => [],
],
// Das genaue Gegenstück zu einfarbig-schwarz: dort trägt die weiße Fläche die
// schwarzen Linien, hier tragen die weißen Linien den freien Grund. KEIN Rot —
// eine einfarbige Fassung ist einfarbig, sonst wäre sie nur ein farbiges Logo
// mit weißen Konturen (so war es bis zum 17.09.2026 und war schlicht falsch).
'einfarbig-weiss' => [
'quelle' => 'aktuell.svg',
// Weiße Konturen auf weißem JPG wären unsichtbar: diese Variante wird auf
// den Anthrazit-Grund der Website gelegt, denn genau dafür ist sie gedacht.
'jpg_bg' => [0x22, 0x22, 0x22],
'ersetzen' => [
'.fil3 {fill:black}' => '.fil3 {fill:white}',
'.fil0 {fill:black;' => '.fil0 {fill:white;',
'.fil1 {fill:white}' => '.fil1 {fill:none}',
'.fil2 {fill:red}' => '.fil2 {fill:none}',
],
],
// Reine Strichzeichnung: Linien schwarz, ALLE Flächen frei. Vorher wurde das Rot
// durch Weiß ersetzt, dann steckte im PNG eine deckend weiße Schildfläche — auf
// farbigem Papier oder einem Briefkopf lag da ein weißer Klotz. Für Stempel,
// Gravur und Stickerei will man die Kontur, nicht die Fläche.
'einfarbig-schwarz' => [
'quelle' => 'aktuell.svg',
'ersetzen' => [
'.fil2 {fill:red}' => '.fil2 {fill:none}',
'.fil1 {fill:white}' => '.fil1 {fill:none}',
],
],
];
const LOGO_PNG_WIDTHS = [500, 1000, 2000];
const LOGO_JPG_WIDTHS = [1000, 2000];
// 90 statt der 78 aus bin/img-resize.php: ein Logo ist Fläche mit harten Kanten,
// bei 78 franst die Rundung des Wappens sichtbar aus. Fotos vertragen 78, Grafik nicht.
const LOGO_JPG_QUALITY = 90;
const LOGO_PREFIX = 'tsv08-kulmbach-logo';
$srcDir = STORAGE_PATH . '/logo-quellen';
$destDir = PUBLIC_PATH . '/assets/downloads/logo';
$tmpDir = sys_get_temp_dir() . '/tsv08-logo-' . getmypid();
Instagram: Scraper raus, Beitraege einzeln ueber oEmbed aufnehmen bin/instagram-sync.php lieferte seit dem 03.09.2026 bei jedem Lauf "Keine Posts ermittelbar". Von einem privaten Anschluss gegengeprueft: feed/user und web_profile_info antworten mit 401 require_login, das Profil-HTML traegt keine Beitragsdaten mehr, auch /embed/ liefert nur noch eine App-Huelle. Es lag also nicht an der Server-IP, und der Scraper ist geloescht, damit ihn niemand wiederbelebt. Graph API (Meta-Konto noetig) und kommerzielle Dienste wurden verworfen. Neu: bin/instagram-add.php. --profil liest die Beitragsliste ueber headless Chrome aus dem gerenderten Profil-DOM (im HTML stehen die Codes nicht) und holt zu jedem neuen Beitrag Bild, Caption und Datum ueber Instagrams oEmbed-Endpunkt; das Datum steckt in der media_id. Daneben einzelne URLs, --liste, --entfernen und --bild als Notfall. Der Shortcode ist Primaerschluessel und damit die komplette Dedupe-Logik. Die Struktur von data/instagram.json bleibt exakt gleich, deshalb war an Startseite, News-Feed und Stadionzeitung nichts zu aendern. Kein Cron mehr: der Job faellt aus public/cron.php, auf dem Webspace gibt es kein Chrome. Die Chrome-Suche liegt jetzt gemeinsam in bin/chrome-lib.php, genutzt von bin/logo-build.php und bin/instagram-add.php. data/instagram.json ist ab jetzt versioniert. Solange sie ignoriert war, lief sie gegen die versionierten Bilder auseinander: lokal lagen 10 Bilder zu 9 Eintraegen, drei Beitraege fehlten im Feed, zwei Eintraege hatten kein Bild. Repariert, jetzt 16 Eintraege zu 16 Bildern. bin/preflight.php prueft das in beide Richtungen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
2026-09-17 11:23:12 +02:00
// --- Chrome finden ---
$chrome = chrome_binary();
if ($chrome === null) {
$fail('Kein Chrome gefunden. Pfad per Umgebungsvariable setzen: CHROME="/Pfad/zu/Chrome" php bin/logo-build.php');
}
if (!is_dir($srcDir)) {
$fail("Quellordner fehlt: {$srcDir}");
}
if (!is_dir($destDir) && !mkdir($destDir, 0775, true) && !is_dir($destDir)) {
$fail("Zielordner nicht anlegbar: {$destDir}");
}
if (!mkdir($tmpDir, 0775, true) && !is_dir($tmpDir)) {
$fail("Arbeitsordner nicht anlegbar: {$tmpDir}");
}
register_shutdown_function(static function () use ($tmpDir): void {
foreach (glob($tmpDir . '/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($tmpDir);
});
$runChrome = static function (array $args) use ($chrome): void {
Instagram: Scraper raus, Beitraege einzeln ueber oEmbed aufnehmen bin/instagram-sync.php lieferte seit dem 03.09.2026 bei jedem Lauf "Keine Posts ermittelbar". Von einem privaten Anschluss gegengeprueft: feed/user und web_profile_info antworten mit 401 require_login, das Profil-HTML traegt keine Beitragsdaten mehr, auch /embed/ liefert nur noch eine App-Huelle. Es lag also nicht an der Server-IP, und der Scraper ist geloescht, damit ihn niemand wiederbelebt. Graph API (Meta-Konto noetig) und kommerzielle Dienste wurden verworfen. Neu: bin/instagram-add.php. --profil liest die Beitragsliste ueber headless Chrome aus dem gerenderten Profil-DOM (im HTML stehen die Codes nicht) und holt zu jedem neuen Beitrag Bild, Caption und Datum ueber Instagrams oEmbed-Endpunkt; das Datum steckt in der media_id. Daneben einzelne URLs, --liste, --entfernen und --bild als Notfall. Der Shortcode ist Primaerschluessel und damit die komplette Dedupe-Logik. Die Struktur von data/instagram.json bleibt exakt gleich, deshalb war an Startseite, News-Feed und Stadionzeitung nichts zu aendern. Kein Cron mehr: der Job faellt aus public/cron.php, auf dem Webspace gibt es kein Chrome. Die Chrome-Suche liegt jetzt gemeinsam in bin/chrome-lib.php, genutzt von bin/logo-build.php und bin/instagram-add.php. data/instagram.json ist ab jetzt versioniert. Solange sie ignoriert war, lief sie gegen die versionierten Bilder auseinander: lokal lagen 10 Bilder zu 9 Eintraegen, drei Beitraege fehlten im Feed, zwei Eintraege hatten kein Bild. Repariert, jetzt 16 Eintraege zu 16 Bildern. bin/preflight.php prueft das in beide Richtungen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
2026-09-17 11:23:12 +02:00
chrome_run($chrome, $args);
};
/** Maße der SVG in mm aus dem Wurzelelement lesen (für die PDF-Seitengröße). */
$svgSize = static function (string $svg) use ($fail): array {
if (!preg_match('/width="([\d.]+)mm"/', $svg, $w) || !preg_match('/height="([\d.]+)mm"/', $svg, $h)) {
if (preg_match('/viewBox="[\d.\s-]*?([\d.]+)\s+([\d.]+)"/', $svg, $vb)) {
return [(float) $vb[1], (float) $vb[2]]; // viewBox-Einheiten als mm deuten
}
$fail('SVG ohne verwertbare Maße (weder mm-Angaben noch viewBox).');
}
return [(float) $w[1], (float) $h[1]];
};
/**
* PNG mit Alphakanal auf eine deckende Fläche legen und als JPG sichern.
* JPG kann keine Transparenz; ohne dieses Unterlegen rechnet GD den Alphakanal
* gegen Schwarz und das Wappen bekäme einen schwarzen Kasten.
*/
$flattenToJpg = static function (string $pngPath, string $jpgPath, int $quality, array $bg): bool {
$png = @imagecreatefrompng($pngPath);
if (!$png) {
return false;
}
$canvas = imagecreatetruecolor(imagesx($png), imagesy($png));
imagefill($canvas, 0, 0, imagecolorallocate($canvas, $bg[0], $bg[1], $bg[2]));
imagecopy($canvas, $png, 0, 0, 0, 0, imagesx($png), imagesy($png));
return imagejpeg($canvas, $jpgPath, $quality);
};
// --- Zielordner leeren --------------------------------------------------------
foreach (glob($destDir . '/*') ?: [] as $old) {
if (is_file($old)) {
unlink($old);
}
}
// --- Erzeugen -----------------------------------------------------------------
$built = [];
foreach ($OUTPUTS as $key => $spec) {
$srcFile = $srcDir . '/' . $spec['quelle'];
if (!is_file($srcFile)) {
$fail("Master-Datei fehlt: {$srcFile}");
}
$base = LOGO_PREFIX . '-' . $key;
$isVector = strtolower(pathinfo($srcFile, PATHINFO_EXTENSION)) === 'svg';
if (!$isVector) {
// Alte Logos liegen oft nur als Scan vor: dann gibt es kein SVG und kein PDF,
// aber PNG und JPG lassen sich wie überall sonst im Projekt erzeugen.
foreach (resize_image_variants($srcFile, $destDir . '/' . $base, LOGO_PNG_WIDTHS, LOGO_JPG_QUALITY, true) as $v) {
$built[] = $v['file'];
}
foreach (resize_image_variants($srcFile, $destDir . '/' . $base, LOGO_JPG_WIDTHS, LOGO_JPG_QUALITY) as $v) {
$built[] = $v['file'];
}
$log("{$key}: aus Rasterquelle erzeugt (kein SVG/PDF möglich).", 'WARN');
continue;
}
// 1. Varianten-SVG
$svg = file_get_contents($srcFile);
if ($svg === false) {
$fail("Master-Datei nicht lesbar: {$srcFile}");
}
foreach ($spec['ersetzen'] as $from => $to) {
if (!str_contains($svg, $from)) {
$fail("Farbklasse „{$from}“ steht nicht in {$spec['quelle']} — die Variante „{$key}“ wäre unverändert.");
}
$svg = str_replace($from, $to, $svg);
}
$svgFile = $destDir . '/' . $base . '.svg';
file_put_contents($svgFile, $svg);
$built[] = $svgFile;
[$mmW, $mmH] = $svgSize($svg);
$ratio = $mmH / $mmW;
// Arbeitskopie: Chrome lädt die SVG relativ zur Wrapper-Datei.
$workSvg = $tmpDir . '/' . $base . '.svg';
file_put_contents($workSvg, $svg);
// 2. PNG, transparent
foreach (LOGO_PNG_WIDTHS as $width) {
$height = (int) round($width * $ratio);
$wrap = $tmpDir . "/wrap-{$key}-{$width}.html";
file_put_contents($wrap, <<<HTML
<!doctype html><meta charset="utf-8">
<style>html,body{margin:0;padding:0;background:transparent}
img{display:block;width:{$width}px;height:{$height}px}</style>
<img src="{$base}.svg" alt="">
HTML);
$pngFile = $destDir . '/' . $base . '-' . $width . '.png';
$runChrome([
'--default-background-color=00000000',
'--window-size=' . $width . ',' . $height,
'--screenshot=' . escapeshellarg($pngFile),
escapeshellarg('file://' . $wrap),
]);
if (!is_file($pngFile) || filesize($pngFile) < 1000) {
$fail("PNG nicht erzeugt: {$pngFile} — läuft Chrome?");
}
$built[] = $pngFile;
// 3. JPG auf weiß, aus der passenden PNG-Breite
if (in_array($width, LOGO_JPG_WIDTHS, true)) {
$jpgFile = $destDir . '/' . $base . '-' . $width . '.jpg';
if (!$flattenToJpg($pngFile, $jpgFile, LOGO_JPG_QUALITY, $spec['jpg_bg'] ?? [255, 255, 255])) {
$fail("JPG nicht erzeugt: {$jpgFile}");
}
$built[] = $jpgFile;
}
}
// 4. PDF, Vektor
$pdfWrap = $tmpDir . "/pdf-{$key}.html";
file_put_contents($pdfWrap, <<<HTML
<!doctype html><meta charset="utf-8">
<style>@page{size:{$mmW}mm {$mmH}mm;margin:0}html,body{margin:0;padding:0}
img{display:block;width:{$mmW}mm;height:{$mmH}mm}</style>
<img src="{$base}.svg" alt="">
HTML);
$pdfFile = $destDir . '/' . $base . '.pdf';
$runChrome([
'--no-pdf-header-footer',
'--print-to-pdf=' . escapeshellarg($pdfFile),
escapeshellarg('file://' . $pdfWrap),
]);
if (!is_file($pdfFile) || filesize($pdfFile) < 500) {
$fail("PDF nicht erzeugt: {$pdfFile}");
}
$built[] = $pdfFile;
$log("{$key}: SVG, " . count(LOGO_PNG_WIDTHS) . ' PNG, ' . count(LOGO_JPG_WIDTHS) . ' JPG und PDF erzeugt.');
}
// --- LIESMICH + ZIP -----------------------------------------------------------
// Die Texte kommen aus data/logo.json, damit Heft-, Seiten- und ZIP-Fassung nicht
// auseinanderlaufen. Fehlt die JSON noch (erster Lauf), bleibt es beim Grundgerüst.
$page = json_load('logo');
$club = json_load('club');
$lines = ['Logo des ' . ($club['name'] ?? 'TSV 08 Kulmbach'), str_repeat('=', 40), ''];
if (!empty($page['notice']['text'])) {
$lines[] = trim($page['notice']['title'] ?? 'Hinweis');
$lines[] = trim($page['notice']['text']);
$lines[] = '';
}
$lines[] = 'Welche Datei wofür';
$lines[] = '-------------------';
$lines[] = 'SVG Vektor, beliebig skalierbar. Erste Wahl für die Druckerei.';
$lines[] = 'PDF Vektor, für Druckereien, die kein SVG annehmen.';
$lines[] = 'PNG Pixelbild mit freigestelltem Grund, für Word, PowerPoint und Aushänge.';
$lines[] = 'JPG Pixelbild mit gefülltem Grund (weiß, bei der Fassung für dunklen Grund anthrazit),';
$lines[] = ' für Portale und Programme, die keine Transparenz können.';
$lines[] = '';
$texte = array_column($page['varianten'] ?? [], null, 'key');
foreach (array_keys($OUTPUTS) as $key) {
$lines[] = ($texte[$key]['title'] ?? $key) . ' (Dateien: ' . LOGO_PREFIX . '-' . $key . '.*)';
if (!empty($texte[$key]['text'])) {
$lines[] = ' ' . $texte[$key]['text'];
}
}
if (!empty($page['farben']['items'])) {
$lines[] = '';
$lines[] = 'Farben';
$lines[] = '------';
foreach ($page['farben']['items'] as $farbe) {
$lines[] = sprintf(
'%s HEX %s RGB %s CMYK %s',
$farbe['name'] ?? '',
$farbe['hex'] ?? '',
$farbe['rgb'] ?? '',
$farbe['cmyk'] ?? ''
);
}
}
$lines[] = '';
$lines[] = 'Alle Dateien und weitere Formate: ' . club_url('logo');
$lines[] = 'Fragen: ' . ($club['email'] ?? '');
$readme = $destDir . '/LIESMICH.txt';
file_put_contents($readme, implode("\n", $lines) . "\n");
$zipFile = $destDir . '/' . LOGO_PREFIX . '-paket.zip';
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
$fail("ZIP nicht anlegbar: {$zipFile}");
}
$zip->addFile($readme, 'LIESMICH.txt');
foreach ($built as $file) {
$zip->addFile($file, LOGO_PREFIX . '/' . basename($file));
}
$zip->close();
// --- Bericht ------------------------------------------------------------------
$total = 0;
echo "\nErzeugt in public/assets/downloads/logo/:\n";
foreach (array_merge($built, [$readme, $zipFile]) as $file) {
$size = (int) filesize($file);
$total += $size;
$dim = '';
if (in_array(strtolower(pathinfo($file, PATHINFO_EXTENSION)), ['png', 'jpg'], true)) {
$info = @getimagesize($file);
$dim = $info ? " {$info[0]}x{$info[1]}" : '';
}
printf(" %-46s %8s KB%s\n", basename($file), number_format($size / 1024, 1, ',', '.'), $dim);
}
printf(" %-46s %8s KB\n", '= gesamt (ohne ZIP doppelt gezählt)', number_format($total / 1024, 1, ',', '.'));
$log('Fertig: ' . (count($built) + 2) . ' Dateien.');