Deploy Ladill QR Plus / deploy (push) Successful in 28s
Full control center for ticketed events, contributions, attendees, badges, and programmes — not a QR utility clone. Includes SSO shell, import command, and platform cutover runbook. Co-authored-by: Cursor <cursoragent@cursor.com>
489 lines
18 KiB
PHP
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;
|
|
}
|
|
}
|