139 lines
4.2 KiB
PHP
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]);
|
|
}
|
|
}
|