feat(logos): Provider-Kette, lokaler Cache und Markenfarben

- simple-icons als kompakter Index im Repository (3.459 Marken, 2 MB gzip),
  erzeugt von scripts/vendor_simple_icons.py bzw. `make vendor-icons`
- logo.dev und Brandfetch als optionale Adapter, ohne Schlüssel übersprungen
- Favicon-Fallback und generierter Buchstaben-Avatar als Garantie
- Bei eindeutigem Offline-Treffer unterbleiben Anfragen nach außen komplett
- Cache im Dateisystem nach SHA-256, Auslieferung nur über /api/logos/{id}
  mit immutable-Header und ETag
- Markenfarbe aus SVG-Fills bzw. per k-Means (k=4) über 64x64 Pixel, dazu eine
  aufgehellte Variante mit mindestens 4,5:1 Kontrast auf dunklem Grund
- Kandidatensuche mit Vorauswahl, Auswahl, Upload und Zurücksetzen
- Bildtyp wird nur noch am Inhalt bestimmt, nicht an der gemeldeten Kopfzeile
- 60 neue Tests, insgesamt 208 grün

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 13:54:21 +02:00
co-authored by Claude Opus 5
parent b586d27b77
commit 8edb69fe6a
16 changed files with 2039 additions and 7 deletions
+267
View File
@@ -0,0 +1,267 @@
"""Ermittlung der Markenfarbe aus einem Logo.
Bei SVG wird die häufigste Nicht-Graustufe aus den `fill`-Attributen genommen,
bei Rastergrafiken entscheidet ein k-Means über die Pixel. Zusätzlich wird eine
aufgehellte Variante berechnet, die auf dem dunklen Hintergrund der Oberfläche
den WCAG-Kontrast von 4,5:1 erreicht.
"""
import colorsys
import hashlib
import io
import logging
import re
from collections import Counter
from dataclasses import dataclass
from PIL import Image, UnidentifiedImageError
logger = logging.getLogger(__name__)
# Hintergrund der dunklen Oberfläche gegen ihn wird der Kontrast geprüft.
DARK_BACKGROUND = "#0f1115"
MIN_CONTRAST = 4.5
# Ab diesem Sättigungswert gilt eine Farbe nicht mehr als Graustufe.
MIN_SATURATION = 0.12
# Cluster unterhalb dieses Anteils sind Ausreißer und werden verworfen.
MIN_CLUSTER_SHARE = 0.05
# Deterministische Ersatzfarben, wenn ein Logo rein grau ist.
FALLBACK_PALETTE = (
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#3b82f6",
"#6366f1",
"#a855f7",
"#ec4899",
)
_FILL_PATTERN = re.compile(r'fill\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8}|rgb\([^)]+\))', re.I)
_STOP_COLOR_PATTERN = re.compile(r'stop-color\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8})', re.I)
_RGB_PATTERN = re.compile(r"rgb\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)", re.I)
@dataclass(frozen=True, slots=True)
class BrandColors:
"""Markenfarbe und die für dunkle Oberflächen aufgehellte Variante."""
color: str
color_dark: str
# --- Umrechnungen --------------------------------------------------------------
def normalize_hex(value: str) -> str | None:
"""Bringt eine Farbangabe auf `#rrggbb`. Ungültiges ergibt None."""
text = value.strip()
if not text.startswith("#"):
text = f"#{text}"
digits = text[1:]
if len(digits) in (4, 8): # Alphakanal abschneiden
digits = digits[:3] if len(digits) == 4 else digits[:6]
if len(digits) == 3:
digits = "".join(char * 2 for char in digits)
if len(digits) != 6 or not all(char in "0123456789abcdefABCDEF" for char in digits):
return None
return f"#{digits.lower()}"
def hex_to_rgb(value: str) -> tuple[int, int, int]:
normalized = normalize_hex(value) or "#000000"
return tuple(int(normalized[index : index + 2], 16) for index in (1, 3, 5)) # type: ignore[return-value]
def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
red, green, blue = (max(0, min(255, round(channel))) for channel in rgb)
return f"#{red:02x}{green:02x}{blue:02x}"
def relative_luminance(rgb: tuple[int, int, int]) -> float:
"""Relative Leuchtdichte nach WCAG 2.1."""
channels = []
for value in rgb:
srgb = value / 255
channels.append(srgb / 12.92 if srgb <= 0.04045 else ((srgb + 0.055) / 1.055) ** 2.4)
red, green, blue = channels
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
def contrast_ratio(first: str, second: str) -> float:
"""Kontrastverhältnis zweier Farben nach WCAG (1:1 bis 21:1)."""
light = relative_luminance(hex_to_rgb(first))
dark = relative_luminance(hex_to_rgb(second))
if light < dark:
light, dark = dark, light
return (light + 0.05) / (dark + 0.05)
def saturation_of(rgb: tuple[int, int, int]) -> float:
_, _, saturation = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb))
return saturation
def lightness_of(rgb: tuple[int, int, int]) -> float:
_, lightness, _ = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb))
return lightness
def is_grayscale(rgb: tuple[int, int, int]) -> bool:
return saturation_of(rgb) < MIN_SATURATION
def lighten_for_dark_background(color: str, background: str = DARK_BACKGROUND) -> str:
"""Hellt eine Farbe im HSL-Raum auf, bis der Kontrast mindestens 4,5:1 beträgt."""
normalized = normalize_hex(color)
if normalized is None:
return "#e2e8f0"
if contrast_ratio(normalized, background) >= MIN_CONTRAST:
return normalized
hue, lightness, saturation = colorsys.rgb_to_hls(
*(channel / 255 for channel in hex_to_rgb(normalized))
)
step = 0.02
while lightness < 0.98:
lightness = min(lightness + step, 0.98)
channels = colorsys.hls_to_rgb(hue, lightness, saturation)
candidate = rgb_to_hex(tuple(round(channel * 255) for channel in channels))
if contrast_ratio(candidate, background) >= MIN_CONTRAST:
return candidate
# Selbst bei maximaler Helligkeit nicht erreichbar (sehr dunkler Farbton): neutral aufhellen.
return "#e2e8f0"
def deterministic_color(name: str) -> str:
"""Feste Farbe aus dem Namens-Hash gleicher Name ergibt immer dieselbe Farbe."""
digest = hashlib.sha256(name.strip().lower().encode("utf-8")).digest()
return FALLBACK_PALETTE[digest[0] % len(FALLBACK_PALETTE)]
def brand_colors(color: str | None, *, fallback_name: str = "") -> BrandColors:
"""Baut das Farbpaar; ohne brauchbare Farbe greift der Namens-Hash."""
normalized = normalize_hex(color) if color else None
if normalized is None:
normalized = deterministic_color(fallback_name)
return BrandColors(color=normalized, color_dark=lighten_for_dark_background(normalized))
# --- SVG -----------------------------------------------------------------------
def color_from_svg(content: bytes) -> str | None:
"""Häufigste Nicht-Graustufe aus `fill`-Attributen und Verlaufsstopps."""
try:
text = content.decode("utf-8", errors="ignore")
except Exception: # pragma: no cover - decode mit errors="ignore" wirft nicht
return None
counter: Counter[str] = Counter()
for match in _FILL_PATTERN.finditer(text):
value = match.group(1)
if value.lower().startswith("rgb("):
numbers = _RGB_PATTERN.match(value)
if numbers is None:
continue
value = rgb_to_hex(tuple(int(part) for part in numbers.groups())) # type: ignore[arg-type]
normalized = normalize_hex(value)
if normalized and not is_grayscale(hex_to_rgb(normalized)):
counter[normalized] += 1
for match in _STOP_COLOR_PATTERN.finditer(text):
normalized = normalize_hex(match.group(1))
if normalized and not is_grayscale(hex_to_rgb(normalized)):
counter[normalized] += 1
if not counter:
return None
return counter.most_common(1)[0][0]
# --- Rastergrafiken ------------------------------------------------------------
def _kmeans(pixels: list[tuple[int, int, int]], k: int = 4, iterations: int = 20):
"""Schlanker k-Means über RGB-Tripel. Liefert (Zentrum, Anzahl) je Cluster."""
if not pixels:
return []
unique = list(dict.fromkeys(pixels))
if len(unique) <= k:
counts = Counter(pixels)
return [(color, counts[color]) for color in unique]
# Deterministische Startpunkte: gleichmäßig über die sortierten Farben verteilt.
ordered = sorted(unique, key=lambda rgb: (relative_luminance(rgb), rgb))
centers = [ordered[round(index * (len(ordered) - 1) / (k - 1))] for index in range(k)]
for _ in range(iterations):
buckets: list[list[tuple[int, int, int]]] = [[] for _ in centers]
for pixel in pixels:
best = min(
range(len(centers)),
key=lambda index: sum(
(pixel[channel] - centers[index][channel]) ** 2 for channel in range(3)
),
)
buckets[best].append(pixel)
moved = False
for index, bucket in enumerate(buckets):
if not bucket:
continue
center = tuple(round(sum(p[c] for p in bucket) / len(bucket)) for c in range(3))
if center != centers[index]:
centers[index] = center # type: ignore[call-overload]
moved = True
if not moved:
break
result = [(centers[index], len(bucket)) for index, bucket in enumerate(buckets) if bucket]
return sorted(result, key=lambda item: item[1], reverse=True)
def color_from_raster(content: bytes) -> str | None:
"""Dominante Farbe einer Rastergrafik über k-Means (k=4) auf 64×64 Pixeln."""
try:
with Image.open(io.BytesIO(content)) as image:
image = image.convert("RGBA")
image.thumbnail((64, 64), Image.Resampling.LANCZOS)
pixels = [
(red, green, blue)
for red, green, blue, alpha in image.getdata()
if alpha >= 128 # durchscheinende Ränder verfälschen die Farbe
]
except (UnidentifiedImageError, OSError, ValueError):
logger.debug("Rastergrafik konnte nicht gelesen werden.", exc_info=True)
return None
if not pixels:
return None
clusters = _kmeans(pixels, k=4)
total = sum(count for _, count in clusters)
if not total:
return None
# Nur Cluster mit nennenswertem Anteil, davon der sattteste; bei ähnlicher
# Sättigung gewinnt der hellere.
relevant = [
(color, count) for color, count in clusters if count / total >= MIN_CLUSTER_SHARE
] or clusters
farbig = [(color, count) for color, count in relevant if not is_grayscale(color)]
if not farbig:
return None
best = max(farbig, key=lambda item: saturation_of(item[0]) * 2 + lightness_of(item[0]))
return rgb_to_hex(best[0])
def extract_color(content: bytes, mime: str) -> str | None:
"""Ermittelt die Markenfarbe passend zum Dateityp."""
if mime == "image/svg+xml":
return color_from_svg(content)
return color_from_raster(content)