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; + } +}