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:
316
src/Framework/QrCode/DataEncoder.php
Normal file
316
src/Framework/QrCode/DataEncoder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
106
src/Framework/QrCode/DataMode.php
Normal file
106
src/Framework/QrCode/DataMode.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
53
src/Framework/QrCode/ErrorCorrectionLevel.php
Normal file
53
src/Framework/QrCode/ErrorCorrectionLevel.php
Normal 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
|
||||
}
|
||||
}
|
||||
441
src/Framework/QrCode/MatrixGenerator.php
Normal file
441
src/Framework/QrCode/MatrixGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
127
src/Framework/QrCode/Patterns/FormatInfo.php
Normal file
127
src/Framework/QrCode/Patterns/FormatInfo.php
Normal 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;
|
||||
}
|
||||
}
|
||||
303
src/Framework/QrCode/Patterns/MaskingPattern.php
Normal file
303
src/Framework/QrCode/Patterns/MaskingPattern.php
Normal 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;
|
||||
}
|
||||
}
|
||||
120
src/Framework/QrCode/QrCodeException.php
Normal file
120
src/Framework/QrCode/QrCodeException.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
186
src/Framework/QrCode/QrCodeGenerator.php
Normal file
186
src/Framework/QrCode/QrCodeGenerator.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
194
src/Framework/QrCode/QrCodeMatrix.php
Normal file
194
src/Framework/QrCode/QrCodeMatrix.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/Framework/QrCode/QrCodeVersion.php
Normal file
133
src/Framework/QrCode/QrCodeVersion.php
Normal 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];
|
||||
}
|
||||
}
|
||||
154
src/Framework/QrCode/ReedSolomon/GaloisField.php
Normal file
154
src/Framework/QrCode/ReedSolomon/GaloisField.php
Normal 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];
|
||||
}
|
||||
}
|
||||
223
src/Framework/QrCode/ReedSolomon/Polynomial.php
Normal file
223
src/Framework/QrCode/ReedSolomon/Polynomial.php
Normal 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;
|
||||
}
|
||||
}
|
||||
129
src/Framework/QrCode/ReedSolomon/ReedSolomonEncoder.php
Normal file
129
src/Framework/QrCode/ReedSolomon/ReedSolomonEncoder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
167
src/Framework/QrCode/Structure/BlockStructure.php
Normal file
167
src/Framework/QrCode/Structure/BlockStructure.php
Normal 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];
|
||||
}
|
||||
}
|
||||
43
src/Framework/QrCode/Structure/QrCodeBlock.php
Normal file
43
src/Framework/QrCode/Structure/QrCodeBlock.php
Normal 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);
|
||||
}
|
||||
}
|
||||
221
src/Framework/QrCode/SvgRenderer.php
Normal file
221
src/Framework/QrCode/SvgRenderer.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user