From e0579f7e241505ef004f984d07accb16d2a05af6 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 12 Jun 2026 20:35:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20add=20QrCode=20=E2=80=94=20theme?= =?UTF-8?q?able=20QR=20with=20own=20zero-dep=20encoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own QR encoder in $libs/qr (ISO/IEC 18004: segment analysis → Reed-Solomon over GF(256) → matrix layout → 8-mask penalty selection), no npm dependency. Eidos renders the module matrix as one SVG path with square/rounded/dots cell shapes; function patterns stay square (via the encoder's `reserved` mask) so every shape scans. Optional logo clears the centre + auto-boosts ECC to H. exports PNG/SVG, baking resolved colours into the clone first (a serialized SVG loses the document's CSS custom properties). Fixed-tone default colours for scannability; themeable via color/background. morfo qr-code (scope: eidos; parts Provider/Pattern/Overlay/ DownloadTrigger) + 'path' added to MorfoElement; recipe with 11 --qr-code-* tokens; canonical demo + nav entry. 11 encoder unit tests; verified end-to-end via jsQR round-trip + browser. Co-Authored-By: Claude Opus 4.8 --- src/libs/qr/index.ts | 2 + src/libs/qr/qr.test.ts | 114 ++++ src/libs/qr/qr.ts | 504 ++++++++++++++++++ src/uix/eidos/components/qr-code/README.md | 116 ++++ src/uix/eidos/components/qr-code/context.ts | 17 + src/uix/eidos/components/qr-code/index.ts | 31 ++ .../qr-code/qr-code-download-trigger.svelte | 23 + src/uix/eidos/components/qr-code/qr-code.css | 31 ++ .../eidos/components/qr-code/qr-code.svelte | 156 ++++++ src/uix/eidos/components/qr-code/types.ts | 46 ++ src/uix/eidos/generated/base.css | 11 + src/uix/eidos/lib/recipes/base.ts | 17 + src/uix/morfo/components/qr-code.ts | 91 ++++ src/uix/morfo/types.ts | 1 + web/routes/uix/+layout@.svelte | 3 +- .../uix/components/qr-code/+page.svelte | 269 ++++++++++ 16 files changed, 1431 insertions(+), 1 deletion(-) create mode 100644 src/libs/qr/index.ts create mode 100644 src/libs/qr/qr.test.ts create mode 100644 src/libs/qr/qr.ts create mode 100644 src/uix/eidos/components/qr-code/README.md create mode 100644 src/uix/eidos/components/qr-code/context.ts create mode 100644 src/uix/eidos/components/qr-code/index.ts create mode 100644 src/uix/eidos/components/qr-code/qr-code-download-trigger.svelte create mode 100644 src/uix/eidos/components/qr-code/qr-code.css create mode 100644 src/uix/eidos/components/qr-code/qr-code.svelte create mode 100644 src/uix/eidos/components/qr-code/types.ts create mode 100644 src/uix/morfo/components/qr-code.ts create mode 100644 web/routes/uix/components/qr-code/+page.svelte diff --git a/src/libs/qr/index.ts b/src/libs/qr/index.ts new file mode 100644 index 000000000..fc404ac6e --- /dev/null +++ b/src/libs/qr/index.ts @@ -0,0 +1,2 @@ +export { encode } from './qr' +export type { ErrorCorrection, QrResult, EncodeOptions } from './qr' diff --git a/src/libs/qr/qr.test.ts b/src/libs/qr/qr.test.ts new file mode 100644 index 000000000..35d157e75 --- /dev/null +++ b/src/libs/qr/qr.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest' + +import { encode, type ErrorCorrection } from './qr' + +const LEVELS: ErrorCorrection[] = ['L', 'M', 'Q', 'H'] + +describe('qr encode — structure', () => { + it('version 1 short input is a 21×21 square matrix', () => { + const qr = encode('HELLO', { errorCorrection: 'M' }) + expect(qr.version).toBe(1) + expect(qr.size).toBe(21) + expect(qr.matrix.length).toBe(21) + for (const row of qr.matrix) { + expect(row.length).toBe(21) + for (const cell of row) expect(typeof cell).toBe('boolean') + } + }) + + it('draws the three finder patterns', () => { + const { matrix } = encode('HELLO', { errorCorrection: 'M' }) + // Top-left finder centred at (3,3): outer ring dark, light ring, dark centre. + expect(matrix[0][0]).toBe(true) // outer ring (dist 3) + expect(matrix[1][1]).toBe(false) // light ring (dist 2) + expect(matrix[3][3]).toBe(true) // centre (dist 0) + // Top-right + bottom-left finders present (their centres are dark). + expect(matrix[3][matrix.length - 4]).toBe(true) + expect(matrix[matrix.length - 4][3]).toBe(true) + }) + + it('draws the timing patterns (alternating on row/col 6)', () => { + const { matrix } = encode('HELLO', { errorCorrection: 'M' }) + expect(matrix[6][8]).toBe(true) // even → dark + expect(matrix[6][9]).toBe(false) // odd → light + expect(matrix[8][6]).toBe(true) + expect(matrix[9][6]).toBe(false) + }) + + it('sets the dark module at [size-8][8]', () => { + const { matrix, size } = encode('HELLO', { errorCorrection: 'M' }) + expect(matrix[size - 8][8]).toBe(true) + }) + + it('matches the known-good golden matrix (regression guard)', () => { + // Captured from this encoder and verified scannable by an independent + // decoder (jsQR) — pins the exact module layout for HELLO @ ECC M. + const golden = [ + '111111100011101111111', + '100000100001001000001', + '101110101010101011101', + '101110101001101011101', + '101110101111101011101', + '100000101100101000001', + '111111101010101111111', + '000000001001100000000', + '101111100000101111100', + '000010011100100101111', + '110011110001010011001', + '001100000100000110000', + '000011110011010010100', + '000000001011111001011', + '111111100000101100101', + '100000101111111001001', + '101110101010100100100', + '101110101000100100100', + '101110101001010010100', + '100000100010000110101', + '111111101011010010100' + ].join('\n') + const { matrix } = encode('HELLO', { errorCorrection: 'M' }) + const actual = matrix.map((row) => row.map((c) => (c ? '1' : '0')).join('')).join('\n') + expect(actual).toBe(golden) + }) +}) + +describe('qr encode — version selection + modes', () => { + it('grows the version as data grows', () => { + const small = encode('A', { errorCorrection: 'M' }) + const big = encode('A'.repeat(300), { errorCorrection: 'M' }) + expect(small.version).toBe(1) + expect(big.version).toBeGreaterThan(small.version) + expect(big.size).toBe(big.version * 4 + 17) + }) + + it('higher ECC needs a larger version for the same data', () => { + const data = 'x'.repeat(120) + const low = encode(data, { errorCorrection: 'L' }) + const high = encode(data, { errorCorrection: 'H' }) + expect(high.version).toBeGreaterThanOrEqual(low.version) + }) + + it('encodes numeric, alphanumeric, byte and unicode without throwing', () => { + for (const v of ['1234567890', 'HELLO WORLD $%*+-./:', 'https://example.com/path?x=1', 'café ☕ 你好']) { + for (const ecc of LEVELS) { + const qr = encode(v, { errorCorrection: ecc }) + expect(qr.matrix.length).toBe(qr.size) + expect(qr.size).toBe(qr.version * 4 + 17) + } + } + }) + + it('respects minVersion', () => { + const qr = encode('A', { errorCorrection: 'M', minVersion: 5 }) + expect(qr.version).toBeGreaterThanOrEqual(5) + }) + + it('throws when data exceeds the version range', () => { + expect(() => encode('A'.repeat(100), { errorCorrection: 'H', maxVersion: 1 })).toThrow() + }) + + it('empty string encodes to version 1', () => { + const qr = encode('', { errorCorrection: 'M' }) + expect(qr.version).toBe(1) + }) +}) diff --git a/src/libs/qr/qr.ts b/src/libs/qr/qr.ts new file mode 100644 index 000000000..3805bd2de --- /dev/null +++ b/src/libs/qr/qr.ts @@ -0,0 +1,504 @@ +/** + * QR code encoder — own, zero-dependency. + * + * Implements the open QR standard (ISO/IEC 18004): segment encoding → + * Reed–Solomon error correction over GF(256) → matrix layout → mask + * selection. No npm dependency — `$libs` is zero-dep. The algorithm is the + * public standard; Nayuki's reference implementation was used to cross-check + * correctness, never imported. + * + * Supports versions 1–40, all four error-correction levels, and numeric / + * alphanumeric / byte (UTF-8) modes with single-best-mode selection. + */ + +export type ErrorCorrection = 'L' | 'M' | 'Q' | 'H' + +export interface QrResult { + /** Square module grid, row-major. `true` = dark module. */ + matrix: boolean[][] + /** + * Function-pattern mask, row-major. `true` = the module is a finder / + * timing / alignment / format / version module (not styleable data). + * Renderers keep these square so scanners lock on regardless of cell shape. + */ + reserved: boolean[][] + /** Side length in modules (`21 + 4 * (version - 1)`). */ + size: number + /** Chosen QR version (1–40). */ + version: number + /** Effective error-correction level used. */ + errorCorrection: ErrorCorrection +} + +export interface EncodeOptions { + errorCorrection?: ErrorCorrection + /** Smallest version to try (1–40). @default 1 */ + minVersion?: number + /** Largest version to try (1–40). @default 40 */ + maxVersion?: number +} + +// ── Error-correction level ordering + format bits ─────────────────────────── +// Format-info 2-bit value per level (NOT the same as the L = { L: 1, M: 0, Q: 3, H: 2 } + +const MIN_VERSION = 1 +const MAX_VERSION = 40 + +// ── Per-(level, version) tables, indexed [eccOrdinal][version]. Index 0 unused. +// ECC codewords per block. +const ECC_CODEWORDS_PER_BLOCK: number[][] = [ + // L + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // M + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + // Q + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // H + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30] +] + +// Number of error-correction blocks. +const NUM_ERROR_CORRECTION_BLOCKS: number[][] = [ + // L + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + // M + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + // Q + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + // H + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81] +] + +/** Total number of data + ECC codewords (8-module words) for a version. */ +function getNumRawDataModules(version: number): number { + let result = (16 * version + 128) * version + 64 + if (version >= 2) { + const numAlign = Math.floor(version / 7) + 2 + result -= (25 * numAlign - 10) * numAlign - 55 + if (version >= 7) result -= 36 + } + return result +} + +/** Number of 8-bit data codewords (excludes ECC) for a version + level. */ +function getNumDataCodewords(version: number, ecc: ErrorCorrection): number { + const o = ECC_ORDER.indexOf(ecc) + return ( + Math.floor(getNumRawDataModules(version) / 8) - + ECC_CODEWORDS_PER_BLOCK[o][version] * NUM_ERROR_CORRECTION_BLOCKS[o][version] + ) +} + +// ── Bit buffer ────────────────────────────────────────────────────────────── +class BitBuffer { + bits: number[] = [] + append(value: number, length: number): void { + for (let i = length - 1; i >= 0; i--) this.bits.push((value >>> i) & 1) + } +} + +// ── Segment modes ─────────────────────────────────────────────────────────── +const ALPHANUMERIC = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' + +function isNumeric(text: string): boolean { + return /^[0-9]*$/.test(text) +} +function isAlphanumeric(text: string): boolean { + for (const ch of text) if (!ALPHANUMERIC.includes(ch)) return false + return true +} + +type Mode = { indicator: number; charCountBits: [number, number, number] } +const MODE_NUMERIC: Mode = { indicator: 0x1, charCountBits: [10, 12, 14] } +const MODE_ALPHANUMERIC: Mode = { indicator: 0x2, charCountBits: [9, 11, 13] } +const MODE_BYTE: Mode = { indicator: 0x4, charCountBits: [8, 16, 16] } + +function charCountBits(mode: Mode, version: number): number { + const i = version <= 9 ? 0 : version <= 26 ? 1 : 2 + return mode.charCountBits[i] +} + +interface Segment { + mode: Mode + numChars: number + data: BitBuffer +} + +function utf8Bytes(text: string): number[] { + return [...new TextEncoder().encode(text)] +} + +/** Pick the single best mode for the whole string + build its segment. */ +function makeSegment(text: string): Segment { + const buf = new BitBuffer() + if (isNumeric(text)) { + for (let i = 0; i < text.length; ) { + const n = Math.min(3, text.length - i) + buf.append(parseInt(text.substring(i, i + n), 10), n * 3 + 1) + i += n + } + return { mode: MODE_NUMERIC, numChars: text.length, data: buf } + } + if (isAlphanumeric(text)) { + let i = 0 + for (; i + 2 <= text.length; i += 2) { + buf.append(ALPHANUMERIC.indexOf(text[i]) * 45 + ALPHANUMERIC.indexOf(text[i + 1]), 11) + } + if (i < text.length) buf.append(ALPHANUMERIC.indexOf(text[i]), 6) + return { mode: MODE_ALPHANUMERIC, numChars: text.length, data: buf } + } + const bytes = utf8Bytes(text) + for (const b of bytes) buf.append(b, 8) + return { mode: MODE_BYTE, numChars: bytes.length, data: buf } +} + +// ── Reed–Solomon over GF(256), primitive 0x11D ────────────────────────────── +const GF_EXP = new Uint8Array(512) +const GF_LOG = new Uint8Array(256) +;(() => { + let x = 1 + for (let i = 0; i < 255; i++) { + GF_EXP[i] = x + GF_LOG[x] = i + x <<= 1 + if (x & 0x100) x ^= 0x11d + } + for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255] +})() + +function gfMul(a: number, b: number): number { + if (a === 0 || b === 0) return 0 + return GF_EXP[GF_LOG[a] + GF_LOG[b]] +} + +/** Generator polynomial coefficients for `degree` ECC codewords. */ +function rsGenerator(degree: number): number[] { + let poly = [1] + for (let i = 0; i < degree; i++) { + const next = new Array(poly.length + 1).fill(0) + for (let j = 0; j < poly.length; j++) { + next[j] ^= gfMul(poly[j], 1) + next[j + 1] ^= gfMul(poly[j], GF_EXP[i]) + } + poly = next + } + return poly +} + +function rsRemainder(data: number[], generator: number[]): number[] { + const result = new Array(generator.length - 1).fill(0) + for (const byte of data) { + const factor = byte ^ result[0] + result.shift() + result.push(0) + for (let j = 0; j < result.length; j++) result[j] ^= gfMul(generator[j + 1], factor) + } + return result +} + +// ── Alignment-pattern centre positions per version ────────────────────────── +function alignmentPositions(version: number): number[] { + if (version === 1) return [] + const num = Math.floor(version / 7) + 2 + const size = version * 4 + 17 + const step = version === 32 ? 26 : Math.ceil((size - 13) / (num * 2 - 2)) * 2 + const result = [6] + for (let pos = size - 7; result.length < num; pos -= step) result.unshift(pos) + return result +} + +// ── BCH error-correction for format / version info ────────────────────────── +function bch(data: number, poly: number, bits: number): number { + let rem = data + for (let i = 0; i < bits; i++) rem = (rem << 1) ^ ((rem >>> (bits - 1)) * poly) + return rem & ((1 << bits) - 1) +} + +// ── Matrix construction ───────────────────────────────────────────────────── +class Matrix { + size: number + modules: boolean[][] + reserved: boolean[][] + constructor(version: number) { + this.size = version * 4 + 17 + this.modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false)) + this.reserved = Array.from({ length: this.size }, () => new Array(this.size).fill(false)) + } + set(x: number, y: number, dark: boolean, reserve = true): void { + this.modules[y][x] = dark + if (reserve) this.reserved[y][x] = true + } + drawFunctionPatterns(version: number): void { + // Timing patterns + for (let i = 0; i < this.size; i++) { + this.set(6, i, i % 2 === 0) + this.set(i, 6, i % 2 === 0) + } + // Finder patterns + separators (3 corners) + this.drawFinder(3, 3) + this.drawFinder(this.size - 4, 3) + this.drawFinder(3, this.size - 4) + // Alignment patterns + const align = alignmentPositions(version) + for (const ay of align) { + for (const ax of align) { + if ((ax === 6 && ay === 6) || (ax === 6 && ay === this.size - 7) || (ax === this.size - 7 && ay === 6)) continue + this.drawAlignment(ax, ay) + } + } + // Dark module + this.set(8, this.size - 8, true) + // Reserve format-info areas (filled later) + this.reserveFormat() + // Version info (v7+) + if (version >= 7) this.drawVersionInfo(version) + } + private drawFinder(cx: number, cy: number): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const x = cx + dx + const y = cy + dy + if (x < 0 || x >= this.size || y < 0 || y >= this.size) continue + const dist = Math.max(Math.abs(dx), Math.abs(dy)) + this.set(x, y, dist !== 2 && dist <= 3) + } + } + } + private drawAlignment(cx: number, cy: number): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + this.set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1) + } + } + } + private reserveFormat(): void { + for (let i = 0; i < 9; i++) { + this.reserved[i][8] = true + this.reserved[8][i] = true + } + for (let i = 0; i < 8; i++) { + this.reserved[this.size - 1 - i][8] = true + this.reserved[8][this.size - 1 - i] = true + } + } + private drawVersionInfo(version: number): void { + // Version info = 6 data bits + 12 BCH bits (generator 0x1F25, degree 12). + const bits = (version << 12) | bch(version, 0x1f25, 12) + for (let i = 0; i < 18; i++) { + const dark = ((bits >>> i) & 1) === 1 + const a = Math.floor(i / 3) + const b = (i % 3) + this.size - 11 + this.set(a, b, dark) + this.set(b, a, dark) + } + } + placeData(codewords: number[]): void { + let i = 0 + for (let right = this.size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5 + for (let vert = 0; vert < this.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j + const upward = ((right + 1) & 2) === 0 + const y = upward ? this.size - 1 - vert : vert + if (this.reserved[y][x]) continue + let dark = false + if (i < codewords.length * 8) dark = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1 + this.modules[y][x] = dark + i++ + } + } + } + } + applyMask(mask: number): void { + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + if (this.reserved[y][x]) continue + let invert = false + switch (mask) { + case 0: invert = (x + y) % 2 === 0; break + case 1: invert = y % 2 === 0; break + case 2: invert = x % 3 === 0; break + case 3: invert = (x + y) % 3 === 0; break + case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break + case 5: invert = ((x * y) % 2) + ((x * y) % 3) === 0; break + case 6: invert = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; break + case 7: invert = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; break + } + if (invert) this.modules[y][x] = !this.modules[y][x] + } + } + } + drawFormatInfo(ecc: ErrorCorrection, mask: number): void { + const data = (ECC_FORMAT_BITS[ecc] << 3) | mask + const bits = ((data << 10) | bch(data, 0x537, 10)) ^ 0x5412 + for (let i = 0; i < 15; i++) { + const dark = ((bits >>> i) & 1) === 1 + // Top-left + if (i < 6) this.modules[i][8] = dark + else if (i < 8) this.modules[i + 1][8] = dark + else if (i === 8) this.modules[8][7] = dark + else this.modules[8][14 - i] = dark + // Top-right + bottom-left + if (i < 8) this.modules[8][this.size - 1 - i] = dark + else this.modules[this.size - 15 + i][8] = dark + } + this.modules[this.size - 8][8] = true + } + penalty(): number { + const n = this.size + let p = 0 + const m = this.modules + // Rule 1: runs of ≥5 same-colour modules (rows + cols) + for (let y = 0; y < n; y++) { + let runColor = m[y][0] + let runLen = 1 + for (let x = 1; x < n; x++) { + if (m[y][x] === runColor) { runLen++; if (runLen === 5) p += 3; else if (runLen > 5) p++ } + else { runColor = m[y][x]; runLen = 1 } + } + } + for (let x = 0; x < n; x++) { + let runColor = m[0][x] + let runLen = 1 + for (let y = 1; y < n; y++) { + if (m[y][x] === runColor) { runLen++; if (runLen === 5) p += 3; else if (runLen > 5) p++ } + else { runColor = m[y][x]; runLen = 1 } + } + } + // Rule 2: 2×2 blocks of same colour + for (let y = 0; y < n - 1; y++) { + for (let x = 0; x < n - 1; x++) { + const c = m[y][x] + if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) p += 3 + } + } + // Rule 3: finder-like patterns (1:1:3:1:1 with 4-module light run) + const pat1 = [true, false, true, true, true, false, true, false, false, false, false] + const pat2 = [false, false, false, false, true, false, true, true, true, false, true] + const lineHas = (line: boolean[]) => { + let count = 0 + for (let i = 0; i + 11 <= n; i++) { + let ok1 = true, ok2 = true + for (let k = 0; k < 11; k++) { if (line[i + k] !== pat1[k]) ok1 = false; if (line[i + k] !== pat2[k]) ok2 = false } + if (ok1 || ok2) count++ + } + return count + } + for (let y = 0; y < n; y++) p += lineHas(m[y]) * 40 + for (let x = 0; x < n; x++) { const col = m.map((row) => row[x]); p += lineHas(col) * 40 } + // Rule 4: dark-module proportion + let dark = 0 + for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (m[y][x]) dark++ + const pct = (dark * 100) / (n * n) + p += Math.floor(Math.abs(pct - 50) / 5) * 10 + return p + } +} + +// ── Assemble interleaved data + ECC codewords ─────────────────────────────── +function buildCodewords(dataCodewords: number[], version: number, ecc: ErrorCorrection): number[] { + const o = ECC_ORDER.indexOf(ecc) + const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[o][version] + const eccLen = ECC_CODEWORDS_PER_BLOCK[o][version] + const rawCodewords = Math.floor(getNumRawDataModules(version) / 8) + const numShort = numBlocks - (rawCodewords % numBlocks) + const shortLen = Math.floor(rawCodewords / numBlocks) - eccLen + + const generator = rsGenerator(eccLen) + const blocks: { data: number[]; ecc: number[] }[] = [] + let k = 0 + for (let b = 0; b < numBlocks; b++) { + const len = shortLen + (b < numShort ? 0 : 1) + const data = dataCodewords.slice(k, k + len) + k += len + blocks.push({ data, ecc: rsRemainder(data, generator) }) + } + + const result: number[] = [] + // Interleave data codewords (short blocks pad with a hole at shortLen) + for (let i = 0; i <= shortLen; i++) { + for (let b = 0; b < numBlocks; b++) { + if (i < shortLen || b >= numShort) result.push(blocks[b].data[i]) + } + } + // Interleave ECC codewords + for (let i = 0; i < eccLen; i++) { + for (let b = 0; b < numBlocks; b++) result.push(blocks[b].ecc[i]) + } + return result +} + +// ── Public API ────────────────────────────────────────────────────────────── +/** Encode `value` to a QR matrix. Throws if it doesn't fit in maxVersion. */ +export function encode(value: string, options: EncodeOptions = {}): QrResult { + const ecc = options.errorCorrection ?? 'M' + const minV = Math.max(MIN_VERSION, options.minVersion ?? MIN_VERSION) + const maxV = Math.min(MAX_VERSION, options.maxVersion ?? MAX_VERSION) + + const seg = makeSegment(value) + + // Smallest version that fits. + let version = -1 + let dataCapacityBits = 0 + for (let v = minV; v <= maxV; v++) { + dataCapacityBits = getNumDataCodewords(v, ecc) * 8 + const used = 4 + charCountBits(seg.mode, v) + seg.data.bits.length + if (used <= dataCapacityBits) { + version = v + break + } + } + if (version === -1) throw new Error('qr: data too long for the requested version range') + + // Assemble the bit stream. + const bb = new BitBuffer() + bb.append(seg.mode.indicator, 4) + bb.append(seg.numChars, charCountBits(seg.mode, version)) + bb.bits.push(...seg.data.bits) + // Terminator + bit padding to a byte boundary. + const capacity = getNumDataCodewords(version, ecc) * 8 + bb.append(0, Math.min(4, capacity - bb.bits.length)) + while (bb.bits.length % 8 !== 0) bb.bits.push(0) + // Byte padding. + for (let pad = 0xec; bb.bits.length < capacity; pad ^= 0xec ^ 0x11) bb.append(pad, 8) + + const dataCodewords: number[] = [] + for (let i = 0; i < bb.bits.length; i += 8) { + let byte = 0 + for (let j = 0; j < 8; j++) byte = (byte << 1) | bb.bits[i + j] + dataCodewords.push(byte) + } + + const allCodewords = buildCodewords(dataCodewords, version, ecc) + + // Build matrix, try all 8 masks, keep the lowest-penalty one. + let best: Matrix | null = null + let bestPenalty = Infinity + let bestMask = 0 + for (let mask = 0; mask < 8; mask++) { + const matrix = new Matrix(version) + matrix.drawFunctionPatterns(version) + matrix.placeData(allCodewords) + matrix.applyMask(mask) + matrix.drawFormatInfo(ecc, mask) + const pen = matrix.penalty() + if (pen < bestPenalty) { + bestPenalty = pen + best = matrix + bestMask = mask + } + } + const chosen = best! + void bestMask + + return { + matrix: chosen.modules, + reserved: chosen.reserved, + size: chosen.size, + version, + errorCorrection: ecc + } +} diff --git a/src/uix/eidos/components/qr-code/README.md b/src/uix/eidos/components/qr-code/README.md new file mode 100644 index 000000000..6efcc3058 --- /dev/null +++ b/src/uix/eidos/components/qr-code/README.md @@ -0,0 +1,116 @@ +# QrCode + +Themeable, accessible QR code — **own zero-dependency encoder**, framework +4-layer rigor, richer than the references it learns from. + +> **Design proposal** (approved 2026-06-12). This README is both the design +> record and the component doc. + +## Why a new one (vs the references) + +| Capability | ark-ui (zag + `uqr`) | qr-code-styling | react-qr-code | **QrCode (ours)** | +| --- | --- | --- | --- | --- | +| Parts / anatomy | ✅ Root·Frame·Pattern·Overlay·Download | ❌ imperative | ❌ single comp | ✅ | +| Encoder | `uqr` dep | qr-code-generator dep | qr.js dep | **own, zero-dep (`$libs/qr`)** | +| Error correction L/M/Q/H | ✅ | ✅ | ✅ | ✅ | +| Themeable colour (tokens) | ❌ `currentColor` only | ✅ free | fg/bg props | ✅ **via tokens + `color`** | +| Cell shapes (square/rounded/dots) | ❌ | ✅ | ❌ | ✅ | +| Logo overlay | ✅ | ✅ | partial | ✅ **+ auto quiet-zone + auto-boost ECC** | +| Download PNG/SVG | ✅ | ✅ | manual | ✅ **DownloadTrigger = ` diff --git a/src/uix/eidos/components/qr-code/qr-code.css b/src/uix/eidos/components/qr-code/qr-code.css new file mode 100644 index 000000000..f76ca8035 --- /dev/null +++ b/src/uix/eidos/components/qr-code/qr-code.css @@ -0,0 +1,31 @@ +/* QrCode — the module path is generated in the wrapper; CSS owns the frame + * chrome + the logo overlay. Default fg/bg are fixed-tone (scannability — see + * the recipe note); `color` / `background` props override the tokens inline. */ + +[data-qr-code] { + display: block; + border-radius: var(--qr-code-radius); + max-width: 100%; + height: auto; +} + +/* Logo overlay — fills the cleared centre (a foreignObject), centres the + * media, paints its own light backing so it reads over the modules. */ +[data-qr-code-overlay] { + box-sizing: border-box; + inline-size: 100%; + block-size: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: var(--qr-code-overlay-bg); + border-radius: var(--qr-code-overlay-radius); + padding: var(--qr-code-overlay-padding); +} + +[data-qr-code-overlay] :where(img, svg) { + inline-size: 100%; + block-size: 100%; + object-fit: contain; +} diff --git a/src/uix/eidos/components/qr-code/qr-code.svelte b/src/uix/eidos/components/qr-code/qr-code.svelte new file mode 100644 index 000000000..42266ddec --- /dev/null +++ b/src/uix/eidos/components/qr-code/qr-code.svelte @@ -0,0 +1,156 @@ + + + + + + {#if logo} + +
{@render logo()}
+
+ {/if} +
+{@render children?.()} diff --git a/src/uix/eidos/components/qr-code/types.ts b/src/uix/eidos/components/qr-code/types.ts new file mode 100644 index 000000000..9295fd888 --- /dev/null +++ b/src/uix/eidos/components/qr-code/types.ts @@ -0,0 +1,46 @@ +import type { Snippet } from 'svelte'; +import type { ResponsiveProp, Size } from '$uix/eidos/lib/types'; +import type { ErrorCorrection } from '$libs/qr'; + +/** QrCode size preset (maps to `--qr-code-size-*`); a raw number is px. */ +export type QrCodeSize = Extract; + +/** Module shape. Finder patterns always stay square so scanners lock on. */ +export type QrCodeCellShape = 'square' | 'rounded' | 'dots'; + +export type { ErrorCorrection }; + +export type QrCodeProps = { + /** Data to encode. @default '' */ + value?: string; + /** Rendered box size — a preset (`'md'`) or raw px number. @default 'md' */ + size?: ResponsiveProp; + /** Redundancy. Auto-boosted to `'H'` when a `logo` is present. @default 'M' */ + errorCorrection?: ErrorCorrection; + /** Module colour (themeable; you own the contrast). @default fixed dark token */ + color?: string; + /** Quiet-zone + module background. @default fixed light token */ + background?: string; + /** Module shape (finders stay square). @default 'square' */ + cellShape?: QrCodeCellShape; + /** Quiet zone in modules (spec minimum 4). @default 4 */ + margin?: number; + /** Accessible name for the Frame. @default value */ + alt?: string; + /** Centred logo / media slot. Auto-clears the cells behind it + boosts ECC. */ + logo?: Snippet; + /** Compound children (e.g. ``). */ + children?: Snippet; + [key: string]: unknown; +}; + +export type QrCodeDownloadFormat = 'png' | 'svg'; + +export type QrCodeDownloadTriggerProps = { + /** Export format. @default 'png' */ + format?: QrCodeDownloadFormat; + /** Download filename (without extension). @default 'qrcode' */ + filename?: string; + children?: Snippet; + [key: string]: unknown; +}; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 06dc82964..2a67cf8c5 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1898,6 +1898,17 @@ --radio-group-label-font-weight: var(--font-weight-medium); --radio-group-label-line-height: var(--leading-ui); --radio-group-label-color: var(--color-content-primary); + --qr-code-fg: #18181b; + --qr-code-bg: #ffffff; + --qr-code-size-xs: 96px; + --qr-code-size-sm: 128px; + --qr-code-size-md: 160px; + --qr-code-size-lg: 200px; + --qr-code-size-xl: 256px; + --qr-code-radius: var(--radius-md); + --qr-code-overlay-bg: #ffffff; + --qr-code-overlay-padding: var(--space-1); + --qr-code-overlay-radius: var(--radius-sm); --radio-cards-gap: var(--space-3); --radio-cards-horizontal-basis: 12rem; --radio-cards-padding-xs: var(--space-3); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 40948bc50..95a937698 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1937,6 +1937,23 @@ export const THEME_BASE_RECIPE_TOKENS = { }, '_palette-text': { value: 'var(--radio-group-dot-color)', scope: 'host' } }, + // QrCode. Default fg/bg are FIXED-TONE (dark modules on a light backing), + // not theme tokens: a QR must stay dark-on-light to scan — inverting it in + // dark mode would break scanners. Theming goes through the `color` / + // `background` props (consumer owns the contrast). + 'qr-code': { + fg: '#18181b', + bg: '#ffffff', + 'size-xs': '96px', + 'size-sm': '128px', + 'size-md': '160px', + 'size-lg': '200px', + 'size-xl': '256px', + radius: 'var(--radius-md)', + 'overlay-bg': '#ffffff', + 'overlay-padding': 'var(--space-1)', + 'overlay-radius': 'var(--radius-sm)' + }, // RadioCards — card-style skin over RadioGroup. Reuses RadioGroup's // palette (`--_radio-group-palette-solid`, declared on the shared // [data-radio-group] host) and `--radio-group-focus-ring`; adds only the diff --git a/src/uix/morfo/components/qr-code.ts b/src/uix/morfo/components/qr-code.ts new file mode 100644 index 000000000..bb9393f03 --- /dev/null +++ b/src/uix/morfo/components/qr-code.ts @@ -0,0 +1,91 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +/** + * QrCode — themeable QR code rendered from the own zero-dependency encoder + * (`$libs/qr`). Eidos-native display primitive (like Avatar / Image): a + * `value` → module matrix → SVG. No soma — there is no interactive state, just + * a derived render. The optional DownloadTrigger is the only action. + * + * `scope: ['eidos']`. The Frame is the root ``; the Pattern is + * the module `` (decorative — the Frame's `aria-label` carries the + * meaning); the Overlay is a centred logo slot; the DownloadTrigger exports the + * code as PNG / SVG. + * + * 0-event surface (like Badge): the only act is downloading, expressed by the + * consumer's handler on the DownloadTrigger. + */ +export const qrCodeMorfo = { + name: 'QrCode', + kebab: 'qr-code', + scope: ['eidos'], + apg: 'https://www.w3.org/WAI/ARIA/apg/#img', + texts: { + download: '#?components.qr-code.download|Download QR code' + }, + parts: [ + { + // Frame — the root ``. `role="img"` + `aria-label` make the QR a + // single labelled graphic; the module pattern inside is decorative. + name: 'Provider', + kebab: 'provider', + archetype: 'provider', + kind: 'public', + defaultElement: 'svg', + role: 'img', + optional: false, + data: [ + { attr: 'data-cell-shape', values: ['square', 'rounded', 'dots'] } + ], + aria: [ + { + attr: 'aria-label', + value: v.propRef('alt'), + severity: 'recommended' + } + ] + }, + { + // The module matrix as one ``. Decorative — aria-hidden. + name: 'Pattern', + kebab: 'pattern', + archetype: 'indicator', + kind: 'public', + defaultElement: 'path', + optional: false, + data: [], + aria: [{ attr: 'aria-hidden', value: v.literal('true') }] + }, + { + // Centred logo / media slot. Decorative — the cells behind it are + // cleared (quiet zone) and ECC is auto-boosted so it stays scannable. + name: 'Overlay', + kebab: 'overlay', + archetype: 'indicator', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [], + aria: [{ attr: 'aria-hidden', value: v.literal('true') }] + }, + { + // Export affordance — `{/snippet}', + ' Download', + '
' + ] + .filter(Boolean) + .join('\n') + ); + + + + + + +{#snippet brandLogo()} + +{/snippet} + +
+
+
Display · QR code
+

