Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.47% covered (success)
92.47%
172 / 186
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
QrCodeSvgRenderer
92.43% covered (success)
92.43%
171 / 185
69.23% covered (warning)
69.23%
9 / 13
89.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderSvg
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 renderDataUri
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 encodePayload
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 determineVersion
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 generateCodewords
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
12
 placeFinderPatterns
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
19
 placeTimingPatterns
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 placeAlignmentPatterns
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
34.19
 reserveFormatAreas
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 placeDataBits
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 getZigzagColumnPairs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 placeColumnPairBits
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Security\Mfa;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Mfa\Qr\QrMatrixMaskEvaluator;
12use App\Core\Security\Mfa\Qr\QrReedSolomonEncoder;
13use InvalidArgumentException;
14
15/**
16 * Pure PHP Local Vector SVG QR Code Engine (ISO/IEC 18004 Compliant).
17 *
18 * Generates self-contained SVG QR codes locally without any external dependencies or network requests.
19 * Fully compliant with strict CSP (no external font or image CDNs required).
20 *
21 * @package App\Core\Security\Mfa
22 */
23final readonly class QrCodeSvgRenderer implements QrCodeSvgRendererInterface
24{
25    /**
26     * Capacity table for Error Correction Level M:
27     * [version => ['totalBytes' => int, 'dataBytes' => int, 'ecBytesPerBlock' => int, 'blocks' => int]].
28     */
29    private const array VERSION_TABLE = [
30        1 => ['total' => 26, 'data' => 16, 'ec' => 10, 'blocks' => 1],
31        2 => ['total' => 44, 'data' => 28, 'ec' => 16, 'blocks' => 1],
32        3 => ['total' => 70, 'data' => 44, 'ec' => 26, 'blocks' => 1],
33        4 => ['total' => 100, 'data' => 64, 'ec' => 18, 'blocks' => 2],
34        5 => ['total' => 134, 'data' => 86, 'ec' => 24, 'blocks' => 2],
35        6 => ['total' => 172, 'data' => 108, 'ec' => 16, 'blocks' => 4],
36        7 => ['total' => 196, 'data' => 124, 'ec' => 18, 'blocks' => 4],
37        8 => ['total' => 242, 'data' => 154, 'ec' => 22, 'blocks' => 4],
38        9 => ['total' => 292, 'data' => 182, 'ec' => 22, 'blocks' => 5],
39        10 => ['total' => 346, 'data' => 216, 'ec' => 26, 'blocks' => 5],
40    ];
41
42    /**
43     * QrCodeSvgRenderer constructor.
44     *
45     * @param QrReedSolomonEncoder  $rsEncoder     Reed-Solomon error correction encoder.
46     * @param QrMatrixMaskEvaluator $maskEvaluator Matrix mask evaluator and formatter.
47     */
48    public function __construct(
49        private QrReedSolomonEncoder $rsEncoder = new QrReedSolomonEncoder(),
50        private QrMatrixMaskEvaluator $maskEvaluator = new QrMatrixMaskEvaluator()
51    ) {
52    }
53
54    /** {@inheritdoc} */
55    public function renderSvg(
56        string $payload,
57        int $pixelSize = 240,
58        string $foregroundColor = '#000000',
59        string $backgroundColor = '#ffffff'
60    ): string {
61        if (trim($payload) === '') {
62            throw new InvalidArgumentException('QR payload cannot be empty.');
63        }
64        if ($pixelSize <= 0) {
65            throw new InvalidArgumentException('Pixel size must be greater than zero.');
66        }
67
68        $matrix = $this->encodePayload($payload);
69        $size = count($matrix);
70        $quietZone = 4;
71        $totalDimension = $size + ($quietZone * 2);
72
73        $pathData = '';
74        for ($r = 0; $r < $size; $r++) {
75            for ($c = 0; $c < $size; $c++) {
76                if ($matrix[$r][$c]) {
77                    $x = $c + $quietZone;
78                    $y = $r + $quietZone;
79                    $pathData .= "M{$x},{$y}h1v1h-1z ";
80                }
81            }
82        }
83
84        return sprintf(
85            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" ' .
86            'shape-rendering="crispEdges">' .
87            '<rect width="100%%" height="100%%" fill="%s"/>' .
88            '<path d="%s" fill="%s"/>' .
89            '</svg>',
90            $totalDimension,
91            $totalDimension,
92            $pixelSize,
93            $pixelSize,
94            htmlspecialchars($backgroundColor, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
95            trim($pathData),
96            htmlspecialchars($foregroundColor, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
97        );
98    }
99
100    /** {@inheritdoc} */
101    public function renderDataUri(string $payload, int $pixelSize = 240): string
102    {
103        $svg = $this->renderSvg($payload, $pixelSize);
104        return 'data:image/svg+xml;base64,' . base64_encode($svg);
105    }
106
107    /**
108     * Encodes arbitrary string payload into a 2D boolean matrix of QR modules.
109     *
110     * @param string $payload Data payload.
111     * @return array<int, array<int, bool>> 2D boolean module matrix.
112     */
113    private function encodePayload(string $payload): array
114    {
115        $len = strlen($payload);
116        $version = $this->determineVersion($len);
117        $moduleCount = 17 + ($version * 4);
118
119        $matrix = array_fill(0, $moduleCount, array_fill(0, $moduleCount, null));
120        $reserved = array_fill(0, $moduleCount, array_fill(0, $moduleCount, false));
121
122        $this->placeFinderPatterns($matrix, $reserved, $moduleCount);
123        $this->placeTimingPatterns($matrix, $reserved, $moduleCount);
124        if ($version >= 2) {
125            $this->placeAlignmentPatterns($matrix, $reserved, $version);
126        }
127        $this->reserveFormatAreas($reserved, $moduleCount);
128
129        $codewords = $this->generateCodewords($payload, $version);
130        $this->placeDataBits($matrix, $reserved, $moduleCount, $codewords);
131        $this->maskEvaluator->applyMaskAndFormat($matrix, $reserved, $moduleCount);
132
133        $result = [];
134        for ($r = 0; $r < $moduleCount; $r++) {
135            $row = [];
136            for ($c = 0; $c < $moduleCount; $c++) {
137                $row[] = (bool) ($matrix[$r][$c] ?? false);
138            }
139            $result[] = $row;
140        }
141
142        return $result;
143    }
144
145    /**
146     * Selects minimum required QR Version capable of holding the payload with Level M error correction.
147     *
148     * @param int $byteLength Payload length in bytes.
149     * @return int QR Version (1-10).
150     */
151    private function determineVersion(int $byteLength): int
152    {
153        foreach (self::VERSION_TABLE as $ver => $spec) {
154            $headerBits = $ver >= 10 ? 20 : 12;
155            $maxDataBytes = (int) floor((($spec['data'] * 8) - $headerBits) / 8);
156            if ($byteLength <= $maxDataBytes) {
157                return $ver;
158            }
159        }
160
161        throw new InvalidArgumentException(
162            "Payload length ({$byteLength} bytes) exceeds maximum supported QR code capacity."
163        );
164    }
165
166    /**
167     * Generates error-corrected codeword sequence according to QR spec.
168     *
169     * @param string $payload Text payload.
170     * @param int    $version QR version.
171     * @return array<int, int> Interleaved codeword bytes.
172     */
173    private function generateCodewords(string $payload, int $version): array
174    {
175        $spec = self::VERSION_TABLE[$version];
176        $totalDataBytes = $spec['data'];
177
178        $bits = '0100'; // Byte mode indicator
179        $charCountBits = $version >= 10 ? 16 : 8;
180        $bits .= str_pad(decbin(strlen($payload)), $charCountBits, '0', STR_PAD_LEFT);
181
182        $len = strlen($payload);
183        for ($i = 0; $i < $len; $i++) {
184            $bits .= str_pad(decbin(ord($payload[$i])), 8, '0', STR_PAD_LEFT);
185        }
186
187        $totalBits = $totalDataBytes * 8;
188        $diff = $totalBits - strlen($bits);
189        if ($diff > 0) {
190            $terminatorLen = min(4, $diff);
191            $bits .= str_repeat('0', $terminatorLen);
192        }
193
194        while ((strlen($bits) % 8) !== 0) {
195            $bits .= '0';
196        }
197
198        $dataBytes = [];
199        $bitLen = strlen($bits);
200        for ($i = 0; $i < $bitLen; $i += 8) {
201            $dataBytes[] = (int) bindec(substr($bits, $i, 8));
202        }
203
204        $padBytes = [0xEC, 0x11];
205        $padIdx = 0;
206        while (count($dataBytes) < $totalDataBytes) {
207            $dataBytes[] = $padBytes[$padIdx % 2];
208            $padIdx++;
209        }
210
211        $blocks = $spec['blocks'];
212        $bytesPerBlock = intdiv($totalDataBytes, $blocks);
213        $ecBytesPerBlock = $spec['ec'];
214
215        $gf = $this->rsEncoder->buildGaloisField();
216        $generator = $this->rsEncoder->buildRsGeneratorPoly($ecBytesPerBlock, $gf);
217
218        $dataBlocks = [];
219        $ecBlocks = [];
220        for ($b = 0; $b < $blocks; $b++) {
221            $blockData = array_slice($dataBytes, $b * $bytesPerBlock, $bytesPerBlock);
222            $dataBlocks[] = $blockData;
223            $ecBlocks[] = $this->rsEncoder->calculateReedSolomonEc($blockData, $ecBytesPerBlock, $generator, $gf);
224        }
225
226        $interleaved = [];
227        for ($i = 0; $i < $bytesPerBlock; $i++) {
228            for ($b = 0; $b < $blocks; $b++) {
229                $interleaved[] = $dataBlocks[$b][$i];
230            }
231        }
232
233        for ($i = 0; $i < $ecBytesPerBlock; $i++) {
234            for ($b = 0; $b < $blocks; $b++) {
235                $interleaved[] = $ecBlocks[$b][$i];
236            }
237        }
238
239        return $interleaved;
240    }
241
242    /**
243     * Places standard finder patterns (7x7 with separator border) at top-left, top-right, and bottom-left.
244     */
245    private function placeFinderPatterns(array &$matrix, array &$reserved, int $n): void
246    {
247        $corners = [[0, 0], [0, $n - 7], [$n - 7, 0]];
248        foreach ($corners as [$top, $left]) {
249            for ($r = -1; $r <= 7; $r++) {
250                for ($c = -1; $c <= 7; $c++) {
251                    $row = $top + $r;
252                    $col = $left + $c;
253                    if ($row < 0 || $row >= $n || $col < 0 || $col >= $n) {
254                        continue;
255                    }
256                    $isBlack = ($r >= 0 && $r <= 6 && $c >= 0 && $c <= 6)
257                        && ($r === 0 || $r === 6 || $c === 0 || $c === 6
258                            || ($r >= 2 && $r <= 4 && $c >= 2 && $c <= 4));
259                    $matrix[$row][$col] = $isBlack;
260                    $reserved[$row][$col] = true;
261                }
262            }
263        }
264    }
265
266    /**
267     * Places timing patterns on row 6 and column 6 connecting the finder patterns.
268     */
269    private function placeTimingPatterns(array &$matrix, array &$reserved, int $n): void
270    {
271        for ($i = 8; $i < $n - 8; $i++) {
272            $val = ($i % 2 === 0);
273            if (!$reserved[6][$i]) {
274                $matrix[6][$i] = $val;
275                $reserved[6][$i] = true;
276            }
277            if (!$reserved[$i][6]) {
278                $matrix[$i][6] = $val;
279                $reserved[$i][6] = true;
280            }
281        }
282    }
283
284    /**
285     * Places alignment patterns for version 2 and higher.
286     */
287    private function placeAlignmentPatterns(array &$matrix, array &$reserved, int $version): void
288    {
289        $centers = match ($version) {
290            2 => [6, 18],
291            3 => [6, 22],
292            4 => [6, 26],
293            5 => [6, 30],
294            6 => [6, 34],
295            7 => [6, 22, 38],
296            8 => [6, 24, 42],
297            9 => [6, 26, 46],
298            10 => [6, 28, 50],
299            default => [6, 18],
300        };
301
302        $cnt = count($centers);
303        for ($rIdx = 0; $rIdx < $cnt; $rIdx++) {
304            for ($cIdx = 0; $cIdx < $cnt; $cIdx++) {
305                $cr = $centers[$rIdx];
306                $cc = $centers[$cIdx];
307
308                if ($reserved[$cr][$cc]) {
309                    continue;
310                }
311
312                for ($dr = -2; $dr <= 2; $dr++) {
313                    for ($dc = -2; $dc <= 2; $dc++) {
314                        $isBlack = (abs($dr) === 2 || abs($dc) === 2 || ($dr === 0 && $dc === 0));
315                        $matrix[$cr + $dr][$cc + $dc] = $isBlack;
316                        $reserved[$cr + $dr][$cc + $dc] = true;
317                    }
318                }
319            }
320        }
321    }
322
323    /**
324     * Reserves format information areas adjacent to finder patterns.
325     */
326    private function reserveFormatAreas(array &$reserved, int $n): void
327    {
328        for ($i = 0; $i <= 8; $i++) {
329            if ($i !== 6) {
330                $reserved[8][$i] = true;
331                $reserved[$i][8] = true;
332            }
333        }
334        for ($i = 0; $i <= 7; $i++) {
335            $reserved[8][$n - 1 - $i] = true;
336            $reserved[$n - 1 - $i][8] = true;
337        }
338        $reserved[$n - 8][8] = true; // Dark module
339    }
340
341    /**
342     * Places interleaved data and error-correction bits in a 2-module-wide zigzag pattern.
343     */
344    private function placeDataBits(array &$matrix, array $reserved, int $n, array $codewords): void
345    {
346        $bits = '';
347        foreach ($codewords as $cw) {
348            $bits .= str_pad(decbin($cw), 8, '0', STR_PAD_LEFT);
349        }
350
351        $bitIdx = 0;
352        $upward = true;
353
354        foreach ($this->getZigzagColumnPairs($n) as $rightCol) {
355            $this->placeColumnPairBits(
356                $matrix,
357                $reserved,
358                $n,
359                $rightCol,
360                $upward,
361                $bits,
362                $bitIdx
363            );
364            $upward = !$upward;
365        }
366    }
367
368    /**
369     * @return array<int, int>
370     */
371    private function getZigzagColumnPairs(int $n): array
372    {
373        $cols = [];
374        $col = $n - 1;
375        while ($col > 0) {
376            if ($col === 6) {
377                $col--;
378            }
379            $cols[] = $col;
380            $col -= 2;
381        }
382
383        return $cols;
384    }
385
386    private function placeColumnPairBits(
387        array &$matrix,
388        array $reserved,
389        int $n,
390        int $rightCol,
391        bool $upward,
392        string $bits,
393        int &$bitIdx
394    ): void {
395        $bitTotal = strlen($bits);
396        for ($rowStep = 0; $rowStep < $n; $rowStep++) {
397            $r = $upward ? ($n - 1 - $rowStep) : $rowStep;
398            for ($dc = 0; $dc < 2; $dc++) {
399                $c = $rightCol - $dc;
400                if (!$reserved[$r][$c]) {
401                    $val = ($bitIdx < $bitTotal && $bits[$bitIdx] === '1');
402                    $matrix[$r][$c] = $val;
403                    $bitIdx++;
404                }
405            }
406        }
407    }
408}