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

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]);
}
}