From be5f11c5ed21263c1abf0681e262769ed1c1b053 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 12 Jul 2026 14:21:10 +0200 Subject: [PATCH] Brand short-link QR PNGs with centered app logo. Use QrBrandedRenderer with high EC qrencode and white-bordered logo plate. Co-authored-by: Cursor --- assets/ac_qr_logo.png | Bin 0 -> 3828 bytes src/QrBrandedRenderer.php | 112 ++++++++++++++++++++++++++++++++++++++ src/QrHandler.php | 18 +----- src/bootstrap.php | 1 + 4 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 assets/ac_qr_logo.png create mode 100644 src/QrBrandedRenderer.php diff --git a/assets/ac_qr_logo.png b/assets/ac_qr_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d95f9cb5d5649cd8dec5b561e7845ad03a64c908 GIT binary patch literal 3828 zcmV00006VoOIv0RI60 z0RN!9r;`8x4yj2*K~!jg)tPyGR8^M8&%JN0dR3`PDhm=p1RHFTK^X-R5kWK`Nvi6t z_ueyqR6=LP1R-?){(Duo-uwRUIrqMEZV~tkkYNNWE4OXj_)%wfw_zAFXU;lt_6fG* z5C9~E5~8V^rYQ(unpSgDQ$GSIBh%?TKtac{TB?W;F~*r71hS?D09G!uX7#FAYwIC4@XWLS zec?sl=5eXMjSuQFpXzl0~Trw1r zHO>4_5QuF%w(ab{Np(%FO2xLVTLIvZ8K6}9xZ;yzr%yvfNumgV0s+9MsQ{4dPFR-B zm}eM)($dmoGO22s=P|#}M`%!zDF86Wsv5e#ZnWml)-Bu4-OJ1vdU$tAKUgsQ4IcntR(NzwMRe!6+{s#|UvIN%@+EMLC-#g|`pTxMmh zlF|qOWG(BX4I5gbtyf-gnWFhXFwR(EQG^SD!?+#W$!4-m9XUc^a!Ek$&ja13bH)db zM9zrR_jOmiEf$xgz8!tXwylw(qNB?v2ZIH@y-q0P z;~W|1!+(x|Ep1)#4mN#8NRgQ-4d)qm1U7HkDm;d&N(cq2=&EixPHQYisI+jw0+I&-6hcV- znL!8v0LH!Ebg%94aABw*6ch+DC5(-LJMfwc$mDu&zimw_l^T&}d0S<0RcU?~%Hs`zCxi}JFx|P# zYcIUG^~*0uLmI~-_WAr(wflN|(lSufwaQ8+altIxdG)ndt7~ij{jtXg5JCa-coL!g ze7S6v6A+$YV$hP%71?&ZfuE?VoKLsB(&@yejUWB%1NR?*YOE134CCwSS`=JUHK24- zQCM(vU2pAk>vmRtan0Xd4M-U0vMd1r7fg{f0I*CG0Xd@QGCFLo#IUEJKp-TaNU@Ig z`r6t9(~s4FuKT{K+RM17$dan5l**pt#XCE==f&IFuDbgA<;#~65VW6>0C8-`b8Ugh zMZTl}K6ry61dtE4fRLYmlI%&QySi?>b>%m*jVXod8XAEr2wYWU0>E64A$+xacX3ff z5STt~+Q7jcW2R+FMDhe5Sj7zFmk8m&B|`aNTe3t6mFo7@ClZ}E-|+WiVf*F?$clnQ zMu9=Y=P~9wj-sd(@w>BT`HcV>Faw-a0)QjtNdci*y&*`5jc10IhwBe{kr42*_^FuYTv^=H_u!>=T0ZR6KRp<9BN0k)&l)i@|M^&P3f9;S=re1k*-HR_gKX&>t83-4b_Ii@! zcsbM3R7I9$+jW`adffB*f#w7ng( ziK^4*mtVg8#O`!*-LucU{^~2^qW@+Cr!87kRx&9X&!}Yz<6LkiIMMy8pcI6}1kb-` z^K&m#U;+TZbeyj4Okt>?D5&PE8(pJw=6*+26wXC;OZNRgWh}07xlK3xxlC?nZQZNS zt=;hc`tj1kfoq{aFt}mU#}!+n@BHzr?;acW>k6S10YRV;qHn5^6CzPS1VVwfC#=?v zTuVI1ksmWToF7C9krV|G5aE>#9d&Wh+_<}P^Z#bsYUZDOTu~Xk^s6VgRD5(0^l;z^ z?AWzSlcjhx+1cFk(wp9$x1Q0*41Z1*SyC;}O{dJPNot$2ppZyVT5w_+03sqKh(PoG z%eDq{)AkH;x?1*BwAXJ7soFKyTwPLJ@c7RkuG_ok;OGH>y!_^GrfX zmSnXz>tu7OaLCt<>alZ9J!bBF0iwF4%d=XhlocOW9-3MftZMApy0fLV^PSlWYib zom5_w=t}+Mo?E^>`p5?Idi;eqW*j#MT(_i9532$w;Ya|0ApilIIDT5LX}w zvA+Rm=yDo*yz2V4FW-JW8QrHD;9AxRv;Stv@(YL%^|iInJ^ARNqKDCTp}jk{Pn$8z z@0Sft0QAud1exTQCrAW@$GGcBKAj>W?Q6w8Z!-7R#CL7^aNn-aN{*URbksDvr}@k? z&swx(sp&e;Kl#Mo-P;Z!{ip^SYpZ-d#Q>lP9Fb7K!95nGL{W4kgt;PLPq`>I?ag#H zw*Tpax18SQQ%_yEuc@xQuw>cQw}m4S({%2;?FQy~BR=lX8>sx^QwfQtp+u#8n?VT~ zSnD7V%kdD2BmqZkY_~VHq+37Vy#4ccf!Q*!`r7TYPMCN8^5v2u)z{TL`oJBB9Q{BB zdQz#NrgAea3Jd}gW3DU@8p-;55m{9ln^V8u(6o2g2kH7s%}*VVg(JnQ{_&qZPAuWw zf8&*A|1s^v82|v=b_)#MVce%mimV_(7^&FYRhxSI{q|j7Z%Q}q^xOoM3YIKedg1vi zQayIXM}K@{-BX7TJ+Dy>6ooX)v?WRaPyou3#MsbndtZC@jSclZNnSpAdZPNnKrndA zn%g7gle?1EkA8UZVJ7d>s0NtFm=K1h$g<2`4~F$9RWWn(?v{qixBa0R@uqE4rX737 zowrM}{;Q{cx%tC`sVfgo2Po#4sz?Bke_sxn{q~eyv9tBFcVEhOcBJEViMN$V5j16_Rfxu?G?XEwl+>GF1q}xD~_2ufdd{*7&N}C8O31aBUoa7{BWcv8Ue&a| z|96->@aSRvw%C?WK5nkve$gct|Ltj~y!VH9Zur54ho5s)jT!-oLjL3xB^epdpEJMl z&X($KmX7Vca>a`2GiU$9${(;{$Fmcxks~l^(j-BySK4p&aBc~ zZo2$%RFvOFBOA~(-KVQU2()Yf`1H#irc3_o@dqZBZA=>3KtVXfkuxR`Ala4bY;AdV z?SD)#*%&qQE)*$@P@r6J0%Z5sHQRSqX42ggOg2W1Y{2mQ8Rv>3am0>z)28=-KfzdI z*9Zo#yZ(oi0wEwvvYpL7`p|;D0Z!h#x-;8zX+0000 */ + private static function logoCandidates(): array { + return [ + dirname(__DIR__) . '/assets/ac_qr_logo.png', + __DIR__ . '/../assets/ac_qr_logo.png', + ]; + } + + private static function logoPath(): ?string { + foreach (self::logoCandidates() as $path) { + if (is_readable($path)) { + return $path; + } + } + return null; + } + + private static function rawQrPng(string $text, int $size): ?string { + $qrencode = trim((string) shell_exec('command -v qrencode 2>/dev/null') ?? ''); + if ($qrencode === '') { + return null; + } + $size = max(128, min(512, $size)); + $moduleSize = max(3, (int) round($size / 33)); + $cmd = sprintf( + '%s -l H -t PNG -s %d -m 2 -o - %s 2>/dev/null', + escapeshellcmd($qrencode), + $moduleSize, + escapeshellarg($text) + ); + $out = shell_exec($cmd); + if (!is_string($out) || strlen($out) < 8) { + return null; + } + return $out; + } + + private static function overlayLogo(string $pngBytes): ?string { + if (!extension_loaded('gd')) { + return null; + } + $logoPath = self::logoPath(); + if ($logoPath === null) { + return null; + } + $qr = @imagecreatefromstring($pngBytes); + if ($qr === false) { + return null; + } + $logo = @imagecreatefrompng($logoPath); + if ($logo === false) { + imagedestroy($qr); + return null; + } + $qrW = imagesx($qr); + $qrH = imagesy($qr); + if ($qrW < 32 || $qrH < 32) { + imagedestroy($qr); + imagedestroy($logo); + return null; + } + + $logoBox = (int) round(min($qrW, $qrH) * self::LOGO_FRACTION); + $border = max(2, (int) round(min($qrW, $qrH) * self::BORDER_FRACTION)); + $plate = $logoBox + 2 * $border; + $plateX = (int) floor(($qrW - $plate) / 2); + $plateY = (int) floor(($qrH - $plate) / 2); + + $white = imagecolorallocate($qr, 255, 255, 255); + imagefilledrectangle($qr, $plateX, $plateY, $plateX + $plate - 1, $plateY + $plate - 1, $white); + + $srcW = imagesx($logo); + $srcH = imagesy($logo); + $dstX = $plateX + $border; + $dstY = $plateY + $border; + imagecopyresampled($qr, $logo, $dstX, $dstY, 0, 0, $logoBox, $logoBox, $srcW, $srcH); + + ob_start(); + imagepng($qr); + $out = ob_get_clean(); + imagedestroy($qr); + imagedestroy($logo); + if (!is_string($out) || strlen($out) < 8) { + return null; + } + return $out; + } +} diff --git a/src/QrHandler.php b/src/QrHandler.php index 62c057c..033b0d2 100644 --- a/src/QrHandler.php +++ b/src/QrHandler.php @@ -30,21 +30,9 @@ final class QrHandler { } private static function renderPng(string $text, int $size): ?string { - $qrencode = trim((string) shell_exec('command -v qrencode 2>/dev/null') ?? ''); - if ($qrencode === '') { - return null; + if (!class_exists('QrBrandedRenderer', false)) { + require_once __DIR__ . '/QrBrandedRenderer.php'; } - $moduleSize = max(2, (int) round($size / 40)); - $cmd = sprintf( - '%s -t PNG -s %d -m 1 -o - %s 2>/dev/null', - escapeshellcmd($qrencode), - $moduleSize, - escapeshellarg($text) - ); - $out = shell_exec($cmd); - if (!is_string($out) || strlen($out) < 8) { - return null; - } - return $out; + return QrBrandedRenderer::renderPng($text, $size); } } diff --git a/src/bootstrap.php b/src/bootstrap.php index 17b7d4c..d8634c8 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -31,4 +31,5 @@ require_once __DIR__ . '/SignerRepository.php'; require_once __DIR__ . '/LinkRepository.php'; require_once __DIR__ . '/ShortenService.php'; require_once __DIR__ . '/RedirectHandler.php'; +require_once __DIR__ . '/QrBrandedRenderer.php'; require_once __DIR__ . '/QrHandler.php';