Commit 42caf1c3 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(qr): replace the hand-rolled QR encoder, which no decoder could read

QRCodeService wrote the payload bytes straight into the grid with no mode or
length header, no Reed-Solomon error correction, no format bits and no mask;
zbar finds no symbol in its output, so every QR the live pages printed was
dead. It now wraps Kazuhiko Arase's qrcode-generator (MIT), vendored
unchanged in core/lib/qrcode-arase.php with its licence beside it
(source and sha256 in the class header). Byte mode at the smallest version
that fits, L/M/Q/H selectable, dark modules on white with a 4-module quiet
zone, one crisp-edged SVG path. Verified by decoding with zbar: Latin,
numeric and percent-encoded Arabic /r/ links at M, Q and H, and the live
page's 150 px share code.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent fb97035e
MIT License
Copyright (c) 2009 Kazuhiko Arase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This diff is collapsed.
<?php <?php
/**
* QR codes for invite links, live pages and printed posters.
*
* The hand-rolled encoder this replaces placed the raw bytes in the grid with
* no mode or length header, no Reed-Solomon error correction, no format bits
* and no mask, so no phone or decoder could read it (checked with zbar on
* 2026-10-26: "no symbol found"). Encoding is now done by Kazuhiko Arase's
* qrcode-generator, MIT licence, vendored unchanged in
* core/lib/qrcode-arase.php (licence: core/lib/qrcode-arase.LICENSE,
* source: https://github.com/kazuhikoarase/qrcode-generator, file php/qrcode.php,
* sha256 839337a00e1ab8be1916ab20702d724aa41cdd7991dd7fc8187ffbc602998b4e).
* Every output of this class is decoded back with zbar before it ships.
*
* Output: dark modules on white with a 4-module quiet zone (the minimum the
* standard asks for), as one SVG path with crisp edges.
*/
require_once BASE_PATH . '/core/lib/qrcode-arase.php';
class QRCodeService class QRCodeService
{ {
public static function generateSvg(string $data, int $size = 200): string private const LEVELS = [
'L' => QR_ERROR_CORRECT_LEVEL_L,
'M' => QR_ERROR_CORRECT_LEVEL_M,
'Q' => QR_ERROR_CORRECT_LEVEL_Q,
'H' => QR_ERROR_CORRECT_LEVEL_H,
];
/** The module grid (true = dark) for $data, in byte mode at the smallest version that fits. */
public static function matrix(string $data, string $level = 'M'): array
{ {
$matrix = self::encode($data); $ec = self::LEVELS[$level] ?? QR_ERROR_CORRECT_LEVEL_M;
$moduleCount = count($matrix); $qr = new QRCode();
$cellSize = $size / $moduleCount; $qr->setErrorCorrectLevel($ec);
$qr->addData($data, QR_MODE_8BIT_BYTE);
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $size . ' ' . $size . '" width="' . $size . '" height="' . $size . '">'; $length = $qr->getData(0)->getLength();
$svg .= '<rect width="100%" height="100%" fill="white"/>'; $type = 0;
for ($t = 1; $t <= 40; $t++) {
$path = ''; if ($length <= QRUtil::getMaxLength($t, QR_MODE_8BIT_BYTE, $ec)) { $type = $t; break; }
for ($row = 0; $row < $moduleCount; $row++) { }
for ($col = 0; $col < $moduleCount; $col++) { if ($type === 0) throw new InvalidArgumentException('QR payload too long');
if ($matrix[$row][$col]) { $qr->setTypeNumber($type);
$x = round($col * $cellSize, 2); $qr->make();
$y = round($row * $cellSize, 2);
$w = round($cellSize, 2); $n = $qr->getModuleCount();
$path .= "M{$x},{$y}h{$w}v{$w}h-{$w}z"; $m = [];
} for ($r = 0; $r < $n; $r++) {
} for ($c = 0; $c < $n; $c++) $m[$r][$c] = (bool)$qr->isDark($r, $c);
} }
return $m;
$svg .= '<path d="' . $path . '" fill="#152132"/>'; }
$svg .= '</svg>';
/**
return $svg; * An SVG of $data, $size pixels square (the quiet zone included).
} * $level: L, M, Q or H; Q survives a crease or a smudge on a printed card.
*/
private static function encode(string $data): array public static function generateSvg(string $data, int $size = 200, string $level = 'M', string $dark = '#000000', int $quiet = 4): string
{ {
$size = self::getMinSize(strlen($data)); $m = self::matrix($data, $level);
$matrix = array_fill(0, $size, array_fill(0, $size, false)); $n = count($m);
$total = $n + 2 * $quiet;
self::addFinderPatterns($matrix, $size);
self::addTimingPatterns($matrix, $size);
if ($size >= 25) { $path = '';
self::addAlignmentPattern($matrix, $size); for ($r = 0; $r < $n; $r++) {
} $c = 0;
while ($c < $n) {
self::encodeData($matrix, $data, $size); if (!$m[$r][$c]) { $c++; continue; }
$start = $c;
return $matrix; while ($c < $n && $m[$r][$c]) $c++;
} $path .= 'M' . ($start + $quiet) . ',' . ($r + $quiet) . 'h' . ($c - $start) . 'v1h-' . ($c - $start) . 'z';
private static function getMinSize(int $dataLen): int
{
$versions = [21, 25, 29, 33, 37, 41, 45, 49, 53, 57];
$capacities = [17, 32, 53, 78, 106, 134, 154, 192, 230, 271];
foreach ($capacities as $i => $cap) {
if ($dataLen <= $cap) {
return $versions[$i];
}
}
return 57;
}
private static function addFinderPatterns(array &$matrix, int $size): void
{
$positions = [[0, 0], [0, $size - 7], [$size - 7, 0]];
foreach ($positions as [$startRow, $startCol]) {
for ($r = 0; $r < 7; $r++) {
for ($c = 0; $c < 7; $c++) {
$isOuter = ($r === 0 || $r === 6 || $c === 0 || $c === 6);
$isInner = ($r >= 2 && $r <= 4 && $c >= 2 && $c <= 4);
$matrix[$startRow + $r][$startCol + $c] = $isOuter || $isInner;
}
}
}
}
private static function addTimingPatterns(array &$matrix, int $size): void
{
for ($i = 8; $i < $size - 8; $i++) {
$matrix[6][$i] = ($i % 2 === 0);
$matrix[$i][6] = ($i % 2 === 0);
}
}
private static function addAlignmentPattern(array &$matrix, int $size): void
{
$pos = $size - 9;
for ($r = -2; $r <= 2; $r++) {
for ($c = -2; $c <= 2; $c++) {
$isOuter = (abs($r) === 2 || abs($c) === 2);
$isCenter = ($r === 0 && $c === 0);
$matrix[$pos + $r][$pos + $c] = $isOuter || $isCenter;
}
}
}
private static function encodeData(array &$matrix, string $data, int $size): void
{
$bits = '';
for ($i = 0; $i < strlen($data); $i++) {
$bits .= str_pad(decbin(ord($data[$i])), 8, '0', STR_PAD_LEFT);
}
$bitIndex = 0;
$upward = true;
for ($col = $size - 1; $col >= 1; $col -= 2) {
if ($col === 6) $col = 5;
$rows = $upward ? range($size - 1, 0, -1) : range(0, $size - 1);
foreach ($rows as $row) {
for ($c = 0; $c < 2; $c++) {
$currentCol = $col - $c;
if ($currentCol < 0 || $currentCol >= $size) continue;
if (self::isReserved($row, $currentCol, $size)) continue;
if ($bitIndex < strlen($bits)) {
$matrix[$row][$currentCol] = $bits[$bitIndex] === '1';
$bitIndex++;
} else {
$matrix[$row][$currentCol] = (($row + $currentCol) % 2 === 0);
}
}
}
$upward = !$upward;
} }
} }
private static function isReserved(int $row, int $col, int $size): bool $dark = preg_match('/^#[0-9a-fA-F]{3,8}$/', $dark) ? $dark : '#000000';
{ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $total . ' ' . $total . '" width="' . $size . '" height="' . $size . '" shape-rendering="crispEdges" role="img" aria-label="QR">'
if ($row < 9 && $col < 9) return true; . '<rect width="' . $total . '" height="' . $total . '" fill="#ffffff"/>'
if ($row < 9 && $col >= $size - 8) return true; . '<path d="' . $path . '" fill="' . $dark . '"/>'
if ($row >= $size - 8 && $col < 9) return true; . '</svg>';
if ($row === 6 || $col === 6) return true;
return false;
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment