Files
ladill-give/app/Services/Qr/QrImageGeneratorService.php
isaaccladandCursor 0860b08af7 Initial Ladill Give extraction — online giving pages at give.ladill.com.
Donation checkout via Ladill Pay (9% fee), church/giving QR pages, SSO, and platform scan forwarding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 07:25:49 +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;
}
}