.svg). * Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild * (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon. * Quelle: Bootstrap Icons (MIT) — neue Icons via bin/icons-add.php hinzufügen. */ function icon(string $name, string $class = '', ?string $label = null): string { static $cache = []; if (!array_key_exists($name, $cache)) { $file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg'; $cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : ''; } if ($cache[$name] === '') { return ''; } $attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"'; $attrs .= $label !== null && $label !== '' ? ' role="img" aria-label="' . e($label) . '"' : ' aria-hidden="true" focusable="false"'; return preg_replace('/.json) mit Request-weitem Cache. * Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise. */ function json_load(string $name): array { static $cache = []; if (!array_key_exists($name, $cache)) { $file = DATA_PATH . '/' . $name . '.json'; if (!is_file($file)) { return []; } $cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); } return $cache[$name]; } /** * Intrinsische Bildmaße als ' width="…" height="…"' für CLS-freie -Tags. * $rel = Pfad relativ zu public/assets/. PNG/JPG via getimagesize, SVG via * viewBox (Fallback: width/height-Attribute, Einheiten werden ignoriert — * fürs Seitenverhältnis reicht die Zahl). Liefert '' wenn nicht bestimmbar. */ function img_intrinsic_attrs(string $rel): string { static $cache = []; if (!isset($cache[$rel])) { $file = PUBLIC_PATH . '/assets/' . ltrim($rel, '/'); $w = $h = 0; if (is_file($file)) { if (str_ends_with(strtolower($file), '.svg')) { $svg = (string) file_get_contents($file); if (preg_match('/viewBox="\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/', $svg, $m)) { [$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])]; } elseif (preg_match('/]*\bwidth="([\d.]+)[a-z%]*"[^>]*\bheight="([\d.]+)[a-z%]*"/s', $svg, $m)) { [$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])]; } } else { [$w, $h] = (getimagesize($file) ?: [0, 0]); } } $cache[$rel] = ($w > 0 && $h > 0) ? ' width="' . $w . '" height="' . $h . '"' : ''; } return $cache[$rel]; } /** * Responsive Bild-Varianten erzeugen (GD, läuft auch auf Shared Hosting) — * schreibt -.jpg je Breite, skaliert nie hoch. Geteilte * Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php * (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten). * Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …]. */ function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78): array { $image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) { 'jpg', 'jpeg' => imagecreatefromjpeg($src), 'png' => imagecreatefrompng($src), 'webp' => imagecreatefromwebp($src), default => null, }; if (!$image) { throw new RuntimeException("Kann Quelle nicht lesen: {$src}"); } $srcW = imagesx($image); $srcH = imagesy($image); $out = []; foreach ($widths as $width) { $w = min((int) $width, $srcW); // nie hochskalieren $h = (int) round($srcH * $w / $srcW); $frame = imagecreatetruecolor($w, $h); imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH); $file = "{$destBase}-{$w}.jpg"; imagejpeg($frame, $file, $quality); $out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)]; } return $out; } /** * Freigestelltes PNG auf den Inhalt zuschneiden (Alpha-Bounding-Box + kleiner * Rand) — Stadionzeitung-Cover: Export-Tools lassen um eine Freistellung oft * viel leeren, transparenten Rand stehen (z. B. 17% oben, ~18–20% seitlich), * dadurch wirkt das Spielerfoto auf dem Cover kleiner als nötig. $padding = * Rand um die erkannte Fläche, Anteil der jeweiligen Kantenlänge (0.02 = 2%). * Liefert false bei Fehlern (kein lesbares PNG, komplett transparent) statt * zu werfen — das Spielerfoto ist ein "nice to have" fürs Cover, ein * Fehlschlag darf die Ausgabe nicht scheitern lassen. */ function crop_transparent_png_to_content(string $src, string $dest, float $padding = 0.02): bool { $im = @imagecreatefrompng($src); if (!$im) { return false; } imagealphablending($im, false); imagesavealpha($im, true); $w = imagesx($im); $h = imagesy($im); $minX = $w; $minY = $h; $maxX = 0; $maxY = 0; $found = false; for ($y = 0; $y < $h; $y++) { for ($x = 0; $x < $w; $x++) { $alpha = (imagecolorat($im, $x, $y) >> 24) & 0x7F; // 0=opak, 127=transparent if ($alpha < 110) { $found = true; $minX = min($minX, $x); $maxX = max($maxX, $x); $minY = min($minY, $y); $maxY = max($maxY, $y); } } } if (!$found) { return false; } $padX = (int) round(($maxX - $minX) * $padding); $padY = (int) round(($maxY - $minY) * $padding); $cropX = max(0, $minX - $padX); $cropY = max(0, $minY - $padY); $cropW = min($w - $cropX, $maxX - $minX + 1 + 2 * $padX); $cropH = min($h - $cropY, $maxY - $minY + 1 + 2 * $padY); $out = imagecreatetruecolor($cropW, $cropH); imagealphablending($out, false); imagesavealpha($out, true); $transparent = imagecolorallocatealpha($out, 0, 0, 0, 127); imagefill($out, 0, 0, $transparent); imagecopy($out, $im, 0, 0, $cropX, $cropY, $cropW, $cropH); return imagepng($out, $dest) && is_file($dest); } /** * QR-Code als PNG erzeugen (Stadionzeitung-Cover: Link zur digitalen Ausgabe). * Nutzt die manuell vendorte "PHP QR Code"-Bibliothek (vendor-manual/phpqrcode, * GD-basiert, kein neues PHP-Modul, kein Composer) statt eines Laufzeit-Diensts. * Die Bibliothek ist alter PHP-5-Code und meldet auf PHP 8 pro Aufruf ein paar * Deprecation-Notices zur Parameter-Reihenfolge — schon beim `require` (nicht * erst beim Aufruf), deshalb error_reporting() um beides herum runtergesetzt. * Genau dasselbe Muster wie bei der vendorten Files-Gallery (siehe * public/dateien/_filesconfig.php, dort `display_errors = 0`). * QR ist ein "nice to have" auf dem Cover: Rückgabe false statt Exception, * ein Fehlschlag darf die Ausgabe nicht scheitern lassen. * * $scale = Pixel pro QR-Modul. Default 6 ergibt gut 200px — richtig für die * Stadionzeitungs-Seiten, viel zu klein für Druckmaterial. Für Plakate und * Aushänge deutlich höher gehen (siehe bin/qr.php). */ function generate_qr_png(string $data, string $destPath, int $scale = 6): bool { $lib = ROOT_PATH . '/vendor-manual/phpqrcode/phpqrcode.php'; if (!is_file($lib)) { return false; } $previous = error_reporting(E_ALL & ~E_DEPRECATED); try { require_once $lib; QRcode::png($data, $destPath, QR_ECLEVEL_M, max(1, $scale), 2); } catch (Throwable) { return false; } finally { error_reporting($previous); } return is_file($destPath); } /** * Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus * data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin — * PHP läuft hier global auf UTC, deshalb explizit. Liefert: * iso maschinenlesbar mit Zone (für