QR code

+

+ Themeable QR rendered from an own zero-dependency encoder (`$libs/qr` — + ISO/IEC 18004, versions 1–40, all ECC levels). Cell shapes (square / rounded / dots) keep + the function patterns square so any scanner locks on; an optional logo clears the centre and + auto-boosts ECC to H; export to PNG / SVG. No npm encoder dependency. +

+
+ encoderown · 0-dep + parts{compiled.parts.order.length} + versions1–40 + eccL · M · Q · H +
+
+ +
+
+ + + PNG + SVG + + +
+
+ encodes + {value || '∅'} + ecc {withLogo ? 'H (logo)' : errorCorrection} +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Pure eidos display — no soma. value → matrix → SVG.

+
+ + + + + + + +
+
+
+ eidos + display + DownloadTrigger + svelte +
+
{snippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+

Cell shapes, themed colour, logo — all stay scannable (function patterns square, ECC auto-boost).

+
+ + + + +
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
valuestring''Data to encode.
sizeResponsiveProp<number | 'xs'…'xl'>'md'Box size (px or preset).
cellShape'square' | 'rounded' | 'dots''square'Data-module shape; finders stay square.
errorCorrection'L' | 'M' | 'Q' | 'H''M'Redundancy. Auto-boosted to 'H' with a logo.
color / backgroundstringfixed tokensThemeable (you own the contrast).
marginnumber4Quiet zone in modules.
logoSnippet—Centred overlay; clears cells + boosts ECC.
altstringvalueAccessible name.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/qr-code.ts. scope:['eidos'] — a display primitive, no soma.

+
+ + + + {#each partsList as part} + + + + + + + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

Tokens at lib/recipes/base.ts. Default fg/bg are fixed-tone (dark-on-light) for scannability — a QR can't invert in dark mode; theming goes through color/background.

+
+ + + + + + + + +
TokenRole
--qr-code-fg / --qr-code-bgmodule colour / quiet-zone (fixed-tone)
--qr-code-size-{'{'}xs…xl{'}'}size presets
--qr-code-radiusframe radius
--qr-code-overlay-bg / -padding / -radiuslogo backing
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + +
PartAttributeValue
frame (svg)role / aria-label"img" / alt ?? value
pattern / overlayaria-hidden"true" (decorative)
download-triggertype / aria-label"button" / "Download QR code"
+
+
+ {/if} +