From eea019bb68692c4097388aa4e3fa33307b0b9e18 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:39 +0200 Subject: [PATCH] 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. --- src/Controller/QrController.php | 306 ++++++++++++++++++++++++ src/Qr/LogoStore.php | 331 ++++++++++++++++++++++++++ src/Qr/QrOptions.php | 165 +++++++++++++ src/Qr/QrRenderer.php | 232 ++++++++++++++++++ src/Repository/QrDesignRepository.php | 70 ++++++ src/Support/Color.php | 114 +++++++++ 6 files changed, 1218 insertions(+) create mode 100644 src/Controller/QrController.php create mode 100644 src/Qr/LogoStore.php create mode 100644 src/Qr/QrOptions.php create mode 100644 src/Qr/QrRenderer.php create mode 100644 src/Repository/QrDesignRepository.php create mode 100644 src/Support/Color.php diff --git a/src/Controller/QrController.php b/src/Controller/QrController.php new file mode 100644 index 0000000..184482d --- /dev/null +++ b/src/Controller/QrController.php @@ -0,0 +1,306 @@ + $args + */ + public function show(Request $request, array $args = []): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + $link = $this->links->find((int) ($args['id'] ?? 0)); + if ($link === null) { + return $this->linkNotFound(); + } + + $options = $this->designs->forLink((int) $link['id']); + + return $this->html('qr/show', [ + 'link' => $link, + 'shortUrl' => $this->shortUrl($link), + 'options' => $options, + 'sizes' => QrOptions::SIZES, + 'levels' => QrOptions::ERROR_CORRECTION_LEVELS, + 'warning' => $options->contrastWarning(), + 'logoIsSvg' => $this->logos->isSvg($options->logoPath), + 'imagickAvailable' => class_exists(\Imagick::class), + 'pageTitle' => 'QR-Code: ' . $link['slug'], + ]); + } + + /** + * Einstellungen speichern. + * + * @param array $args + */ + public function save(Request $request, array $args = []): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + if (($response = $this->requireCsrf($this->csrf, $request)) !== null) { + return $response; + } + + $link = $this->links->find((int) ($args['id'] ?? 0)); + if ($link === null) { + return $this->linkNotFound(); + } + + $current = $this->designs->forLink((int) $link['id']); + $options = QrOptions::fromInput($request->post, $current); + $this->designs->save((int) $link['id'], $options); + + $this->session->flash('success', 'QR-Einstellungen wurden gespeichert.'); + + return $this->redirect('/admin/links/' . (int) $link['id'] . '/qr'); + } + + /** + * @param array $args + */ + public function uploadLogo(Request $request, array $args = []): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + if (($response = $this->requireCsrf($this->csrf, $request)) !== null) { + return $response; + } + + $link = $this->links->find((int) ($args['id'] ?? 0)); + if ($link === null) { + return $this->linkNotFound(); + } + + $linkId = (int) $link['id']; + $current = $this->designs->forLink($linkId); + + try { + /** @var array $file */ + $file = $request->files['logo'] ?? []; + $filename = $this->logos->store($file, $linkId); + + if ($current->hasLogo()) { + $this->logos->delete($current->logoPath); + } + + // Mit Logo wird die Fehlerkorrektur automatisch auf H gesetzt. + $this->designs->save($linkId, $current->withLogoPath($filename)); + + $message = 'Logo wurde hochgeladen. Fehlerkorrektur automatisch auf Level H gesetzt.'; + if ($this->logos->isSvg($filename) && !class_exists(\Imagick::class)) { + $message .= ' Hinweis: Ohne die PHP-Erweiterung "imagick" kann das SVG-Logo nur im SVG-Download eingebettet werden.'; + } + + $this->session->flash('success', $message); + } catch (Throwable $e) { + $this->session->flash('error', $e->getMessage()); + } + + return $this->redirect('/admin/links/' . $linkId . '/qr'); + } + + /** + * @param array $args + */ + public function deleteLogo(Request $request, array $args = []): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + if (($response = $this->requireCsrf($this->csrf, $request)) !== null) { + return $response; + } + + $link = $this->links->find((int) ($args['id'] ?? 0)); + if ($link === null) { + return $this->linkNotFound(); + } + + $linkId = (int) $link['id']; + $current = $this->designs->forLink($linkId); + + if ($current->hasLogo()) { + $this->logos->delete($current->logoPath); + $this->designs->clearLogo($linkId); + } + + $this->session->flash('success', 'Logo wurde entfernt.'); + + return $this->redirect('/admin/links/' . $linkId . '/qr'); + } + + /** + * Live-Vorschau: rendert den QR-Code mit den Parametern aus der + * Query-String, ohne etwas zu speichern. + * + * @param array $args + */ + public function preview(Request $request, array $args = []): Response + { + if (!$this->auth->check()) { + return Response::html('Unauthorized', 401); + } + + $link = $this->links->find((int) ($args['id'] ?? 0)); + if ($link === null) { + return Response::html('Not Found', 404); + } + + $options = QrOptions::fromInput($request->query, $this->designs->forLink((int) $link['id'])); + + // Vorschau immer klein rendern - schnell und ausreichend scharf. + $preview = clone $options; + $preview->size = 512; + + try { + $body = $this->renderer->png($this->shortUrl($link), $preview); + } catch (Throwable $e) { + // Fallback: ohne Logo rendern, damit die Vorschau nie leer + // bleibt. Die Ursache landet im Fehlerprotokoll, nicht in + // der Antwort - Ausnahmetexte koennen Serverpfade enthalten. + error_log('[urlshort] QR-Vorschau ohne Logo: ' . $e->getMessage()); + + $body = $this->renderer->png($this->shortUrl($link), $preview->withLogoPath(null)); + + return Response::binary($body, 'image/png', ['Cache-Control' => 'no-store']); + } + + return Response::binary($body, 'image/png', ['Cache-Control' => 'no-store']); + } + + /** + * @param array $args + */ + public function download(Request $request, array $args = []): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + $link = $this->links->find((int) ($args['id'] ?? 0)); + if ($link === null) { + return $this->linkNotFound(); + } + + $format = strtolower($request->query('format', 'png')) === 'svg' ? 'svg' : 'png'; + $options = QrOptions::fromInput($request->query, $this->designs->forLink((int) $link['id'])); + $filename = sprintf('qr-%s-%dpx.%s', (string) $link['slug'], $options->size, $format); + + try { + $body = $format === 'svg' + ? $this->renderer->svg($this->shortUrl($link), $options) + : $this->renderer->png($this->shortUrl($link), $options); + } catch (Throwable $e) { + $this->session->flash('error', 'Der QR-Code konnte nicht erzeugt werden: ' . $e->getMessage()); + + return $this->redirect('/admin/links/' . (int) $link['id'] . '/qr'); + } + + return Response::binary( + $body, + $format === 'svg' ? 'image/svg+xml' : 'image/png', + [ + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + 'Content-Length' => (string) strlen($body), + 'Cache-Control' => 'no-store', + ], + ); + } + + /** + * Liefert hochgeladene Logos aus. Die Dateien liegen ausserhalb des + * Webroots und sind nur nach Login erreichbar. + * + * @param array $args + */ + public function serveUpload(Request $request, array $args = []): Response + { + if (!$this->auth->check()) { + return Response::html('Unauthorized', 401); + } + + $path = $this->logos->pathFor($args['file'] ?? ''); + if ($path === null) { + return Response::html('Not Found', 404); + } + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $type = match ($extension) { + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'svg' => 'image/svg+xml', + default => 'application/octet-stream', + }; + + // Uploads werden strikt abgeschottet ausgeliefert: "sandbox" + // verhindert, dass ein praepariertes SVG im Ursprung dieser + // Anwendung Skripte ausfuehrt, "default-src 'none'" unterbindet + // jedes Nachladen. Zusammen mit der Bereinigung beim Upload ist + // gespeichertes XSS damit doppelt abgesichert. + return Response::binary((string) file_get_contents($path), $type, [ + 'Cache-Control' => 'private, max-age=300', + 'Content-Disposition' => 'inline; filename="' . rawurlencode(basename($path)) . '"', + 'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; sandbox", + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + /** + * Kurz-URL aus der Domain des Links - nicht aus APP_BASE_URL, + * damit der QR-Code auf die richtige Domain zeigt. + * + * @param array $link + */ + private function shortUrl(array $link): string + { + return LinkRepository::shortUrlOf($link, $this->config->baseUrl()); + } + + private function linkNotFound(): Response + { + return $this->html('errors/error', [ + 'title' => 'Kurzlink nicht gefunden', + 'message' => 'Der gewünschte Kurzlink existiert nicht (mehr).', + ], 404); + } +} diff --git a/src/Qr/LogoStore.php b/src/Qr/LogoStore.php new file mode 100644 index 0000000..5f0f3e7 --- /dev/null +++ b/src/Qr/LogoStore.php @@ -0,0 +1,331 @@ + 'png', + 'image/jpeg' => 'jpg', + 'image/svg+xml' => 'svg', + ]; + + /** Obergrenze je Kante – schuetzt vor sehr grossen Bildern beim Skalieren. */ + private const MAX_DIMENSION = 4000; + + public function __construct( + private readonly string $directory, + private readonly int $maxBytes = 2097152, + ) { + } + + public function directory(): string + { + return $this->directory; + } + + /** + * Speichert einen Upload und liefert den Dateinamen zurueck. + * + * @param array $file Eintrag aus $_FILES + */ + public function store(array $file, int $linkId): string + { + $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); + + if ($error === UPLOAD_ERR_NO_FILE) { + throw new RuntimeException('Es wurde keine Datei ausgewaehlt.'); + } + + if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) { + throw new RuntimeException('Die Datei ist zu gross.'); + } + + if ($error !== UPLOAD_ERR_OK) { + throw new RuntimeException('Der Upload ist fehlgeschlagen (Fehlercode ' . $error . ').'); + } + + $tmpPath = (string) ($file['tmp_name'] ?? ''); + if ($tmpPath === '' || !is_readable($tmpPath)) { + throw new RuntimeException('Die hochgeladene Datei konnte nicht gelesen werden.'); + } + + $size = (int) ($file['size'] ?? 0); + if ($size <= 0 || $size > $this->maxBytes) { + throw new RuntimeException(sprintf( + 'Die Datei darf hoechstens %d KB gross sein.', + intdiv($this->maxBytes, 1024), + )); + } + + $mime = $this->detectMimeType($tmpPath, (string) ($file['name'] ?? '')); + if (!isset(self::ALLOWED[$mime])) { + throw new RuntimeException('Nur PNG, JPG und SVG werden als Logo unterstuetzt.'); + } + + $extension = self::ALLOWED[$mime]; + + // Rastergrafiken schon hier gegen GD pruefen: getimagesize() liest + // nur den Header und akzeptiert auch beschaedigte Dateien, die beim + // Erzeugen des QR-Codes dann scheitern wuerden. + if ($extension !== 'svg') { + $this->assertRasterImageIsUsable($tmpPath); + } + $filename = sprintf('logo-%d-%s.%s', $linkId, bin2hex(random_bytes(6)), $extension); + $target = $this->directory . '/' . $filename; + + $this->ensureDirectory(); + + if ($extension === 'svg') { + $sanitized = $this->sanitizeSvg((string) file_get_contents($tmpPath)); + if (file_put_contents($target, $sanitized) === false) { + throw new RuntimeException('Das Logo konnte nicht gespeichert werden.'); + } + } else { + $moved = is_uploaded_file($tmpPath) + ? move_uploaded_file($tmpPath, $target) + : rename($tmpPath, $target); + + if (!$moved) { + throw new RuntimeException('Das Logo konnte nicht gespeichert werden.'); + } + } + + @chmod($target, 0644); + + return $filename; + } + + /** + * Stellt sicher, dass GD die Datei tatsaechlich dekodieren kann und + * die Abmessungen im vernuenftigen Rahmen liegen. + */ + private function assertRasterImageIsUsable(string $path): void + { + $info = @getimagesize($path); + if (!is_array($info) || (int) $info[0] < 1 || (int) $info[1] < 1) { + throw new RuntimeException('Die Bilddatei konnte nicht gelesen werden.'); + } + + if ((int) $info[0] > self::MAX_DIMENSION || (int) $info[1] > self::MAX_DIMENSION) { + throw new RuntimeException(sprintf( + 'Das Logo ist mit %d x %d Pixeln zu gross (maximal %d Pixel je Kante).', + (int) $info[0], + (int) $info[1], + self::MAX_DIMENSION, + )); + } + + $image = @imagecreatefromstring((string) file_get_contents($path)); + if ($image === false) { + throw new RuntimeException( + 'Die Bilddatei ist beschaedigt oder wird von diesem Server nicht unterstuetzt. ' + . 'Bitte ein anderes PNG oder JPG verwenden.', + ); + } + + imagedestroy($image); + } + + private function detectMimeType(string $path, string $originalName): string + { + $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + + // SVG ist XML - finfo meldet je nach System text/plain oder image/svg+xml. + if ($extension === 'svg') { + $head = (string) file_get_contents($path, false, null, 0, 1024); + + return str_contains($head, '#is' => '', + // ... auch als selbstschliessende Variante + '#<\s*(script|foreignObject|iframe|embed|object|handler)\b[^>]*/\s*>#i' => '', + // Doctype, Entities (XXE) und Verarbeitungsanweisungen - + // eine xml-stylesheet-Anweisung kann externes CSS nachladen. + '##is' => '', + '##is' => '', + '#<\?xml-stylesheet.*?\?>#is' => '', + // Event-Handler (onload, onclick, onbegin, ...) + '#\son[a-z]+\s*=\s*"[^"]*"#i' => '', + "#\son[a-z]+\s*=\s*'[^']*'#i" => '', + '#\son[a-z]+\s*=\s*[^\s>]+#i' => '', + // CSS-Importe + '#@import\b[^;]*;?#i' => '', + ]; + + foreach ($replacements as $pattern => $replacement) { + $svg = preg_replace($pattern, $replacement, $svg) ?? $svg; + } + + $svg = $this->stripExternalReferences($svg); + + return trim($svg); + } + + /** + * Laesst nur harmlose Verweise stehen: Fragmente (#id) und + * eingebettete Bilder als data:-URI. Alles andere - http, https, + * file, javascript, //host - wird entfernt. + */ + private function stripExternalReferences(string $svg): string + { + $svg = preg_replace_callback( + '#\s(?:xlink:)?href\s*=\s*(["\'])(.*?)\1#is', + static function (array $matches): string { + $value = trim(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + if (str_starts_with($value, '#')) { + return $matches[0]; + } + + if (preg_match('#^data:image/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=\s]*$#i', $value) === 1) { + return $matches[0]; + } + + return ''; + }, + $svg, + ) ?? $svg; + + // url(...) in Attributen und Style-Bloecken: nur Fragmente behalten. + return preg_replace_callback( + '#url\(\s*(["\']?)([^)"\']*)\1\s*\)#i', + static function (array $matches): string { + $value = trim($matches[2]); + + return str_starts_with($value, '#') ? $matches[0] : 'none'; + }, + $svg, + ) ?? $svg; + } + + public function pathFor(?string $filename): ?string + { + if ($filename === null || $filename === '') { + return null; + } + + // Kein Verzeichniswechsel zulassen. + $filename = basename($filename); + $path = $this->directory . '/' . $filename; + + return is_file($path) ? $path : null; + } + + public function delete(?string $filename): void + { + $path = $this->pathFor($filename); + if ($path !== null) { + @unlink($path); + } + + // Ggf. gecachte Rasterversion mitloeschen. + if ($filename !== null && str_ends_with(strtolower($filename), '.svg')) { + @unlink($this->directory . '/' . basename($filename) . '.raster.png'); + } + } + + public function isSvg(?string $filename): bool + { + return $filename !== null && str_ends_with(strtolower($filename), '.svg'); + } + + /** + * Liefert einen Pfad, den GD verarbeiten kann. SVGs werden dafuer + * mit Imagick gerastert (sofern verfuegbar) und zwischengespeichert. + */ + public function rasterPathFor(?string $filename, int $width): ?string + { + $path = $this->pathFor($filename); + if ($path === null) { + return null; + } + + if (!$this->isSvg($filename)) { + return $path; + } + + if (!class_exists(\Imagick::class)) { + throw new RuntimeException( + 'SVG-Logos koennen fuer PNG-Ausgaben nur mit der PHP-Erweiterung "imagick" gerastert werden. ' + . 'Bitte ein PNG-Logo verwenden oder den SVG-Download nutzen.', + ); + } + + $cache = $this->directory . '/' . basename((string) $filename) . '.raster.png'; + if (is_file($cache) && filemtime($cache) >= filemtime($path)) { + return $cache; + } + + try { + $imagick = new \Imagick(); + $imagick->setBackgroundColor(new \ImagickPixel('transparent')); + $imagick->readImage($path); + $imagick->setImageFormat('png32'); + $imagick->resizeImage(max(32, $width), 0, \Imagick::FILTER_LANCZOS, 1); + $imagick->writeImage($cache); + $imagick->clear(); + } catch (\Throwable $e) { + throw new RuntimeException('Das SVG-Logo konnte nicht in eine Rastergrafik umgewandelt werden.', 0, $e); + } + + return $cache; + } + + private function ensureDirectory(): void + { + if (!is_dir($this->directory) && !@mkdir($this->directory, 0775, true) && !is_dir($this->directory)) { + throw new RuntimeException(sprintf('Upload-Verzeichnis "%s" konnte nicht angelegt werden.', $this->directory)); + } + + if (!is_writable($this->directory)) { + throw new RuntimeException(sprintf('Upload-Verzeichnis "%s" ist nicht beschreibbar.', $this->directory)); + } + } +} diff --git a/src/Qr/QrOptions.php b/src/Qr/QrOptions.php new file mode 100644 index 0000000..08f5e8c --- /dev/null +++ b/src/Qr/QrOptions.php @@ -0,0 +1,165 @@ + */ + public const SIZES = [256, 512, 1024, 2048]; + + /** @var list */ + public const ERROR_CORRECTION_LEVELS = ['L', 'M', 'Q', 'H']; + + public const MIN_MARGIN = 0; + public const MAX_MARGIN = 10; + public const MIN_LOGO_SCALE = 10; + public const MAX_LOGO_SCALE = 30; + + public function __construct( + public string $foreground = '#000000', + public string $background = '#ffffff', + public int $size = 512, + /** Quiet Zone in Modulen (die Spezifikation empfiehlt 4). */ + public int $margin = 4, + public string $errorCorrection = 'M', + public ?string $logoPath = null, + /** Logobreite in Prozent der QR-Breite. */ + public int $logoScale = 22, + public bool $logoPunchout = true, + ) { + $this->normalize(); + } + + /** + * @param array $row + */ + public static function fromRow(array $row): self + { + return new self( + foreground: (string) ($row['foreground_color'] ?? '#000000'), + background: (string) ($row['background_color'] ?? '#ffffff'), + size: (int) ($row['size'] ?? 512), + margin: (int) ($row['margin'] ?? 4), + errorCorrection: (string) ($row['error_correction'] ?? 'M'), + logoPath: isset($row['logo_path']) && $row['logo_path'] !== '' ? (string) $row['logo_path'] : null, + logoScale: (int) ($row['logo_scale'] ?? 22), + logoPunchout: (bool) ($row['logo_punchout'] ?? true), + ); + } + + /** + * Baut Optionen aus (ungepruefter) Benutzereingabe. + * + * @param array $input + */ + public static function fromInput(array $input, ?QrOptions $base = null): self + { + $base ??= new self(); + + $value = static fn (string $key, string $fallback): string => isset($input[$key]) && is_scalar($input[$key]) + ? trim((string) $input[$key]) + : $fallback; + + $options = new self( + foreground: $value('foreground', $base->foreground), + background: $value('background', $base->background), + size: (int) $value('size', (string) $base->size), + margin: (int) $value('margin', (string) $base->margin), + errorCorrection: $value('error_correction', $base->errorCorrection), + logoPath: $base->logoPath, + logoScale: (int) $value('logo_scale', (string) $base->logoScale), + logoPunchout: isset($input['logo_punchout']) + ? in_array((string) $input['logo_punchout'], ['1', 'on', 'true'], true) + : $base->logoPunchout, + ); + + return $options; + } + + private function normalize(): void + { + $this->foreground = Color::normalizeHex($this->foreground, '#000000'); + $this->background = Color::normalizeHex($this->background, '#ffffff'); + + if (!in_array($this->size, self::SIZES, true)) { + $this->size = 512; + } + + $this->margin = max(self::MIN_MARGIN, min(self::MAX_MARGIN, $this->margin)); + $this->logoScale = max(self::MIN_LOGO_SCALE, min(self::MAX_LOGO_SCALE, $this->logoScale)); + + $level = strtoupper($this->errorCorrection); + $this->errorCorrection = in_array($level, self::ERROR_CORRECTION_LEVELS, true) ? $level : 'M'; + + // Mit Logo ist Level H Pflicht, damit der Code lesbar bleibt. + if ($this->hasLogo()) { + $this->errorCorrection = 'H'; + } + } + + public function hasLogo(): bool + { + return $this->logoPath !== null && $this->logoPath !== ''; + } + + public function withLogoPath(?string $path): self + { + $clone = clone $this; + $clone->logoPath = $path; + $clone->normalize(); + + return $clone; + } + + public function contrastWarning(): ?string + { + return Color::qrWarning($this->foreground, $this->background); + } + + public function contrastRatio(): float + { + return Color::contrastRatio($this->foreground, $this->background); + } + + /** + * Parameter fuer die Vorschau-URL. + * + * @return array + */ + public function toQueryParams(): array + { + return [ + 'foreground' => $this->foreground, + 'background' => $this->background, + 'size' => (string) $this->size, + 'margin' => (string) $this->margin, + 'error_correction' => $this->errorCorrection, + 'logo_scale' => (string) $this->logoScale, + 'logo_punchout' => $this->logoPunchout ? '1' : '0', + ]; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'foreground' => $this->foreground, + 'background' => $this->background, + 'size' => $this->size, + 'margin' => $this->margin, + 'error_correction' => $this->errorCorrection, + 'logo_path' => $this->logoPath, + 'logo_scale' => $this->logoScale, + 'logo_punchout' => $this->logoPunchout, + ]; + } +} diff --git a/src/Qr/QrRenderer.php b/src/Qr/QrRenderer.php new file mode 100644 index 0000000..67da2c9 --- /dev/null +++ b/src/Qr/QrRenderer.php @@ -0,0 +1,232 @@ +hasLogo()) { + $logoPath = $this->logoStore->rasterPathFor( + $options->logoPath, + (int) round($options->size * $options->logoScale / 100), + ); + + if ($logoPath !== null) { + $logo = new Logo( + path: $logoPath, + resizeToWidth: (int) round($options->size * $options->logoScale / 100), + punchoutBackground: $options->logoPunchout, + ); + } + } + + return (new PngWriter())->write($this->buildQrCode($data, $options), $logo)->getString(); + } + + public function svg(string $data, QrOptions $options): string + { + $writer = new SvgWriter(); + $qrCode = $this->buildQrCode($data, $options); + $writerOptions = [SvgWriter::WRITER_OPTION_EXCLUDE_XML_DECLARATION => false]; + + $logoPath = $options->hasLogo() ? $this->logoStore->pathFor($options->logoPath) : null; + + if ($logoPath === null) { + return $writer->write($qrCode, null, null, $writerOptions)->getString(); + } + + // SVG-Logos betten wir selbst ein: die Bibliothek verlangt dafuer + // feste Pixelmasse und eine zuverlaessige MIME-Erkennung, was je + // nach Server nicht gegeben ist. + if ($this->logoStore->isSvg($options->logoPath)) { + return $this->embedLogoInSvg( + $writer->write($qrCode, null, null, $writerOptions)->getString(), + $logoPath, + $options, + ); + } + + $side = (int) round($options->size * $options->logoScale / 100); + + try { + return $writer->write( + $qrCode, + new Logo( + path: $logoPath, + resizeToWidth: $side, + resizeToHeight: $side, + punchoutBackground: $options->logoPunchout, + ), + null, + $writerOptions, + )->getString(); + } catch (\Throwable) { + // Fallback: Logo selbst einbetten, damit der Download nie scheitert. + return $this->embedLogoInSvg( + $writer->write($qrCode, null, null, $writerOptions)->getString(), + $logoPath, + $options, + ); + } + } + + private function buildQrCode(string $data, QrOptions $options): QrCode + { + [$innerSize, $marginPx] = $this->resolveSizes($data, $options); + + return new QrCode( + data: $data, + encoding: new Encoding('UTF-8'), + errorCorrectionLevel: $this->errorCorrectionLevel($options->errorCorrection), + size: $innerSize, + margin: $marginPx, + roundBlockSizeMode: RoundBlockSizeMode::Margin, + foregroundColor: $this->color($options->foreground), + backgroundColor: $this->color($options->background), + ); + } + + /** + * Rechnet den in Modulen angegebenen Rand in Pixel um, sodass das + * Endbild exakt die gewuenschte Kantenlaenge hat. + * + * @return array{0:int,1:int} innere Groesse, Rand in Pixel + */ + private function resolveSizes(string $data, QrOptions $options): array + { + if ($options->margin <= 0) { + return [$options->size, 0]; + } + + $probe = new QrCode( + data: $data, + encoding: new Encoding('UTF-8'), + errorCorrectionLevel: $this->errorCorrectionLevel($options->errorCorrection), + size: $options->size, + margin: 0, + roundBlockSizeMode: RoundBlockSizeMode::Margin, + ); + + try { + $blockCount = (new MatrixFactory())->create($probe)->getBlockCount(); + } catch (\Throwable) { + $blockCount = 33; // konservativer Mittelwert + } + + $blockCount = max(21, $blockCount); + $marginPx = (int) round($options->size / ($blockCount + 2 * $options->margin) * $options->margin); + $marginPx = max(0, min($marginPx, (int) floor($options->size * 0.25))); + $innerSize = max(64, $options->size - 2 * $marginPx); + + return [$innerSize, $marginPx]; + } + + private function errorCorrectionLevel(string $level): ErrorCorrectionLevel + { + return match (strtoupper($level)) { + 'L' => ErrorCorrectionLevel::Low, + 'Q' => ErrorCorrectionLevel::Quartile, + 'H' => ErrorCorrectionLevel::High, + default => ErrorCorrectionLevel::Medium, + }; + } + + private function color(string $hex): EndroidColor + { + [$r, $g, $b] = \App\Support\Color::toRgb($hex); + + return new EndroidColor($r, $g, $b); + } + + /** + * Betten ein Logo als mittig in ein fertiges SVG ein. + */ + private function embedLogoInSvg(string $svg, string $logoPath, QrOptions $options): string + { + if (!is_file($logoPath)) { + return $svg; + } + + if (preg_match('/]*\bviewBox\s*=\s*"([^"]+)"/i', $svg, $matches) !== 1) { + return $svg; + } + + $parts = preg_split('/[\s,]+/', trim($matches[1])) ?: []; + if (count($parts) !== 4) { + return $svg; + } + + $viewWidth = (float) $parts[2]; + $viewHeight = (float) $parts[3]; + $logoWidth = $viewWidth * $options->logoScale / 100; + $logoHeight = $logoWidth; + $x = ($viewWidth - $logoWidth) / 2; + $y = ($viewHeight - $logoHeight) / 2; + + $mime = str_ends_with(strtolower($logoPath), '.svg') + ? 'image/svg+xml' + : (string) (@getimagesize($logoPath)['mime'] ?? 'image/png'); + + $punchout = ''; + if ($options->logoPunchout) { + $pad = $logoWidth * 0.08; + $punchout = sprintf( + '', + $this->num($x - $pad), + $this->num($y - $pad), + $this->num($logoWidth + 2 * $pad), + $this->num($logoHeight + 2 * $pad), + htmlspecialchars($options->background, ENT_QUOTES, 'UTF-8'), + ); + } + + $image = sprintf( + '', + $this->num($x), + $this->num($y), + $this->num($logoWidth), + $this->num($logoHeight), + $mime, + base64_encode((string) file_get_contents($logoPath)), + ); + + $position = strripos($svg, ''); + if ($position === false) { + throw new RuntimeException('Unerwartete SVG-Ausgabe.'); + } + + return substr($svg, 0, $position) . $punchout . $image . substr($svg, $position); + } + + private function num(float $value): string + { + return rtrim(rtrim(number_format($value, 3, '.', ''), '0'), '.'); + } +} diff --git a/src/Repository/QrDesignRepository.php b/src/Repository/QrDesignRepository.php new file mode 100644 index 0000000..d5a80c3 --- /dev/null +++ b/src/Repository/QrDesignRepository.php @@ -0,0 +1,70 @@ +db->fetchOne('SELECT * FROM qr_designs WHERE link_id = ?', [$linkId]); + + return $row === null ? new QrOptions() : QrOptions::fromRow($row); + } + + public function save(int $linkId, QrOptions $options): void + { + $now = gmdate('Y-m-d H:i:s'); + $exists = $this->db->fetchValue('SELECT 1 FROM qr_designs WHERE link_id = ?', [$linkId]) !== null; + + $params = [ + $options->foreground, + $options->background, + $options->size, + $options->margin, + $options->errorCorrection, + $options->logoPath, + $options->logoScale, + $options->logoPunchout ? 1 : 0, + $now, + $linkId, + ]; + + if ($exists) { + $this->db->execute( + 'UPDATE qr_designs SET foreground_color = ?, background_color = ?, size = ?, margin = ?, + error_correction = ?, logo_path = ?, logo_scale = ?, logo_punchout = ?, updated_at = ? + WHERE link_id = ?', + $params, + ); + + return; + } + + $this->db->execute( + 'INSERT INTO qr_designs + (foreground_color, background_color, size, margin, error_correction, logo_path, logo_scale, logo_punchout, updated_at, link_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + $params, + ); + } + + public function clearLogo(int $linkId): void + { + $this->db->execute( + 'UPDATE qr_designs SET logo_path = NULL, updated_at = ? WHERE link_id = ?', + [gmdate('Y-m-d H:i:s'), $linkId], + ); + } +} diff --git a/src/Support/Color.php b/src/Support/Color.php new file mode 100644 index 0000000..cc844b1 --- /dev/null +++ b/src/Support/Color.php @@ -0,0 +1,114 @@ + 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; + } +}