Logo-Downloadseite /logo mit Erzeuger-Skript

Damit die Abteilungen das Wappen selbst holen können, statt jedes Mal zu fragen:
drei Varianten (farbig, einfarbig schwarz, einfarbig weiß) in SVG, PDF, PNG und JPG,
dazu ein ZIP über alles und die Vereinsfarben. Verlinkt nur in der Fußzeile.

Bisher lag das Logo genau einmal im Projekt, als eine einzige SVG. Es gab kein PNG,
kein JPG, kein PDF und keine einfarbige Fassung.

bin/logo-build.php erzeugt alle Dateien aus einer Master-SVG unter storage/logo-quellen/.
Gerastert wird mit headless Chrome, weil GD kein SVG lesen kann und weder Inkscape noch
rsvg-convert noch ImageMagick verfügbar sind; das PDF ist dabei echter Vektor. Das Skript
läuft einmalig lokal, nichts davon im Request-Pfad. bin/preflight.php meldet als Fehler,
wenn eine Download-Datei fehlt: die Komponente ließe den Knopf sonst still weg.

Die beiden einfarbigen Fassungen sind exakte Negative voneinander, nur Linien, alle
Flächen frei. EPS gibt es bewusst nicht, dafür fehlt jedes Werkzeug; SVG und PDF decken
die Druckereien ab. Frühere Vereinslogos sind vorbereitet, aber im Projekt liegt bisher
kein einziges historisches Wappen vor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
This commit is contained in:
2026-09-17 10:10:08 +02:00
parent e6ac00b248
commit 75fce4f680
35 changed files with 1141 additions and 2 deletions

368
bin/logo-build.php Normal file
View File

@@ -0,0 +1,368 @@
<?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';
$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();
// --- Chrome finden ------------------------------------------------------------
// Reihenfolge: Umgebungsvariable CHROME, dann die üblichen Installationsorte.
$chrome = getenv('CHROME') ?: '';
if ($chrome === '') {
foreach ([
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
] as $candidate) {
if (is_executable($candidate)) {
$chrome = $candidate;
break;
}
}
}
if ($chrome === '' || !is_executable($chrome)) {
$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);
});
/**
* Chrome einmal aufrufen.
*
* KEIN --user-data-dir: naheliegend wäre ein eigener Profilordner, damit ein offenes
* Chrome-Fenster den Lauf nicht stört. Chrome 152 bleibt damit aber beim ERSTEN Aufruf
* endlos hängen (gemessen: kein Abbruch nach zwei Minuten, kein Bild, keine Meldung).
* Ohne den Schalter läuft jeder Aufruf in wenigen Sekunden durch, auch bei geöffnetem
* Chrome. Wer hier etwas ändert, misst die Laufzeit nach.
*/
$runChrome = static function (array $args) use ($chrome): void {
$cmd = escapeshellarg($chrome)
. ' --headless --disable-gpu --hide-scrollbars --force-color-profile=srgb'
. ' --no-first-run --no-default-browser-check'
. ' ' . implode(' ', $args) . ' 2>/dev/null';
exec($cmd);
};
/** 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.');