Files
ladill-qr-plus/app/Support/Qr/QrStyleNormalizer.php
T
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

139 lines
4.2 KiB
PHP

<?php
namespace App\Support\Qr;
class QrStyleNormalizer
{
/**
* Silently tune styles so more phone cameras (including Samsung) can read the code.
*
* @param array<string, mixed> $style
* @return array<string, mixed>
*/
public static function normalize(array $style): array
{
$style = self::ensureContrast($style);
$style = self::ensureQuietZone($style);
$style = self::ensureRenderScale($style);
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureContrast(array $style): array
{
$target = 4.5;
$attempts = 0;
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
$fg = self::hexToRgb((string) $style['foreground']);
$bg = self::hexToRgb((string) $style['background']);
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
} else {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
}
$attempts++;
}
if (QrScanReliability::contrastRatio($style) < $target) {
$style['foreground'] = '#000000';
$style['background'] = '#ffffff';
}
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureQuietZone(array $style): array
{
$margin = (int) ($style['margin'] ?? 4);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$margin = max($margin, 5);
}
$style['margin'] = max(4, min(10, $margin));
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureRenderScale(array $style): array
{
$scale = (int) ($style['scale'] ?? 8);
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
if ($module['circular'] ?? false) {
$scale = max($scale, 10);
}
if (! empty($style['logo_path'])) {
$scale = max($scale, 10);
}
$style['scale'] = max(6, min(16, $scale));
return $style;
}
/** @return array{0: int, 1: int, 2: int} */
private static 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))];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function rgbToHex(array $rgb): string
{
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function darkenToward(array $rgb, float $amount): array
{
return [
(int) max(0, $rgb[0] * (1 - $amount)),
(int) max(0, $rgb[1] * (1 - $amount)),
(int) max(0, $rgb[2] * (1 - $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function lightenToward(array $rgb, float $amount): array
{
return [
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function relativeLuminance(array $rgb): float
{
$channels = [];
foreach ($rgb as $value) {
$v = $value / 255;
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}