includes/graphs/qrcode.php
<?php
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| Local SVG QR code generator
|--------------------------------------------------------------------------
|
| Minimal QR encoder used by the TOTP setup flow.
| It generates a version 10, error-correction level L QR code as inline SVG.
| This keeps setup fully local and avoids external chart or QR APIs.
|
*/
function brivaciaQrByteCapacity(): int
{
// Version 10-L: 274 data codewords, 4 mode bits, 16 length bits.
return 271;
}
function brivaciaQrSvg(string $data): string
{
$bytes = array_values(unpack('C*', $data) ?: []);
if (count($bytes) === 0 || count($bytes) > brivaciaQrByteCapacity()) {
return '';
}
$matrix = brivaciaQrMatrix($bytes);
$quiet = 4;
$size = count($matrix);
$viewBox = $size + ($quiet * 2);
$path = '';
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
if (!empty($matrix[$y][$x])) {
$path .= 'M' . ($x + $quiet) . ' ' . ($y + $quiet) . 'h1v1h-1z';
}
}
}
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $viewBox . ' ' . $viewBox . '" role="img" aria-label="TOTP QR code" shape-rendering="crispEdges">'
. '<rect width="' . $viewBox . '" height="' . $viewBox . '" fill="#fff"/>'
. '<path d="' . $path . '" fill="#111"/>'
. '</svg>';
}
function brivaciaQrMatrix(array $bytes): array
{
$version = 10;
$size = 21 + (($version - 1) * 4);
$dataCodewords = 274;
$errorCodewordsPerBlock = 18;
$blocks = [68, 68, 69, 69];
$codewords = brivaciaQrEncodeBytes($bytes, $dataCodewords);
$interleaved = brivaciaQrInterleaveBlocks($codewords, $blocks, $errorCodewordsPerBlock);
$bits = [];
foreach ($interleaved as $codeword) {
for ($bit = 7; $bit >= 0; $bit--) {
$bits[] = ($codeword >> $bit) & 1;
}
}
[$baseMatrix, $functionMap] = brivaciaQrBaseMatrix($version, $size);
brivaciaQrPlaceData($baseMatrix, $functionMap, $bits);
$bestMatrix = $baseMatrix;
$bestPenalty = PHP_INT_MAX;
for ($mask = 0; $mask < 8; $mask++) {
$testMatrix = $baseMatrix;
brivaciaQrApplyMask($testMatrix, $functionMap, $mask);
brivaciaQrDrawFormatBits($testMatrix, $functionMap, $mask);
$penalty = brivaciaQrPenalty($testMatrix);
if ($penalty < $bestPenalty) {
$bestPenalty = $penalty;
$bestMatrix = $testMatrix;
}
}
return $bestMatrix;
}
function brivaciaQrEncodeBytes(array $bytes, int $dataCodewords): array
{
$bits = [];
brivaciaQrAppendBits($bits, 0b0100, 4); // Byte mode.
brivaciaQrAppendBits($bits, count($bytes), 16); // Version 10 uses a 16-bit byte count.
foreach ($bytes as $byte) {
brivaciaQrAppendBits($bits, (int)$byte, 8);
}
$capacity = $dataCodewords * 8;
$terminator = min(4, $capacity - count($bits));
for ($i = 0; $i < $terminator; $i++) {
$bits[] = 0;
}
while (count($bits) % 8 !== 0) {
$bits[] = 0;
}
$codewords = [];
foreach (array_chunk($bits, 8) as $chunk) {
$value = 0;
foreach ($chunk as $bit) {
$value = ($value << 1) | (int)$bit;
}
$codewords[] = $value;
}
$pads = [0xEC, 0x11];
$padIndex = 0;
while (count($codewords) < $dataCodewords) {
$codewords[] = $pads[$padIndex % 2];
$padIndex++;
}
return $codewords;
}
function brivaciaQrAppendBits(array &$bits, int $value, int $length): void
{
for ($i = $length - 1; $i >= 0; $i--) {
$bits[] = ($value >> $i) & 1;
}
}
function brivaciaQrInterleaveBlocks(array $codewords, array $blockSizes, int $eccLength): array
{
$dataBlocks = [];
$eccBlocks = [];
$offset = 0;
foreach ($blockSizes as $size) {
$block = array_slice($codewords, $offset, $size);
$offset += $size;
$dataBlocks[] = $block;
$eccBlocks[] = brivaciaQrReedSolomonRemainder($block, $eccLength);
}
$output = [];
$maxDataLength = max($blockSizes);
for ($i = 0; $i < $maxDataLength; $i++) {
foreach ($dataBlocks as $block) {
if (array_key_exists($i, $block)) {
$output[] = $block[$i];
}
}
}
for ($i = 0; $i < $eccLength; $i++) {
foreach ($eccBlocks as $block) {
$output[] = $block[$i];
}
}
return $output;
}
function brivaciaQrGaloisTables(): array
{
static $tables = null;
if ($tables !== null) {
return $tables;
}
$exp = array_fill(0, 512, 0);
$log = array_fill(0, 256, 0);
$value = 1;
for ($i = 0; $i < 255; $i++) {
$exp[$i] = $value;
$log[$value] = $i;
$value <<= 1;
if (($value & 0x100) !== 0) {
$value ^= 0x11D;
}
}
for ($i = 255; $i < 512; $i++) {
$exp[$i] = $exp[$i - 255];
}
$tables = [$exp, $log];
return $tables;
}
function brivaciaQrGaloisMultiply(int $a, int $b): int
{
if ($a === 0 || $b === 0) {
return 0;
}
[$exp, $log] = brivaciaQrGaloisTables();
return $exp[$log[$a] + $log[$b]];
}
function brivaciaQrGeneratorPolynomial(int $degree): array
{
$polynomial = [1];
[$exp] = brivaciaQrGaloisTables();
for ($i = 0; $i < $degree; $i++) {
$next = array_fill(0, count($polynomial) + 1, 0);
foreach ($polynomial as $index => $coefficient) {
$next[$index] ^= brivaciaQrGaloisMultiply($coefficient, 1);
$next[$index + 1] ^= brivaciaQrGaloisMultiply($coefficient, $exp[$i]);
}
$polynomial = $next;
}
return $polynomial;
}
function brivaciaQrReedSolomonRemainder(array $data, int $degree): array
{
$generator = brivaciaQrGeneratorPolynomial($degree);
$remainder = array_fill(0, $degree, 0);
foreach ($data as $byte) {
$factor = ((int)$byte) ^ $remainder[0];
array_shift($remainder);
$remainder[] = 0;
for ($i = 0; $i < $degree; $i++) {
$remainder[$i] ^= brivaciaQrGaloisMultiply($generator[$i + 1], $factor);
}
}
return $remainder;
}
function brivaciaQrBaseMatrix(int $version, int $size): array
{
$matrix = array_fill(0, $size, array_fill(0, $size, 0));
$functionMap = array_fill(0, $size, array_fill(0, $size, false));
brivaciaQrDrawFinder($matrix, $functionMap, 0, 0);
brivaciaQrDrawFinder($matrix, $functionMap, $size - 7, 0);
brivaciaQrDrawFinder($matrix, $functionMap, 0, $size - 7);
brivaciaQrDrawTiming($matrix, $functionMap, $size);
brivaciaQrDrawAlignments($matrix, $functionMap, [6, 28, 50]);
brivaciaQrSetFunction($matrix, $functionMap, 8, (4 * $version) + 9, 1);
brivaciaQrReserveFormat($functionMap, $size);
brivaciaQrDrawVersionBits($matrix, $functionMap, $version, $size);
return [$matrix, $functionMap];
}
function brivaciaQrSetFunction(array &$matrix, array &$functionMap, int $x, int $y, int $value): void
{
if (!isset($matrix[$y][$x])) {
return;
}
$matrix[$y][$x] = $value ? 1 : 0;
$functionMap[$y][$x] = true;
}
function brivaciaQrDrawFinder(array &$matrix, array &$functionMap, int $x, int $y): void
{
for ($dy = -1; $dy <= 7; $dy++) {
for ($dx = -1; $dx <= 7; $dx++) {
$xx = $x + $dx;
$yy = $y + $dy;
if (!isset($matrix[$yy][$xx])) {
continue;
}
$isFinder = $dx >= 0 && $dx <= 6 && $dy >= 0 && $dy <= 6;
$isBlack = $isFinder && (
$dx === 0 || $dx === 6 || $dy === 0 || $dy === 6 ||
($dx >= 2 && $dx <= 4 && $dy >= 2 && $dy <= 4)
);
brivaciaQrSetFunction($matrix, $functionMap, $xx, $yy, $isBlack ? 1 : 0);
}
}
}
function brivaciaQrDrawTiming(array &$matrix, array &$functionMap, int $size): void
{
for ($i = 8; $i < $size - 8; $i++) {
$value = $i % 2 === 0 ? 1 : 0;
brivaciaQrSetFunction($matrix, $functionMap, $i, 6, $value);
brivaciaQrSetFunction($matrix, $functionMap, 6, $i, $value);
}
}
function brivaciaQrDrawAlignments(array &$matrix, array &$functionMap, array $centers): void
{
$last = end($centers);
foreach ($centers as $cx) {
foreach ($centers as $cy) {
if (($cx === 6 && $cy === 6) || ($cx === 6 && $cy === $last) || ($cx === $last && $cy === 6)) {
continue;
}
for ($dy = -2; $dy <= 2; $dy++) {
for ($dx = -2; $dx <= 2; $dx++) {
$distance = max(abs($dx), abs($dy));
brivaciaQrSetFunction($matrix, $functionMap, $cx + $dx, $cy + $dy, $distance !== 1 ? 1 : 0);
}
}
}
}
}
function brivaciaQrReserveFormat(array &$functionMap, int $size): void
{
$coords = brivaciaQrFormatCoordinates($size);
foreach ($coords as [$x, $y]) {
if (isset($functionMap[$y][$x])) {
$functionMap[$y][$x] = true;
}
}
}
function brivaciaQrFormatCoordinates(int $size): array
{
$coords = [];
for ($i = 0; $i <= 5; $i++) {
$coords[$i] = [8, $i];
}
$coords[6] = [8, 7];
$coords[7] = [8, 8];
$coords[8] = [7, 8];
for ($i = 9; $i <= 14; $i++) {
$coords[$i] = [14 - $i, 8];
}
for ($i = 0; $i <= 7; $i++) {
$coords[15 + $i] = [$size - 1 - $i, 8];
}
for ($i = 8; $i <= 14; $i++) {
$coords[15 + $i] = [8, $size - 15 + $i];
}
return $coords;
}
function brivaciaQrFormatBits(int $mask): int
{
$data = (0b01 << 3) | $mask; // Error correction level L.
$bits = $data << 10;
$generator = 0x537;
for ($i = 14; $i >= 10; $i--) {
if ((($bits >> $i) & 1) !== 0) {
$bits ^= $generator << ($i - 10);
}
}
return (($data << 10) | $bits) ^ 0x5412;
}
function brivaciaQrDrawFormatBits(array &$matrix, array &$functionMap, int $mask): void
{
$size = count($matrix);
$bits = brivaciaQrFormatBits($mask);
$coords = brivaciaQrFormatCoordinates($size);
foreach ($coords as $i => [$x, $y]) {
$matrix[$y][$x] = ($bits >> ($i % 15)) & 1;
$functionMap[$y][$x] = true;
}
}
function brivaciaQrVersionBits(int $version): int
{
$bits = $version << 12;
$generator = 0x1F25;
for ($i = 17; $i >= 12; $i--) {
if ((($bits >> $i) & 1) !== 0) {
$bits ^= $generator << ($i - 12);
}
}
return ($version << 12) | $bits;
}
function brivaciaQrDrawVersionBits(array &$matrix, array &$functionMap, int $version, int $size): void
{
$bits = brivaciaQrVersionBits($version);
for ($i = 0; $i < 18; $i++) {
$bit = ($bits >> $i) & 1;
$a = $size - 11 + ($i % 3);
$b = intdiv($i, 3);
brivaciaQrSetFunction($matrix, $functionMap, $a, $b, $bit);
brivaciaQrSetFunction($matrix, $functionMap, $b, $a, $bit);
}
}
function brivaciaQrPlaceData(array &$matrix, array $functionMap, array $bits): void
{
$size = count($matrix);
$bitIndex = 0;
$direction = -1;
$y = $size - 1;
for ($x = $size - 1; $x > 0; $x -= 2) {
if ($x === 6) {
$x--;
}
while (true) {
for ($dx = 0; $dx < 2; $dx++) {
$xx = $x - $dx;
if (empty($functionMap[$y][$xx])) {
$matrix[$y][$xx] = $bits[$bitIndex] ?? 0;
$bitIndex++;
}
}
$y += $direction;
if ($y < 0 || $y >= $size) {
$y -= $direction;
$direction = -$direction;
break;
}
}
}
}
function brivaciaQrApplyMask(array &$matrix, array $functionMap, int $mask): void
{
$size = count($matrix);
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
if (!empty($functionMap[$y][$x])) {
continue;
}
if (brivaciaQrMaskBit($mask, $x, $y)) {
$matrix[$y][$x] ^= 1;
}
}
}
}
function brivaciaQrMaskBit(int $mask, int $x, int $y): bool
{
return match ($mask) {
0 => (($x + $y) % 2) === 0,
1 => ($y % 2) === 0,
2 => ($x % 3) === 0,
3 => (($x + $y) % 3) === 0,
4 => ((intdiv($y, 2) + intdiv($x, 3)) % 2) === 0,
5 => ((($x * $y) % 2) + (($x * $y) % 3)) === 0,
6 => (((($x * $y) % 2) + (($x * $y) % 3)) % 2) === 0,
7 => (((($x + $y) % 2) + (($x * $y) % 3)) % 2) === 0,
default => false,
};
}
function brivaciaQrPenalty(array $matrix): int
{
$size = count($matrix);
$penalty = 0;
for ($y = 0; $y < $size; $y++) {
$penalty += brivaciaQrLinePenalty($matrix[$y]);
}
for ($x = 0; $x < $size; $x++) {
$line = [];
for ($y = 0; $y < $size; $y++) {
$line[] = $matrix[$y][$x];
}
$penalty += brivaciaQrLinePenalty($line);
}
for ($y = 0; $y < $size - 1; $y++) {
for ($x = 0; $x < $size - 1; $x++) {
$value = $matrix[$y][$x];
if (
$matrix[$y][$x + 1] === $value &&
$matrix[$y + 1][$x] === $value &&
$matrix[$y + 1][$x + 1] === $value
) {
$penalty += 3;
}
}
}
$dark = 0;
foreach ($matrix as $row) {
foreach ($row as $value) {
$dark += (int)$value;
}
}
$total = $size * $size;
$penalty += (int)(floor(abs(($dark * 20) - ($total * 10)) / $total) * 10);
return $penalty;
}
function brivaciaQrLinePenalty(array $line): int
{
$penalty = 0;
$runColor = $line[0];
$runLength = 1;
$pattern = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
$inverse = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
for ($i = 1, $length = count($line); $i < $length; $i++) {
if ($line[$i] === $runColor) {
$runLength++;
} else {
if ($runLength >= 5) {
$penalty += 3 + ($runLength - 5);
}
$runColor = $line[$i];
$runLength = 1;
}
}
if ($runLength >= 5) {
$penalty += 3 + ($runLength - 5);
}
for ($i = 0, $max = count($line) - 11; $i <= $max; $i++) {
$slice = array_slice($line, $i, 11);
if ($slice === $pattern || $slice === $inverse) {
$penalty += 40;
}
}
return $penalty;
}