encodedPayload(); $style = $qrCode->style(); $basePath = $qrCode->user_id . '/codes/' . $qrCode->id; $pngBinary = $this->renderPng($url, $style); $svgMarkup = $this->renderSvg($url, $style); $pngPath = $basePath . '/qr.png'; $svgPath = $basePath . '/qr.svg'; Storage::disk('qr')->put($pngPath, $pngBinary); Storage::disk('qr')->put($svgPath, $svgMarkup); $qrCode->update([ 'png_path' => $pngPath, 'svg_path' => $svgPath, ]); } public function ensureValidImages(QrCodeModel $qrCode): QrCodeModel { if ($this->pngIsValid($qrCode->png_path)) { return $qrCode; } $this->generateAndStore($qrCode); return $qrCode->fresh(); } public function previewDataUri(QrCodeModel $qrCode): string { $qrCode = $this->ensureValidImages($qrCode); if ($qrCode->png_path && Storage::disk('qr')->exists($qrCode->png_path)) { $bytes = Storage::disk('qr')->get($qrCode->png_path); if ($this->isValidPngBinary($bytes)) { return 'data:image/png;base64,' . base64_encode($bytes); } } $png = $this->renderPng($qrCode->encodedPayload(), $qrCode->style()); $this->generateAndStore($qrCode); return 'data:image/png;base64,' . base64_encode($png); } public function logoDataUri(string $path): ?string { if (! Storage::disk('qr')->exists($path)) { return null; } $content = Storage::disk('qr')->get($path); if ($content === null) { return null; } $mimeType = Storage::disk('qr')->mimeType($path); if (! $mimeType) { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $mimeType = match ($ext) { 'png' => 'image/png', 'jpg', 'jpeg' => 'image/jpeg', 'svg' => 'image/svg+xml', 'gif' => 'image/gif', 'webp' => 'image/webp', default => 'image/png', }; } return 'data:' . $mimeType . ';base64,' . base64_encode($content); } /** * @param array $style */ public function renderPng(string $content, array $style): string { $style = QrStyleDefaults::mergeForRender($style); $options = $this->buildOptions($style, QRCode::OUTPUT_IMAGE_PNG); $binary = (new QRCode($options))->render($content); if ($style['logo_path'] && Storage::disk('qr')->exists($style['logo_path'])) { $binary = $this->applyLogo($binary, Storage::disk('qr')->path($style['logo_path'])); } return $this->applyFrame($binary, (string) ($style['frame_style'] ?? 'none'), $style); } /** * @param array $style */ public function renderSvg(string $content, array $style): string { $style = QrStyleDefaults::mergeForRender($style); $options = $this->buildOptions($style, QRCode::OUTPUT_MARKUP_SVG); return (new QRCode($options))->render($content); } /** @param array $style */ private function buildOptions(array $style, string $outputType): QROptions { $fg = $this->hexToRgb((string) $style['foreground']); $bg = $this->hexToRgb((string) $style['background']); $ecc = match ($style['error_correction']) { 'L' => EccLevel::L, 'Q' => EccLevel::Q, 'H' => EccLevel::H, default => EccLevel::M, }; $hasLogo = ! empty($style['logo_path']); $module = QrModuleStyleCatalog::optionsFor((string) $style['module_style']); $moduleUsesCircular = (bool) $module['circular']; $finderOuter = (string) ($style['finder_outer'] ?? 'square'); $finderInner = (string) ($style['finder_inner'] ?? 'square'); $finderUsesCircular = $finderOuter !== 'square' || $finderInner === 'dot'; $usesCircular = $moduleUsesCircular || $finderUsesCircular; $connectPaths = $module['connect_paths'] && $outputType === QRCode::OUTPUT_MARKUP_SVG; $circleRadius = (float) $module['circle_radius']; if ($finderOuter === 'circle') { $circleRadius = max($circleRadius, 0.5); } elseif ($finderOuter === 'rounded') { $circleRadius = max($circleRadius, 0.38); } $keepAsSquare = $this->resolveKeepAsSquare($moduleUsesCircular, $finderOuter, $finderInner); return new QROptions([ 'outputType' => $outputType, 'outputBase64' => false, 'scale' => (int) $style['scale'], 'eccLevel' => $ecc, 'addQuietzone' => true, 'quietzoneSize' => (int) $style['margin'], 'bgColor' => $bg, 'drawCircularModules' => $usesCircular, 'circleRadius' => $circleRadius, 'connectPaths' => $connectPaths, 'gdImageUseUpscale' => true, 'keepAsSquare' => $keepAsSquare, 'addLogoSpace' => $hasLogo, 'logoSpaceWidth' => $hasLogo ? 13 : null, 'logoSpaceHeight' => $hasLogo ? 13 : null, 'moduleValues' => [ QRMatrix::M_DATA_DARK => $fg, QRMatrix::M_FINDER_DARK => $fg, QRMatrix::M_ALIGNMENT_DARK => $fg, QRMatrix::M_TIMING_DARK => $fg, QRMatrix::M_FORMAT_DARK => $fg, QRMatrix::M_VERSION_DARK => $fg, QRMatrix::M_FINDER_DOT => $fg, ], ]); } /** @return list */ private function resolveKeepAsSquare(bool $moduleUsesCircular, string $finderOuter, string $finderInner): array { $finderUsesCircular = ($finderOuter !== 'square') || ($finderInner === 'dot') || ($finderInner === 'rounded'); if (! $moduleUsesCircular && ! $finderUsesCircular) { return []; } $keep = [ // Structural modules must always stay square for reliable scanning. QRMatrix::M_ALIGNMENT_DARK, QRMatrix::M_TIMING_DARK, QRMatrix::M_FORMAT_DARK, QRMatrix::M_VERSION_DARK, // White separator (M_FINDER light modules) must always stay square. // Removing it causes the circular dark-ring modules to lose their solid // white backdrop, which produces a broken / empty-looking eye pattern. QRMatrix::M_FINDER, ]; // Data modules stay square when only the finder style is circular. if (! $moduleUsesCircular) { $keep[] = QRMatrix::M_DATA_DARK; } // Outer finder frame: square keeps the ring solid; non-square renders it as dots. if ($finderOuter === 'square') { $keep[] = QRMatrix::M_FINDER_DARK; } // Inner finder dot: 'dot' and 'rounded' both get circular treatment. if ($finderInner !== 'dot' && $finderInner !== 'rounded') { $keep[] = QRMatrix::M_FINDER_DOT; } return array_values(array_unique($keep)); } /** @param array $style */ private function applyFrame(string $pngBinary, string $frameStyle, array $style = []): string { $frame = QrFrameStyleCatalog::all()[$frameStyle] ?? QrFrameStyleCatalog::all()['none']; $borderPx = (int) $frame['border_px']; $mode = (string) ($frame['mode'] ?? 'border'); if ($borderPx === 0 || ! extension_loaded('gd')) { return $pngBinary; } $source = @imagecreatefromstring($pngBinary); if (! $source) { return $pngBinary; } $width = imagesx($source); $height = imagesy($source); $labelHeight = in_array($mode, ['label', 'pill'], true) ? max(44, (int) round($height * 0.18)) : 0; $canvasWidth = $width + ($borderPx * 2); $canvasHeight = $height + ($borderPx * 2) + $labelHeight; $frameColorHex = trim((string) ($style['frame_color'] ?? '#000000')); if (! preg_match('/^#[0-9a-fA-F]{6}$/', $frameColorHex)) { $frameColorHex = '#000000'; } $canvas = imagecreatetruecolor($canvasWidth, $canvasHeight); $white = imagecolorallocate($canvas, 255, 255, 255); // For border mode the padding area IS the frame — fill with frame colour. // For label/pill modes the background stays white; only the CTA element uses frame colour. if ($mode === 'border') { [$fr, $fg, $fb] = $this->hexToRgb($frameColorHex); $frameBg = imagecolorallocate($canvas, $fr, $fg, $fb); imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $frameBg); } else { imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $white); } imagecopy($canvas, $source, $borderPx, $borderPx, 0, 0, $width, $height); if ($labelHeight > 0) { $customText = trim((string) ($style['frame_text'] ?? '')); $ctaText = $customText !== '' ? $customText : (string) ($frame['cta'] ?? 'SCAN ME'); $this->drawFrameLabel($canvas, $ctaText, $borderPx, $width, $height, $labelHeight, $mode, $frameColorHex); } ob_start(); imagepng($canvas); $result = (string) ob_get_clean(); imagedestroy($source); imagedestroy($canvas); return $result; } private function drawFrameLabel(\GdImage $canvas, string $text, int $borderPx, int $qrWidth, int $qrHeight, int $labelHeight, string $mode, string $frameColorHex = '#000000'): void { $canvasWidth = imagesx($canvas); $canvasHeight = imagesy($canvas); [$fr, $fg, $fb] = $this->hexToRgb($frameColorHex); $frameColor = imagecolorallocate($canvas, $fr, $fg, $fb); // Choose black or white text depending on frame colour luminance $luminance = (0.2126 * $fr + 0.7152 * $fg + 0.0722 * $fb) / 255; $textOnFrame = $luminance > 0.35 ? imagecolorallocate($canvas, 0, 0, 0) : imagecolorallocate($canvas, 255, 255, 255); $dividerColor = imagecolorallocate($canvas, $fr, $fg, $fb); $labelTop = $borderPx + $qrHeight; $labelBottom = $canvasHeight - max(8, (int) round($borderPx * 0.6)); if ($mode === 'pill') { $pillMargin = max(12, (int) round($borderPx * 0.9)); $pillTop = $labelTop + max(7, (int) round($labelHeight * 0.18)); $pillBottom = $labelBottom - max(5, (int) round($labelHeight * 0.12)); imagefilledrectangle($canvas, $pillMargin + 10, $pillTop, $canvasWidth - $pillMargin - 10, $pillBottom, $frameColor); imagefilledellipse($canvas, $pillMargin + 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor); imagefilledellipse($canvas, $canvasWidth - $pillMargin - 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor); $this->drawCenteredString($canvas, $text, $textOnFrame, 5, $pillTop, $pillBottom); return; } imageline($canvas, $borderPx + 8, $labelTop + 3, $borderPx + $qrWidth - 8, $labelTop + 3, $dividerColor); $this->drawCenteredString($canvas, $text, $frameColor, 5, $labelTop + 7, $labelBottom); } private function drawCenteredString(\GdImage $canvas, string $text, int $color, int $font, int $top, int $bottom): void { $text = strtoupper($text); $font = max(1, min(5, $font)); $textWidth = imagefontwidth($font) * strlen($text); $textHeight = imagefontheight($font); $x = max(0, (int) round((imagesx($canvas) - $textWidth) / 2)); $y = max($top, (int) round($top + (($bottom - $top - $textHeight) / 2))); imagestring($canvas, $font, $x, $y, $text, $color); } /** @return array{0: int, 1: int, 2: int} */ private function hexToRgb(string $hex): array { $hex = ltrim(trim($hex), '#'); if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) { return [0, 0, 0]; } return [ hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2)), ]; } private function applyLogo(string $pngBinary, string $logoPath): string { if (! extension_loaded('gd')) { return $pngBinary; } $qr = imagecreatefromstring($pngBinary); $logo = @imagecreatefromstring((string) file_get_contents($logoPath)); if (! $qr || ! $logo) { return $pngBinary; } $qrW = imagesx($qr); $qrH = imagesy($qr); $logoW = imagesx($logo); $logoH = imagesy($logo); $target = (int) round(min($qrW, $qrH) * 0.22); $resized = imagecreatetruecolor($target, $target); imagealphablending($resized, false); imagesavealpha($resized, true); $transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127); imagefilledrectangle($resized, 0, 0, $target, $target, $transparent); imagecopyresampled($resized, $logo, 0, 0, 0, 0, $target, $target, $logoW, $logoH); $pad = (int) round($target * 0.12); $bgSize = $target + ($pad * 2); $bgX = (int) (($qrW - $bgSize) / 2); $bgY = (int) (($qrH - $bgSize) / 2); $white = imagecolorallocate($qr, 255, 255, 255); imagefilledrectangle($qr, $bgX, $bgY, $bgX + $bgSize, $bgY + $bgSize, $white); imagecopy($qr, $resized, $bgX + $pad, $bgY + $pad, 0, 0, $target, $target); ob_start(); imagepng($qr); $result = (string) ob_get_clean(); imagedestroy($qr); imagedestroy($logo); imagedestroy($resized); return $result; } private function pngIsValid(?string $path): bool { if (! $path || ! Storage::disk('qr')->exists($path)) { return false; } return $this->isValidPngBinary(Storage::disk('qr')->get($path)); } /** * Sanitize a client-supplied QR SVG before storing it. QR SVGs are paths, * rects, gradients, clip-paths and an embedded data: logo — never scripts. * Strips script/foreignObject, inline event handlers, javascript: URIs and * any non-data external image/href references. Returns null if it isn't an * SVG. Downloads are served as attachments, so this is defence-in-depth. */ public function sanitizeSvg(string $svg): ?string { $svg = trim($svg); if (! preg_match('/]/i', $svg)) { return null; } // Drop XML declaration / doctype. $svg = preg_replace('/<\?xml.*?\?>/is', '', $svg); $svg = preg_replace('//is', '', $svg); // Remove dangerous elements entirely (with or without content). $svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg); $svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*/?>#is', '', $svg); // Strip inline event handlers (onload, onclick, …). $svg = preg_replace('/\son[a-z]+\s*=\s*"[^"]*"/i', '', $svg); $svg = preg_replace("/\son[a-z]+\s*=\s*'[^']*'/i", '', $svg); // Neutralise javascript: in any href / xlink:href. $svg = preg_replace('/((?:xlink:)?href)\s*=\s*"\s*javascript:[^"]*"/i', '$1="#"', $svg); $svg = preg_replace("/((?:xlink:)?href)\s*=\s*'\s*javascript:[^']*'/i", '$1="#"', $svg); // Remove external (non-data:) image references; keep embedded data: logos. $svg = preg_replace('/])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg); $svg = trim($svg); if (! str_contains($svg, ']*>/i', $svg, $m)) { $open = $m[0]; $fixed = $open; if (! preg_match('/\sxmlns\s*=/i', $fixed)) { $fixed = preg_replace('/exists($path)) { return null; } $bytes = Storage::disk('qr')->get($path); if ($this->isValidPngBinary($bytes)) { return $bytes; } if (str_starts_with($bytes, 'iVBORw0KGgo')) { $decoded = base64_decode($bytes, true); if ($decoded !== false && $this->isValidPngBinary($decoded)) { Storage::disk('qr')->put($path, $decoded); return $decoded; } } return null; } }