diff --git a/src/components/qr-code/model/encode.ts b/src/components/qr-code/model/encode.ts new file mode 100644 index 000000000..d8656bd4c --- /dev/null +++ b/src/components/qr-code/model/encode.ts @@ -0,0 +1,221 @@ +import type { QrEncodingMode, QrErrorCorrectionLevel } from '../types.js'; +import { getDataCodewordsCount, interleaveBlocks } from './error-correction.js'; + +const EC_LEVEL_INDEX = { L: 0, M: 1, Q: 2, H: 3 } as const; +const ALPHANUMERIC_MAP = new Map( + [...'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'].map((char, index) => [ + char, + index, + ]) +); +const PAD_BYTES = [0xec, 0x11]; +const TEXT_ENCODER = new TextEncoder(); + +function getAlphanumericValue(char: string): number { + return ALPHANUMERIC_MAP.get(char) ?? -1; +} + +function isNumeric(str: string): boolean { + return /^\d+$/.test(str); +} + +function isAlphanumeric(str: string): boolean { + for (const char of str) { + if (!ALPHANUMERIC_MAP.has(char)) { + return false; + } + } + return true; +} + +function detectEncodingMode(data: string): QrEncodingMode { + if (isNumeric(data)) return 'numeric'; + if (isAlphanumeric(data)) return 'alphanumeric'; + return 'byte'; +} + +function getCharacterCountBits(mode: QrEncodingMode, version: number): number { + switch (mode) { + case 'numeric': + return version < 10 ? 10 : version < 27 ? 12 : 14; + case 'alphanumeric': + return version < 10 ? 9 : version < 27 ? 11 : 13; + case 'byte': + return version < 10 ? 8 : version < 27 ? 16 : 16; + default: + throw new Error(`Unsupported encoding mode: ${mode}`); + } +} + +const MODE_INDICATORS: Record = { + numeric: 0b0001, + alphanumeric: 0b0010, + byte: 0b0100, +}; + +function pushBits(bits: number[], value: number, length: number): void { + for (let i = length - 1; i >= 0; i--) { + bits.push((value >> i) & 1); + } +} + +function encodeData( + data: string, + mode: QrEncodingMode, + version: number +): number[] { + const bits: number[] = []; + const byteEncoded = mode === 'byte' ? TEXT_ENCODER.encode(data) : null; + + pushBits(bits, MODE_INDICATORS[mode], 4); + + const charCount = byteEncoded ? byteEncoded.length : data.length; + pushBits(bits, charCount, getCharacterCountBits(mode, version)); + + switch (mode) { + case 'numeric': + for (let i = 0; i < data.length; i += 3) { + const chunk = data.slice(i, i + 3); + const value = Number.parseInt(chunk, 10); + + if (chunk.length === 3) pushBits(bits, value, 10); + else if (chunk.length === 2) pushBits(bits, value, 7); + else pushBits(bits, value, 4); + } + break; + case 'alphanumeric': + for (let i = 0; i < data.length; i += 2) { + if (i + 1 < data.length) { + const value = + getAlphanumericValue(data[i]) * 45 + + getAlphanumericValue(data[i + 1]); + pushBits(bits, value, 11); + } else { + pushBits(bits, getAlphanumericValue(data[i]), 6); + } + } + break; + default: + for (const byte of byteEncoded!) { + pushBits(bits, byte, 8); + } + break; + } + + return bits; +} + +function bitsToBytes(bits: number[]): number[] { + const bytes: number[] = []; + for (let i = 0; i < bits.length; i += 8) { + let byte = 0; + for (let j = 0; j < 8 && i + j < bits.length; j++) { + byte = (byte << 1) | bits[i + j]; + } + bytes.push(byte); + } + return bytes; +} + +function padData(data: number[], totalBytes: number): number[] { + const result = data.slice(); + + if (result.length > totalBytes) { + throw new Error( + 'Data exceeds maximum capacity for this version and error correction level' + ); + } + + let padIndex = 0; + while (result.length < totalBytes) { + result.push(PAD_BYTES[padIndex % 2]); + padIndex++; + } + return result; +} + +/** Result produced by `encodeQR`. */ +export type EncodeResult = { + /** Interleaved data + ECC codewords ready for matrix placement. */ + codewords: number[]; + /** Encoding mode that was applied to the input string. */ + mode: QrEncodingMode; + /** QR version (1–40) used for this code. */ + version: number; + /** Numeric index of the error correction level (L=0, M=1, Q=2, H=3). */ + ecLevelIndex: number; +}; + +/** + * Encodes a string into QR codewords (data + error correction), selecting the + * smallest version that fits unless `requestedVersion` is specified. + * + * @throws When `data` is empty, the requested version is out of range, or the + * data exceeds the capacity of the requested version. + */ +export function encodeQR( + data: string, + ecLevel: QrErrorCorrectionLevel = 'M', + requestedVersion?: number +): EncodeResult { + if (data.length === 0) { + throw new Error('Data cannot be empty'); + } + + const ecIndex = EC_LEVEL_INDEX[ecLevel]; + const mode = detectEncodingMode(data); + + let version = 1; + let bits: number[]; + + if (requestedVersion != null) { + if (requestedVersion < 1 || requestedVersion > 40) { + throw new Error('Requested version must be between 1 and 40'); + } + version = requestedVersion; + bits = encodeData(data, mode, version); + if ( + Math.ceil((bits.length + 4) / 8) > getDataCodewordsCount(version, ecIndex) + ) { + throw new Error( + `Data too long for version ${version} and error correction level ${ecLevel}` + ); + } + } else { + bits = []; + for (let v = 1; v <= 40; v++) { + const candidateBits = encodeData(data, mode, v); + if ( + Math.ceil((candidateBits.length + 4) / 8) <= + getDataCodewordsCount(v, ecIndex) + ) { + version = v; + bits = candidateBits; + break; + } + } + } + + const capacity = getDataCodewordsCount(version, ecIndex); + + const maxBits = capacity * 8; + const terminatorLength = Math.min(4, maxBits - bits.length); + for (let i = 0; i < terminatorLength; i++) { + bits.push(0); + } + + while (bits.length % 8 !== 0) { + bits.push(0); + } + + const dataBytes = bitsToBytes(bits); + const paddedData = padData(dataBytes, capacity); + const codewords = interleaveBlocks(paddedData, version, ecIndex); + + return { + codewords, + mode, + version, + ecLevelIndex: ecIndex, + }; +} diff --git a/src/components/qr-code/model/error-correction.ts b/src/components/qr-code/model/error-correction.ts new file mode 100644 index 000000000..686122153 --- /dev/null +++ b/src/components/qr-code/model/error-correction.ts @@ -0,0 +1,1190 @@ +const EXP_TABLE: number[] = new Array(512); +const LOG_TABLE: number[] = new Array(256); +const POLYNOMIALS = new Map(); + +(function initTables() { + let x = 1; + for (let i = 0; i < 256; i++) { + EXP_TABLE[i] = x; + LOG_TABLE[x] = i; + x <<= 1; + if (x & 0x100) { + x ^= 0x11d; + } + } + for (let i = 256; i < 512; i++) { + EXP_TABLE[i] = EXP_TABLE[i - 256]; + } +})(); + +function gfMul(a: number, b: number): number { + if (a === 0 || b === 0) return 0; + return EXP_TABLE[(LOG_TABLE[a] + LOG_TABLE[b]) % 255]; +} + +function gfPow(a: number, power: number): number { + return EXP_TABLE[(LOG_TABLE[a] * power) % 255]; +} + +function generatePolynomial(degree: number): number[] { + let poly = [1]; + + for (let i = 0; i < degree; i++) { + const term = [1, gfPow(2, i)]; + const newPoly = new Array(poly.length + term.length - 1).fill(0); + for (let j = 0; j < poly.length; j++) { + for (let k = 0; k < term.length; k++) { + newPoly[j + k] ^= gfMul(poly[j], term[k]); + } + } + poly = newPoly; + } + return poly; +} + +function getPolynomial(degree: number): number[] { + let poly = POLYNOMIALS.get(degree); + if (!poly) { + poly = generatePolynomial(degree); + POLYNOMIALS.set(degree, poly); + } + return poly; +} + +/** + * Calculates the error correction codewords for the given data and degree. + */ +/** Computes Reed-Solomon error correction codewords for a data block of the given EC degree. */ +export function calculateECC(data: number[], degree: number): number[] { + const poly = getPolynomial(degree); + const ecc: number[] = new Array(data.length + degree).fill(0); + + for (let i = 0; i < data.length; i++) { + ecc[i] = data[i]; + } + + for (let i = 0; i < data.length; i++) { + const factor = ecc[i]; + if (factor !== 0) { + for (let j = 0; j < poly.length; j++) { + ecc[i + j] ^= gfMul(poly[j], factor); + } + } + } + + return ecc.slice(data.length, data.length + degree); +} + +/** Describes one error correction block configuration for a QR version + EC level. */ +export type ECBlock = { + ecPerBlock: number; + groups: { numBlocks: number; dataCW: number }[]; +}; + +export const EC_BLOCKS_TABLE: ECBlock[][] = [ + // Version 1 + [ + { ecPerBlock: 7, groups: [{ numBlocks: 1, dataCW: 19 }] }, + { ecPerBlock: 10, groups: [{ numBlocks: 1, dataCW: 16 }] }, + { ecPerBlock: 13, groups: [{ numBlocks: 1, dataCW: 13 }] }, + { ecPerBlock: 17, groups: [{ numBlocks: 1, dataCW: 9 }] }, + ], + // Version 2 + [ + { ecPerBlock: 10, groups: [{ numBlocks: 1, dataCW: 34 }] }, + { ecPerBlock: 16, groups: [{ numBlocks: 1, dataCW: 28 }] }, + { ecPerBlock: 22, groups: [{ numBlocks: 1, dataCW: 22 }] }, + { ecPerBlock: 28, groups: [{ numBlocks: 1, dataCW: 16 }] }, + ], + // Version 3 + [ + { ecPerBlock: 15, groups: [{ numBlocks: 1, dataCW: 55 }] }, + { ecPerBlock: 26, groups: [{ numBlocks: 1, dataCW: 44 }] }, + { ecPerBlock: 18, groups: [{ numBlocks: 2, dataCW: 17 }] }, + { ecPerBlock: 22, groups: [{ numBlocks: 2, dataCW: 13 }] }, + ], + // Version 4 + [ + { ecPerBlock: 20, groups: [{ numBlocks: 1, dataCW: 80 }] }, + { ecPerBlock: 18, groups: [{ numBlocks: 2, dataCW: 32 }] }, + { ecPerBlock: 26, groups: [{ numBlocks: 2, dataCW: 24 }] }, + { ecPerBlock: 16, groups: [{ numBlocks: 4, dataCW: 9 }] }, + ], + // Version 5 + [ + { ecPerBlock: 26, groups: [{ numBlocks: 1, dataCW: 108 }] }, + { ecPerBlock: 24, groups: [{ numBlocks: 2, dataCW: 43 }] }, + { + ecPerBlock: 18, + groups: [ + { numBlocks: 2, dataCW: 15 }, + { numBlocks: 2, dataCW: 16 }, + ], + }, + { + ecPerBlock: 22, + groups: [ + { numBlocks: 2, dataCW: 11 }, + { numBlocks: 2, dataCW: 12 }, + ], + }, + ], + // Version 6 + [ + { ecPerBlock: 18, groups: [{ numBlocks: 2, dataCW: 68 }] }, + { ecPerBlock: 16, groups: [{ numBlocks: 4, dataCW: 27 }] }, + { ecPerBlock: 24, groups: [{ numBlocks: 4, dataCW: 19 }] }, + { ecPerBlock: 28, groups: [{ numBlocks: 4, dataCW: 15 }] }, + ], + // Version 7 + [ + { ecPerBlock: 20, groups: [{ numBlocks: 2, dataCW: 78 }] }, + { ecPerBlock: 18, groups: [{ numBlocks: 4, dataCW: 31 }] }, + { + ecPerBlock: 18, + groups: [ + { numBlocks: 2, dataCW: 14 }, + { numBlocks: 4, dataCW: 15 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 4, dataCW: 13 }, + { numBlocks: 1, dataCW: 14 }, + ], + }, + ], + // Version 8 + [ + { ecPerBlock: 24, groups: [{ numBlocks: 2, dataCW: 97 }] }, + { + ecPerBlock: 22, + groups: [ + { numBlocks: 2, dataCW: 38 }, + { numBlocks: 2, dataCW: 39 }, + ], + }, + { + ecPerBlock: 22, + groups: [ + { numBlocks: 4, dataCW: 18 }, + { numBlocks: 2, dataCW: 19 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 4, dataCW: 14 }, + { numBlocks: 2, dataCW: 15 }, + ], + }, + ], + // Version 9 + [ + { ecPerBlock: 30, groups: [{ numBlocks: 2, dataCW: 116 }] }, + { + ecPerBlock: 22, + groups: [ + { numBlocks: 3, dataCW: 36 }, + { numBlocks: 2, dataCW: 37 }, + ], + }, + { + ecPerBlock: 20, + groups: [ + { numBlocks: 4, dataCW: 16 }, + { numBlocks: 4, dataCW: 17 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 4, dataCW: 12 }, + { numBlocks: 4, dataCW: 13 }, + ], + }, + ], + // Version 10 + [ + { + ecPerBlock: 18, + groups: [ + { numBlocks: 2, dataCW: 68 }, + { numBlocks: 2, dataCW: 69 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 4, dataCW: 43 }, + { numBlocks: 1, dataCW: 44 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 6, dataCW: 19 }, + { numBlocks: 2, dataCW: 20 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 6, dataCW: 15 }, + { numBlocks: 2, dataCW: 16 }, + ], + }, + ], + // Version 11 + [ + { ecPerBlock: 20, groups: [{ numBlocks: 4, dataCW: 81 }] }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 1, dataCW: 50 }, + { numBlocks: 4, dataCW: 51 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 4, dataCW: 22 }, + { numBlocks: 4, dataCW: 23 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 3, dataCW: 12 }, + { numBlocks: 8, dataCW: 13 }, + ], + }, + ], + // Version 12 + [ + { + ecPerBlock: 24, + groups: [ + { numBlocks: 2, dataCW: 92 }, + { numBlocks: 2, dataCW: 93 }, + ], + }, + { + ecPerBlock: 22, + groups: [ + { numBlocks: 6, dataCW: 36 }, + { numBlocks: 2, dataCW: 37 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 4, dataCW: 20 }, + { numBlocks: 6, dataCW: 21 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 7, dataCW: 14 }, + { numBlocks: 4, dataCW: 15 }, + ], + }, + ], + // Version 13 + [ + { ecPerBlock: 26, groups: [{ numBlocks: 4, dataCW: 107 }] }, + { + ecPerBlock: 22, + groups: [ + { numBlocks: 8, dataCW: 37 }, + { numBlocks: 1, dataCW: 38 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 8, dataCW: 20 }, + { numBlocks: 4, dataCW: 21 }, + ], + }, + { + ecPerBlock: 22, + groups: [ + { numBlocks: 12, dataCW: 11 }, + { numBlocks: 4, dataCW: 12 }, + ], + }, + ], + // Version 14 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 3, dataCW: 115 }, + { numBlocks: 1, dataCW: 116 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 4, dataCW: 40 }, + { numBlocks: 5, dataCW: 41 }, + ], + }, + { + ecPerBlock: 20, + groups: [ + { numBlocks: 11, dataCW: 16 }, + { numBlocks: 5, dataCW: 17 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 11, dataCW: 12 }, + { numBlocks: 5, dataCW: 13 }, + ], + }, + ], + // Version 15 + [ + { + ecPerBlock: 22, + groups: [ + { numBlocks: 5, dataCW: 87 }, + { numBlocks: 1, dataCW: 88 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 5, dataCW: 41 }, + { numBlocks: 5, dataCW: 42 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 5, dataCW: 24 }, + { numBlocks: 7, dataCW: 25 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 11, dataCW: 12 }, + { numBlocks: 7, dataCW: 13 }, + ], + }, + ], + // Version 16 + [ + { + ecPerBlock: 24, + groups: [ + { numBlocks: 5, dataCW: 98 }, + { numBlocks: 1, dataCW: 99 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 7, dataCW: 45 }, + { numBlocks: 3, dataCW: 46 }, + ], + }, + { + ecPerBlock: 24, + groups: [ + { numBlocks: 15, dataCW: 19 }, + { numBlocks: 2, dataCW: 20 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 3, dataCW: 15 }, + { numBlocks: 13, dataCW: 16 }, + ], + }, + ], + // Version 17 + [ + { + ecPerBlock: 28, + groups: [ + { numBlocks: 1, dataCW: 107 }, + { numBlocks: 5, dataCW: 108 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 10, dataCW: 46 }, + { numBlocks: 1, dataCW: 47 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 1, dataCW: 22 }, + { numBlocks: 15, dataCW: 23 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 2, dataCW: 14 }, + { numBlocks: 17, dataCW: 15 }, + ], + }, + ], + // Version 18 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 5, dataCW: 120 }, + { numBlocks: 1, dataCW: 121 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 9, dataCW: 43 }, + { numBlocks: 4, dataCW: 44 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 17, dataCW: 22 }, + { numBlocks: 1, dataCW: 23 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 2, dataCW: 14 }, + { numBlocks: 19, dataCW: 15 }, + ], + }, + ], + // Version 19 + [ + { + ecPerBlock: 28, + groups: [ + { numBlocks: 3, dataCW: 113 }, + { numBlocks: 4, dataCW: 114 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 3, dataCW: 44 }, + { numBlocks: 11, dataCW: 45 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 17, dataCW: 21 }, + { numBlocks: 4, dataCW: 22 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 9, dataCW: 13 }, + { numBlocks: 16, dataCW: 14 }, + ], + }, + ], + // Version 20 + [ + { + ecPerBlock: 28, + groups: [ + { numBlocks: 3, dataCW: 107 }, + { numBlocks: 5, dataCW: 108 }, + ], + }, + { + ecPerBlock: 26, + groups: [ + { numBlocks: 3, dataCW: 41 }, + { numBlocks: 13, dataCW: 42 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 15, dataCW: 24 }, + { numBlocks: 5, dataCW: 25 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 15, dataCW: 15 }, + { numBlocks: 10, dataCW: 16 }, + ], + }, + ], + // Version 21 + [ + { + ecPerBlock: 28, + groups: [ + { numBlocks: 4, dataCW: 116 }, + { numBlocks: 4, dataCW: 117 }, + ], + }, + { ecPerBlock: 26, groups: [{ numBlocks: 17, dataCW: 42 }] }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 17, dataCW: 22 }, + { numBlocks: 6, dataCW: 23 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 19, dataCW: 16 }, + { numBlocks: 6, dataCW: 17 }, + ], + }, + ], + // Version 22 + [ + { + ecPerBlock: 28, + groups: [ + { numBlocks: 2, dataCW: 111 }, + { numBlocks: 7, dataCW: 112 }, + ], + }, + { ecPerBlock: 28, groups: [{ numBlocks: 17, dataCW: 46 }] }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 7, dataCW: 24 }, + { numBlocks: 16, dataCW: 25 }, + ], + }, + { ecPerBlock: 24, groups: [{ numBlocks: 34, dataCW: 13 }] }, + ], + // Version 23 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 4, dataCW: 121 }, + { numBlocks: 5, dataCW: 122 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 4, dataCW: 47 }, + { numBlocks: 14, dataCW: 48 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 11, dataCW: 24 }, + { numBlocks: 14, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 16, dataCW: 15 }, + { numBlocks: 14, dataCW: 16 }, + ], + }, + ], + // Version 24 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 6, dataCW: 117 }, + { numBlocks: 4, dataCW: 118 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 6, dataCW: 45 }, + { numBlocks: 14, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 11, dataCW: 24 }, + { numBlocks: 16, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 30, dataCW: 16 }, + { numBlocks: 2, dataCW: 17 }, + ], + }, + ], + // Version 25 + [ + { + ecPerBlock: 26, + groups: [ + { numBlocks: 8, dataCW: 106 }, + { numBlocks: 4, dataCW: 107 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 8, dataCW: 47 }, + { numBlocks: 13, dataCW: 48 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 7, dataCW: 24 }, + { numBlocks: 22, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 22, dataCW: 15 }, + { numBlocks: 13, dataCW: 16 }, + ], + }, + ], + // Version 26 + [ + { + ecPerBlock: 28, + groups: [ + { numBlocks: 10, dataCW: 114 }, + { numBlocks: 2, dataCW: 115 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 19, dataCW: 46 }, + { numBlocks: 4, dataCW: 47 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 28, dataCW: 22 }, + { numBlocks: 6, dataCW: 23 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 33, dataCW: 16 }, + { numBlocks: 4, dataCW: 17 }, + ], + }, + ], + // Version 27 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 8, dataCW: 122 }, + { numBlocks: 4, dataCW: 123 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 22, dataCW: 45 }, + { numBlocks: 3, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 8, dataCW: 23 }, + { numBlocks: 26, dataCW: 24 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 12, dataCW: 15 }, + { numBlocks: 28, dataCW: 16 }, + ], + }, + ], + // Version 28 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 3, dataCW: 117 }, + { numBlocks: 10, dataCW: 118 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 3, dataCW: 45 }, + { numBlocks: 23, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 4, dataCW: 24 }, + { numBlocks: 31, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 11, dataCW: 15 }, + { numBlocks: 31, dataCW: 16 }, + ], + }, + ], + // Version 29 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 7, dataCW: 116 }, + { numBlocks: 7, dataCW: 117 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 21, dataCW: 45 }, + { numBlocks: 7, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 1, dataCW: 23 }, + { numBlocks: 37, dataCW: 24 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 19, dataCW: 15 }, + { numBlocks: 26, dataCW: 16 }, + ], + }, + ], + // Version 30 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 5, dataCW: 115 }, + { numBlocks: 10, dataCW: 116 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 19, dataCW: 45 }, + { numBlocks: 10, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 15, dataCW: 24 }, + { numBlocks: 25, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 23, dataCW: 15 }, + { numBlocks: 25, dataCW: 16 }, + ], + }, + ], + // Version 31 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 13, dataCW: 115 }, + { numBlocks: 3, dataCW: 116 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 2, dataCW: 45 }, + { numBlocks: 29, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 42, dataCW: 24 }, + { numBlocks: 1, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 23, dataCW: 15 }, + { numBlocks: 28, dataCW: 16 }, + ], + }, + ], + // Version 32 + [ + { ecPerBlock: 30, groups: [{ numBlocks: 17, dataCW: 115 }] }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 10, dataCW: 45 }, + { numBlocks: 23, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 10, dataCW: 24 }, + { numBlocks: 35, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 19, dataCW: 15 }, + { numBlocks: 35, dataCW: 16 }, + ], + }, + ], + // Version 33 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 17, dataCW: 115 }, + { numBlocks: 1, dataCW: 116 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 14, dataCW: 45 }, + { numBlocks: 21, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 29, dataCW: 24 }, + { numBlocks: 19, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 11, dataCW: 15 }, + { numBlocks: 46, dataCW: 16 }, + ], + }, + ], + // Version 34 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 13, dataCW: 115 }, + { numBlocks: 6, dataCW: 116 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 14, dataCW: 45 }, + { numBlocks: 23, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 44, dataCW: 24 }, + { numBlocks: 7, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 59, dataCW: 16 }, + { numBlocks: 1, dataCW: 17 }, + ], + }, + ], + // Version 35 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 12, dataCW: 121 }, + { numBlocks: 7, dataCW: 122 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 12, dataCW: 45 }, + { numBlocks: 26, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 39, dataCW: 24 }, + { numBlocks: 14, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 22, dataCW: 15 }, + { numBlocks: 41, dataCW: 16 }, + ], + }, + ], + // Version 36 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 6, dataCW: 121 }, + { numBlocks: 14, dataCW: 122 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 6, dataCW: 45 }, + { numBlocks: 34, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 46, dataCW: 24 }, + { numBlocks: 10, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 2, dataCW: 15 }, + { numBlocks: 64, dataCW: 16 }, + ], + }, + ], + // Version 37 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 17, dataCW: 122 }, + { numBlocks: 4, dataCW: 123 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 29, dataCW: 45 }, + { numBlocks: 14, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 49, dataCW: 24 }, + { numBlocks: 10, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 24, dataCW: 15 }, + { numBlocks: 46, dataCW: 16 }, + ], + }, + ], + // Version 38 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 4, dataCW: 122 }, + { numBlocks: 18, dataCW: 123 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 13, dataCW: 45 }, + { numBlocks: 32, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 48, dataCW: 24 }, + { numBlocks: 14, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 42, dataCW: 15 }, + { numBlocks: 32, dataCW: 16 }, + ], + }, + ], + // Version 39 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 20, dataCW: 117 }, + { numBlocks: 4, dataCW: 118 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 40, dataCW: 45 }, + { numBlocks: 7, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 43, dataCW: 24 }, + { numBlocks: 22, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 10, dataCW: 15 }, + { numBlocks: 67, dataCW: 16 }, + ], + }, + ], + // Version 40 + [ + { + ecPerBlock: 30, + groups: [ + { numBlocks: 19, dataCW: 118 }, + { numBlocks: 6, dataCW: 119 }, + ], + }, + { + ecPerBlock: 28, + groups: [ + { numBlocks: 18, dataCW: 45 }, + { numBlocks: 31, dataCW: 46 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 34, dataCW: 24 }, + { numBlocks: 34, dataCW: 25 }, + ], + }, + { + ecPerBlock: 30, + groups: [ + { numBlocks: 20, dataCW: 15 }, + { numBlocks: 61, dataCW: 16 }, + ], + }, + ], +]; + +/** Returns the total number of data codewords available for the given version and EC level index. */ +export function getDataCodewordsCount( + version: number, + errorCorrectionLevel: number +): number { + const ecBlock = EC_BLOCKS_TABLE[version - 1][errorCorrectionLevel]; + let totalDataCodewords = 0; + + for (const group of ecBlock.groups) { + totalDataCodewords += group.numBlocks * group.dataCW; + } + return totalDataCodewords; +} + +/** Interleaves data and ECC codewords from all blocks per the QR specification, producing the final codeword sequence. */ +export function interleaveBlocks( + dataBlocks: number[], + version: number, + errorCorrectionLevel: number +): number[] { + const ecBlock = EC_BLOCKS_TABLE[version - 1][errorCorrectionLevel]; + const blocks: number[][] = []; + const eccBlocks: number[][] = []; + + let offset = 0; + + for (const group of ecBlock.groups) { + for (let b = 0; b < group.numBlocks; b++) { + const block = dataBlocks.slice(offset, offset + group.dataCW); + blocks.push(block); + eccBlocks.push(calculateECC(block, ecBlock.ecPerBlock)); + offset += group.dataCW; + } + } + + const interleaved: number[] = []; + const maxDataLength = Math.max(...blocks.map((b) => b.length)); + for (let i = 0; i < maxDataLength; i++) { + for (const block of blocks) { + if (i < block.length) { + interleaved.push(block[i]); + } + } + } + + const maxECCLength = ecBlock.ecPerBlock; + for (let i = 0; i < maxECCLength; i++) { + for (const eccBlock of eccBlocks) { + if (i < eccBlock.length) { + interleaved.push(eccBlock[i]); + } + } + } + + return interleaved; +} diff --git a/src/components/qr-code/model/mask.ts b/src/components/qr-code/model/mask.ts new file mode 100644 index 000000000..36d687c49 --- /dev/null +++ b/src/components/qr-code/model/mask.ts @@ -0,0 +1,216 @@ +/** One of the eight QR mask pattern indices (0–7). */ +export type MaskPattern = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; + +const MASK_PATTERN_FUNCTIONS: ((row: number, col: number) => boolean)[] = [ + (row, col) => (row + col) % 2 === 0, + (row, _) => row % 2 === 0, + (_, col) => col % 3 === 0, + (row, col) => (row + col) % 3 === 0, + (row, col) => (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0, + (row, col) => ((row * col) % 2) + ((row * col) % 3) === 0, + (row, col) => (((row * col) % 2) + ((row * col) % 3)) % 2 === 0, + (row, col) => (((row + col) % 2) + ((row * col) % 3)) % 2 === 0, +]; + +/** + * Applies the given mask pattern to all non-function modules of the matrix, + * returning a new 2D array (the input is not mutated). + */ +export function applyMask( + matrix: boolean[][], + functionModules: boolean[][], + pattern: MaskPattern +): boolean[][] { + const condition = MASK_PATTERN_FUNCTIONS[pattern]; + return matrix.map((row, r) => + row.map((val, c) => { + if (functionModules[r][c]) return val; + return condition(r, c) ? !val : val; + }) + ); +} + +// Penalty Rule 1: Adjacent modules in row/column in same color +function penaltyRule1(matrix: boolean[][]): number { + const size = matrix.length; + let penalty = 0; + + for (let r = 0; r < size; r++) { + // Compute horizontal runs + let runLen = 1; + for (let c = 1; c < size; c++) { + if (matrix[r][c] === matrix[r][c - 1]) { + runLen++; + } else { + if (runLen >= 5) penalty += 3 + (runLen - 5); + runLen = 1; + } + } + if (runLen >= 5) penalty += 3 + (runLen - 5); + + // Compute vertical runs + runLen = 1; + for (let i = 1; i < size; i++) { + if (matrix[i][r] === matrix[i - 1][r]) { + runLen++; + } else { + if (runLen >= 5) penalty += 3 + (runLen - 5); + runLen = 1; + } + } + if (runLen >= 5) penalty += 3 + (runLen - 5); + } + + return penalty; +} + +// Penalty Rule 2: Blocks of modules in same color +function penaltyRule2(matrix: boolean[][]): number { + const size = matrix.length; + let penalty = 0; + + for (let r = 0; r < size - 1; r++) { + for (let c = 0; c < size - 1; c++) { + const value = matrix[r][c]; + if ( + matrix[r][c + 1] === value && + matrix[r + 1][c] === value && + matrix[r + 1][c + 1] === value + ) { + penalty += 3; + } + } + } + + return penalty; +} + +const N3_PATTERN1 = [ + true, + false, + true, + true, + true, + false, + true, + false, + false, + false, + false, +]; +const N3_PATTERN2 = [ + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, +]; + +function matchPatternInRow( + row: boolean[], + start: number, + pattern: boolean[] +): boolean { + for (let i = 0; i < pattern.length; i++) { + if (row[start + i] !== pattern[i]) return false; + } + return true; +} + +function matchPatternInColumn( + matrix: boolean[][], + startRow: number, + col: number, + pattern: boolean[] +): boolean { + for (let i = 0; i < pattern.length; i++) { + if (matrix[startRow + i][col] !== pattern[i]) return false; + } + return true; +} + +// Penalty Rule 3: Patterns similar to the finder patterns +function penaltyRule3(matrix: boolean[][]): number { + const size = matrix.length; + let penalty = 0; + + for (let r = 0; r < size; r++) { + const row = matrix[r]; + for (let c = 0; c <= size - 11; c++) { + if ( + matchPatternInRow(row, c, N3_PATTERN1) || + matchPatternInRow(row, c, N3_PATTERN2) + ) { + penalty += 40; + } + } + } + + for (let c = 0; c < size; c++) { + for (let r = 0; r <= size - 11; r++) { + if ( + matchPatternInColumn(matrix, r, c, N3_PATTERN1) || + matchPatternInColumn(matrix, r, c, N3_PATTERN2) + ) { + penalty += 40; + } + } + } + + return penalty; +} + +// Penalty Rule 4: Proportion of dark modules in entire symbol +function penaltyRule4(matrix: boolean[][]): number { + const size = matrix.length; + let darkCount = 0; + const totalModules = size * size; + + for (const row of matrix) { + for (const val of row) { + if (val) darkCount++; + } + } + + const darkRatio = (darkCount / totalModules) * 100; + return Math.floor(Math.abs(darkRatio - 50) / 5) * 10; +} + +/** Computes the total QR penalty score for a masked matrix (sum of rules 1–4). */ +export function calculatePenalty(matrix: boolean[][]): number { + return ( + penaltyRule1(matrix) + + penaltyRule2(matrix) + + penaltyRule3(matrix) + + penaltyRule4(matrix) + ); +} + +/** Evaluates all eight mask patterns and returns the one with the lowest penalty score. */ +export function selectBestMask( + matrix: boolean[][], + functionModules: boolean[][] +): MaskPattern { + let bestMask: MaskPattern = 0; + let lowestPenalty = Number.POSITIVE_INFINITY; + + for (let pattern = 0; pattern < 8; pattern++) { + const maskedMatrix = applyMask( + matrix, + functionModules, + pattern as MaskPattern + ); + const penalty = calculatePenalty(maskedMatrix); + if (penalty < lowestPenalty) { + lowestPenalty = penalty; + bestMask = pattern as MaskPattern; + } + } + return bestMask; +} diff --git a/src/components/qr-code/model/matrix.ts b/src/components/qr-code/model/matrix.ts new file mode 100644 index 000000000..10ca76e6f --- /dev/null +++ b/src/components/qr-code/model/matrix.ts @@ -0,0 +1,405 @@ +import type { QrErrorCorrectionLevel } from '../types.js'; +import { encodeQR } from './encode.js'; +import { applyMask, selectBestMask } from './mask.js'; + +// Alignment pattern locations for each version (1-40) of QR code. +const ALIGNMENT_PATTERN_TABLE: number[][] = [ + [], // V1 + [6, 18], // V2 + [6, 22], // V3 + [6, 26], // V4 + [6, 30], // V5 + [6, 34], // V6 + [6, 22, 38], // V7 + [6, 24, 42], // V8 + [6, 26, 46], // V9 + [6, 28, 50], // V10 + [6, 30, 54], // V11 + [6, 32, 58], // V12 + [6, 34, 62], // V13 + [6, 26, 46, 66], // V14 + [6, 26, 48, 70], // V15 + [6, 26, 50, 74], // V16 + [6, 30, 54, 78], // V17 + [6, 30, 56, 82], // V18 + [6, 30, 58, 86], // V19 + [6, 34, 62, 90], // V20 + [6, 28, 50, 72, 94], // V21 + [6, 26, 50, 74, 98], // V22 + [6, 30, 54, 78, 102], // V23 + [6, 28, 54, 80, 106], // V24 + [6, 32, 58, 84, 110], // V25 + [6, 30, 58, 86, 114], // V26 + [6, 34, 62, 90, 118], // V27 + [6, 26, 50, 74, 98, 122], // V28 + [6, 30, 54, 78, 102, 126], // V29 + [6, 26, 52, 78, 104, 130], // V30 + [6, 30, 56, 82, 108, 132], // V31 + [6, 34, 60, 86, 112, 136], // V32 + [6, 30, 58, 86, 114, 142], // V33 + [6, 34, 62, 90, 118, 146], // V34 + [6, 30, 54, 78, 102, 126, 150], // V35 + [6, 24, 50, 76, 102, 128, 154], // V36 + [6, 28, 54, 80, 106, 132, 158], // V37 + [6, 32, 58, 84, 110, 136, 162], // V38 + [6, 26, 54, 82, 110, 138, 166], // V39 + [6, 30, 58, 86, 114, 142, 170], // V40 +]; + +// Format information strings for each combination of error correction level and mask pattern. +const FORMAT_INFO_TABLE: number[] = [ + // L (EC level bits 01) + 0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976, + // M (EC level bits 00) + 0x5412, 0x5125, 0x5e7c, 0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0, + // Q (EC level bits 11) + 0x355f, 0x3068, 0x3f31, 0x3a06, 0x24b4, 0x2183, 0x2eda, 0x2bed, + // H (EC level bits 10) + 0x1689, 0x13be, 0x1ce7, 0x19d0, 0x0762, 0x0255, 0x0d0c, 0x083b, +]; + +// Version information strings for versions 7 and above (6 bits per version). +const VERSION_INFO_TABLE: number[] = [ + 0x07c94, // V7 + 0x085bc, // V8 + 0x09a99, // V9 + 0x0a4d3, // V10 + 0x0bbf6, // V11 + 0x0c762, // V12 + 0x0d847, // V13 + 0x0e60d, // V14 + 0x0f928, // V15 + 0x10b78, // V16 + 0x1145d, // V17 + 0x12a17, // V18 + 0x13532, // V19 + 0x149a6, // V20 + 0x15683, // V21 + 0x168c9, // V22 + 0x177ec, // V23 + 0x18ec4, // V24 + 0x191e1, // V25 + 0x1afab, // V26 + 0x1b08e, // V27 + 0x1cc1a, // V28 + 0x1d33f, // V29 + 0x1ed75, // V30 + 0x1f250, // V31 + 0x209d5, // V32 + 0x216f0, // V33 + 0x228ba, // V34 + 0x2379f, // V35 + 0x24b0b, // V36 + 0x2542e, // V37 + 0x26a64, // V38 + 0x27541, // V39 + 0x28c69, // V40 +]; + +const EC_LEVEL_FORMAT_INDEX: Record = { + L: 0, + M: 1, + Q: 2, + H: 3, +}; + +function createMatrix(size: number): boolean[][] { + return Array.from({ length: size }, () => new Array(size).fill(false)); +} + +/** + * Places a finder pattern at the specified position in the matrix and marks the function modules. + * A finder pattern is a 7x7 module pattern with a specific arrangement of black and white modules, + * surrounded by a 1-module white separator. This function updates both the main matrix and the + * functionModules matrix to indicate which modules are part of the finder pattern and its separator. + */ +function placeFinderPattern( + matrix: boolean[][], + functionModules: boolean[][], + row: number, + col: number +): void { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const mr = row + r; + const mc = col + c; + if (mr < 0 || mr >= matrix.length || mc < 0 || mc >= matrix.length) { + continue; + } + functionModules[mr][mc] = true; + if (r === -1 || r === 7 || c === -1 || c === 7) { + matrix[mr][mc] = false; // Separator (white border) + } else if (r === 0 || r === 6 || c === 0 || c === 6) { + matrix[mr][mc] = true; // Finder pattern (black) + } else if (r >= 2 && r <= 4 && c >= 2 && c <= 4) { + matrix[mr][mc] = true; // inner square (black) + } else { + matrix[mr][mc] = false; // inner area (white) + } + } + } +} + +/** + * Places an alignment pattern at the specified position in the matrix and marks the function modules. + * An alignment pattern is a 5x5 module pattern with a specific arrangement of black and white modules. + * This function updates both the main matrix and the functionModules matrix to indicate which modules + * are part of the alignment pattern. + */ +function placeAlignmentPattern( + matrix: boolean[][], + functionModules: boolean[][], + row: number, + col: number +): void { + for (let r = -2; r <= 2; r++) { + for (let c = -2; c <= 2; c++) { + const mr = row + r; + const mc = col + c; + functionModules[mr][mc] = true; + if (r === -2 || r === 2 || c === -2 || c === 2) { + matrix[mr][mc] = true; // Alignment pattern (black) + } else if (r === 0 && c === 0) { + matrix[mr][mc] = true; // center module (black) + } else { + matrix[mr][mc] = false; // other modules (white) + } + } + } +} + +/** + * Places timing patterns in the matrix and marks the function modules. + * Timing patterns are alternating black and white modules that run horizontally and vertically between the finder patterns. + * They help the QR code reader determine the size of the modules and the overall structure of the QR code. + * This function updates both the main matrix and the functionModules matrix to indicate which modules are part of the timing patterns. + */ +function placeTimingPatterns( + matrix: boolean[][], + functionModules: boolean[][] +): void { + const size = matrix.length; + for (let i = 8; i < size - 8; i++) { + const bit = i % 2 === 0; + matrix[6][i] = bit; + matrix[i][6] = bit; + functionModules[6][i] = true; + functionModules[i][6] = true; + } +} + +const FORMAT_INFO_POSITIONS: [number, number][] = [ + [8, 0], + [8, 1], + [8, 2], + [8, 3], + [8, 4], + [8, 5], + [8, 7], + [8, 8], + [7, 8], + [5, 8], + [4, 8], + [3, 8], + [2, 8], + [1, 8], + [0, 8], +]; + +/** + * Reserves the areas in the matrix for format information and marks them as function modules. + * Format information consists of 15 bits that encode the error correction level and mask pattern. + * These bits are placed in specific positions around the top-left finder pattern and along the timing patterns. + * This function updates the functionModules matrix to indicate which modules are reserved for format information. + */ +function reserveFormatInfoAreas( + functionModules: boolean[][], + size: number +): void { + for (const [r, c] of FORMAT_INFO_POSITIONS) { + functionModules[r][c] = true; + } + + for (let i = 0; i < 8; i++) { + functionModules[8][size - 1 - i] = true; + } + + for (let i = 0; i < 7; i++) { + functionModules[size - 7 + i][8] = true; + } +} + +/** + * Reserves the areas in the matrix for version information (for versions 7 and above) and marks them as function modules. + * Version information consists of 18 bits that encode the version number and is placed in specific positions near the top-right and bottom-left finder patterns. + * This function updates both the main matrix and the functionModules matrix to indicate which modules are reserved for version information. + */ +function reserveVersionInfoAreas( + matrix: boolean[][], + functionModules: boolean[][], + version: number +): void { + if (version < 7) return; + const size = matrix.length; + const versionInfo = VERSION_INFO_TABLE[version - 7]; + + for (let i = 0; i < 18; i++) { + const bit = (versionInfo >> i) & 1; + const r = Math.floor(i / 3); + const c = i % 3; + matrix[r][size - 11 + c] = bit === 1; + functionModules[r][size - 11 + c] = true; + matrix[size - 11 + c][r] = bit === 1; + functionModules[size - 11 + c][r] = true; + } +} + +/** + * Places the data bits from the codewords into the matrix in a zig-zag pattern, skipping function modules. + * The data bits are placed starting from the bottom-right corner of the matrix and moving upwards in a zig-zag pattern. + * The function iterates through the codewords and places each bit in the appropriate position in the matrix, while skipping any modules that are reserved for function patterns (finder, alignment, timing, format info, version info). + * If there are more bits than available modules, the remaining bits are treated as padding and set to 0 (white). + */ +function placeDataBits( + matrix: boolean[][], + functionModules: boolean[][], + codewords: number[] +): void { + const size = matrix.length; + const totalBits = codewords.length * 8; + let bitIndex = 0; + + let col = size - 1; + let goingUp = true; + + while (col >= 0) { + if (col === 6) { + col--; // Skip vertical timing pattern + continue; + } + + const rowStart = goingUp ? size - 1 : 0; + const rowEnd = goingUp ? -1 : size; + const rowStep = goingUp ? -1 : 1; + + for (let row = rowStart; row !== rowEnd; row += rowStep) { + for (let dc = 0; dc < 2; dc++) { + const c = col - dc; + if (c < 0) continue; + if (functionModules[row][c]) continue; + if (bitIndex < totalBits) { + const byteIndex = Math.floor(bitIndex / 8); + const bitPosition = 7 - (bitIndex % 8); + matrix[row][c] = ((codewords[byteIndex] >> bitPosition) & 1) === 1; + bitIndex++; + } else { + matrix[row][c] = false; // Padding bits (set to 0) + } + } + } + col -= 2; + goingUp = !goingUp; + } +} + +/** Result produced by `generateQRCodeMatrix`. */ +export interface QRCodeMatrixResult { + /** The fully masked QR boolean matrix ready for rendering. */ + matrix: boolean[][]; + /** QR version (1–40) that was used. */ + version: number; + /** Side length of the matrix in modules (= `version * 4 + 17`). */ + size: number; +} + +/** + * Main entry point for QR matrix generation. Encodes `data`, builds the module + * matrix (finder, alignment, timing, data, format), and applies the optimal mask. + */ +export function generateQRCodeMatrix( + data: string, + ecLevel: QrErrorCorrectionLevel = 'M', + requiredVersion?: number +): QRCodeMatrixResult { + const encoded = encodeQR(data, ecLevel, requiredVersion); + const { codewords, version } = encoded; + + const size = version * 4 + 17; + const matrix = createMatrix(size); + const functionModules = createMatrix(size); + + // Place finder patterns + placeFinderPattern(matrix, functionModules, 0, 0); + placeFinderPattern(matrix, functionModules, 0, size - 7); + placeFinderPattern(matrix, functionModules, size - 7, 0); + + // Place timing patterns + placeTimingPatterns(matrix, functionModules); + + // Dark module (fixed black module for all versions) + const darkRow = 4 * version + 9; + matrix[darkRow][8] = true; + functionModules[darkRow][8] = true; + + // Place alignment patterns + const alignmentPositions = ALIGNMENT_PATTERN_TABLE[version - 1]; + for (const r of alignmentPositions) { + for (const c of alignmentPositions) { + // Skip if this position overlaps with a finder pattern + if ( + (r <= 8 && c <= 8) || + (r <= 8 && c >= size - 8) || + (r >= size - 8 && c <= 8) + ) { + continue; + } + placeAlignmentPattern(matrix, functionModules, r, c); + } + } + + // Version information (for versions 7 and above) + reserveVersionInfoAreas(matrix, functionModules, version); + + // Format information + reserveFormatInfoAreas(functionModules, size); + + // Place data bits + placeDataBits(matrix, functionModules, codewords); + + // Select best mask + const bestMask = selectBestMask(matrix, functionModules); + + // Apply the best mask to the data modules + const maskedMatrix = applyMask(matrix, functionModules, bestMask); + + // Add format information with the selected mask pattern + const ecFormatIndex = EC_LEVEL_FORMAT_INDEX[ecLevel]; + const formatBits = FORMAT_INFO_TABLE[ecFormatIndex * 8 + bestMask]; + writeFormatInfo(maskedMatrix, formatBits, size); + + return { matrix: maskedMatrix, version, size }; +} + +function writeFormatInfo( + matrix: boolean[][], + formatBits: number, + size: number +): void { + for (let i = 0; i < 15; i++) { + const bit = (formatBits >> (14 - i)) & 1; + const [r, c] = FORMAT_INFO_POSITIONS[i]; + matrix[r][c] = bit === 1; + } + + for (let i = 0; i < 8; i++) { + const bit = (formatBits >> i) & 1; + matrix[8][size - 1 - i] = bit === 1; + } + + for (let i = 0; i < 7; i++) { + const bit = (formatBits >> (i + 7)) & 1; + matrix[size - 7 + i][8] = bit === 1; + } + + matrix[size - 8][8] = true; +} diff --git a/src/components/qr-code/model/qr-model.spec.ts b/src/components/qr-code/model/qr-model.spec.ts new file mode 100644 index 000000000..a867e2322 --- /dev/null +++ b/src/components/qr-code/model/qr-model.spec.ts @@ -0,0 +1,449 @@ +import { expect } from '@open-wc/testing'; + +import { encodeQR } from './encode.js'; +import { + calculateECC, + getDataCodewordsCount, + interleaveBlocks, +} from './error-correction.js'; +import { + applyMask, + calculatePenalty, + type MaskPattern, + selectBestMask, +} from './mask.js'; +import { generateQRCodeMatrix } from './matrix.js'; + +function makeMatrix(size: number, fill = false): boolean[][] { + return Array.from({ length: size }, () => new Array(size).fill(fill)); +} + +describe('QR model - encodeQR', () => { + describe('Mode detection', () => { + it('detects numeric mode for digit-only strings', () => { + const result = encodeQR('0123456789'); + expect(result.mode).to.equal('numeric'); + }); + + it('detects alphanumeric mode for uppercase + allowed symbols', () => { + const result = encodeQR('HELLO WORLD'); + expect(result.mode).to.equal('alphanumeric'); + }); + + it('falls back to byte mode for lowercase strings', () => { + const result = encodeQR('Hello World'); + expect(result.mode).to.equal('byte'); + }); + + it('falls back to byte mode for mixed-case strings', () => { + const result = encodeQR('Test123'); + expect(result.mode).to.equal('byte'); + }); + }); + + describe('Auto version selection', () => { + it('selects version 1 for short numeric data at level M', () => { + const result = encodeQR('42', 'M'); + expect(result.version).to.equal(1); + }); + + it('selects version 1 for short byte data at level M', () => { + const result = encodeQR('Hello World', 'M'); + expect(result.version).to.equal(1); + }); + + it('selects a higher version when data exceeds version-1 capacity', () => { + // 152 digits exceed V1–L capacity (41 numeric chars max) + const longNumeric = '1'.repeat(152); + const result = encodeQR(longNumeric, 'L'); + expect(result.version).to.be.greaterThan(1); + }); + + it('selects version 1 for a single alphanumeric character', () => { + const result = encodeQR('A', 'H'); + expect(result.version).to.equal(1); + }); + }); + + describe('EC level index', () => { + const levels = [ + { level: 'L', index: 0 }, + { level: 'M', index: 1 }, + { level: 'Q', index: 2 }, + { level: 'H', index: 3 }, + ] as const; + + for (const { level, index } of levels) { + it(`returns ecLevelIndex ${index} for level ${level}`, () => { + const result = encodeQR('TEST', level); + expect(result.ecLevelIndex).to.equal(index); + }); + } + }); + + describe('Output shape', () => { + it('returns a non-empty codewords array', () => { + const result = encodeQR('Hello World', 'M'); + expect(result.codewords).to.be.an('array'); + expect(result.codewords.length).to.be.greaterThan(0); + }); + + it('all codewords are integers in 0-255', () => { + const result = encodeQR('HELLO WORLD', 'M'); + for (const cw of result.codewords) { + expect(cw).to.be.within(0, 255); + } + }); + + it('uses requested version when explicitly provided', () => { + const result = encodeQR('Hi', 'M', 3); + expect(result.version).to.equal(3); + }); + }); + + describe('Error cases', () => { + it('throws for empty string', () => { + expect(() => encodeQR('')).to.throw(); + }); + + it('throws for requestedVersion below 1', () => { + expect(() => encodeQR('A', 'M', 0)).to.throw(); + }); + + it('throws for requestedVersion above 40', () => { + expect(() => encodeQR('A', 'M', 41)).to.throw(); + }); + + it('throws when data is too long for the explicitly requested version', () => { + // A 200-character lowercase string cannot fit in version 1 at any EC level + expect(() => encodeQR('a'.repeat(200), 'M', 1)).to.throw(); + }); + }); +}); + +describe('QR model - error correction', () => { + describe('getDataCodewordsCount', () => { + it('returns 19 for V1/L', () => { + expect(getDataCodewordsCount(1, 0)).to.equal(19); + }); + + it('returns 16 for V1/M', () => { + expect(getDataCodewordsCount(1, 1)).to.equal(16); + }); + + it('returns 13 for V1/Q', () => { + expect(getDataCodewordsCount(1, 2)).to.equal(13); + }); + + it('returns 9 for V1/H', () => { + expect(getDataCodewordsCount(1, 3)).to.equal(9); + }); + }); + + describe('calculateECC', () => { + it('returns an array whose length equals the requested degree', () => { + expect(calculateECC([1, 2, 3], 5).length).to.equal(5); + expect(calculateECC([10, 20, 30, 40], 10).length).to.equal(10); + }); + + it('returns an empty array for degree 0', () => { + expect(calculateECC([1, 2, 3], 0)).to.deep.equal([]); + }); + + it('is deterministic - same input always produces same output', () => { + const data = [ + 32, 91, 11, 120, 209, 114, 220, 77, 67, 64, 236, 17, 236, 17, 236, 17, + ]; + const ecc1 = calculateECC(data, 10); + const ecc2 = calculateECC(data, 10); + expect(ecc1).to.deep.equal(ecc2); + }); + + it('produces the correct ECC for the "HELLO WORLD" V1/M data codewords', () => { + // Data codewords for "HELLO WORLD" at V1/M (from ISO 18004:2015 Annex I) + const data = [ + 32, 91, 11, 120, 209, 114, 220, 77, 67, 64, 236, 17, 236, 17, 236, 17, + ]; + const ecc = calculateECC(data, 10); + expect(ecc).to.deep.equal([196, 35, 39, 119, 235, 215, 231, 226, 93, 23]); + }); + }); + + describe('interleaveBlocks', () => { + describe('single-block version (V1/M)', () => { + const data = Array.from({ length: 16 }, (_, i) => i); + + it('outputs length = data codewords + EC codewords', () => { + const result = interleaveBlocks(data, 1, 1); + expect(result.length).to.equal(26); // 16 data + 10 EC + }); + + it('starts with the data codewords in order', () => { + const result = interleaveBlocks(data, 1, 1); + expect(result.slice(0, 16)).to.deep.equal(data); + }); + + it('EC bytes equal calculateECC on the same data', () => { + const result = interleaveBlocks(data, 1, 1); + const ecc = calculateECC(data, 10); + expect(result.slice(16)).to.deep.equal(ecc); + }); + }); + + describe('multi-block version (V5/M – 2 blocks of 43 data CW)', () => { + // V5/M: EC_BLOCKS_TABLE[4][1] = { ecPerBlock: 24, groups: [{ numBlocks: 2, dataCW: 43 }] } + const totalDataCW = 2 * 43; // 86 + const data = Array.from({ length: totalDataCW }, (_, i) => i % 256); + + it('outputs length = data codewords + total EC codewords', () => { + const result = interleaveBlocks(data, 5, 1); + expect(result.length).to.equal(totalDataCW + 2 * 24); // 86 + 48 = 134 + }); + + it('interleaves data from block 0 and block 1 alternately', () => { + const result = interleaveBlocks(data, 5, 1); + // Block 0 starts at index 0, block 1 starts at index 43 + expect(result[0]).to.equal(data[0]); // block 0, codeword 0 + expect(result[1]).to.equal(data[43]); // block 1, codeword 0 + expect(result[2]).to.equal(data[1]); // block 0, codeword 1 + expect(result[3]).to.equal(data[44]); // block 1, codeword 1 + }); + }); + }); +}); + +describe('QR model - masking', () => { + describe('applyMask', () => { + it('does not toggle function modules', () => { + const matrix = makeMatrix(3, false); + const functionModules = makeMatrix(3, false); + functionModules[1][1] = true; // mark (1,1) as a function module + + // Pattern 0 toggles (row+col)%2===0 → (1,1) would normally be toggled + const result = applyMask(matrix, functionModules, 0); + expect(result[1][1]).to.equal(false); // untouched + }); + + it('toggles non-function modules according to pattern 0 condition', () => { + const matrix = makeMatrix(3, false); + const functionModules = makeMatrix(3, false); + const result = applyMask(matrix, functionModules, 0); + + // Pattern 0: (row+col)%2===0 → these should be flipped to true + expect(result[0][0]).to.equal(true); + expect(result[0][2]).to.equal(true); + expect(result[1][1]).to.equal(true); + expect(result[2][0]).to.equal(true); + expect(result[2][2]).to.equal(true); + + // Odd positions remain false + expect(result[0][1]).to.equal(false); + expect(result[1][0]).to.equal(false); + }); + + it('toggles non-function modules according to pattern 1 (even row)', () => { + const matrix = makeMatrix(4, false); + const functionModules = makeMatrix(4, false); + const result = applyMask(matrix, functionModules, 1 as MaskPattern); + + // Pattern 1: row%2===0 → rows 0 and 2 are all-true, rows 1 and 3 are all-false + for (let c = 0; c < 4; c++) { + expect(result[0][c]).to.equal(true); + expect(result[2][c]).to.equal(true); + expect(result[1][c]).to.equal(false); + expect(result[3][c]).to.equal(false); + } + }); + + it('returns a new matrix (does not mutate the original)', () => { + const matrix = makeMatrix(3, false); + const functionModules = makeMatrix(3, false); + applyMask(matrix, functionModules, 0); + expect(matrix[0][0]).to.equal(false); + }); + }); + + describe('calculatePenalty', () => { + it('returns a non-negative number', () => { + const matrix = makeMatrix(5, false); + expect(calculatePenalty(matrix)).to.be.greaterThanOrEqual(0); + }); + + it('returns a positive penalty for an all-dark matrix', () => { + const matrix = makeMatrix(10, true); + expect(calculatePenalty(matrix)).to.be.greaterThan(0); + }); + + it('returns a positive penalty for an all-light matrix', () => { + const matrix = makeMatrix(10, false); + expect(calculatePenalty(matrix)).to.be.greaterThan(0); + }); + }); + + describe('selectBestMask', () => { + it('returns a value in the range [0, 7]', () => { + const { matrix, size } = generateQRCodeMatrix('TEST', 'M'); + // Rebuild a clean unmasked matrix from scratch is complex; + // instead verify the contract on any valid matrix + const functionModules = makeMatrix(size, false); + const best = selectBestMask(matrix, functionModules); + expect(best).to.be.within(0, 7); + }); + + it('returns the pattern with the lowest penalty score', () => { + const { matrix, size } = generateQRCodeMatrix('A', 'M'); + const functionModules = makeMatrix(size, false); + const best = selectBestMask(matrix, functionModules); + + let lowestPenalty = Number.POSITIVE_INFINITY; + let expectedBest: MaskPattern = 0; + for (let p = 0; p < 8; p++) { + const masked = applyMask(matrix, functionModules, p as MaskPattern); + const penalty = calculatePenalty(masked); + if (penalty < lowestPenalty) { + lowestPenalty = penalty; + expectedBest = p as MaskPattern; + } + } + expect(best).to.equal(expectedBest); + }); + }); +}); + +describe('QR model - matrix generation', () => { + // V1/M with byte-mode data + const V1_DATA = 'Hello World'; + let v1Result: ReturnType; + + before(() => { + v1Result = generateQRCodeMatrix(V1_DATA, 'M'); + }); + + describe('Output shape', () => { + it('size equals version * 4 + 17', () => { + expect(v1Result.size).to.equal(v1Result.version * 4 + 17); + }); + + it('matrix is square with side length equal to size', () => { + const { matrix, size } = v1Result; + expect(matrix.length).to.equal(size); + for (const row of matrix) { + expect(row.length).to.equal(size); + } + }); + + it('matrix contains only boolean values', () => { + for (const row of v1Result.matrix) { + for (const cell of row) { + expect(typeof cell).to.equal('boolean'); + } + } + }); + + it('returns version 1 for short byte data at level M', () => { + expect(v1Result.version).to.equal(1); + }); + + it('returns size 21 for version 1', () => { + expect(v1Result.size).to.equal(21); + }); + + it('returns a higher version for data too large for V1', () => { + const result = generateQRCodeMatrix('A'.repeat(50), 'M'); + expect(result.version).to.be.greaterThan(1); + }); + }); + + describe('Finder patterns', () => { + const FINDER_PATTERN = [ + [true, true, true, true, true, true, true], + [true, false, false, false, false, false, true], + [true, false, true, true, true, false, true], + [true, false, true, true, true, false, true], + [true, false, true, true, true, false, true], + [true, false, false, false, false, false, true], + [true, true, true, true, true, true, true], + ]; + + function extractRegion( + matrix: boolean[][], + rowStart: number, + colStart: number, + size = 7 + ): boolean[][] { + return Array.from({ length: size }, (_, r) => + Array.from( + { length: size }, + (_, c) => matrix[rowStart + r][colStart + c] + ) + ); + } + + it('top-left finder pattern matches the expected bit layout', () => { + const region = extractRegion(v1Result.matrix, 0, 0); + expect(region).to.deep.equal(FINDER_PATTERN); + }); + + it('top-right finder pattern matches the expected bit layout', () => { + const { matrix, size } = v1Result; + const region = extractRegion(matrix, 0, size - 7); + expect(region).to.deep.equal(FINDER_PATTERN); + }); + + it('bottom-left finder pattern matches the expected bit layout', () => { + const { matrix, size } = v1Result; + const region = extractRegion(matrix, size - 7, 0); + expect(region).to.deep.equal(FINDER_PATTERN); + }); + }); + + describe('Timing patterns', () => { + it('row 6 alternates between finder patterns (even col = dark)', () => { + const { matrix, size } = v1Result; + for (let c = 8; c < size - 8; c++) { + expect(matrix[6][c]).to.equal( + c % 2 === 0, + `matrix[6][${c}] should be ${c % 2 === 0}` + ); + } + }); + + it('col 6 alternates between finder patterns (even row = dark)', () => { + const { matrix, size } = v1Result; + for (let r = 8; r < size - 8; r++) { + expect(matrix[r][6]).to.equal( + r % 2 === 0, + `matrix[${r}][6] should be ${r % 2 === 0}` + ); + } + }); + }); + + describe('Dark module', () => { + it('is always true regardless of mask or version', () => { + for (const version of [1, 5, 10]) { + const result = generateQRCodeMatrix('A', 'M', version); + expect(result.matrix[4 * version + 9][8]).to.equal(true); + } + }); + }); + + describe('Version information (V7+)', () => { + it('version info area near top-right corner is non-trivial for V7', () => { + const result = generateQRCodeMatrix('A'.repeat(20), 'M', 7); + const { matrix, size } = result; + + // 6×3 block at top-right: cols size-11..size-9, rows 0..5 + let hasTrue = false; + let hasFalse = false; + for (let r = 0; r < 6; r++) { + for (let c = size - 11; c < size - 8; c++) { + if (matrix[r][c]) hasTrue = true; + else hasFalse = true; + } + } + expect(hasTrue).to.equal(true); + expect(hasFalse).to.equal(true); + }); + }); +}); diff --git a/src/components/qr-code/qr-code.spec.ts b/src/components/qr-code/qr-code.spec.ts new file mode 100644 index 000000000..c04bc9594 --- /dev/null +++ b/src/components/qr-code/qr-code.spec.ts @@ -0,0 +1,210 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import { defineComponents } from '../common/definitions/defineComponents.js'; +import IgcQrCodeComponent from './qr-code.js'; + +describe('IgcQrCodeComponent', () => { + before(() => { + defineComponents(IgcQrCodeComponent); + }); + + function getSvg(el: IgcQrCodeComponent): SVGElement | null { + return el.renderRoot.querySelector('svg'); + } + + describe('Accessibility', () => { + it('passes the a11y audit when a value is set', async () => { + const el = await fixture( + html`` + ); + await expect(el).shadowDom.to.be.accessible(); + }); + + it('renders an SVG with a element for screen readers', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + const title = el.renderRoot.querySelector('svg title'); + expect(title).to.exist; + expect(title?.textContent).to.include('https://example.com'); + }); + + it('uses aria-label as the SVG title content when provided', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + value="https://example.com" + aria-label="Scan me" + ></igc-qr-code>` + ); + const title = el.renderRoot.querySelector('svg title'); + expect(title?.textContent).to.equal('Scan me'); + }); + }); + + describe('Default property values', () => { + let el: IgcQrCodeComponent; + + beforeEach(async () => { + el = await fixture(html`<igc-qr-code></igc-qr-code>`); + }); + + it('size defaults to 128', () => { + expect(el.size).to.equal(128); + }); + + it('margin defaults to 4', () => { + expect(el.margin).to.equal(4); + }); + + it('errorLevel defaults to M', () => { + expect(el.errorLevel).to.equal('M'); + }); + + it('dotStyle defaults to square', () => { + expect(el.dotStyle).to.equal('square'); + }); + + it('squareStyle defaults to square', () => { + expect(el.squareStyle).to.equal('square'); + }); + + it('value defaults to undefined', () => { + expect(el.value).to.be.undefined; + }); + }); + + describe('Rendering', () => { + it('renders nothing when value is not set', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code></igc-qr-code>` + ); + expect(getSvg(el)).to.be.null; + }); + + it('renders an SVG element when value is set', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + expect(getSvg(el)).to.exist; + }); + + it('sets SVG width and height to match the size property', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" size="256"></igc-qr-code>` + ); + const svgEl = getSvg(el)!; + expect(svgEl.getAttribute('width')).to.equal('256'); + expect(svgEl.getAttribute('height')).to.equal('256'); + }); + + it('updates SVG dimensions when size changes', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" size="128"></igc-qr-code>` + ); + + el.size = 320; + await elementUpdated(el); + + const svgEl = getSvg(el)!; + expect(svgEl.getAttribute('width')).to.equal('320'); + expect(svgEl.getAttribute('height')).to.equal('320'); + }); + + it('re-renders when value changes', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="initial"></igc-qr-code>` + ); + + const svgBefore = getSvg(el)!.outerHTML; + + el.value = 'https://changed.example.com/with/a/longer/path'; + await elementUpdated(el); + + const svgAfter = getSvg(el)!.outerHTML; + expect(svgAfter).not.to.equal(svgBefore); + }); + + it('clears the SVG when value is set back to undefined', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + + el.value = undefined; + await elementUpdated(el); + + expect(getSvg(el)).to.be.null; + }); + + it('renders a background rect and at least one data path', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + const svgEl = getSvg(el)!; + expect(svgEl.querySelector('rect')).to.exist; + expect(svgEl.querySelector('path')).to.exist; + }); + + it('renders three finder-pattern corner groups', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + // Each corner renders a <g> with two <path> children; there are 3 corners + const cornerGroups = el.renderRoot.querySelectorAll('svg > g > g'); + expect(cornerGroups.length).to.equal(3); + }); + }); + + describe('Attribute reflection', () => { + it('reflects dot-style attribute to dotStyle property', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code dot-style="circle"></igc-qr-code>` + ); + expect(el.dotStyle).to.equal('circle'); + }); + + it('reflects square-style attribute to squareStyle property', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code square-style="rounded"></igc-qr-code>` + ); + expect(el.squareStyle).to.equal('rounded'); + }); + + it('reflects error-level attribute to errorLevel property', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code error-level="H"></igc-qr-code>` + ); + expect(el.errorLevel).to.equal('H'); + }); + + it('reflects version attribute to version property', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code version="5"></igc-qr-code>` + ); + expect(el.version).to.equal(5); + }); + }); + + describe('Style variants', () => { + const dotStyles = ['square', 'circle', 'rounded'] as const; + const squareStyles = ['square', 'circle', 'rounded'] as const; + + for (const style of dotStyles) { + it(`renders data path with dot-style="${style}"`, async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" dot-style=${style}></igc-qr-code>` + ); + expect(el.renderRoot.querySelector('path')).to.exist; + }); + } + + for (const style of squareStyles) { + it(`renders corner groups with square-style="${style}"`, async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" square-style=${style}></igc-qr-code>` + ); + const cornerGroups = el.renderRoot.querySelectorAll('svg > g > g'); + expect(cornerGroups.length).to.equal(3); + }); + } + }); +}); diff --git a/src/components/qr-code/qr-code.ts b/src/components/qr-code/qr-code.ts new file mode 100644 index 000000000..e31a4628f --- /dev/null +++ b/src/components/qr-code/qr-code.ts @@ -0,0 +1,169 @@ +import { css, html, LitElement, nothing, svg } from 'lit'; +import { property } from 'lit/decorators.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { isEmpty } from '../common/util.js'; +import { generateQRCodeMatrix } from './model/matrix.js'; +import { renderQrCorner } from './renderer/corner.js'; +import { getFinderPatterns, renderDataModules } from './renderer/svg.js'; +import type { + QrCornerSquareStyle, + QrDotStyle, + QrErrorCorrectionLevel, +} from './types.js'; + +/** + * + * Generates a QR code based on the provided value and options. + * The component renders an SVG representation of the QR code, which can be customized using various properties. + * + * @element igc-qr-code + * + * @cssproperty --igc-qr-dark - The color used for the dark modules of the QR code. Default is #000. + * @cssproperty --igc-qr-background - The color used for the background of the QR code. Default is #fff. + * @cssproperty --qr-corner-square-fill - The fill color for the corner squares of the QR code. Default is black. + * @cssproperty --qr-corner-dot-fill - The fill color for the corner dots of the QR code. Default is black. + */ +export default class IgcQrCodeComponent extends LitElement { + public static readonly tagName = 'igc-qr-code'; + + public static override styles = css` + :host { + display: inline-block; + contain: content; + } + `; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcQrCodeComponent); + } + + /** + * The value to be encoded in the QR code. This can be any string, such as a URL, text, or other data. + * When this property is set, the component will generate a QR code representing the provided value. + * + * @attr value + */ + @property() + public value?: string; + + /** + * The version of the QR code to generate, which determines the size and data capacity of the QR code. + * Valid values are integers from 1 to 40, where each version corresponds to a specific module size and data capacity. + * + * If not specified, the component will automatically select the smallest version that can accommodate the provided value. + * + * @attr version + */ + @property({ type: Number }) + public version?: number; + + /** + * The error correction level for the QR code, which determines the QR code's ability to be read if it is partially obscured or damaged. + * Valid values are 'L', 'M', 'Q', and 'H', where 'L' provides the lowest level of error correction and 'H' provides the highest level. + * + * @attr error-level + * @default 'M' + */ + @property({ attribute: 'error-level' }) + public errorLevel?: QrErrorCorrectionLevel = 'M'; + + /** + * The size of the QR code in pixels. This determines the width and height of the generated QR code. The default value is 128 pixels. + * + * @attr size + * @default 128 + */ + @property({ type: Number }) + public size = 128; + + /** + * The margin around the QR code in pixels. This is the whitespace area surrounding the QR code, + * which helps ensure that it can be properly scanned. + * + * @attr margin + * @default 4 + */ + @property({ type: Number }) + public margin = 4; + + /** + * The style of the data modules (dots) in the QR code. This can be 'square', 'circle', or 'rounded'. + * + * @attr dot-style + * @default 'square' + */ + @property({ attribute: 'dot-style' }) + public dotStyle: QrDotStyle = 'square'; + + /** + * The style of the corner squares in the QR code. This can be 'square', 'circle', or 'rounded'. + * + * @attr square-style + * @default 'square' + */ + @property({ attribute: 'square-style' }) + public squareStyle: QrCornerSquareStyle = 'square'; + + protected override render() { + if (!this.value) return null; + + const { matrix, size } = generateQRCodeMatrix( + this.value, + this.errorLevel, + this.version + ); + + const totalModules = size + this.margin * 2; + const moduleSize = size / totalModules; + const marginPx = this.margin * moduleSize; + const svgSize = moduleSize * (size + this.margin * 2); + + const dataModules = renderDataModules( + matrix, + moduleSize, + marginPx, + this.dotStyle + ); + + const paths = isEmpty(dataModules) + ? nothing + : svg`<path d=${dataModules.join(' ')} fill="var(--igc-qr-dark, #000)"/>`; + + const patterns = getFinderPatterns(size, moduleSize, marginPx).map( + ({ x, y }) => + renderQrCorner({ + x, + y, + size: moduleSize, + dotStyle: this.dotStyle, + squareStyle: this.squareStyle, + }) + ); + + return html` + <svg + xmlns="http://www.w3.org/2000/svg" + role="img" + width=${this.size} + height=${this.size} + viewBox="0 0 ${svgSize} ${svgSize}" + > + <title>${this.ariaLabel ?? `QR code: ${this.value}`} + + + ${paths}${patterns} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-qr-code': IgcQrCodeComponent; + } +} diff --git a/src/components/qr-code/renderer/corner.ts b/src/components/qr-code/renderer/corner.ts new file mode 100644 index 000000000..fab9b7f49 --- /dev/null +++ b/src/components/qr-code/renderer/corner.ts @@ -0,0 +1,31 @@ +import { svg, type TemplateResult } from 'lit'; +import type { QrCornerProperties } from '../types.js'; +import { cornerDotPath, cornerSquarePath } from './svg.js'; + +/** Renders a finder-pattern corner as a Lit SVG template, composing the outer square and inner dot paths. */ +export function renderQrCorner({ + x, + y, + size, + dotStyle, + squareStyle, +}: QrCornerProperties): TemplateResult { + const outerSize = 7 * size; + const innerSize = 3 * size; + const innerOffset = 2 * size; + + const squarePath = cornerSquarePath(x, y, outerSize, squareStyle); + const dotPath = cornerDotPath( + x + innerOffset, + y + innerOffset, + innerSize, + dotStyle + ); + + return svg` + + + + + `; +} diff --git a/src/components/qr-code/renderer/svg.ts b/src/components/qr-code/renderer/svg.ts new file mode 100644 index 000000000..9510d4526 --- /dev/null +++ b/src/components/qr-code/renderer/svg.ts @@ -0,0 +1,258 @@ +import type { + QrCornerDotStyle, + QrCornerSquareStyle, + QrDotStyle, +} from '../types.js'; + +type DotNeighbor = { + top: boolean; + right: boolean; + bottom: boolean; + left: boolean; +}; + +function squarePath(x: number, y: number, s: number): string { + return `M${x},${y}h${s}v${s}h${-s}Z`; +} + +function roundedRect( + x: number, + y: number, + width: number, + height: number, + radius: number +): string { + const cr = Math.min(radius, width / 2, height / 2); + return ( + `M${x + cr},${y}` + + `h${width - 2 * cr}` + + `q${cr},0 ${cr},${cr}` + + `v${height - 2 * cr}` + + `q0,${cr} ${-cr},${cr}` + + `h${-(width - 2 * cr)}` + + `q${-cr},0 ${-cr},${-cr}` + + `v${-(height - 2 * cr)}` + + `q0,${-cr} ${cr},${-cr}z` + ); +} + +function roundedRectPerCorner( + x: number, + y: number, + width: number, + height: number, + rTL: number, + rTR: number, + rBR: number, + rBL: number +): string { + return ( + `M${x + rTL},${y}` + + `h${width - rTL - rTR}` + + `q${rTR},0 ${rTR},${rTR}` + + `v${height - rTR - rBR}` + + `q0,${rBR} ${-rBR},${rBR}` + + `h${-(width - rBR - rBL)}` + + `q${-rBL},0 ${-rBL},${-rBL}` + + `v${-(height - rBL - rTL)}` + + `q0,${-rTL} ${rTL},${-rTL}z` + ); +} + +/** Returns an SVG path string for a single data module at `(x, y)` with side `s`, in the given style. + * For `'rounded'`, adjacent module flags control which corners are rounded. */ +export function dotPath( + x: number, + y: number, + s: number, + style: QrDotStyle, + neighbors?: DotNeighbor +): string { + switch (style) { + case 'square': + return squarePath(x, y, s); + case 'circle': { + const cx = x + s / 2; + const cy = y + s / 2; + const r = s / 2; + return ( + `M${cx - r},${cy}` + + `a${r},${r} 0 1,0 ${r * 2},0` + + `a${r},${r} 0 1,0 ${-r * 2},0z` + ); + } + case 'rounded': { + const R = s * 0.45; + const n = neighbors || { + top: false, + right: false, + bottom: false, + left: false, + }; + const rTL = n.top || n.left ? 0 : R; + const rTR = n.top || n.right ? 0 : R; + const rBR = n.bottom || n.right ? 0 : R; + const rBL = n.bottom || n.left ? 0 : R; + return roundedRectPerCorner(x, y, s, s, rTL, rTR, rBR, rBL); + } + } +} + +/** Returns an SVG path string for the inner dot of a finder-pattern corner. */ +export function cornerDotPath( + x: number, + y: number, + size: number, + style: QrCornerDotStyle +): string { + switch (style) { + case 'circle': { + const cx = x + size / 2; + const cy = y + size / 2; + const r = size / 2; + return ( + `M${cx - r},${cy}` + + `a${r},${r} 0 1,0 ${r * 2},0` + + `a${r},${r} 0 1,0 ${-r * 2},0z` + ); + } + case 'rounded': { + return roundedRect(x, y, size, size, size * 0.3); + } + default: + return squarePath(x, y, size); + } +} + +/** Returns an SVG path string (outer ring with inner cutout) for the outer square of a finder-pattern corner. */ +export function cornerSquarePath( + x: number, + y: number, + size: number, + style: QrCornerSquareStyle +): string { + const moduleSize = size / 7; + const inner = size - 2 * moduleSize; + const innerOffset = moduleSize; + + switch (style) { + case 'square': { + const outer = squarePath(x, y, size); + const cut = squarePath(x + innerOffset, y + innerOffset, inner); + return `${outer} ${cut}`; + } + case 'rounded': { + const outer = roundedRect(x, y, size, size, size * 0.15); + const cut = squarePath(x + innerOffset, y + innerOffset, inner); + return `${outer} ${cut}`; + } + default: { + const cx = x + size / 2; + const cy = y + size / 2; + const rOuter = size / 2; + const rInner = inner / 2; + return ( + `M${cx - rOuter},${cy}` + + `a${rOuter},${rOuter} 0 1,0 ${rOuter * 2},0` + + `a${rOuter},${rOuter} 0 1,0 ${-rOuter * 2},0z` + + `M${cx - rInner},${cy}` + + `a${rInner},${rInner} 0 1,1 ${rInner * 2},0` + + `a${rInner},${rInner} 0 1,1 ${-rInner * 2},0z` + ); + } + } +} + +/** Converts a module grid index to a pixel coordinate, accounting for margin. */ +export function moduleToPx( + moduleIndex: number, + moduleSize: number, + marginPx: number +): number { + return moduleIndex * moduleSize + marginPx; +} + +function finderCorners(size: number): [number, number][] { + return [ + [0, 0], // Top-left + [0, size - 7], // Top-right + [size - 7, 0], // Bottom-left + ]; +} + +/** + * Returns the set of flat module indices `(row * size + col)` occupied by the + * three finder patterns (including their separators), used to skip those modules + * during data rendering. + */ +export function getFinderPatternModules(size: number): Set { + const modules = new Set(); + + for (const [startRow, startCol] of finderCorners(size)) { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const row = startRow + r; + const col = startCol + c; + if (row >= 0 && col >= 0 && row < size && col < size) { + modules.add(row * size + col); + } + } + } + } + + return modules; +} + +/** + * Generates SVG path strings for all dark data modules in the matrix, + * skipping finder-pattern areas. Returns one path string per visible module. + */ +export function renderDataModules( + data: boolean[][], + moduleSize: number, + marginPx: number, + dotStyle: QrDotStyle +): string[] { + const size = data.length; + const finderModules = getFinderPatternModules(size); + const paths: string[] = []; + + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + if (finderModules.has(r * size + c)) continue; + if (!data[r][c]) continue; + + const x = moduleToPx(c, moduleSize, marginPx); + const y = moduleToPx(r, moduleSize, marginPx); + + let neighbors: DotNeighbor | undefined; + if (dotStyle === 'rounded') { + neighbors = { + top: r > 0 && data[r - 1][c], + right: c < size - 1 && data[r][c + 1], + bottom: r < size - 1 && data[r + 1][c], + left: c > 0 && data[r][c - 1], + }; + } + + paths.push(dotPath(x, y, moduleSize, dotStyle, neighbors)); + } + } + + return paths; +} + +/** + * Returns the pixel top-left coordinates `{ x, y }` for each of the three + * finder-pattern corners, ready to pass to `renderQrCorner`. + */ +export function getFinderPatterns( + size: number, + moduleSize: number, + marginPx: number +) { + return finderCorners(size).map(([r, c]) => ({ + x: moduleToPx(c, moduleSize, marginPx), + y: moduleToPx(r, moduleSize, marginPx), + })); +} diff --git a/src/components/qr-code/types.ts b/src/components/qr-code/types.ts new file mode 100644 index 000000000..3dbc3d5b0 --- /dev/null +++ b/src/components/qr-code/types.ts @@ -0,0 +1,45 @@ +/** Shape of individual data modules in the QR code body. */ +export type QrDotStyle = 'square' | 'circle' | 'rounded'; + +/** Shape of the inner dot inside each finder-pattern corner. */ +export type QrCornerDotStyle = 'square' | 'circle' | 'rounded'; + +/** Shape of the outer square of each finder-pattern corner. */ +export type QrCornerSquareStyle = 'square' | 'circle' | 'rounded'; + +/** + * QR error correction level. Higher levels recover more data but reduce capacity. + * - `L` ~7%, `M` ~15%, `Q` ~25%, `H` ~30%. + */ +export type QrErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H'; + +/** + * QR data encoding mode, selected automatically based on the input string. + * - `numeric` — digits only; `alphanumeric` — digits + uppercase letters + a few symbols; `byte` — arbitrary UTF-8. + */ +export type QrEncodingMode = 'numeric' | 'alphanumeric' | 'byte'; + +/** Generation options passed through to the encoder. */ +export type QrCodeOptions = { + errorCorrectionLevel?: QrErrorCorrectionLevel; + version?: number; +}; + +/** Visual style overrides for finder-pattern corners, used by higher-level APIs. */ +export type QrCornerOptions = { + dot?: { + style?: QrCornerDotStyle; + }; + square?: { + style?: QrCornerSquareStyle; + }; +}; + +/** Input properties for `renderQrCorner`. */ +export type QrCornerProperties = { + x: number; + y: number; + size: number; + dotStyle: QrCornerDotStyle; + squareStyle: QrCornerSquareStyle; +}; diff --git a/src/index.ts b/src/index.ts index cc7eefc8e..0ae5554eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,7 @@ export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcHighlightComponent } from './components/highlight/highlight.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; export { default as IgcThemeProviderComponent } from './components/theme-provider/theme-provider.js'; +export { default as IgcQrCodeComponent } from './components/qr-code/qr-code.js'; // definitions export { defineComponents } from './components/common/definitions/defineComponents.js'; diff --git a/stories/qr-code.stories.ts b/stories/qr-code.stories.ts new file mode 100644 index 000000000..be158f80b --- /dev/null +++ b/stories/qr-code.stories.ts @@ -0,0 +1,389 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { defineComponents, IgcQrCodeComponent } from 'igniteui-webcomponents'; +import { disableStoryControls } from './story.js'; + +defineComponents(IgcQrCodeComponent); + +// region default +const metadata: Meta = { + title: 'QrCode', + component: 'igc-qr-code', + parameters: { + docs: { + description: { + component: + '\nGenerates a QR code based on the provided value and options.\nThe component renders an SVG representation of the QR code, which can be customized using various properties.', + }, + }, + }, + argTypes: { + value: { + type: 'string', + description: + 'The value to be encoded in the QR code. This can be any string, such as a URL, text, or other data.\nWhen this property is set, the component will generate a QR code representing the provided value.', + control: 'text', + }, + version: { + type: 'number', + description: + 'The version of the QR code to generate, which determines the size and data capacity of the QR code.\nValid values are integers from 1 to 40, where each version corresponds to a specific module size and data capacity.\n\nIf not specified, the component will automatically select the smallest version that can accommodate the provided value.', + control: 'number', + }, + errorLevel: { + type: '"L" | "M" | "Q" | "H"', + description: + "The error correction level for the QR code, which determines the QR code's ability to be read if it is partially obscured or damaged.\nValid values are 'L', 'M', 'Q', and 'H', where 'L' provides the lowest level of error correction and 'H' provides the highest level.", + options: ['L', 'M', 'Q', 'H'], + control: { type: 'select' }, + table: { defaultValue: { summary: 'M' } }, + }, + size: { + type: 'number', + description: + 'The size of the QR code in pixels. This determines the width and height of the generated QR code. The default value is 128 pixels.', + control: 'number', + table: { defaultValue: { summary: '128' } }, + }, + margin: { + type: 'number', + description: + 'The margin around the QR code in pixels. This is the whitespace area surrounding the QR code,\nwhich helps ensure that it can be properly scanned.', + control: 'number', + table: { defaultValue: { summary: '4' } }, + }, + dotStyle: { + type: '"square" | "circle" | "rounded"', + description: + "The style of the data modules (dots) in the QR code. This can be 'square', 'circle', or 'rounded'.", + options: ['square', 'circle', 'rounded'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'square' } }, + }, + squareStyle: { + type: '"square" | "circle" | "rounded"', + description: + "The style of the corner squares in the QR code. This can be 'square', 'circle', or 'rounded'.", + options: ['square', 'circle', 'rounded'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'square' } }, + }, + }, + args: { + errorLevel: 'M', + size: 128, + margin: 4, + dotStyle: 'square', + squareStyle: 'square', + }, +}; + +export default metadata; + +interface IgcQrCodeArgs { + /** + * The value to be encoded in the QR code. This can be any string, such as a URL, text, or other data. + * When this property is set, the component will generate a QR code representing the provided value. + */ + value: string; + /** + * The version of the QR code to generate, which determines the size and data capacity of the QR code. + * Valid values are integers from 1 to 40, where each version corresponds to a specific module size and data capacity. + * + * If not specified, the component will automatically select the smallest version that can accommodate the provided value. + */ + version: number; + /** + * The error correction level for the QR code, which determines the QR code's ability to be read if it is partially obscured or damaged. + * Valid values are 'L', 'M', 'Q', and 'H', where 'L' provides the lowest level of error correction and 'H' provides the highest level. + */ + errorLevel: 'L' | 'M' | 'Q' | 'H'; + /** The size of the QR code in pixels. This determines the width and height of the generated QR code. The default value is 128 pixels. */ + size: number; + /** + * The margin around the QR code in pixels. This is the whitespace area surrounding the QR code, + * which helps ensure that it can be properly scanned. + */ + margin: number; + /** The style of the data modules (dots) in the QR code. This can be 'square', 'circle', or 'rounded'. */ + dotStyle: 'square' | 'circle' | 'rounded'; + /** The style of the corner squares in the QR code. This can be 'square', 'circle', or 'rounded'. */ + squareStyle: 'square' | 'circle' | 'rounded'; +} +type Story = StoryObj; + +// endregion + +export const Default: Story = { + args: { + value: 'https://www.infragistics.com/products/ignite-ui-web-components', + }, +}; + +export const DotStyles: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + square +
+
+ + circle +
+
+ + rounded +
+
+ `, +}; + +export const CornerSquareStyles: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + square +
+
+ + circle +
+
+ + rounded +
+
+ `, +}; + +export const CombinedStyles: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + circle / circle +
+
+ + rounded / rounded +
+
+ + circle / square +
+
+ + rounded / circle +
+
+ `, +}; + +export const CustomColors: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + Blue +
+
+ + Red +
+
+ + Inverted +
+
+ + Green +
+
+ `, +}; + +export const FinderPatternColors: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + Red corners +
+
+ + Blue corners +
+
+ + Purple square / pink dot +
+
+ + Orange square / navy dot +
+
+ + Grey data / golden corners +
+
+ + Dark bg / accent corners +
+
+ `, +}; + +export const Sizes: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + 128px +
+
+ + 192px +
+
+ + 256px +
+
+ + 320px +
+
+ `, +};