Files
moneyfyandClaude Opus 5 8edb69fe6a 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
2026-09-09 13:54:21 +02:00

268 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)