Files
ladill-qr-plus/app/Services/Qr/QrImageGeneratorService.php
isaaccladandCursor cd6571f199
Deploy Ladill QR Plus / deploy (push) Failing after 1s
Extract Ladill QR Plus as standalone app at qr.ladill.com.
Utility QR types only (URL, WiFi, link list, business, app download) with
SSO, Billing API integration, public /q resolver, and qr-plus:import for
platform migration. vCard and commerce types stay on the platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:31:24 +00:00

489 lines
18 KiB
PHP

<?php
namespace App\Services\Qr;
use App\Models\QrCode as QrCodeModel;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrStyleDefaults;
use chillerlan\QRCode\Common\EccLevel;
use chillerlan\QRCode\Data\QRMatrix;
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\QRCode;
use Illuminate\Support\Facades\Storage;
class QrImageGeneratorService
{
public function generateAndStore(QrCodeModel $qrCode): void
{
$url = $qrCode->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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int> */
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<string, mixed> $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('/<svg[\s>]/i', $svg)) {
return null;
}
// Drop XML declaration / doctype.
$svg = preg_replace('/<\?xml.*?\?>/is', '', $svg);
$svg = preg_replace('/<!DOCTYPE.*?>/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('/<image\b(?:(?!href)[^>])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg);
$svg = trim($svg);
if (! str_contains($svg, '<svg')) {
return null;
}
// Guarantee namespaces + an XML prolog so the file renders in strict SVG
// viewers (Illustrator, Inkscape, macOS Preview, print pipelines), not just
// browsers. qr-code-styling's DOM serialization can omit xmlns:xlink, which
// browsers tolerate but standalone viewers reject (logo/refs fail to render).
if (preg_match('/<svg\b[^>]*>/i', $svg, $m)) {
$open = $m[0];
$fixed = $open;
if (! preg_match('/\sxmlns\s*=/i', $fixed)) {
$fixed = preg_replace('/<svg\b/i', '<svg xmlns="http://www.w3.org/2000/svg"', $fixed, 1);
}
if (str_contains($svg, 'xlink:') && ! preg_match('/\sxmlns:xlink\s*=/i', $fixed)) {
$fixed = preg_replace('/<svg\b/i', '<svg xmlns:xlink="http://www.w3.org/1999/xlink"', $fixed, 1);
}
if ($fixed !== $open) {
$svg = substr_replace($svg, $fixed, strpos($svg, $open), strlen($open));
}
}
if (! preg_match('/^\s*<\?xml/i', $svg)) {
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $svg;
}
return $svg;
}
public function isValidPngBinary(string $bytes): bool
{
if ($bytes === '') {
return false;
}
if (str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) {
return true;
}
// Legacy bug: PNG was stored as a base64 string instead of raw bytes.
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
return false;
}
return false;
}
public function normalizeStoredPng(?string $path): ?string
{
if (! $path || ! Storage::disk('qr')->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;
}
}