feat(qr): QR-Code-Generator mit Farben, Logo und Live-Vorschau

Erzeugt PNG und SVG ueber endroid/qr-code. Die eingestellte Groesse gilt
fuer das fertige Bild inklusive Quiet Zone; der Rand wird in Modulen
angegeben und ueber die Matrix in Pixel umgerechnet.

Kontrastwarnung nach WCAG, zusaetzlich Hinweis auf invertierte Codes -
die erkennen viele Scanner nicht. Mit Logo wird die Fehlerkorrektur
zwingend auf Level H gesetzt.

Uploads werden gegen GD geprueft (getimagesize allein akzeptiert auch
beschaedigte Dateien), in der Groesse und den Abmessungen begrenzt und
ausserhalb des Webroots abgelegt. SVGs werden vor dem Speichern von
Skripten, Event-Handlern, Entities und externen Referenzen befreit.
This commit is contained in:
2026-08-03 18:11:39 +02:00
parent f48f7916b3
commit eea019bb68
6 changed files with 1218 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Hex-Farben, Kontrastberechnung (WCAG) fuer die QR-Code-Warnung.
*/
final class Color
{
/**
* Empfohlenes Mindest-Kontrastverhaeltnis fuer zuverlaessig
* scannbare QR-Codes.
*/
public const MIN_CONTRAST_RATIO = 4.0;
public static function isValidHex(string $hex): bool
{
return (bool) preg_match('/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', trim($hex));
}
/**
* Normalisiert auf "#rrggbb" (Kleinbuchstaben). Ungueltige Werte
* ergeben den Fallback.
*/
public static function normalizeHex(string $hex, string $fallback = '#000000'): string
{
$hex = strtolower(trim($hex));
if (!self::isValidHex($hex)) {
return $fallback;
}
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
return '#' . $hex;
}
/**
* @return array{0:int,1:int,2:int} r, g, b
*/
public static function toRgb(string $hex): array
{
$hex = ltrim(self::normalizeHex($hex), '#');
return [
(int) hexdec(substr($hex, 0, 2)),
(int) hexdec(substr($hex, 2, 2)),
(int) hexdec(substr($hex, 4, 2)),
];
}
/**
* Relative Luminanz nach WCAG 2.1.
*/
public static function luminance(string $hex): float
{
$channels = array_map(static function (int $value): float {
$c = $value / 255;
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
}, self::toRgb($hex));
return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
}
/**
* Kontrastverhaeltnis zwischen 1.0 und 21.0.
*/
public static function contrastRatio(string $foreground, string $background): float
{
$l1 = self::luminance($foreground);
$l2 = self::luminance($background);
$lighter = max($l1, $l2);
$darker = min($l1, $l2);
return round(($lighter + 0.05) / ($darker + 0.05), 2);
}
/**
* QR-Codes brauchen zusaetzlich einen dunklen Vordergrund auf hellem
* Grund - invertierte Codes werden von vielen Scannern nicht erkannt.
*/
public static function isInverted(string $foreground, string $background): bool
{
return self::luminance($foreground) > self::luminance($background);
}
/**
* Liefert eine Warnung oder null, wenn die Kombination unbedenklich ist.
*/
public static function qrWarning(string $foreground, string $background): ?string
{
$ratio = self::contrastRatio($foreground, $background);
if ($ratio < self::MIN_CONTRAST_RATIO) {
return sprintf(
'Zu geringer Kontrast (%.1f:1, empfohlen mindestens %.1f:1). Der QR-Code ist moeglicherweise nicht zuverlaessig scannbar.',
$ratio,
self::MIN_CONTRAST_RATIO,
);
}
if (self::isInverted($foreground, $background)) {
return 'Heller Vordergrund auf dunklem Hintergrund: viele Scanner erkennen invertierte QR-Codes nicht. Besser dunkle Module auf hellem Grund.';
}
return null;
}
}