Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use App\Framework\QrCode\Structure\BlockStructure;
use InvalidArgumentException;
/**
* QR Code Data Encoder
*
* Encodes data into the bit stream format required for QR codes.
* Handles different data modes and creates the final codeword sequence.
*/
final class DataEncoder
{
private BlockStructure $blockStructure;
public function __construct()
{
$this->blockStructure = new BlockStructure();
}
/**
* Encode data into QR code codewords with error correction
*/
public function encode(string $data, QrCodeVersion $version, ErrorCorrectionLevel $errorLevel): array
{
// Step 1: Create data bit stream
$mode = DataMode::detectForData($data);
$bitStream = '';
$modeIndicator = $this->addModeIndicator($mode);
$bitStream .= $modeIndicator;
error_log("DEBUG: Mode indicator: " . strlen($modeIndicator) . " bits");
$charCount = $this->addCharacterCountIndicator($data, $mode, $version);
$bitStream .= $charCount;
error_log("DEBUG: After char count: " . strlen($bitStream) . " bits total");
$encodedData = $this->encodeData($data, $mode);
$bitStream .= $encodedData;
error_log("DEBUG: After data encoding: " . strlen($bitStream) . " bits total (added " . strlen($encodedData) . " bits)");
$terminator = $this->addTerminator($bitStream, $version, $errorLevel);
$bitStream .= $terminator;
error_log("DEBUG: After terminator: " . strlen($bitStream) . " bits total");
$bitStream = $this->padToByteLength($bitStream);
error_log("DEBUG: After byte padding: " . strlen($bitStream) . " bits total");
$bitStream = $this->addPadding($bitStream, $version, $errorLevel);
// Step 2: Convert bit stream to data codewords
$dataCodewords = $this->bitStreamToCodewords($bitStream);
// Step 3: Create data blocks with error correction
$blocks = $this->blockStructure->createBlocks(
$dataCodewords,
$version->getVersion(),
$errorLevel->value
);
// Step 4: Interleave blocks to create final codeword sequence
return $this->blockStructure->interleaveBlocks($blocks);
}
/**
* Add mode indicator to bit stream (4 bits)
*/
private function addModeIndicator(DataMode $mode): string
{
return $this->intToBits($mode->getIndicator(), 4);
}
/**
* Add character count indicator
*/
private function addCharacterCountIndicator(string $data, DataMode $mode, QrCodeVersion $version): string
{
$characterCount = match($mode) {
DataMode::NUMERIC => strlen($data),
DataMode::ALPHANUMERIC => strlen($data),
DataMode::BYTE => strlen($data),
DataMode::KANJI => $this->getKanjiCount($data),
};
$bits = $mode->getCharacterCountBits($version);
return $this->intToBits($characterCount, $bits);
}
/**
* Encode the actual data based on mode
*/
private function encodeData(string $data, DataMode $mode): string
{
return match($mode) {
DataMode::NUMERIC => $this->encodeNumeric($data),
DataMode::ALPHANUMERIC => $this->encodeAlphanumeric($data),
DataMode::BYTE => $this->encodeByte($data),
DataMode::KANJI => $this->encodeKanji($data),
};
}
/**
* Encode numeric data (0-9)
*/
private function encodeNumeric(string $data): string
{
$bitStream = '';
$length = strlen($data);
// Process 3 digits at a time
for ($i = 0; $i < $length; $i += 3) {
$group = substr($data, $i, 3);
$value = (int) $group;
$bits = match(strlen($group)) {
3 => 10, // 3 digits = 10 bits
2 => 7, // 2 digits = 7 bits
1 => 4, // 1 digit = 4 bits
};
$bitStream .= $this->intToBits($value, $bits);
}
return $bitStream;
}
/**
* Encode alphanumeric data
*/
private function encodeAlphanumeric(string $data): string
{
$alphanumericMap = [
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
'A' => 10, 'B' => 11, 'C' => 12, 'D' => 13, 'E' => 14, 'F' => 15, 'G' => 16, 'H' => 17, 'I' => 18, 'J' => 19,
'K' => 20, 'L' => 21, 'M' => 22, 'N' => 23, 'O' => 24, 'P' => 25, 'Q' => 26, 'R' => 27, 'S' => 28, 'T' => 29,
'U' => 30, 'V' => 31, 'W' => 32, 'X' => 33, 'Y' => 34, 'Z' => 35, ' ' => 36, '$' => 37, '%' => 38, '*' => 39,
'+' => 40, '-' => 41, '.' => 42, '/' => 43, ':' => 44,
];
$bitStream = '';
$length = strlen($data);
// Process 2 characters at a time
for ($i = 0; $i < $length; $i += 2) {
if ($i + 1 < $length) {
// Two characters: value = (45 * first) + second
$char1 = $data[$i];
$char2 = $data[$i + 1];
if (! isset($alphanumericMap[$char1]) || ! isset($alphanumericMap[$char2])) {
throw new InvalidArgumentException("Invalid alphanumeric character in data");
}
$value = 45 * $alphanumericMap[$char1] + $alphanumericMap[$char2];
$bitStream .= $this->intToBits($value, 11);
} else {
// Single character
$char = $data[$i];
if (! isset($alphanumericMap[$char])) {
throw new InvalidArgumentException("Invalid alphanumeric character in data");
}
$value = $alphanumericMap[$char];
$bitStream .= $this->intToBits($value, 6);
}
}
return $bitStream;
}
/**
* Encode byte data (8 bits per byte)
*/
private function encodeByte(string $data): string
{
$bitStream = '';
for ($i = 0; $i < strlen($data); $i++) {
$byte = ord($data[$i]);
$bitStream .= $this->intToBits($byte, 8);
}
return $bitStream;
}
/**
* Encode Kanji data (placeholder - complex implementation needed)
*/
private function encodeKanji(string $data): string
{
// Simplified Kanji encoding - in practice this needs proper Shift_JIS handling
throw new InvalidArgumentException("Kanji encoding not yet implemented");
}
/**
* Add terminator (0000) if space allows
*/
private function addTerminator(string $bitStream, QrCodeVersion $version, ErrorCorrectionLevel $errorLevel): string
{
$maxCapacityBits = $version->getDataCapacity($errorLevel);
$currentBits = strlen($bitStream);
// Add up to 4 terminator bits if space allows
$terminatorBits = min(4, $maxCapacityBits - $currentBits);
if ($terminatorBits > 0) {
$bitStream .= str_repeat('0', $terminatorBits);
}
return $bitStream;
}
/**
* Pad bit stream to byte boundary
*/
private function padToByteLength(string $bitStream): string
{
$remainder = strlen($bitStream) % 8;
if ($remainder > 0) {
$padding = 8 - $remainder;
$bitStream .= str_repeat('0', $padding);
}
return $bitStream;
}
/**
* Add padding bytes to reach required capacity
*/
private function addPadding(string $bitStream, QrCodeVersion $version, ErrorCorrectionLevel $errorLevel): string
{
$maxCapacityBits = $version->getDataCapacity($errorLevel);
$currentBits = strlen($bitStream);
// DEBUG: Output detailed information
error_log("DEBUG addPadding - Version: {$version->getVersion()}, Error Level: {$errorLevel->value}");
error_log("DEBUG addPadding - Current bits: $currentBits, Max capacity: $maxCapacityBits");
error_log("DEBUG addPadding - Bitstream sample (first 100 chars): " . substr($bitStream, 0, 100));
if ($currentBits > $maxCapacityBits) {
throw new InvalidArgumentException("Data exceeds capacity for version {$version->getVersion()}: {$currentBits} bits > {$maxCapacityBits} bits");
}
// Add alternating padding bytes: 11101100, 00010001
$paddingBytes = [
'11101100', // 236 in decimal
'00010001', // 17 in decimal
];
$paddingIndex = 0;
while (strlen($bitStream) < $maxCapacityBits) {
$remaining = $maxCapacityBits - strlen($bitStream);
if ($remaining >= 8) {
$bitStream .= $paddingBytes[$paddingIndex % 2];
$paddingIndex++;
} else {
// Add remaining bits as zeros
$bitStream .= str_repeat('0', $remaining);
break;
}
}
// Trim to exact capacity
return substr($bitStream, 0, $maxCapacityBits);
}
/**
* Convert integer to binary string with specified bit count
*/
private function intToBits(int $value, int $bits): string
{
return str_pad(decbin($value), $bits, '0', STR_PAD_LEFT);
}
/**
* Get Kanji character count (placeholder)
*/
private function getKanjiCount(string $data): int
{
// Simplified - real implementation would count actual Kanji characters
return mb_strlen($data, 'UTF-8');
}
/**
* Convert bit stream to array of data codewords (bytes)
*/
private function bitStreamToCodewords(string $bitStream): array
{
$codewords = [];
$length = strlen($bitStream);
// Convert every 8 bits to a byte
for ($i = 0; $i < $length; $i += 8) {
$byte = substr($bitStream, $i, 8);
// Pad if necessary (shouldn't happen after proper bit stream creation)
if (strlen($byte) < 8) {
$byte = str_pad($byte, 8, '0', STR_PAD_RIGHT);
}
$codewords[] = bindec($byte);
}
return $codewords;
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
/**
* QR Code Data Mode
*
* Defines how data is encoded in the QR code.
* Different modes are optimized for different types of data.
*/
enum DataMode: int
{
case NUMERIC = 0b0001; // 0-9 digits only
case ALPHANUMERIC = 0b0010; // 0-9, A-Z, space, $, %, *, +, -, ., /, :
case BYTE = 0b0100; // Any 8-bit data (UTF-8, binary)
case KANJI = 0b1000; // Japanese Kanji characters
/**
* Get the mode indicator bits
*/
public function getIndicator(): int
{
return $this->value;
}
/**
* Get the character count bits for this mode and version
*/
public function getCharacterCountBits(QrCodeVersion $version): int
{
$versionNumber = $version->getVersion();
return match($this) {
self::NUMERIC => match(true) {
$versionNumber <= 9 => 10,
$versionNumber <= 26 => 12,
default => 14,
},
self::ALPHANUMERIC => match(true) {
$versionNumber <= 9 => 9,
$versionNumber <= 26 => 11,
default => 13,
},
self::BYTE => match(true) {
$versionNumber <= 9 => 8,
$versionNumber <= 26 => 16,
default => 16,
},
self::KANJI => match(true) {
$versionNumber <= 9 => 8,
$versionNumber <= 26 => 10,
default => 12,
},
};
}
/**
* Detect optimal mode for given data
*/
public static function detectForData(string $data): self
{
// Check if numeric (digits only)
if (ctype_digit($data)) {
return self::NUMERIC;
}
// Check if alphanumeric
if (self::isAlphanumeric($data)) {
return self::ALPHANUMERIC;
}
// Default to byte mode for any other data
return self::BYTE;
}
/**
* Check if string contains only alphanumeric QR characters
*/
private static function isAlphanumeric(string $data): bool
{
$alphanumericChars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
for ($i = 0; $i < strlen($data); $i++) {
if (strpos($alphanumericChars, $data[$i]) === false) {
return false;
}
}
return true;
}
/**
* Get efficiency ratio (bits per character)
*/
public function getEfficiency(): float
{
return match($this) {
self::NUMERIC => 3.33, // 10 bits for 3 digits
self::ALPHANUMERIC => 5.5, // 11 bits for 2 characters
self::BYTE => 8.0, // 8 bits per byte
self::KANJI => 13.0, // 13 bits per Kanji
};
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
/**
* QR Code Error Correction Level
*
* Defines the amount of error correction data included in the QR code.
* Higher levels can recover from more damage but require more space.
*/
enum ErrorCorrectionLevel: string
{
case L = 'L'; // Low - ~7% error recovery
case M = 'M'; // Medium - ~15% error recovery
case Q = 'Q'; // Quartile - ~25% error recovery
case H = 'H'; // High - ~30% error recovery
/**
* Get the error correction capacity percentage
*/
public function getCapacityPercentage(): int
{
return match($this) {
self::L => 7,
self::M => 15,
self::Q => 25,
self::H => 30,
};
}
/**
* Get the format indicator for this level
*/
public function getFormatIndicator(): int
{
return match($this) {
self::L => 0b01,
self::M => 0b00,
self::Q => 0b11,
self::H => 0b10,
};
}
/**
* Get recommended level for TOTP (balance between size and recovery)
*/
public static function forTotp(): self
{
return self::M; // Medium is good balance for TOTP URLs
}
}

View File

@@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use App\Framework\QrCode\Patterns\FormatInfo;
use App\Framework\QrCode\Patterns\MaskingPattern;
/**
* QR Code Matrix Generator
*
* Generates the complete QR code matrix including:
* - Finder patterns (corner squares)
* - Timing patterns (alternating lines)
* - Data placement
* - Masking patterns
*/
final readonly class MatrixGenerator
{
/**
* Generate QR code matrix from encoded data
*/
public function generateMatrix(
array $codewords,
QrCodeVersion $version,
ErrorCorrectionLevel $errorLevel
): QrCodeMatrix {
$size = $version->getModuleCount();
$matrix = QrCodeMatrix::create($size);
// Step 1: Add function patterns (finder, timing, etc.)
$matrix = $this->addFunctionPatterns($matrix, $version);
// Step 2: Add data modules
$matrix = $this->addDataModules($matrix, $codewords, $version);
// Step 3: Find and apply best masking pattern
$maskResult = MaskingPattern::findAndApplyBest($matrix, $version);
$matrix = $maskResult['matrix'];
$maskPattern = $maskResult['pattern'];
// Step 4: Add format information (standard QR placement)
$matrix = $this->addFormatInformation($matrix, $errorLevel, $maskPattern);
return $matrix;
}
/**
* Add all function patterns (finder, separators, timing, dark module)
*/
private function addFunctionPatterns(QrCodeMatrix $matrix, QrCodeVersion $version): QrCodeMatrix
{
$size = $version->getModuleCount();
// Add finder patterns (3 corner squares)
$matrix = $this->addFinderPattern($matrix, 0, 0); // Top-left
$matrix = $this->addFinderPattern($matrix, $size - 7, 0); // Top-right
$matrix = $this->addFinderPattern($matrix, 0, $size - 7); // Bottom-left
// Add separators (white borders around finder patterns)
$matrix = $this->addSeparators($matrix, $size);
// Add timing patterns (alternating black/white lines)
$matrix = $this->addTimingPatterns($matrix, $size);
// Add dark module (always at specific position)
$matrix = $this->addDarkModule($matrix, $version);
// Add alignment patterns (for versions 2+)
if ($version->hasAlignmentPatterns()) {
$matrix = $this->addAlignmentPatterns($matrix, $version);
}
return $matrix;
}
/**
* Add finder pattern (7x7 square with specific pattern)
*/
private function addFinderPattern(QrCodeMatrix $matrix, int $startRow, int $startCol): QrCodeMatrix
{
$pattern = [
[1,1,1,1,1,1,1],
[1,0,0,0,0,0,1],
[1,0,1,1,1,0,1],
[1,0,1,1,1,0,1],
[1,0,1,1,1,0,1],
[1,0,0,0,0,0,1],
[1,1,1,1,1,1,1],
];
$updates = [];
for ($i = 0; $i < 7; $i++) {
for ($j = 0; $j < 7; $j++) {
$updates[] = [
'row' => $startRow + $i,
'col' => $startCol + $j,
'dark' => (bool) $pattern[$i][$j],
];
}
}
return $matrix->withModules($updates);
}
/**
* Add white separator borders around finder patterns
*/
private function addSeparators(QrCodeMatrix $matrix, int $size): QrCodeMatrix
{
$updates = [];
// Top-left separator
for ($i = 0; $i < 8; $i++) {
if ($i < 8) {
$updates[] = ['row' => 7, 'col' => $i, 'dark' => false];
}
if ($i < 8) {
$updates[] = ['row' => $i, 'col' => 7, 'dark' => false];
}
}
// Top-right separator
for ($i = 0; $i < 8; $i++) {
if ($size - 8 + $i >= 0) {
$updates[] = ['row' => 7, 'col' => $size - 8 + $i, 'dark' => false];
}
if ($size - 8 >= 0) {
$updates[] = ['row' => $i, 'col' => $size - 8, 'dark' => false];
}
}
// Bottom-left separator
for ($i = 0; $i < 8; $i++) {
if ($size - 8 >= 0) {
$updates[] = ['row' => $size - 8, 'col' => $i, 'dark' => false];
}
if ($size - 8 + $i < $size) {
$updates[] = ['row' => $size - 8 + $i, 'col' => 7, 'dark' => false];
}
}
return $matrix->withModules($updates);
}
/**
* Add timing patterns (alternating horizontal and vertical lines)
*/
private function addTimingPatterns(QrCodeMatrix $matrix, int $size): QrCodeMatrix
{
$updates = [];
// Horizontal timing pattern (row 6)
for ($col = 8; $col < $size - 8; $col++) {
$updates[] = ['row' => 6, 'col' => $col, 'dark' => ($col % 2) === 0];
}
// Vertical timing pattern (column 6)
for ($row = 8; $row < $size - 8; $row++) {
$updates[] = ['row' => $row, 'col' => 6, 'dark' => ($row % 2) === 0];
}
return $matrix->withModules($updates);
}
/**
* Add dark module (always present at specific position)
*/
private function addDarkModule(QrCodeMatrix $matrix, QrCodeVersion $version): QrCodeMatrix
{
$row = (4 * $version->getVersion()) + 9;
$col = 8;
return $matrix->withModule($row, $col, true);
}
/**
* Add alignment patterns (for versions 2+)
*/
private function addAlignmentPatterns(QrCodeMatrix $matrix, QrCodeVersion $version): QrCodeMatrix
{
$positions = $version->getAlignmentPositions();
$pattern = [
[1,1,1,1,1],
[1,0,0,0,1],
[1,0,1,0,1],
[1,0,0,0,1],
[1,1,1,1,1],
];
$updates = [];
foreach ($positions as $row) {
foreach ($positions as $col) {
// Skip positions that conflict with finder patterns
if ($this->isFinderPatternArea($row, $col, $version->getModuleCount())) {
continue;
}
// Add 5x5 alignment pattern centered at (row, col)
for ($i = -2; $i <= 2; $i++) {
for ($j = -2; $j <= 2; $j++) {
$updates[] = [
'row' => $row + $i,
'col' => $col + $j,
'dark' => (bool) $pattern[$i + 2][$j + 2],
];
}
}
}
}
return $matrix->withModules($updates);
}
/**
* Place data modules in zigzag pattern
*/
private function addDataModules(QrCodeMatrix $matrix, array $codewords, QrCodeVersion $version): QrCodeMatrix
{
$size = $version->getModuleCount();
// Convert codewords to bit string
$bitString = '';
foreach ($codewords as $codeword) {
$bitString .= str_pad(decbin($codeword), 8, '0', STR_PAD_LEFT);
}
$dataIndex = 0;
$updates = [];
// Start from bottom-right, move in zigzag pattern
$direction = -1; // -1 = up, 1 = down
for ($col = $size - 1; $col > 0; $col -= 2) {
// Skip timing column
if ($col === 6) {
$col--;
}
for ($i = 0; $i < $size; $i++) {
$row = $direction === -1 ? $size - 1 - $i : $i;
// Place data in two columns (col and col-1)
for ($c = 0; $c < 2; $c++) {
$currentCol = $col - $c;
if (! $this->isFunctionModule($row, $currentCol, $version)) {
$bit = $dataIndex < strlen($bitString) ? $bitString[$dataIndex] : '0';
$updates[] = [
'row' => $row,
'col' => $currentCol,
'dark' => $bit === '1',
];
$dataIndex++;
}
}
}
$direction *= -1; // Switch direction
}
return $matrix->withModules($updates);
}
/**
* Add format information around finder patterns according to QR standard
*/
private function addFormatInformation(QrCodeMatrix $matrix, ErrorCorrectionLevel $errorLevel, int $maskPattern): QrCodeMatrix
{
$size = $matrix->getSize();
$formatBits = FormatInfo::generate($errorLevel, $maskPattern);
$updates = [];
// Place format information bits according to QR Code specification
// Total: 15 bits placed in specific positions around finder patterns
// Horizontal format info (around top-left finder pattern)
// Bits 0-5: positions (8,0) to (8,5)
for ($i = 0; $i < 6; $i++) {
$updates[] = [
'row' => 8,
'col' => $i,
'dark' => $formatBits[$i] === 1,
];
}
// Bit 6: position (8,7) - skip (8,6) which is timing pattern
$updates[] = [
'row' => 8,
'col' => 7,
'dark' => $formatBits[6] === 1,
];
// Bit 7: position (8,8)
$updates[] = [
'row' => 8,
'col' => 8,
'dark' => $formatBits[7] === 1,
];
// Bits 8-14: positions (8, size-7) to (8, size-1) - around top-right finder
for ($i = 8; $i < 15; $i++) {
$col = $size - 15 + $i;
if ($col >= 0 && $col < $size) {
$updates[] = [
'row' => 8,
'col' => $col,
'dark' => $formatBits[$i] === 1,
];
}
}
// Vertical format info (around bottom-left finder pattern)
// Bits 0-6: positions (size-1,8) to (size-7,8) - going upward
for ($i = 0; $i < 7; $i++) {
$row = $size - 1 - $i;
if ($row >= 0) {
$updates[] = [
'row' => $row,
'col' => 8,
'dark' => $formatBits[$i] === 1,
];
}
}
// Bits 7-14: positions (7,8) to (0,8) - skip (6,8) which is timing
for ($i = 7; $i < 15; $i++) {
$row = 14 - $i;
if ($row >= 0 && $row !== 6) { // Skip timing pattern row
$updates[] = [
'row' => $row,
'col' => 8,
'dark' => $formatBits[$i] === 1,
];
}
}
return $matrix->withModules($updates);
}
/**
* Add simplified format information for basic QR code compatibility
*/
private function addSimpleFormatInfo(QrCodeMatrix $matrix, ErrorCorrectionLevel $errorLevel, int $maskPattern): QrCodeMatrix
{
$size = $matrix->getSize();
$updates = [];
// Basic format info pattern for M error correction + mask pattern
$formatBits = FormatInfo::generate($errorLevel, $maskPattern);
// Place some format bits horizontally (row 8)
for ($i = 0; $i < min(6, count($formatBits)); $i++) {
if ($i < $size) {
$updates[] = [
'row' => 8,
'col' => $i,
'dark' => $formatBits[$i] === 1,
];
}
}
// Place some format bits vertically (col 8)
for ($i = 0; $i < min(6, count($formatBits)); $i++) {
if (($size - 1 - $i) >= 0) {
$updates[] = [
'row' => $size - 1 - $i,
'col' => 8,
'dark' => $formatBits[$i] === 1,
];
}
}
return $matrix->withModules($updates);
}
/**
* Check if position is in finder pattern area
*/
private function isFinderPatternArea(int $row, int $col, int $size): bool
{
// Top-left finder pattern + separator
if ($row <= 8 && $col <= 8) {
return true;
}
// Top-right finder pattern + separator
if ($row <= 8 && $col >= $size - 8) {
return true;
}
// Bottom-left finder pattern + separator
if ($row >= $size - 8 && $col <= 8) {
return true;
}
return false;
}
/**
* Check if position is a function module (finder, timing, etc.)
*/
private function isFunctionModule(int $row, int $col, QrCodeVersion $version): bool
{
$size = $version->getModuleCount();
// Finder patterns and separators
if ($this->isFinderPatternArea($row, $col, $size)) {
return true;
}
// Timing patterns
if (($row === 6 && $col >= 8 && $col < $size - 8) ||
($col === 6 && $row >= 8 && $row < $size - 8)) {
return true;
}
// Dark module
if ($row === (4 * $version->getVersion()) + 9 && $col === 8) {
return true;
}
// Alignment patterns (simplified check)
if ($version->hasAlignmentPatterns()) {
$positions = $version->getAlignmentPositions();
foreach ($positions as $alignRow) {
foreach ($positions as $alignCol) {
if (! $this->isFinderPatternArea($alignRow, $alignCol, $size)) {
if (abs($row - $alignRow) <= 2 && abs($col - $alignCol) <= 2) {
return true;
}
}
}
}
}
return false;
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode\Patterns;
use App\Framework\QrCode\ErrorCorrectionLevel;
/**
* QR Code Format Information
*
* Encodes error correction level and mask pattern into format info bits
* placed around finder patterns for QR code readers to identify settings
*/
final class FormatInfo
{
/**
* Generate format information bits for error correction level and mask pattern
*/
public static function generate(ErrorCorrectionLevel $errorLevel, int $maskPattern): array
{
// Error correction level indicators
$errorLevelBits = match($errorLevel) {
ErrorCorrectionLevel::L => 0b01, // 01
ErrorCorrectionLevel::M => 0b00, // 00
ErrorCorrectionLevel::Q => 0b11, // 11
ErrorCorrectionLevel::H => 0b10, // 10
};
// Combine error level (2 bits) and mask pattern (3 bits)
$formatData = ($errorLevelBits << 3) | ($maskPattern & 0b111);
// Apply BCH error correction for format info
$formatInfo = self::applyFormatErrorCorrection($formatData);
// Apply mask pattern 0x5412 (standard QR format info mask)
$formatInfo ^= 0x5412;
// Convert to array of bits (MSB first)
$bits = [];
for ($i = 14; $i >= 0; $i--) {
$bits[] = ($formatInfo >> $i) & 1;
}
return $bits;
}
/**
* Apply BCH error correction to format data (5 bits -> 15 bits)
*/
private static function applyFormatErrorCorrection(int $data): int
{
// Generator polynomial for QR format info: x^10 + x^8 + x^5 + x^4 + x^2 + x + 1
$generator = 0x537; // 10100110111 in binary
// Shift data to make room for error correction bits
$data <<= 10;
// Perform BCH division
for ($i = 4; $i >= 0; $i--) {
if ($data & (1 << ($i + 10))) {
$data ^= $generator << $i;
}
}
return $data;
}
/**
* Get format info placement positions for QR code matrix
*/
public static function getPlacementPositions(int $size): array
{
$positions = [];
// Horizontal format info (row 8)
for ($i = 0; $i <= 5; $i++) {
$positions[] = ['row' => 8, 'col' => $i, 'bit_index' => $i];
}
$positions[] = ['row' => 8, 'col' => 7, 'bit_index' => 6]; // Skip col 6 (timing)
$positions[] = ['row' => 8, 'col' => 8, 'bit_index' => 7];
// Right side (horizontal)
for ($i = 0; $i < 7; $i++) {
$positions[] = ['row' => 8, 'col' => $size - 7 + $i, 'bit_index' => 8 + $i];
}
// Vertical format info (col 8)
for ($i = 0; $i < 6; $i++) {
$positions[] = ['row' => $size - 1 - $i, 'col' => 8, 'bit_index' => $i];
}
for ($i = 0; $i < 8; $i++) {
if ($i == 6) {
continue;
} // Skip row 6 (timing)
$bitIndex = $i < 6 ? 14 - $i : 14 - ($i - 1);
$positions[] = ['row' => $i, 'col' => 8, 'bit_index' => $bitIndex];
}
return $positions;
}
/**
* Place format information in QR code matrix
*/
public static function placeInMatrix(array $matrix, ErrorCorrectionLevel $errorLevel, int $maskPattern): array
{
$size = count($matrix);
$formatBits = self::generate($errorLevel, $maskPattern);
$positions = self::getPlacementPositions($size);
$updates = [];
foreach ($positions as $pos) {
if (isset($formatBits[$pos['bit_index']])) {
$updates[] = [
'row' => $pos['row'],
'col' => $pos['col'],
'dark' => $formatBits[$pos['bit_index']] === 1,
];
}
}
return $updates;
}
}

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode\Patterns;
use App\Framework\QrCode\QrCodeMatrix;
use App\Framework\QrCode\QrCodeVersion;
/**
* QR Code Masking Patterns
*
* Applies masking patterns to data modules and evaluates quality
* to select the best pattern for readability
*/
final class MaskingPattern
{
/**
* Available masking patterns (0-7)
*/
private const PATTERNS = [
0 => 'pattern0',
1 => 'pattern1',
2 => 'pattern2',
3 => 'pattern3',
4 => 'pattern4',
5 => 'pattern5',
6 => 'pattern6',
7 => 'pattern7',
];
/**
* Find and apply the best masking pattern
*/
public static function findAndApplyBest(QrCodeMatrix $matrix, QrCodeVersion $version): array
{
$bestPattern = 0;
$lowestPenalty = PHP_INT_MAX;
$bestMatrix = null;
// Try all 8 patterns and find the one with lowest penalty
for ($pattern = 0; $pattern < 8; $pattern++) {
$testMatrix = self::applyPattern($matrix, $pattern, $version);
$penalty = self::calculatePenalty($testMatrix, $version);
if ($penalty < $lowestPenalty) {
$lowestPenalty = $penalty;
$bestPattern = $pattern;
$bestMatrix = $testMatrix;
}
}
return ['matrix' => $bestMatrix, 'pattern' => $bestPattern];
}
/**
* Apply specific masking pattern to data modules
*/
public static function applyPattern(QrCodeMatrix $matrix, int $pattern, QrCodeVersion $version): QrCodeMatrix
{
$size = $version->getModuleCount();
$updates = [];
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col < $size; $col++) {
// Only mask data modules, not function patterns
if (! self::isFunctionModule($row, $col, $version)) {
$shouldMask = self::shouldMaskModule($row, $col, $pattern);
if ($shouldMask) {
$currentValue = $matrix->getModule($row, $col);
$updates[] = [
'row' => $row,
'col' => $col,
'dark' => ! $currentValue, // Flip the bit
];
}
}
}
}
return $matrix->withModules($updates);
}
/**
* Check if module should be masked based on pattern
*/
private static function shouldMaskModule(int $row, int $col, int $pattern): bool
{
return match($pattern) {
0 => ($row + $col) % 2 === 0,
1 => $row % 2 === 0,
2 => $col % 3 === 0,
3 => ($row + $col) % 3 === 0,
4 => (intval($row / 2) + intval($col / 3)) % 2 === 0,
5 => (($row * $col) % 2) + (($row * $col) % 3) === 0,
6 => ((($row * $col) % 2) + (($row * $col) % 3)) % 2 === 0,
7 => ((($row + $col) % 2) + (($row * $col) % 3)) % 2 === 0,
default => false
};
}
/**
* Calculate penalty score for masking pattern quality
*/
private static function calculatePenalty(QrCodeMatrix $matrix, QrCodeVersion $version): int
{
$penalty = 0;
$size = $version->getModuleCount();
// Rule 1: Adjacent modules in same color (horizontal and vertical)
$penalty += self::calculateConsecutiveModulesPenalty($matrix, $size);
// Rule 2: Block of modules in same color
$penalty += self::calculateBlockPenalty($matrix, $size);
// Rule 3: Patterns similar to finder patterns
$penalty += self::calculateFinderLikePenalty($matrix, $size);
// Rule 4: Proportion of dark modules
$penalty += self::calculateProportionPenalty($matrix, $size);
return $penalty;
}
/**
* Penalty for consecutive modules of same color
*/
private static function calculateConsecutiveModulesPenalty(QrCodeMatrix $matrix, int $size): int
{
$penalty = 0;
// Check rows
for ($row = 0; $row < $size; $row++) {
$count = 1;
$prevDark = $matrix->getModule($row, 0);
for ($col = 1; $col < $size; $col++) {
$isDark = $matrix->getModule($row, $col);
if ($isDark === $prevDark) {
$count++;
} else {
if ($count >= 5) {
$penalty += $count - 2;
}
$count = 1;
$prevDark = $isDark;
}
}
if ($count >= 5) {
$penalty += $count - 2;
}
}
// Check columns
for ($col = 0; $col < $size; $col++) {
$count = 1;
$prevDark = $matrix->getModule(0, $col);
for ($row = 1; $row < $size; $row++) {
$isDark = $matrix->getModule($row, $col);
if ($isDark === $prevDark) {
$count++;
} else {
if ($count >= 5) {
$penalty += $count - 2;
}
$count = 1;
$prevDark = $isDark;
}
}
if ($count >= 5) {
$penalty += $count - 2;
}
}
return $penalty;
}
/**
* Penalty for 2x2 blocks of same color
*/
private static function calculateBlockPenalty(QrCodeMatrix $matrix, int $size): int
{
$penalty = 0;
for ($row = 0; $row < $size - 1; $row++) {
for ($col = 0; $col < $size - 1; $col++) {
$color = $matrix->getModule($row, $col);
if ($color === $matrix->getModule($row, $col + 1) &&
$color === $matrix->getModule($row + 1, $col) &&
$color === $matrix->getModule($row + 1, $col + 1)) {
$penalty += 3;
}
}
}
return $penalty;
}
/**
* Penalty for finder-like patterns
*/
private static function calculateFinderLikePenalty(QrCodeMatrix $matrix, int $size): int
{
$penalty = 0;
$finderPattern = [true, false, true, true, true, false, true, false, false, false, false]; // 10111010000
// Check rows
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col <= $size - 11; $col++) {
$matches = true;
for ($i = 0; $i < 11; $i++) {
if ($matrix->getModule($row, $col + $i) !== $finderPattern[$i]) {
$matches = false;
break;
}
}
if ($matches) {
$penalty += 40;
}
}
}
// Check columns
for ($col = 0; $col < $size; $col++) {
for ($row = 0; $row <= $size - 11; $row++) {
$matches = true;
for ($i = 0; $i < 11; $i++) {
if ($matrix->getModule($row + $i, $col) !== $finderPattern[$i]) {
$matches = false;
break;
}
}
if ($matches) {
$penalty += 40;
}
}
}
return $penalty;
}
/**
* Penalty for proportion of dark modules
*/
private static function calculateProportionPenalty(QrCodeMatrix $matrix, int $size): int
{
$darkCount = 0;
$totalCount = $size * $size;
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col < $size; $col++) {
if ($matrix->getModule($row, $col)) {
$darkCount++;
}
}
}
$percentage = ($darkCount * 100) / $totalCount;
$k = (int) abs(($percentage - 50) / 5);
return $k * 10;
}
/**
* Check if position is a function module (finder, timing, etc.)
*/
private static function isFunctionModule(int $row, int $col, QrCodeVersion $version): bool
{
$size = $version->getModuleCount();
// Finder patterns (3 corners)
if (($row < 9 && $col < 9) || // Top-left
($row < 9 && $col >= $size - 8) || // Top-right
($row >= $size - 8 && $col < 9)) { // Bottom-left
return true;
}
// Timing patterns
if (($row === 6) || ($col === 6)) {
return true;
}
// Dark module
if ($row === 4 * $version->getVersion() + 9 && $col === 8) {
return true;
}
// Format info areas (exclude from masking since added after)
if (($row === 8 && ($col < 9 || $col >= $size - 8)) ||
($col === 8 && ($row < 9 || $row >= $size - 7))) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* QR Code Exception
*
* Thrown when QR code generation or processing fails.
*/
final class QrCodeException extends FrameworkException
{
/**
* Data too large for any QR code version
*/
public static function dataTooLarge(int $dataLength): self
{
return self::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Data too large for QR code: {$dataLength} bytes exceeds maximum capacity"
)->withData([
'data_length' => $dataLength,
'max_capacity' => 2953, // Version 40, Level L
'operation' => 'qr_generation',
]);
}
/**
* Invalid QR code version
*/
public static function invalidVersion(int $version): self
{
return self::create(
ErrorCode::VAL_PARAMETER_INVALID,
"Invalid QR code version: {$version}"
)->withData([
'version' => $version,
'valid_range' => '1-40',
]);
}
/**
* Encoding failed
*/
public static function encodingFailed(string $reason, ?string $data = null): self
{
$context = ExceptionContext::forOperation('qr.encode', 'DataEncoder')
->withData([
'reason' => $reason,
'data_length' => $data ? strlen($data) : null,
]);
return self::fromContext(
"QR code encoding failed: {$reason}",
$context,
ErrorCode::PROCESSING_FAILED
);
}
/**
* Matrix generation failed
*/
public static function matrixGenerationFailed(string $reason): self
{
return self::forOperation(
'qr.matrix_generation',
'MatrixGenerator',
"QR code matrix generation failed: {$reason}",
ErrorCode::PROCESSING_FAILED
)->withData(['reason' => $reason]);
}
/**
* SVG rendering failed
*/
public static function renderingFailed(string $reason): self
{
return self::forOperation(
'qr.render',
'SvgRenderer',
"QR code rendering failed: {$reason}",
ErrorCode::PROCESSING_FAILED
)->withData(['reason' => $reason]);
}
/**
* Invalid TOTP URI format
*/
public static function invalidTotpUri(string $uri): self
{
return self::create(
ErrorCode::VAL_FORMAT_INVALID,
"Invalid TOTP URI format"
)->withData([
'uri_prefix' => substr($uri, 0, 20),
'expected_prefix' => 'otpauth://totp/',
'operation' => 'totp_qr_generation',
]);
}
/**
* Configuration error
*/
public static function invalidConfiguration(string $parameter, mixed $value): self
{
return self::create(
ErrorCode::CONFIG_INVALID_VALUE,
"Invalid QR code configuration: {$parameter}"
)->withData([
'parameter' => $parameter,
'value' => $value,
'component' => 'QrCodeGenerator',
]);
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use InvalidArgumentException;
/**
* QR Code Generator
*
* Main facade for generating QR codes from data.
* Orchestrates encoding, matrix generation, and rendering.
*/
final readonly class QrCodeGenerator
{
public function __construct(
private DataEncoder $dataEncoder,
private MatrixGenerator $matrixGenerator,
private SvgRenderer $svgRenderer
) {
}
/**
* Create QR code generator with default components
*/
public static function create(): self
{
return new self(
dataEncoder: new DataEncoder(),
matrixGenerator: new MatrixGenerator(),
svgRenderer: new SvgRenderer()
);
}
/**
* Create QR code generator for TOTP URLs
*/
public static function forTotp(): self
{
return new self(
dataEncoder: new DataEncoder(),
matrixGenerator: new MatrixGenerator(),
svgRenderer: SvgRenderer::withModuleSize(4)->withQuietZone(2)
);
}
/**
* Generate QR code as SVG string
*/
public function generateSvg(
string $data,
?ErrorCorrectionLevel $errorLevel = null,
?QrCodeVersion $version = null
): string {
$matrix = $this->generateMatrix($data, $errorLevel, $version);
return $this->svgRenderer->render($matrix);
}
/**
* Generate QR code as SVG data URI
*/
public function generateDataUri(
string $data,
?ErrorCorrectionLevel $errorLevel = null,
?QrCodeVersion $version = null
): string {
$matrix = $this->generateMatrix($data, $errorLevel, $version);
return $this->svgRenderer->renderAsDataUri($matrix);
}
/**
* Generate QR code matrix
*/
public function generateMatrix(
string $data,
?ErrorCorrectionLevel $errorLevel = null,
?QrCodeVersion $version = null
): QrCodeMatrix {
if (empty($data)) {
throw new InvalidArgumentException('QR code data cannot be empty');
}
$errorLevel = $errorLevel ?? ErrorCorrectionLevel::forTotp();
$version = $version ?? $this->determineOptimalVersion($data, $errorLevel);
// Encode data into bit stream
$encodedData = $this->dataEncoder->encode($data, $version, $errorLevel);
// Generate matrix from encoded data
return $this->matrixGenerator->generateMatrix($encodedData, $version, $errorLevel);
}
/**
* Create generator with custom renderer
*/
public function withRenderer(SvgRenderer $renderer): self
{
return new self(
dataEncoder: $this->dataEncoder,
matrixGenerator: $this->matrixGenerator,
svgRenderer: $renderer
);
}
/**
* Get recommended configuration for data
*/
public function analyzeData(string $data): array
{
if (empty($data)) {
throw new InvalidArgumentException('Cannot analyze empty data');
}
$mode = DataMode::detectForData($data);
$errorLevel = ErrorCorrectionLevel::forTotp();
$version = $this->determineOptimalVersion($data, $errorLevel);
return [
'data_length' => strlen($data),
'detected_mode' => $mode,
'recommended_error_level' => $errorLevel,
'optimal_version' => $version->getVersion(),
'matrix_size' => $version->getModuleCount(),
'data_capacity' => $version->getDataCapacity($errorLevel),
'efficiency' => $mode->getEfficiency(),
'capacity_used' => round((strlen($data) / $version->getDataCapacity($errorLevel)) * 100, 1),
];
}
/**
* Generate QR code for TOTP URI
*/
public function generateTotpQrCode(string $totpUri): string
{
// Validate TOTP URI format
if (! str_starts_with($totpUri, 'otpauth://totp/')) {
throw new InvalidArgumentException('Invalid TOTP URI format');
}
// Use appropriate settings for TOTP (allow larger versions)
$errorLevel = ErrorCorrectionLevel::M; // Medium error correction for TOTP
return $this->generateSvg($totpUri, $errorLevel, null); // Auto-detect version
}
/**
* Determine the optimal QR code version for given data
*/
private function determineOptimalVersion(string $data, ErrorCorrectionLevel $errorLevel): QrCodeVersion
{
$dataLength = strlen($data);
// Try to find the smallest version that can fit the data
return QrCodeVersion::forDataLength($dataLength, $errorLevel);
}
/**
* Get generator configuration
*/
public function getConfiguration(): array
{
return [
'encoder' => DataEncoder::class,
'matrix_generator' => MatrixGenerator::class,
'renderer' => $this->svgRenderer->getConfiguration(),
];
}
/**
* Validate data before encoding
*/
private function validateData(string $data): void
{
if (strlen($data) > 2953) { // Max capacity for version 40, error level L
throw new InvalidArgumentException('Data too large for QR code: ' . strlen($data) . ' bytes');
}
// Check for null bytes or other problematic characters
if (strpos($data, "\0") !== false) {
throw new InvalidArgumentException('Data contains null bytes');
}
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use InvalidArgumentException;
/**
* QR Code Matrix
*
* Represents the 2D matrix of modules (black/white squares) that make up a QR code.
* Each module is either true (black) or false (white).
*/
final readonly class QrCodeMatrix
{
/**
* @param array<array<bool>> $modules 2D array of boolean values
*/
public function __construct(
private array $modules,
private int $size
) {
$this->validateMatrix($modules, $size);
}
/**
* Create empty matrix of given size
*/
public static function create(int $size): self
{
if ($size < 1) {
throw new InvalidArgumentException("Matrix size must be positive, got: {$size}");
}
$modules = array_fill(0, $size, array_fill(0, $size, false));
return new self($modules, $size);
}
/**
* Get the size of the matrix (width/height in modules)
*/
public function getSize(): int
{
return $this->size;
}
/**
* Get module value at position (row, column)
*/
public function getModule(int $row, int $col): bool
{
if (! $this->isValidPosition($row, $col)) {
throw new InvalidArgumentException("Position ({$row}, {$col}) is out of bounds for size {$this->size}");
}
return $this->modules[$row][$col];
}
/**
* Check if module at position is dark (true)
*/
public function isDark(int $row, int $col): bool
{
return $this->getModule($row, $col);
}
/**
* Check if module at position is light (false)
*/
public function isLight(int $row, int $col): bool
{
return ! $this->getModule($row, $col);
}
/**
* Set module value at position
*/
public function withModule(int $row, int $col, bool $dark): self
{
if (! $this->isValidPosition($row, $col)) {
throw new InvalidArgumentException("Position ({$row}, {$col}) is out of bounds for size {$this->size}");
}
$newModules = $this->modules;
$newModules[$row][$col] = $dark;
return new self($newModules, $this->size);
}
/**
* Set multiple modules at once
* @param array<array{row: int, col: int, dark: bool}> $updates
*/
public function withModules(array $updates): self
{
$newModules = $this->modules;
foreach ($updates as $update) {
if (! isset($update['row'], $update['col'], $update['dark'])) {
throw new InvalidArgumentException('Update must have row, col, and dark keys');
}
if (! $this->isValidPosition($update['row'], $update['col'])) {
throw new InvalidArgumentException("Position ({$update['row']}, {$update['col']}) is out of bounds");
}
$newModules[$update['row']][$update['col']] = $update['dark'];
}
return new self($newModules, $this->size);
}
/**
* Get all modules as 2D array
*/
public function getModules(): array
{
return $this->modules;
}
/**
* Convert matrix to string representation (for debugging)
*/
public function toString(string $darkChar = '█', string $lightChar = ' '): string
{
$result = '';
for ($row = 0; $row < $this->size; $row++) {
for ($col = 0; $col < $this->size; $col++) {
$result .= $this->modules[$row][$col] ? $darkChar : $lightChar;
}
$result .= "\n";
}
return $result;
}
/**
* Get matrix statistics (for debugging/optimization)
*/
public function getStatistics(): array
{
$darkCount = 0;
$totalCount = $this->size * $this->size;
for ($row = 0; $row < $this->size; $row++) {
for ($col = 0; $col < $this->size; $col++) {
if ($this->modules[$row][$col]) {
$darkCount++;
}
}
}
return [
'size' => $this->size,
'total_modules' => $totalCount,
'dark_modules' => $darkCount,
'light_modules' => $totalCount - $darkCount,
'dark_ratio' => $darkCount / $totalCount,
];
}
/**
* Check if position is within matrix bounds
*/
private function isValidPosition(int $row, int $col): bool
{
return $row >= 0 && $row < $this->size && $col >= 0 && $col < $this->size;
}
/**
* Validate matrix structure
*/
private function validateMatrix(array $modules, int $size): void
{
if (count($modules) !== $size) {
throw new InvalidArgumentException("Matrix must have {$size} rows, got: " . count($modules));
}
foreach ($modules as $row => $cols) {
if (! is_array($cols) || count($cols) !== $size) {
throw new InvalidArgumentException("Row {$row} must have {$size} columns, got: " . count($cols));
}
foreach ($cols as $col => $value) {
if (! is_bool($value)) {
throw new InvalidArgumentException("Module at ({$row}, {$col}) must be boolean, got: " . gettype($value));
}
}
}
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use InvalidArgumentException;
/**
* QR Code Version (Size)
*
* QR codes come in versions 1-40, where each version determines the size:
* Version 1: 21x21 modules
* Version 2: 25x25 modules
* Version N: (17 + 4*N) x (17 + 4*N) modules
*/
final readonly class QrCodeVersion
{
public function __construct(
private int $version
) {
if ($version < 1 || $version > 40) {
throw new InvalidArgumentException("QR Code version must be between 1 and 40, got: {$version}");
}
}
/**
* Create version 1 (smallest)
*/
public static function v1(): self
{
return new self(1);
}
/**
* Create version for typical TOTP URLs
*/
public static function forTotp(): self
{
return new self(3); // Good size for TOTP URLs (~130 chars)
}
/**
* Get the version number (1-40)
*/
public function getVersion(): int
{
return $this->version;
}
/**
* Get the module count per side (21, 25, 29, etc.)
*/
public function getModuleCount(): int
{
return 17 + 4 * $this->version;
}
/**
* Get the total data capacity for this version/error correction level
*/
public function getDataCapacity(ErrorCorrectionLevel $errorLevel): int
{
// Data capacity table for first few versions (in BYTES) - From QR Code specification
$capacitiesInBytes = [
1 => ['L' => 19, 'M' => 16, 'Q' => 13, 'H' => 9], // Version 1
2 => ['L' => 34, 'M' => 28, 'Q' => 22, 'H' => 16], // Version 2
3 => ['L' => 55, 'M' => 44, 'Q' => 34, 'H' => 26], // Version 3
4 => ['L' => 80, 'M' => 64, 'Q' => 48, 'H' => 36], // Version 4
5 => ['L' => 108, 'M' => 86, 'Q' => 62, 'H' => 46], // Version 5
6 => ['L' => 136, 'M' => 108, 'Q' => 76, 'H' => 60], // Version 6
];
if (! isset($capacitiesInBytes[$this->version])) {
// For higher versions, use approximation based on module count
$totalModules = $this->getModuleCount() ** 2;
$dataModulesRatio = 0.6; // Approximate ratio of data modules to total modules
$errorReduction = $errorLevel->getCapacityPercentage() / 100;
$estimatedBytes = (int) (($totalModules * $dataModulesRatio * (1 - $errorReduction)) / 8);
return $estimatedBytes * 8; // Convert to bits
}
// Convert bytes to bits (1 byte = 8 bits)
return $capacitiesInBytes[$this->version][$errorLevel->value] * 8;
}
/**
* Determine minimum version needed for data length (in bytes)
*/
public static function forDataLength(int $dataLength, ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M): self
{
// Convert bytes to bits for comparison (8 bits per byte + overhead for mode/length indicators)
$requiredBits = ($dataLength * 8) + 32; // Add 32 bits overhead for mode, length, terminator
for ($version = 1; $version <= 40; $version++) {
$qrVersion = new self($version);
if ($qrVersion->getDataCapacity($errorLevel) >= $requiredBits) {
return $qrVersion;
}
}
throw new InvalidArgumentException("Data too large for any QR Code version: {$dataLength} bytes ({$requiredBits} bits needed)");
}
/**
* Check if this version has alignment patterns
*/
public function hasAlignmentPatterns(): bool
{
return $this->version >= 2;
}
/**
* Get alignment pattern positions for this version
*/
public function getAlignmentPositions(): array
{
if (! $this->hasAlignmentPatterns()) {
return [];
}
// Simplified - in full implementation this would be complete table
$patterns = [
2 => [6, 18],
3 => [6, 22],
4 => [6, 26],
5 => [6, 30],
];
return $patterns[$this->version] ?? [6, 6 + 4 * $this->version];
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode\ReedSolomon;
/**
* Galois Field GF(256) for Reed-Solomon Error Correction
*
* Implements Galois Field arithmetic using primitive polynomial x^8 + x^4 + x^3 + x^2 + 1
*/
final class GaloisField
{
private const PRIMITIVE_POLYNOMIAL = 0x11d; // x^8 + x^4 + x^3 + x^2 + 1
private array $expTable;
private array $logTable;
public function __construct()
{
$this->buildTables();
}
/**
* Get singleton instance
*/
public static function getInstance(): self
{
static $instance = null;
if ($instance === null) {
$instance = new self();
}
return $instance;
}
/**
* Add two numbers in GF(256)
*/
public function add(int $a, int $b): int
{
return $a ^ $b;
}
/**
* Subtract two numbers in GF(256) (same as addition)
*/
public function subtract(int $a, int $b): int
{
return $a ^ $b;
}
/**
* Multiply two numbers in GF(256)
*/
public function multiply(int $a, int $b): int
{
if ($a === 0 || $b === 0) {
return 0;
}
return $this->expTable[($this->logTable[$a] + $this->logTable[$b]) % 255];
}
/**
* Divide two numbers in GF(256)
*/
public function divide(int $a, int $b): int
{
if ($b === 0) {
throw new \InvalidArgumentException('Division by zero in GF(256)');
}
if ($a === 0) {
return 0;
}
return $this->expTable[($this->logTable[$a] - $this->logTable[$b] + 255) % 255];
}
/**
* Raise a number to a power in GF(256)
*/
public function power(int $base, int $exponent): int
{
if ($base === 0) {
return 0;
}
if ($exponent === 0) {
return 1;
}
return $this->expTable[($this->logTable[$base] * $exponent) % 255];
}
/**
* Get multiplicative inverse of a number in GF(256)
*/
public function inverse(int $a): int
{
if ($a === 0) {
throw new \InvalidArgumentException('Cannot find inverse of 0 in GF(256)');
}
return $this->expTable[255 - $this->logTable[$a]];
}
/**
* Build exponential and logarithm tables
*/
private function buildTables(): void
{
$this->expTable = array_fill(0, 512, 0);
$this->logTable = array_fill(0, 256, 0);
$x = 1;
for ($i = 0; $i < 255; $i++) {
$this->expTable[$i] = $x;
$this->logTable[$x] = $i;
$x <<= 1;
if ($x & 0x100) {
$x ^= self::PRIMITIVE_POLYNOMIAL;
}
}
// Extend exp table for easier calculations
for ($i = 255; $i < 512; $i++) {
$this->expTable[$i] = $this->expTable[$i - 255];
}
}
/**
* Get exp table value
*/
public function exp(int $a): int
{
return $this->expTable[$a];
}
/**
* Get log table value
*/
public function log(int $a): int
{
if ($a === 0) {
throw new \InvalidArgumentException('Cannot take log of 0');
}
return $this->logTable[$a];
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode\ReedSolomon;
/**
* Polynomial for Reed-Solomon Error Correction
*/
final class Polynomial
{
private GaloisField $field;
private array $coefficients;
public function __construct(GaloisField $field, array $coefficients)
{
$this->field = $field;
// Remove leading zeros
$firstNonZero = 0;
while ($firstNonZero < count($coefficients) && $coefficients[$firstNonZero] === 0) {
$firstNonZero++;
}
if ($firstNonZero === count($coefficients)) {
$this->coefficients = [0];
} else {
$this->coefficients = array_slice($coefficients, $firstNonZero);
}
}
/**
* Get coefficient at given power
*/
public function getCoefficient(int $degree): int
{
$index = count($this->coefficients) - 1 - $degree;
return $index < 0 ? 0 : $this->coefficients[$index];
}
/**
* Get all coefficients
*/
public function getCoefficients(): array
{
return $this->coefficients;
}
/**
* Get degree of polynomial
*/
public function getDegree(): int
{
return count($this->coefficients) - 1;
}
/**
* Check if polynomial is zero
*/
public function isZero(): bool
{
return count($this->coefficients) === 1 && $this->coefficients[0] === 0;
}
/**
* Add two polynomials
*/
public function add(self $other): self
{
if ($this->isZero()) {
return $other;
}
if ($other->isZero()) {
return $this;
}
$smallerCoeffs = $this->coefficients;
$largerCoeffs = $other->coefficients;
if (count($smallerCoeffs) > count($largerCoeffs)) {
[$smallerCoeffs, $largerCoeffs] = [$largerCoeffs, $smallerCoeffs];
}
$sumDiff = array_fill(0, count($largerCoeffs), 0);
$lengthDiff = count($largerCoeffs) - count($smallerCoeffs);
// Copy first part
for ($i = 0; $i < $lengthDiff; $i++) {
$sumDiff[$i] = $largerCoeffs[$i];
}
// Add overlapping part
for ($i = $lengthDiff; $i < count($largerCoeffs); $i++) {
$sumDiff[$i] = $this->field->add($smallerCoeffs[$i - $lengthDiff], $largerCoeffs[$i]);
}
return new self($this->field, $sumDiff);
}
/**
* Multiply two polynomials
*/
public function multiply(self $other): self
{
if ($this->isZero() || $other->isZero()) {
return new self($this->field, [0]);
}
$aCoeffs = $this->coefficients;
$bCoeffs = $other->coefficients;
$product = array_fill(0, count($aCoeffs) + count($bCoeffs) - 1, 0);
for ($i = 0; $i < count($aCoeffs); $i++) {
for ($j = 0; $j < count($bCoeffs); $j++) {
$product[$i + $j] = $this->field->add(
$product[$i + $j],
$this->field->multiply($aCoeffs[$i], $bCoeffs[$j])
);
}
}
return new self($this->field, $product);
}
/**
* Multiply by scalar
*/
public function multiplyByScalar(int $scalar): self
{
if ($scalar === 0) {
return new self($this->field, [0]);
}
if ($scalar === 1) {
return $this;
}
$result = array_fill(0, count($this->coefficients), 0);
for ($i = 0; $i < count($this->coefficients); $i++) {
$result[$i] = $this->field->multiply($this->coefficients[$i], $scalar);
}
return new self($this->field, $result);
}
/**
* Multiply by monomial
*/
public function multiplyByMonomial(int $degree, int $coefficient): self
{
if ($coefficient === 0) {
return new self($this->field, [0]);
}
$result = array_fill(0, count($this->coefficients) + $degree, 0);
for ($i = 0; $i < count($this->coefficients); $i++) {
$result[$i] = $this->field->multiply($this->coefficients[$i], $coefficient);
}
return new self($this->field, $result);
}
/**
* Divide polynomials
*/
public function divide(self $other): array
{
if ($other->isZero()) {
throw new \InvalidArgumentException('Cannot divide by zero polynomial');
}
$quotient = new self($this->field, [0]);
$remainder = $this;
$denominatorLeadingTerm = $other->getCoefficient($other->getDegree());
$inverseDenominatorLeadingTerm = $this->field->inverse($denominatorLeadingTerm);
while ($remainder->getDegree() >= $other->getDegree() && ! $remainder->isZero()) {
$degreeDifference = $remainder->getDegree() - $other->getDegree();
$scale = $this->field->multiply(
$remainder->getCoefficient($remainder->getDegree()),
$inverseDenominatorLeadingTerm
);
$term = $other->multiplyByMonomial($degreeDifference, $scale);
$iterationQuotient = new self($this->field, [$scale]);
if ($degreeDifference > 0) {
$iterationQuotient = $iterationQuotient->multiplyByMonomial($degreeDifference, 1);
}
$quotient = $quotient->add($iterationQuotient);
$remainder = $remainder->add($term);
}
return [$quotient, $remainder];
}
/**
* Evaluate polynomial at given point
*/
public function evaluateAt(int $a): int
{
if ($a === 0) {
return $this->getCoefficient(0);
}
$result = $this->coefficients[0];
for ($i = 1; $i < count($this->coefficients); $i++) {
$result = $this->field->add(
$this->field->multiply($a, $result),
$this->coefficients[$i]
);
}
return $result;
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode\ReedSolomon;
/**
* Reed-Solomon Encoder for QR Code Error Correction
*
* Generates error correction codewords for QR code data blocks
* using Reed-Solomon error correction codes over GF(256)
*/
final class ReedSolomonEncoder
{
private GaloisField $field;
private array $generatorPolynomials;
public function __construct()
{
$this->field = GaloisField::getInstance();
$this->generatorPolynomials = [];
}
/**
* Encode data bytes with error correction codewords
*/
public function encode(array $dataBytes, int $ecCodewords): array
{
if (count($dataBytes) === 0) {
throw new \InvalidArgumentException('Data bytes cannot be empty');
}
if ($ecCodewords <= 0) {
throw new \InvalidArgumentException('Error correction codewords must be positive');
}
// Get generator polynomial for this number of error correction codewords
$generator = $this->getGeneratorPolynomial($ecCodewords);
// Create info polynomial from data bytes
$infoCoeffs = array_merge($dataBytes, array_fill(0, $ecCodewords, 0));
$info = new Polynomial($this->field, $infoCoeffs);
// Divide info polynomial by generator polynomial
[$quotient, $remainder] = $info->divide($generator);
// Extract error correction codewords from remainder
$numZeroCoeffs = $ecCodewords - $remainder->getDegree() - 1;
$ecBytes = array_fill(0, $numZeroCoeffs, 0);
$remainderCoeffs = $remainder->getCoefficients();
for ($i = count($remainderCoeffs) - 1; $i >= 0; $i--) {
$ecBytes[] = $remainderCoeffs[$i];
}
return array_merge($dataBytes, $ecBytes);
}
/**
* Get generator polynomial for given number of error correction codewords
*/
private function getGeneratorPolynomial(int $degree): Polynomial
{
if (isset($this->generatorPolynomials[$degree])) {
return $this->generatorPolynomials[$degree];
}
// Build generator polynomial: (x - α^0)(x - α^1)...(x - α^(degree-1))
$generator = new Polynomial($this->field, [1]);
for ($i = 0; $i < $degree; $i++) {
$root = $this->field->exp($i);
$factor = new Polynomial($this->field, [1, $root]);
$generator = $generator->multiply($factor);
}
$this->generatorPolynomials[$degree] = $generator;
return $generator;
}
/**
* Calculate required error correction codewords for QR code version and level
*/
public static function getErrorCorrectionCodewords(int $version, string $errorLevel): int
{
// Error correction codewords lookup table for QR versions 1-40
$ecTable = [
'L' => [7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
'M' => [10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
'Q' => [13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
'H' => [17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
];
if (! isset($ecTable[$errorLevel])) {
throw new \InvalidArgumentException("Invalid error correction level: $errorLevel");
}
if ($version < 1 || $version > 40) {
throw new \InvalidArgumentException("Invalid QR version: $version");
}
return $ecTable[$errorLevel][$version - 1];
}
/**
* Get data capacity for QR code version and error correction level
*/
public static function getDataCapacity(int $version, string $errorLevel): int
{
// Total codewords for each version
$totalCodewords = [
26, 44, 70, 100, 134, 172, 196, 242, 292, 346,
404, 466, 532, 581, 655, 733, 815, 901, 991, 1085,
1156, 1258, 1364, 1474, 1588, 1706, 1828, 1921, 2051, 2185,
2323, 2465, 2611, 2761, 2876, 3034, 3196, 3362, 3532, 3706,
];
if ($version < 1 || $version > 40) {
throw new \InvalidArgumentException("Invalid QR version: $version");
}
$total = $totalCodewords[$version - 1];
$errorCorrection = self::getErrorCorrectionCodewords($version, $errorLevel);
return $total - $errorCorrection;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode\Structure;
use App\Framework\QrCode\ReedSolomon\ReedSolomonEncoder;
/**
* QR Code Block Structure Manager
*
* Handles data block division and error correction code generation
* according to QR code specification
*/
final class BlockStructure
{
private ReedSolomonEncoder $encoder;
public function __construct()
{
$this->encoder = new ReedSolomonEncoder();
}
/**
* Create blocks from data codewords
*/
public function createBlocks(array $dataCodewords, int $version, string $errorLevel): array
{
$blockInfo = $this->getBlockInfo($version, $errorLevel);
$blocks = [];
$dataOffset = 0;
// Create Group 1 blocks (if any)
for ($i = 0; $i < $blockInfo['group1_blocks']; $i++) {
$blockData = array_slice(
$dataCodewords,
$dataOffset,
$blockInfo['group1_data_per_block']
);
$errorCodewords = $this->encoder->encode(
$blockData,
$blockInfo['error_correction_per_block']
);
$blocks[] = new QrCodeBlock(
$blockData,
array_slice($errorCodewords, count($blockData))
);
$dataOffset += $blockInfo['group1_data_per_block'];
}
// Create Group 2 blocks (if any)
for ($i = 0; $i < $blockInfo['group2_blocks']; $i++) {
$blockData = array_slice(
$dataCodewords,
$dataOffset,
$blockInfo['group2_data_per_block']
);
$errorCodewords = $this->encoder->encode(
$blockData,
$blockInfo['error_correction_per_block']
);
$blocks[] = new QrCodeBlock(
$blockData,
array_slice($errorCodewords, count($blockData))
);
$dataOffset += $blockInfo['group2_data_per_block'];
}
return $blocks;
}
/**
* Interleave data and error correction codewords from blocks
*/
public function interleaveBlocks(array $blocks): array
{
if (empty($blocks)) {
return [];
}
$result = [];
// Interleave data codewords
$maxDataLength = max(array_map(fn ($block) => $block->getDataLength(), $blocks));
for ($i = 0; $i < $maxDataLength; $i++) {
foreach ($blocks as $block) {
if ($i < $block->getDataLength()) {
$result[] = $block->dataCodewords[$i];
}
}
}
// Interleave error correction codewords
$maxEcLength = max(array_map(fn ($block) => $block->getErrorCorrectionLength(), $blocks));
for ($i = 0; $i < $maxEcLength; $i++) {
foreach ($blocks as $block) {
if ($i < $block->getErrorCorrectionLength()) {
$result[] = $block->errorCorrectionCodewords[$i];
}
}
}
return $result;
}
/**
* Get block structure information for version and error level
*/
private function getBlockInfo(int $version, string $errorLevel): array
{
// Block structure lookup table for QR versions 1-10 (simplified for now)
$blockStructures = [
1 => [
'L' => ['group1_blocks' => 1, 'group1_data_per_block' => 19, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 7],
'M' => ['group1_blocks' => 1, 'group1_data_per_block' => 16, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 10],
'Q' => ['group1_blocks' => 1, 'group1_data_per_block' => 13, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 13],
'H' => ['group1_blocks' => 1, 'group1_data_per_block' => 9, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 17],
],
2 => [
'L' => ['group1_blocks' => 1, 'group1_data_per_block' => 34, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 10],
'M' => ['group1_blocks' => 1, 'group1_data_per_block' => 28, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 16],
'Q' => ['group1_blocks' => 1, 'group1_data_per_block' => 22, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 22],
'H' => ['group1_blocks' => 1, 'group1_data_per_block' => 16, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 28],
],
3 => [
'L' => ['group1_blocks' => 1, 'group1_data_per_block' => 55, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 15],
'M' => ['group1_blocks' => 1, 'group1_data_per_block' => 44, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 26],
'Q' => ['group1_blocks' => 2, 'group1_data_per_block' => 17, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 18],
'H' => ['group1_blocks' => 2, 'group1_data_per_block' => 13, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 22],
],
4 => [
'L' => ['group1_blocks' => 1, 'group1_data_per_block' => 80, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 20],
'M' => ['group1_blocks' => 2, 'group1_data_per_block' => 32, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 18],
'Q' => ['group1_blocks' => 2, 'group1_data_per_block' => 24, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 26],
'H' => ['group1_blocks' => 4, 'group1_data_per_block' => 9, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 16],
],
5 => [
'L' => ['group1_blocks' => 1, 'group1_data_per_block' => 108, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 26],
'M' => ['group1_blocks' => 2, 'group1_data_per_block' => 43, 'group2_blocks' => 0, 'group2_data_per_block' => 0, 'error_correction_per_block' => 24],
'Q' => ['group1_blocks' => 2, 'group1_data_per_block' => 15, 'group2_blocks' => 2, 'group2_data_per_block' => 16, 'error_correction_per_block' => 18],
'H' => ['group1_blocks' => 2, 'group1_data_per_block' => 11, 'group2_blocks' => 2, 'group2_data_per_block' => 12, 'error_correction_per_block' => 22],
],
];
if (! isset($blockStructures[$version][$errorLevel])) {
// Default fallback for unsupported versions
return [
'group1_blocks' => 1,
'group1_data_per_block' => ReedSolomonEncoder::getDataCapacity($version, $errorLevel),
'group2_blocks' => 0,
'group2_data_per_block' => 0,
'error_correction_per_block' => ReedSolomonEncoder::getErrorCorrectionCodewords($version, $errorLevel),
];
}
return $blockStructures[$version][$errorLevel];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode\Structure;
/**
* QR Code Data Block
*
* Represents a single data block with error correction codewords
*/
final readonly class QrCodeBlock
{
public function __construct(
public array $dataCodewords,
public array $errorCorrectionCodewords
) {
}
/**
* Get total codewords (data + error correction)
*/
public function getTotalCodewords(): array
{
return array_merge($this->dataCodewords, $this->errorCorrectionCodewords);
}
/**
* Get number of data codewords
*/
public function getDataLength(): int
{
return count($this->dataCodewords);
}
/**
* Get number of error correction codewords
*/
public function getErrorCorrectionLength(): int
{
return count($this->errorCorrectionCodewords);
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Framework\QrCode;
use InvalidArgumentException;
/**
* SVG Renderer for QR Code Matrix
*
* Renders a QR code matrix as scalable SVG format.
* Supports customizable colors, sizes, and quiet zones.
*/
final readonly class SvgRenderer
{
public function __construct(
private int $moduleSize = 4,
private int $quietZone = 4,
private string $foregroundColor = '#000000',
private string $backgroundColor = '#FFFFFF'
) {
if ($moduleSize < 1) {
throw new InvalidArgumentException("Module size must be positive, got: {$moduleSize}");
}
if ($quietZone < 0) {
throw new InvalidArgumentException("Quiet zone must be non-negative, got: {$quietZone}");
}
}
/**
* Create renderer with custom module size
*/
public static function withModuleSize(int $moduleSize): self
{
return new self(moduleSize: $moduleSize);
}
/**
* Create compact renderer (small size, no quiet zone)
*/
public static function compact(): self
{
return new self(moduleSize: 2, quietZone: 0);
}
/**
* Create large renderer with quiet zone
*/
public static function large(): self
{
return new self(moduleSize: 8, quietZone: 4);
}
/**
* Render QR code matrix as SVG string
*/
public function render(QrCodeMatrix $matrix): string
{
$matrixSize = $matrix->getSize();
$totalSize = ($matrixSize + 2 * $this->quietZone) * $this->moduleSize;
$svg = $this->createSvgHeader($totalSize);
$svg .= $this->createBackground($totalSize);
$svg .= $this->renderModules($matrix);
$svg .= $this->createSvgFooter();
return $svg;
}
/**
* Render as data URI for direct embedding
*/
public function renderAsDataUri(QrCodeMatrix $matrix): string
{
$svg = $this->render($matrix);
$base64 = base64_encode($svg);
return "data:image/svg+xml;base64,{$base64}";
}
/**
* Get the dimensions of the rendered SVG
*/
public function getDimensions(QrCodeMatrix $matrix): array
{
$matrixSize = $matrix->getSize();
$totalSize = ($matrixSize + 2 * $this->quietZone) * $this->moduleSize;
return [
'width' => $totalSize,
'height' => $totalSize,
'matrix_size' => $matrixSize,
'module_size' => $this->moduleSize,
'quiet_zone' => $this->quietZone,
];
}
/**
* Create custom renderer with different colors
*/
public function withColors(string $foreground, string $background): self
{
return new self(
moduleSize: $this->moduleSize,
quietZone: $this->quietZone,
foregroundColor: $foreground,
backgroundColor: $background
);
}
/**
* Create custom renderer with different quiet zone
*/
public function withQuietZone(int $quietZone): self
{
return new self(
moduleSize: $this->moduleSize,
quietZone: $quietZone,
foregroundColor: $this->foregroundColor,
backgroundColor: $this->backgroundColor
);
}
/**
* Create SVG header with viewport and namespaces
*/
private function createSvgHeader(int $totalSize): string
{
return sprintf(
'<svg width="%d" height="%d" viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg">',
$totalSize,
$totalSize,
$totalSize,
$totalSize
);
}
/**
* Create background rectangle
*/
private function createBackground(int $totalSize): string
{
return sprintf(
'<rect width="%d" height="%d" fill="%s"/>',
$totalSize,
$totalSize,
htmlspecialchars($this->backgroundColor)
);
}
/**
* Render all dark modules as rectangles
*/
private function renderModules(QrCodeMatrix $matrix): string
{
$svg = '';
$matrixSize = $matrix->getSize();
for ($row = 0; $row < $matrixSize; $row++) {
for ($col = 0; $col < $matrixSize; $col++) {
if ($matrix->isDark($row, $col)) {
$x = ($col + $this->quietZone) * $this->moduleSize;
$y = ($row + $this->quietZone) * $this->moduleSize;
$svg .= sprintf(
'<rect x="%d" y="%d" width="%d" height="%d" fill="%s"/>',
$x,
$y,
$this->moduleSize,
$this->moduleSize,
htmlspecialchars($this->foregroundColor)
);
}
}
}
return $svg;
}
/**
* Create SVG footer
*/
private function createSvgFooter(): string
{
return '</svg>';
}
/**
* Validate color format (hex or named colors)
*/
private function isValidColor(string $color): bool
{
// Check hex colors (#RGB, #RRGGBB)
if (preg_match('/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/', $color)) {
return true;
}
// Check some common named colors
$namedColors = [
'black', 'white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta',
'gray', 'grey', 'orange', 'purple', 'pink', 'brown', 'transparent',
];
return in_array(strtolower($color), $namedColors, true);
}
/**
* Get configuration array
*/
public function getConfiguration(): array
{
return [
'module_size' => $this->moduleSize,
'quiet_zone' => $this->quietZone,
'foreground_color' => $this->foregroundColor,
'background_color' => $this->backgroundColor,
];
}
}