diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b24dfc36..079f27ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - #### Icon - `registerIcon` and `registerIconFromText` now accept a `RegisterIconOptions` object as their third argument in addition to the existing plain collection string. Setting `stripMeta: true` removes `` and `<desc>` elements from the stored SVG, preventing the browser from displaying a native tooltip on hover. The title text is still captured and exposed as the `aria-label` of the host `<igc-icon>` element. Any `aria-labelledby` / `aria-describedby` references on the root `<svg>` that pointed to the stripped elements' IDs are cleaned up automatically. [#1822](https://github.com/IgniteUI/igniteui-webcomponents/issues/1822) +- #### QR Code + - Added the new `igc-qr-code` component. Renders a scannable QR code as an SVG based on the provided `value`, automatically encoding it in numeric, alphanumeric, or byte mode. [#2308](https://github.com/IgniteUI/igniteui-webcomponents/pull/2308) + - Supports an explicit `version` (1-40) and `error-level` (`L`/`M`/`Q`/`H`), or automatically picks the smallest version and level that fit the data. + - `size` and `margin` control the rendered pixel dimensions and the quiet zone (in modules) around the code. + - Optional branding logo via `logo-src`, with `logo-size` and `logo-margin` controlling how much of the code's safe, scannable area it covers; the error correction level is raised automatically to accommodate it unless explicitly set. + - `dot-style` and `square-style` customize the shape of the data modules and finder-pattern corners (`square`, `circle`, `rounded`). + - Themable via the `--ig-qr-code-background`, `--ig-qr-code-dark-color`, `--ig-qr-code-corner-square-color`, and `--ig-qr-code-corner-dot-color` CSS custom properties, and exposes `background`, `dots`, `corner-square`, and `corner-dot` CSS parts. - #### Virtual Scroll - Added the new `igc-virtual-scroll` component. It efficiently renders large or unbounded lists by only rendering the items currently within the viewport, plus a configurable `overScan`. [#2222](https://github.com/IgniteUI/igniteui-webcomponents/pull/2222) - Supports both `vertical` and `horizontal` orientation, including RTL layouts. - - Item sizes may be fixed, estimated via `estimatedItemSize`, or fully variable — each rendered item is measured automatically and its estimate is corrected on the fly, without requiring any manual intervention. + - Item sizes may be fixed, estimated via `estimatedItemSize`, or fully variable - each rendered item is measured automatically and its estimate is corrected on the fly, without requiring any manual intervention. - Added `scrollToIndex()` for programmatically scrolling to a given item, with configurable alignment (`block`/`inline`) and scroll `behavior` (`auto`/`smooth`). The target offset is automatically corrected as previously unmeasured items around it get measured, so the requested item ends up precisely aligned even when it wasn't previously rendered. - Added the `igcDataRequest` event, emitted when the scroll position approaches the end of the currently loaded data, to support infinite-scroll / remote data loading scenarios. - Added the `igcStateChange` event, emitted after each render pass with a snapshot of the current virtual window (`startIndex`, `endIndex`, `viewportSize`, `totalSize`). - - Added the `layoutComplete` property — a promise that resolves once the current render pass, and any follow-up renders triggered by item measurement, have fully settled. + - Added the `layoutComplete` property - a promise that resolves once the current render pass, and any follow-up renders triggered by item measurement, have fully settled. - Transparently degrades past the maximum scroll coordinate supported by the browser, so lists far larger than the DOM would normally allow keep scrolling and rendering correctly. ### Changed diff --git a/package-lock.json b/package-lock.json index 8069d7a4a..bb37cbc4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", "igniteui-i18n-resources": "^1.0.5", - "igniteui-theming": "^27.4.0", + "igniteui-theming": "^28.0.0-beta.1", "keep-a-changelog": "^3.0.5", "lint-staged": "^17.2.0", "lit-analyzer": "^2.0.3", @@ -7888,9 +7888,9 @@ } }, "node_modules/igniteui-theming": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-27.4.0.tgz", - "integrity": "sha512-0lxn6jDcPKdEtewxx/q+F0MBGhlfXCx1nzfFRS55GmfcYAexNwAjfjS2MlE+Tc/aN33spDA9GkP6cQNI2s+BdA==", + "version": "28.0.0-beta.1", + "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-28.0.0-beta.1.tgz", + "integrity": "sha512-2Cd9h7uIBQPnDgBK+WNAltRVCJo/1K2D8byScgzqNQU2ekvd9zdgpa4Pycf7ygsmBR6c8pCMK9IjJDZL5nHx8Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4c3165bb4..4dfebaf47 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", "igniteui-i18n-resources": "^1.0.5", - "igniteui-theming": "^27.4.0", + "igniteui-theming": "^28.0.0-beta.1", "keep-a-changelog": "^3.0.5", "lint-staged": "^17.2.0", "lit-analyzer": "^2.0.3", diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 7e6a0a027..216a86fcf 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -643,6 +643,10 @@ function fillPool(bytes: number): void { poolOffset += bytes; } +/** + * Generates a unique string ID of the specified size using a URL-friendly alphabet. + * The default size is 21 characters, which provides a very low probability of collisions. + */ export function nanoid(size = 21): string { const bytes = size | 0; fillPool(bytes); diff --git a/src/components/qr-code/model/encode.ts b/src/components/qr-code/model/encode.ts new file mode 100644 index 000000000..24a28b215 --- /dev/null +++ b/src/components/qr-code/model/encode.ts @@ -0,0 +1,229 @@ +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<string, number>( + [...'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<QrEncodingMode, number> = { + 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; + } + } + + // encodeData() always produces a non-empty bit sequence, so an empty + // `bits` here means no version (1-40) could fit the data. + if (bits.length === 0) { + throw new Error( + `Data too long to fit in any QR version (1-40) at error correction level '${ecLevel}'` + ); + } + } + + 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..ccefd889d --- /dev/null +++ b/src/components/qr-code/model/error-correction.ts @@ -0,0 +1,1187 @@ +const EXP_TABLE: number[] = new Array(512); +const LOG_TABLE: number[] = new Array(256); +const POLYNOMIALS = new Map<number, number[]>(); + +(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; +} + +/** 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<QrErrorCorrectionLevel, number> = { + 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..4b4a35013 --- /dev/null +++ b/src/components/qr-code/model/qr-model.spec.ts @@ -0,0 +1,454 @@ +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(); + }); + + it('throws when data does not fit in any auto-selected version', () => { + // Byte mode at 'H' tops out at 1273 data bytes for version 40. + expect(() => encodeQR('a'.repeat(2000), 'H')).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<typeof generateQRCodeMatrix>; + + 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..a375932b4 --- /dev/null +++ b/src/components/qr-code/qr-code.spec.ts @@ -0,0 +1,603 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { configureTheme } from '../../theming/config.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { asNumber } from '../common/util.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`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + await expect(el).shadowDom.to.be.accessible(); + }); + + it('renders an SVG with a <title> 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('Theming', () => { + function getPart(el: IgcQrCodeComponent, part: string): SVGElement | null { + return el.renderRoot.querySelector(`[part~="${part}"]`); + } + + function fillOf(el: SVGElement | null): string { + return getComputedStyle(el as unknown as Element).fill; + } + + it('exposes background, dots, corner-square and corner-dot parts', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + + expect(getPart(el, 'background')).to.exist; + expect(getPart(el, 'dots')).to.exist; + expect( + el.renderRoot.querySelectorAll('[part~="corner-square"]').length + ).to.equal(3); + expect( + el.renderRoot.querySelectorAll('[part~="corner-dot"]').length + ).to.equal(3); + }); + + it('defaults to the schema colors (white background, black modules/corners)', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + + expect(fillOf(getPart(el, 'background'))).to.equal('rgb(255, 255, 255)'); + expect(fillOf(getPart(el, 'dots'))).to.equal('rgb(0, 0, 0)'); + expect(fillOf(getPart(el, 'corner-square'))).to.equal('rgb(0, 0, 0)'); + expect(fillOf(getPart(el, 'corner-dot'))).to.equal('rgb(0, 0, 0)'); + }); + + it('overrides colors via the documented CSS custom properties', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + value="https://example.com" + style="--ig-qr-code-background: rgb(232, 244, 255); --ig-qr-code-dark-color: rgb(0, 102, 204);" + ></igc-qr-code>` + ); + + expect(fillOf(getPart(el, 'background'))).to.equal('rgb(232, 244, 255)'); + expect(fillOf(getPart(el, 'dots'))).to.equal('rgb(0, 102, 204)'); + }); + + it('cascades --ig-qr-code-dark-color to corner-square and corner-dot when unset', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + value="https://example.com" + style="--ig-qr-code-dark-color: rgb(204, 51, 0);" + ></igc-qr-code>` + ); + + expect(fillOf(getPart(el, 'corner-square'))).to.equal('rgb(204, 51, 0)'); + expect(fillOf(getPart(el, 'corner-dot'))).to.equal('rgb(204, 51, 0)'); + }); + + it('lets an explicit corner-dot override win over the dark-color cascade', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + value="https://example.com" + style="--ig-qr-code-dark-color: rgb(204, 51, 0); --ig-qr-code-corner-dot-color: rgb(0, 0, 255);" + ></igc-qr-code>` + ); + + expect(fillOf(getPart(el, 'corner-square'))).to.equal('rgb(204, 51, 0)'); + expect(fillOf(getPart(el, 'corner-dot'))).to.equal('rgb(0, 0, 255)'); + }); + + it('does not invert colors when the global theme switches to dark variant', async () => { + configureTheme('material', 'dark'); + + try { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="https://example.com"></igc-qr-code>` + ); + await elementUpdated(el); + + expect(fillOf(getPart(el, 'background'))).to.equal( + 'rgb(255, 255, 255)' + ); + expect(fillOf(getPart(el, 'dots'))).to.equal('rgb(0, 0, 0)'); + expect(fillOf(getPart(el, 'corner-square'))).to.equal('rgb(0, 0, 0)'); + expect(fillOf(getPart(el, 'corner-dot'))).to.equal('rgb(0, 0, 0)'); + } finally { + configureTheme('material', 'light'); + } + }); + }); + + 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); + }); + } + }); + + describe('Logo', () => { + // Minimal 1×1 transparent PNG — loads synchronously in all browsers. + const VALID_LOGO = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + describe('Default property values', () => { + let el: IgcQrCodeComponent; + + beforeEach(async () => { + el = await fixture(html`<igc-qr-code></igc-qr-code>`); + }); + + it('logoSrc defaults to undefined', () => { + expect(el.logoSrc).to.be.undefined; + }); + + it('logoSize defaults to 0.4', () => { + expect(el.logoSize).to.equal(0.4); + }); + + it('logoMargin defaults to undefined', () => { + expect(el.logoMargin).to.be.undefined; + }); + }); + + describe('Attribute reflection', () => { + it('reflects logo-src to logoSrc', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + logo-src="https://example.com/logo.png" + ></igc-qr-code>` + ); + expect(el.logoSrc).to.equal('https://example.com/logo.png'); + }); + + it('reflects logo-size to logoSize', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code logo-size="0.3"></igc-qr-code>` + ); + expect(el.logoSize).to.equal(0.3); + }); + + it('reflects logo-margin to logoMargin', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code logo-margin="4"></igc-qr-code>` + ); + expect(el.logoMargin).to.equal(4); + }); + }); + + describe('URL validation', () => { + const UNSAFE_URLS = [ + 'javascript:alert(1)', + 'JAVASCRIPT:alert(1)', + 'vbscript:msgbox(1)', + 'VBSCRIPT:msgbox(1)', + 'data:text/html,<h1>hi</h1>', + 'data:application/javascript,alert(1)', + ]; + + for (const url of UNSAFE_URLS) { + it(`blocks unsafe logo URL: "${url.slice(0, 40)}"`, async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = url; + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.be.null; + }); + } + + it('allows a data:image/ URI', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.exist; + }); + + it('allows a regular https:// URL', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + value="test" + logo-src="https://example.com/logo.png" + ></igc-qr-code>` + ); + expect(el.renderRoot.querySelector('image')).to.exist; + }); + }); + + describe('Load errors', () => { + // Well-formed data URI, but not a decodable image. + const BROKEN_LOGO = 'data:image/png;base64,not-a-real-png'; + + it('falls back to no logo when the image fails to load', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" logo-src=${BROKEN_LOGO}></igc-qr-code>` + ); + + // Wait for the Image's load/error event to settle. + await new Promise((resolve) => setTimeout(resolve, 50)); + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.be.null; + expect(el.renderRoot.querySelector('mask')).to.be.null; + }); + + it('recovers once logoSrc is changed to a valid image', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" logo-src=${BROKEN_LOGO}></igc-qr-code>` + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + await elementUpdated(el); + + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.exist; + }); + }); + + describe('SVG structure', () => { + it('renders no <image> or <mask> when logoSrc is not set', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + expect(el.renderRoot.querySelector('image')).to.be.null; + expect(el.renderRoot.querySelector('defs')).to.be.null; + }); + + it('renders an <image> and a <mask> when a valid logo is provided', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.exist; + expect(el.renderRoot.querySelector('mask')).to.exist; + }); + + it('renders the <image> outside the masked <g>', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + const imageEl = el.renderRoot.querySelector('image')!; + expect(imageEl.closest('g')).to.be.null; + }); + + it('the <g> containing dots and finders has the mask applied', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + const maskId = el.renderRoot.querySelector('mask')!.getAttribute('id'); + const maskedGroup = el.renderRoot.querySelector('g[mask]')!; + expect(maskedGroup.getAttribute('mask')).to.equal(`url(#${maskId})`); + }); + + it('the mask ID is stable across re-renders', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + const idBefore = el.renderRoot + .querySelector('mask')! + .getAttribute('id'); + + el.value = 'updated value'; + await elementUpdated(el); + + const idAfter = el.renderRoot.querySelector('mask')!.getAttribute('id'); + expect(idBefore).to.equal(idAfter); + }); + + it('removes <image> and <mask> when logoSrc is cleared', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + el.logoSrc = undefined; + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.be.null; + expect(el.renderRoot.querySelector('defs')).to.be.null; + }); + + it('removes <image> and <mask> when logoSrc is replaced with an unsafe URL', async () => { + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + el.logoSrc = 'javascript:alert(1)'; + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.be.null; + expect(el.renderRoot.querySelector('defs')).to.be.null; + }); + }); + + describe('Error correction level', () => { + it('a higher error correction level produces a larger QR code', async () => { + // "testtest" (8 bytes) fits in V1-L (19 codewords) but requires V2 for H (9 codewords). + const elH = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="testtest" error-level="H"></igc-qr-code>` + ); + const elL = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="testtest" error-level="L"></igc-qr-code>` + ); + + const viewBoxH = getSvg(elH)!.getAttribute('viewBox')!.split(' '); + const viewBoxL = getSvg(elL)!.getAttribute('viewBox')!.split(' '); + + expect(Number(viewBoxH[2])).to.be.greaterThan(Number(viewBoxL[2])); + }); + + it('caps the logo area to the safe area of the chosen EC level', async () => { + // logo-size="1" — requests max area; L caps at 2.25%, H at 9% + const elL = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + value="test" + error-level="L" + logo-size="1" + ></igc-qr-code>` + ); + const elH = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code + value="test" + error-level="H" + logo-size="1" + ></igc-qr-code>` + ); + + elL.logoSrc = VALID_LOGO; + elH.logoSrc = VALID_LOGO; + await elementUpdated(elL); + await elementUpdated(elH); + + const widthL = asNumber( + elL.renderRoot.querySelector('image')!.getAttribute('width')! + ); + const widthH = asNumber( + elH.renderRoot.querySelector('image')!.getAttribute('width')! + ); + + expect(widthL).to.be.lessThan(widthH); + }); + }); + + describe('logoMargin', () => { + it('reduces the visible logo dimensions relative to the cleared area', async () => { + const elNoMargin = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test"></igc-qr-code>` + ); + const elWithMargin = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" logo-margin="10"></igc-qr-code>` + ); + + elNoMargin.logoSrc = VALID_LOGO; + elWithMargin.logoSrc = VALID_LOGO; + await elementUpdated(elNoMargin); + await elementUpdated(elWithMargin); + + const widthNoMargin = asNumber( + elNoMargin.renderRoot.querySelector('image')!.getAttribute('width')! + ); + const widthWithMargin = asNumber( + elWithMargin.renderRoot.querySelector('image')!.getAttribute('width')! + ); + + expect(widthWithMargin).to.be.lessThan(widthNoMargin); + }); + + it('does not render an image when logoMargin consumes the entire logo box', async () => { + // With logo-size="0" the box collapses to zero; no image should render. + const el = await fixture<IgcQrCodeComponent>( + html`<igc-qr-code value="test" logo-size="0"></igc-qr-code>` + ); + el.logoSrc = VALID_LOGO; + await elementUpdated(el); + + expect(el.renderRoot.querySelector('image')).to.be.null; + }); + }); + }); +}); diff --git a/src/components/qr-code/qr-code.ts b/src/components/qr-code/qr-code.ts new file mode 100644 index 000000000..45ea12c00 --- /dev/null +++ b/src/components/qr-code/qr-code.ts @@ -0,0 +1,354 @@ +import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { addThemingController } from '../../theming/theming-controller.js'; +import { createAbortHandle } from '../common/abort-handler.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { bindIf, clamp, nanoid } from '../common/util.js'; +import type { QRCodeMatrixResult } from './model/matrix.js'; +import { generateQRCodeMatrix } from './model/matrix.js'; +import { + DEFAULT_SIZE_RATIO, + MAX_SAFE_AREA, + SAFE_AREAS, +} from './renderer/constants.js'; +import { renderQrFinders } from './renderer/corner.js'; +import { renderQrDots } from './renderer/dots.js'; +import { renderQrMaskAndImage } from './renderer/image.js'; +import { styles } from './themes/qr-code.base.css.js'; +import { styles as shared } from './themes/shared/qr-code.common.css.js'; +import { all } from './themes/themes.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 --ig-qr-code-background - The background color of the QR code. Default is `white`. + * @cssproperty --ig-qr-code-dark-color - The color of the data modules (dots), and the corner square/dot colors unless overridden below. Default is `black`. + * @cssproperty --ig-qr-code-corner-square-color - The color of the outer finder-pattern corner squares. Defaults to `--ig-qr-code-dark-color`. + * @cssproperty --ig-qr-code-corner-dot-color - The color of the inner finder-pattern corner dots. Defaults to `--ig-qr-code-dark-color`. + * + * @csspart background - The background rect of the QR code. + * @csspart dots - The data modules (dots) of the QR code. + * @csspart corner-square - The outer corner (finder-pattern) squares of the QR code. + * @csspart corner-dot - The inner corner (finder-pattern) dots of the QR code. + */ +export default class IgcQrCodeComponent extends LitElement { + public static readonly tagName = 'igc-qr-code'; + + public static override styles = [styles, shared]; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcQrCodeComponent); + } + + private readonly _abortHandle = createAbortHandle(); + private readonly _maskId = nanoid(8); + private readonly _maskUrl = `url(#${this._maskId})`; + private _matrixCache?: { + value: string; + errorLevel: QrErrorCorrectionLevel; + version?: number; + result: QRCodeMatrixResult; + }; + + @state() + private _logoAspectRatio = 1; + + @state() + private _logoLoadFailed = false; + + constructor() { + super(); + addThemingController(this, all); + } + + /** + * 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 (quiet zone) around the QR code, expressed as a number of QR code modules rather + * than pixels. This is the blank border area surrounding the code, which helps ensure that it + * can be properly scanned. + * + * @attr margin + * @default 4 + */ + @property({ type: Number }) + public margin = 4; + + /** + * The source URL of an optional logo image to be displayed at the center of the QR code. The logo can help with branding and recognition. + * If provided, the component will attempt to render the logo within the QR code while maintaining scannability. + * + * @attr logo-src + */ + @property({ attribute: 'logo-src' }) + public logoSrc?: string; + + /** + * The size of the logo, as a ratio of the maximum area that can safely be obscured by a logo + * while the QR code remains scannable (up to 9% of the code's area, at the highest error + * correction level). The value should be a number between 0 and 1, where 0 means no logo and 1 + * means the logo will cover the full safe area (not the entire QR code). + * The default value is 0.4, meaning the logo covers 40% of that safe area (~3.6% of the QR code). + * + * When `error-level` is not explicitly set, the smallest error correction level that can + * accommodate the requested logo size is chosen automatically. + * + * @attr logo-size + * @default 0.4 + */ + @property({ type: Number, attribute: 'logo-size' }) + public logoSize = 0.4; + + /** + * The margin around the logo in pixels. This is the whitespace area surrounding the logo within the QR code, + * which helps ensure that the logo does not interfere with the QR code's scannability. + * + * @attr logo-margin + */ + @property({ type: Number, attribute: 'logo-margin' }) + public logoMargin?: number; + + /** + * The style of the data modules (dots) in the QR code, and of the inner dot of each finder-pattern + * corner. 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'; + + /** @internal */ + protected override update(props: PropertyValues<this>): void { + if (props.has('logoSrc')) { + this._resolveAspectRatio(); + } + + super.update(props); + } + + private _resolveAspectRatio(): void { + this._abortHandle.abort(); + this._logoLoadFailed = false; + this._logoAspectRatio = 1; + + if (!this._hasValidLogoSrc()) { + return; + } + + const signal = this._abortHandle.signal; + const img = new Image(); + img.src = this.logoSrc!; + + if (img.complete) { + if (img.naturalWidth && img.naturalHeight) { + this._logoAspectRatio = img.naturalWidth / img.naturalHeight; + } else { + this._logoLoadFailed = true; + } + return; + } + + img.addEventListener( + 'load', + () => { + if (img.naturalWidth && img.naturalHeight) { + this._logoAspectRatio = img.naturalWidth / img.naturalHeight; + } else { + this._logoLoadFailed = true; + } + }, + { once: true, signal } + ); + + img.addEventListener( + 'error', + () => { + this._logoLoadFailed = true; + }, + { once: true, signal } + ); + } + + /** + * Determines whether a valid logo source is provided. + * + * The method checks if the `logoSrc` property is set and if it does not start with potentially unsafe schemes like 'javascript:' or 'vbscript:'. + * It also ensures that if the source is a data URI, it must be an image type. + * This validation helps prevent security risks associated with rendering untrusted content in the QR code. + */ + private _hasValidLogoSrc(): boolean { + if (!this.logoSrc) return false; + const s = this.logoSrc.trim().toLowerCase(); + if (s.startsWith('javascript:') || s.startsWith('vbscript:')) return false; + if (s.startsWith('data:') && !s.startsWith('data:image/')) return false; + return true; + } + + private _pickErrorLevel(area: number): QrErrorCorrectionLevel { + if (area <= SAFE_AREAS.L) return 'L'; + if (area <= SAFE_AREAS.M) return 'M'; + if (area <= SAFE_AREAS.Q) return 'Q'; + return 'H'; + } + + private _getErrorLevelAndArea(hasLogo: boolean) { + const userErrorLevel = this.errorLevel; + const size = this.logoSize; + const sizeRatio = hasLogo ? clamp(size ?? DEFAULT_SIZE_RATIO, 0, 1) : 0; + const targetArea = sizeRatio * MAX_SAFE_AREA; + + let errorLevel: QrErrorCorrectionLevel; + let area: number; + + if (userErrorLevel) { + errorLevel = userErrorLevel; + area = Math.min(targetArea, SAFE_AREAS[userErrorLevel]); + } else if (targetArea > 0) { + errorLevel = this._pickErrorLevel(targetArea); + area = targetArea; + } else { + errorLevel = 'M'; + area = 0; + } + + return { errorLevel, area }; + } + + private _getMatrix( + value: string, + errorLevel: QrErrorCorrectionLevel + ): QRCodeMatrixResult { + const cached = this._matrixCache; + if ( + cached && + cached.value === value && + cached.errorLevel === errorLevel && + cached.version === this.version + ) { + return cached.result; + } + + const result = generateQRCodeMatrix(value, errorLevel, this.version); + this._matrixCache = { value, errorLevel, version: this.version, result }; + return result; + } + + protected override render() { + if (!this.value) return nothing; + + const hasLogo = this._hasValidLogoSrc() && !this._logoLoadFailed; + const { errorLevel, area } = this._getErrorLevelAndArea(hasLogo); + + const { matrix, size } = this._getMatrix(this.value, errorLevel); + + const totalModules = size + this.margin * 2; + const moduleSize = size / totalModules; + const marginPx = this.margin * moduleSize; + const svgSize = moduleSize * (size + this.margin * 2); + + const { mask, image, shouldApplyMask } = renderQrMaskAndImage({ + hasLogo, + src: this.logoSrc!, + aspectRatio: this._logoAspectRatio, + area, + size: this.size, + margin: this.logoMargin, + svgSize, + maskId: this._maskId, + }); + + 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}`} + + + ${mask} + + ${renderQrDots({ + matrix, + moduleSize, + marginPx, + dotStyle: this.dotStyle, + })} + ${renderQrFinders({ + size, + moduleSize, + marginPx, + dotStyle: this.dotStyle, + squareStyle: this.squareStyle, + })} + + ${image} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-qr-code': IgcQrCodeComponent; + } +} diff --git a/src/components/qr-code/renderer/constants.ts b/src/components/qr-code/renderer/constants.ts new file mode 100644 index 000000000..8b6ac3d51 --- /dev/null +++ b/src/components/qr-code/renderer/constants.ts @@ -0,0 +1,24 @@ +/** + * Safe area ratios for different error correction levels in QR codes. + * These values represent the maximum proportion of the QR code area that can be obscured (e.g., by a logo) while still maintaining scannability. + * + * - `L` (Low) allows for up to 2.25% of the area to be obscured. + * - `M` (Medium) allows for up to 4% of the area to be obscured. + * - `Q` (Quartile) allows for up to 6.25% of the area to be obscured. + * - `H` (High) allows for up to 9% of the area to be obscured. + */ +const SAFE_AREAS = { L: 0.0225, M: 0.04, Q: 0.0625, H: 0.09 } as const; + +/** + * Maximum safe area for a logo in the QR code, based on the highest error correction level (H). + * This means that if the logo covers more than 9% of the QR code area, it may not be scannable. + */ +const MAX_SAFE_AREA = SAFE_AREAS.H; + +/** + * Default size ratio for the logo relative to the QR code. This means that by default, the logo will occupy 40% of the QR code area. + * This is a conservative default that allows for a reasonably sized logo while maintaining good scannability. + */ +const DEFAULT_SIZE_RATIO = 0.4; + +export { DEFAULT_SIZE_RATIO, MAX_SAFE_AREA, SAFE_AREAS }; diff --git a/src/components/qr-code/renderer/corner.ts b/src/components/qr-code/renderer/corner.ts new file mode 100644 index 000000000..1f758af0d --- /dev/null +++ b/src/components/qr-code/renderer/corner.ts @@ -0,0 +1,72 @@ +import { svg, type TemplateResult } from 'lit'; +import type { QrCornerDotStyle, QrCornerSquareStyle } from '../types.js'; +import { + cornerDotPath, + cornerSquarePath, + getFinderPatterns, +} from './helpers.js'; + +type QrCornerProperties = { + x: number; + y: number; + size: number; + dotStyle: QrCornerDotStyle; + squareStyle: QrCornerSquareStyle; +}; + +/** 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` + + + + + `; +} + +type RenderFindersProperties = { + size: number; + moduleSize: number; + marginPx: number; + dotStyle: QrCornerDotStyle; + squareStyle: QrCornerSquareStyle; +}; + +/** + * Renders all three finder-pattern corners as an array of Lit SVG templates, given the QR code size, module size, margin, and styles. + */ +export function renderQrFinders({ + size, + moduleSize, + marginPx, + dotStyle, + squareStyle, +}: RenderFindersProperties): TemplateResult[] { + return getFinderPatterns(size, moduleSize, marginPx).map(({ x, y }) => + renderQrCorner({ + x, + y, + size: moduleSize, + dotStyle, + squareStyle, + }) + ); +} diff --git a/src/components/qr-code/renderer/dots.ts b/src/components/qr-code/renderer/dots.ts new file mode 100644 index 000000000..d8636699e --- /dev/null +++ b/src/components/qr-code/renderer/dots.ts @@ -0,0 +1,24 @@ +import { svg, type TemplateResult } from 'lit'; +import type { QrDotStyle } from '../types.js'; +import { renderDataModules } from './helpers.js'; + +type RenderQrDotsProperties = { + matrix: boolean[][]; + moduleSize: number; + marginPx: number; + dotStyle: QrDotStyle; +}; + +/** + * Renders the data modules of the QR code as a single SVG path, given the QR code matrix, module size, margin, and dot style. + * Returns a Lit SVG template. + */ +export function renderQrDots({ + matrix, + moduleSize, + marginPx, + dotStyle, +}: RenderQrDotsProperties): TemplateResult { + const modules = renderDataModules(matrix, moduleSize, marginPx, dotStyle); + return svg``; +} diff --git a/src/components/qr-code/renderer/helpers.ts b/src/components/qr-code/renderer/helpers.ts new file mode 100644 index 000000000..a0192236a --- /dev/null +++ b/src/components/qr-code/renderer/helpers.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. */ +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. */ +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. + */ +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/renderer/image.ts b/src/components/qr-code/renderer/image.ts new file mode 100644 index 000000000..35508514e --- /dev/null +++ b/src/components/qr-code/renderer/image.ts @@ -0,0 +1,61 @@ +import { nothing, svg } from 'lit'; + +type RenderQrMaskAndImageProperties = { + hasLogo: boolean; + src: string; + aspectRatio: number; + area: number; + size: number; + margin?: number; + svgSize: number; + maskId: string; +}; + +/** + * Renders an SVG mask and image element for the QR code's logo, based on the presence of a logo, + * its source, aspect ratio, desired area, QR code size, margin, overall SVG size, and a unique mask ID. + * + * The mask is a white rectangle with a black cutout where the logo will be placed, ensuring the QR code + * remains scannable while accommodating the logo. + */ +export function renderQrMaskAndImage({ + hasLogo, + src, + aspectRatio, + area, + size, + margin, + svgSize, + maskId, +}: RenderQrMaskAndImageProperties) { + const clamped = area * svgSize * svgSize; + const boxWidth = Math.sqrt(clamped * aspectRatio); + const boxHeight = Math.sqrt(clamped / aspectRatio); + const boxMargin = (margin ?? 0) * (svgSize / size); + const boxX = (svgSize - boxWidth) / 2; + const boxY = (svgSize - boxHeight) / 2; + const x = boxX + boxMargin; + const y = boxY + boxMargin; + const width = Math.max(0, boxWidth - boxMargin * 2); + const height = Math.max(0, boxHeight - boxMargin * 2); + + const shouldApplyMask = hasLogo && width > 0 && height > 0; + + const mask = shouldApplyMask + ? svg` + + + + + + + ` + : nothing; + + const image = + hasLogo && width > 0 && height > 0 + ? svg`` + : nothing; + + return { mask, image, shouldApplyMask }; +} diff --git a/src/components/qr-code/themes/dark/_themes.scss b/src/components/qr-code/themes/dark/_themes.scss new file mode 100644 index 000000000..6287424d6 --- /dev/null +++ b/src/components/qr-code/themes/dark/_themes.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/dark/qr-code' as *; + +$material: digest-schema($dark-material-qr-code); +$bootstrap: digest-schema($dark-bootstrap-qr-code); +$fluent: digest-schema($dark-fluent-qr-code); +$indigo: digest-schema($dark-indigo-qr-code); diff --git a/src/components/qr-code/themes/dark/qr-code.bootstrap.scss b/src/components/qr-code/themes/dark/qr-code.bootstrap.scss new file mode 100644 index 000000000..68cc3c7bb --- /dev/null +++ b/src/components/qr-code/themes/dark/qr-code.bootstrap.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/qr-code/themes/dark/qr-code.fluent.scss b/src/components/qr-code/themes/dark/qr-code.fluent.scss new file mode 100644 index 000000000..679ee2e59 --- /dev/null +++ b/src/components/qr-code/themes/dark/qr-code.fluent.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/qr-code/themes/dark/qr-code.indigo.scss b/src/components/qr-code/themes/dark/qr-code.indigo.scss new file mode 100644 index 000000000..5c1ebeb56 --- /dev/null +++ b/src/components/qr-code/themes/dark/qr-code.indigo.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/qr-code/themes/dark/qr-code.material.scss b/src/components/qr-code/themes/dark/qr-code.material.scss new file mode 100644 index 000000000..0dddc23d0 --- /dev/null +++ b/src/components/qr-code/themes/dark/qr-code.material.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/qr-code/themes/light/_themes.scss b/src/components/qr-code/themes/light/_themes.scss new file mode 100644 index 000000000..077d52b7b --- /dev/null +++ b/src/components/qr-code/themes/light/_themes.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/light/qr-code' as *; + +$base: digest-schema($light-qr-code); +$material: digest-schema($material-qr-code); +$bootstrap: digest-schema($bootstrap-qr-code); +$fluent: digest-schema($fluent-qr-code); +$indigo: digest-schema($indigo-qr-code); diff --git a/src/components/qr-code/themes/light/qr-code.bootstrap.scss b/src/components/qr-code/themes/light/qr-code.bootstrap.scss new file mode 100644 index 000000000..8bade7490 --- /dev/null +++ b/src/components/qr-code/themes/light/qr-code.bootstrap.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/qr-code/themes/light/qr-code.fluent.scss b/src/components/qr-code/themes/light/qr-code.fluent.scss new file mode 100644 index 000000000..4d87074b4 --- /dev/null +++ b/src/components/qr-code/themes/light/qr-code.fluent.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/qr-code/themes/light/qr-code.indigo.scss b/src/components/qr-code/themes/light/qr-code.indigo.scss new file mode 100644 index 000000000..641839724 --- /dev/null +++ b/src/components/qr-code/themes/light/qr-code.indigo.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/qr-code/themes/light/qr-code.material.scss b/src/components/qr-code/themes/light/qr-code.material.scss new file mode 100644 index 000000000..3271cfef8 --- /dev/null +++ b/src/components/qr-code/themes/light/qr-code.material.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/qr-code/themes/light/qr-code.shared.scss b/src/components/qr-code/themes/light/qr-code.shared.scss new file mode 100644 index 000000000..4a7e8438a --- /dev/null +++ b/src/components/qr-code/themes/light/qr-code.shared.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $base; + +:host { + @include css-vars-from-theme($theme); +} diff --git a/src/components/qr-code/themes/qr-code.base.scss b/src/components/qr-code/themes/qr-code.base.scss new file mode 100644 index 000000000..76a67a347 --- /dev/null +++ b/src/components/qr-code/themes/qr-code.base.scss @@ -0,0 +1,7 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: inline-block; + contain: content; +} diff --git a/src/components/qr-code/themes/shared/qr-code.common.scss b/src/components/qr-code/themes/shared/qr-code.common.scss new file mode 100644 index 000000000..223339f4a --- /dev/null +++ b/src/components/qr-code/themes/shared/qr-code.common.scss @@ -0,0 +1,20 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $base; + +[part='background'] { + fill: var-get($theme, 'background'); +} + +[part='dots'] { + fill: var-get($theme, 'dark-color'); +} + +[part='corner-square'] { + fill: var-get($theme, 'corner-square-color'); +} + +[part='corner-dot'] { + fill: var-get($theme, 'corner-dot-color'); +} diff --git a/src/components/qr-code/themes/themes.ts b/src/components/qr-code/themes/themes.ts new file mode 100644 index 000000000..465a1565b --- /dev/null +++ b/src/components/qr-code/themes/themes.ts @@ -0,0 +1,52 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; +// Dark Overrides +import { styles as bootstrapDark } from './dark/qr-code.bootstrap.css.js'; +import { styles as fluentDark } from './dark/qr-code.fluent.css.js'; +import { styles as indigoDark } from './dark/qr-code.indigo.css.js'; +import { styles as materialDark } from './dark/qr-code.material.css.js'; +// Light Overrides +import { styles as bootstrapLight } from './light/qr-code.bootstrap.css.js'; +import { styles as fluentLight } from './light/qr-code.fluent.css.js'; +import { styles as indigoLight } from './light/qr-code.indigo.css.js'; +import { styles as materialLight } from './light/qr-code.material.css.js'; +import { styles as shared } from './light/qr-code.shared.css.js'; + +const light = { + shared: css` + ${shared} + `, + material: css` + ${materialLight} + `, + bootstrap: css` + ${bootstrapLight} + `, + fluent: css` + ${fluentLight} + `, + indigo: css` + ${indigoLight} + `, +}; + +const dark = { + shared: css` + ${shared} + `, + material: css` + ${materialDark} + `, + bootstrap: css` + ${bootstrapDark} + `, + fluent: css` + ${fluentDark} + `, + indigo: css` + ${indigoDark} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/src/components/qr-code/types.ts b/src/components/qr-code/types.ts new file mode 100644 index 000000000..7e268ed13 --- /dev/null +++ b/src/components/qr-code/types.ts @@ -0,0 +1,20 @@ +/** 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'; diff --git a/src/index.ts b/src/index.ts index 528b319e4..f09018ca6 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'; export { default as IgcVirtualScrollComponent } from './components/virtualization/virtualization.js'; export type { IgcVirtualScrollComponentEventMap, diff --git a/stories/qr-code.stories.ts b/stories/qr-code.stories.ts new file mode 100644 index 000000000..eb5ebf0e1 --- /dev/null +++ b/stories/qr-code.stories.ts @@ -0,0 +1,538 @@ +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 (quiet zone) around the QR code, expressed as a number of QR code modules rather\nthan pixels. This is the blank border area surrounding the code, which helps ensure that it\ncan be properly scanned.', + control: 'number', + table: { defaultValue: { summary: '4' } }, + }, + logoSrc: { + type: 'string', + description: + 'The source URL of an optional logo image to be displayed at the center of the QR code. The logo can help with branding and recognition.\nIf provided, the component will attempt to render the logo within the QR code while maintaining scannability.', + control: 'text', + }, + logoSize: { + type: 'number', + description: + "The size of the logo, as a ratio of the maximum area that can safely be obscured by a logo\nwhile the QR code remains scannable (up to 9% of the code's area, at the highest error\ncorrection level). The value should be a number between 0 and 1, where 0 means no logo and 1\nmeans the logo will cover the full safe area (not the entire QR code).\nThe default value is 0.4, meaning the logo covers 40% of that safe area (~3.6% of the QR code).\n\nWhen `error-level` is not explicitly set, the smallest error correction level that can\naccommodate the requested logo size is chosen automatically.", + control: 'number', + table: { defaultValue: { summary: '0.4' } }, + }, + logoMargin: { + type: 'number', + description: + "The margin around the logo in pixels. This is the whitespace area surrounding the logo within the QR code,\nwhich helps ensure that the logo does not interfere with the QR code's scannability.", + control: 'number', + }, + dotStyle: { + type: '"square" | "circle" | "rounded"', + description: + "The style of the data modules (dots) in the QR code, and of the inner dot of each finder-pattern\ncorner. 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, + logoSize: 0.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 (quiet zone) around the QR code, expressed as a number of QR code modules rather + * than pixels. This is the blank border area surrounding the code, which helps ensure that it + * can be properly scanned. + */ + margin: number; + /** + * The source URL of an optional logo image to be displayed at the center of the QR code. The logo can help with branding and recognition. + * If provided, the component will attempt to render the logo within the QR code while maintaining scannability. + */ + logoSrc: string; + /** + * The size of the logo, as a ratio of the maximum area that can safely be obscured by a logo + * while the QR code remains scannable (up to 9% of the code's area, at the highest error + * correction level). The value should be a number between 0 and 1, where 0 means no logo and 1 + * means the logo will cover the full safe area (not the entire QR code). + * The default value is 0.4, meaning the logo covers 40% of that safe area (~3.6% of the QR code). + * + * When `error-level` is not explicitly set, the smallest error correction level that can + * accommodate the requested logo size is chosen automatically. + */ + logoSize: number; + /** + * The margin around the logo in pixels. This is the whitespace area surrounding the logo within the QR code, + * which helps ensure that the logo does not interfere with the QR code's scannability. + */ + logoMargin: number; + /** + * The style of the data modules (dots) in the QR code, and of the inner dot of each finder-pattern + * corner. 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 WithLogo: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + JavaScript +
+ +
+ + HTML5 +
+ +
+ + CSS3 +
+
+ + Ignite UI +
+
+ `, +}; + +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 +
+
+ `, +};