feat(util): add a QR encoder (#59056)

This commit is contained in:
Kit Langton
2026-10-25 13:47:54 -07:00
committed by GitHub
Unverified
parent 28907e89c0
commit 9aeaa3c3b5
5 changed files with 1066 additions and 1 deletions
+3
View File
@@ -917,6 +917,7 @@
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native": "catalog:",
"jsqr": "1.4.0",
},
},
"packages/web": {
@@ -4011,6 +4012,8 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
"just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="],
"just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="],
+2 -1
View File
@@ -70,6 +70,7 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native": "catalog:"
"@typescript/native": "catalog:",
"jsqr": "1.4.0"
}
}
+323
View File
@@ -0,0 +1,323 @@
// QR Code encoder (ISO/IEC 18004) for byte-mode text at error correction level L, following the structure of
// Project Nayuki's QR Code generator (MIT): https://www.nayuki.io/page/qr-code-generator-library
// Error correction codewords per block and block count at level L, for versions 1 to 40.
const ECC_PER_BLOCK = [
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,
]
const BLOCKS = [
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,
]
const MASKS = [
(x: number, y: number) => (x + y) % 2 === 0,
(_: number, y: number) => y % 2 === 0,
(x: number) => x % 3 === 0,
(x: number, y: number) => (x + y) % 3 === 0,
(x: number, y: number) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x: number, y: number) => ((x * y) % 2) + ((x * y) % 3) === 0,
(x: number, y: number) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
(x: number, y: number) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
]
type Draw = (x: number, y: number, dark: boolean) => void
/** Encodes text as UTF-8 into the smallest QR Code that fits. Rows of modules without a quiet zone; true is dark. */
export function encode(text: string) {
const bytes = new TextEncoder().encode(text)
const version = Array.from({ length: 40 }, (_, i) => i + 1).find(
(v) => 4 + (v < 10 ? 8 : 16) + bytes.length * 8 <= dataCapacity(v) * 8,
)
if (version === undefined) throw new RangeError(`Text is too long for a QR Code: ${bytes.length} bytes`)
const size = version * 4 + 17
const modules = Array.from({ length: size }, () => Array<boolean>(size).fill(false))
const reserved = Array.from({ length: size }, () => Array<boolean>(size).fill(false))
drawFunctionPatterns(version, size, (x, y, dark) => {
modules[y][x] = dark
reserved[y][x] = true
})
drawCodewords(modules, reserved, addErrorCorrection(dataCodewords(bytes, version), version))
const candidates = MASKS.map((mask, index) => {
const masked = modules.map((row, y) => row.map((dark, x) => dark !== (!reserved[y][x] && mask(x, y))))
drawFormat(index, size, (x, y, dark) => {
masked[y][x] = dark
})
return masked
})
const scores = candidates.map(penalty)
return candidates[scores.indexOf(Math.min(...scores))]
}
/** Renders text as an SVG QR Code with 10-unit modules and a quiet zone of `border` modules. */
export function renderSVG(text: string, options: { border?: number; blackColor?: string; whiteColor?: string } = {}) {
const modules = addBorder(encode(text), options.border ?? 1)
const width = modules.length * 10
const path = modules
.flatMap((row, y) => row.flatMap((dark, x) => (dark ? [`M${x * 10},${y * 10}h10v10h-10z`] : [])))
.join("")
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${width}">` +
`<rect fill="${escapeAttribute(options.whiteColor ?? "white")}" width="${width}" height="${width}"/>` +
`<path fill="${escapeAttribute(options.blackColor ?? "black")}" d="${path}"/></svg>`
)
}
/**
* Renders text as a QR Code for a terminal, two modules per character with half blocks. Light modules draw as blocks
* and dark modules as the terminal background, rows are joined with "\n", and the quiet zone is `border` modules.
*/
export function renderUnicodeCompact(text: string, options: { border?: number } = {}) {
const modules = addBorder(encode(text), options.border ?? 1)
return Array.from({ length: Math.ceil(modules.length / 2) }, (_, line) =>
modules[line * 2]
.map((top, x) => {
// The symbol has an odd number of rows, so the last line's missing bottom half shows the terminal background.
const bottom = modules[line * 2 + 1]?.[x] ?? true
if (top) return bottom ? " " : "\u2584"
return bottom ? "\u2580" : "\u2588"
})
.join(""),
).join("\n")
}
function drawFunctionPatterns(version: number, size: number, set: Draw) {
for (let i = 0; i < size; i++) {
set(6, i, i % 2 === 0)
set(i, 6, i % 2 === 0)
}
for (const [cx, cy] of [
[3, 3],
[size - 4, 3],
[3, size - 4],
])
for (let dy = -4; dy <= 4; dy++)
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx
const y = cy + dy
const distance = Math.max(Math.abs(dx), Math.abs(dy))
if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, distance !== 2 && distance !== 4)
}
const positions = alignmentPositions(version, size)
const last = positions.length - 1
positions.forEach((cx, i) =>
positions.forEach((cy, j) => {
// Skip the three corners that hold finder patterns.
if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) return
for (let dy = -2; dy <= 2; dy++)
for (let dx = -2; dx <= 2; dx++) set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
}),
)
// Reserves the format areas; the real format bits are drawn once the mask is chosen.
drawFormat(0, size, set)
if (version < 7) return
const bits = bch(version, 12, 0x1f25)
for (let i = 0; i < 18; i++) {
const a = size - 11 + (i % 3)
const b = Math.floor(i / 3)
const dark = ((bits >>> i) & 1) === 1
set(a, b, dark)
set(b, a, dark)
}
}
function alignmentPositions(version: number, size: number) {
if (version === 1) return []
const count = Math.floor(version / 7) + 2
const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2
return [6, ...Array.from({ length: count - 1 }, (_, i) => size - 7 - (count - 2 - i) * step)]
}
function drawFormat(mask: number, size: number, set: Draw) {
// Level L is 0b01 in the format information.
const bits = bch((1 << 3) | mask, 10, 0x537) ^ 0x5412
const bit = (i: number) => ((bits >>> i) & 1) === 1
for (let i = 0; i <= 5; i++) set(8, i, bit(i))
set(8, 7, bit(6))
set(8, 8, bit(7))
set(7, 8, bit(8))
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i))
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i))
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i))
set(8, size - 8, true)
}
// Appends the BCH remainder of `data` for a generator polynomial of the given degree.
function bch(data: number, degree: number, generator: number) {
let remainder = data
for (let i = 0; i < degree; i++) remainder = (remainder << 1) ^ ((remainder >>> (degree - 1)) * generator)
return (data << degree) | remainder
}
function dataCodewords(bytes: Uint8Array, version: number) {
const capacity = dataCapacity(version)
const bits: number[] = []
const append = (value: number, length: number) => {
for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1)
}
append(0b0100, 4)
append(bytes.length, version < 10 ? 8 : 16)
bytes.forEach((byte) => append(byte, 8))
append(0, Math.min(4, capacity * 8 - bits.length))
append(0, (8 - (bits.length % 8)) % 8)
const data = Array.from({ length: bits.length / 8 }, (_, i) =>
bits.slice(i * 8, i * 8 + 8).reduce((byte, bit) => (byte << 1) | bit, 0),
)
return [...data, ...Array.from({ length: capacity - data.length }, (_, i) => (i % 2 === 0 ? 0xec : 0x11))]
}
function addErrorCorrection(data: number[], version: number) {
const blocks = BLOCKS[version - 1]
const ecc = ECC_PER_BLOCK[version - 1]
const raw = Math.floor(rawModules(version) / 8)
// The first `short` blocks hold one data codeword fewer than the rest.
const short = blocks - (raw % blocks)
const shortLength = Math.floor(raw / blocks) - ecc
const divisor = reedSolomonDivisor(ecc)
const split = Array.from({ length: blocks }, (_, i) => {
const start = i * shortLength + Math.max(0, i - short)
return data.slice(start, start + shortLength + (i < short ? 0 : 1))
})
const interleave = (rows: number[][], length: number) =>
Array.from({ length }, (_, i) => rows.flatMap((row) => (i < row.length ? [row[i]] : []))).flat()
return [
...interleave(split, shortLength + 1),
...interleave(
split.map((block) => reedSolomonRemainder(block, divisor)),
ecc,
),
]
}
function drawCodewords(modules: boolean[][], reserved: boolean[][], codewords: number[]) {
const size = modules.length
let i = 0
for (let right = size - 1; right >= 1; right -= 2) {
// The vertical timing pattern takes column 6, so the column pair shifts left past it.
if (right === 6) right = 5
for (let vertical = 0; vertical < size; vertical++)
for (let j = 0; j < 2; j++) {
const x = right - j
const y = ((right + 1) & 2) === 0 ? size - 1 - vertical : vertical
if (reserved[y][x] || i >= codewords.length * 8) continue
modules[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1
i++
}
}
}
function penalty(modules: boolean[][]) {
const size = modules.length
const columns = modules.map((_, x) => modules.map((row) => row[x]))
const lines = [...modules, ...columns].reduce((sum, line) => sum + linePenalty(line), 0)
// Each 2x2 block of one colour, found from its bottom-right module.
const boxes = modules
.slice(1)
.flatMap((row, y) =>
row.slice(1).filter((dark, x) => dark === row[x] && dark === modules[y][x] && dark === modules[y][x + 1]),
).length
const dark = modules.flat().filter(Boolean).length
const total = size * size
return lines + boxes * 3 + (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * 10
}
// Scores runs of five or more same-coloured modules, and 1:1:3:1:1 finder-like patterns with 4 light modules on a side.
function linePenalty(line: boolean[]) {
const size = line.length
const history = [0, 0, 0, 0, 0, 0, 0]
const push = (length: number) => {
history.pop()
history.unshift(history[0] === 0 ? length + size : length)
}
const finders = () => {
const n = history[1]
const core = n > 0 && history[2] === n && history[3] === n * 3 && history[4] === n && history[5] === n
return (
(core && history[0] >= n * 4 && history[6] >= n ? 1 : 0) +
(core && history[6] >= n * 4 && history[0] >= n ? 1 : 0)
)
}
let result = 0
let color = false
let run = 0
for (const dark of line) {
if (dark === color) {
run++
if (run === 5) result += 3
if (run > 5) result++
continue
}
push(run)
if (!color) result += finders() * 40
color = dark
run = 1
}
if (color) {
push(run)
run = 0
}
push(run + size)
return result + finders() * 40
}
function reedSolomonDivisor(degree: number) {
const result = Array<number>(degree).fill(0)
result[degree - 1] = 1
let root = 1
for (let i = 0; i < degree; i++) {
for (let j = 0; j < degree; j++) {
result[j] = multiply(result[j], root)
if (j + 1 < degree) result[j] ^= result[j + 1]
}
root = multiply(root, 2)
}
return result
}
function reedSolomonRemainder(data: number[], divisor: number[]) {
return data.reduce(
(remainder, byte) => {
const factor = byte ^ remainder[0]
return [...remainder.slice(1), 0].map((value, i) => value ^ multiply(divisor[i], factor))
},
divisor.map(() => 0),
)
}
// Multiplies in GF(2^8) modulo x^8 + x^4 + x^3 + x^2 + 1.
function multiply(x: number, y: number) {
let z = 0
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d)
z ^= ((y >>> i) & 1) * x
}
return z
}
function rawModules(version: number) {
const base = (16 * version + 128) * version + 64
if (version < 2) return base
const count = Math.floor(version / 7) + 2
return base - ((25 * count - 10) * count - 55) - (version >= 7 ? 36 : 0)
}
function dataCapacity(version: number) {
return Math.floor(rawModules(version) / 8) - ECC_PER_BLOCK[version - 1] * BLOCKS[version - 1]
}
function addBorder(modules: boolean[][], border: number) {
const width = modules.length + border * 2
const blank = () => Array<boolean>(width).fill(false)
const edge = Array.from({ length: border }, blank)
const padding = Array<boolean>(border).fill(false)
return [...edge, ...modules.map((row) => [...padding, ...row, ...padding]), ...Array.from({ length: border }, blank)]
}
function escapeAttribute(value: string) {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
}
+625
View File
@@ -0,0 +1,625 @@
[
{
"version": 1,
"text": "hello",
"rows": [
"3fcb7f",
"305a41",
"37595d",
"374a5d",
"37515d",
"305341",
"3fd57f",
"201f00",
"3a6c76",
"2fb843",
"26f58d",
"22920b",
"216d50",
"201e35",
"3fdcae",
"304fb0",
"374a71",
"37562f",
"374d15",
"305cc0",
"3fd72a"
]
},
{
"version": 4,
"text": "http://192.168.1.2:4096/auth/connect/abc_DEF-123_4567890123",
"rows": [
"3fc9d4e7f",
"3053e5e41",
"375fbd65d",
"374ef235d",
"3750eea5d",
"305ae7541",
"3fd55557f",
"20195db00",
"3a6404a76",
"2a864f96d",
"244c168d3",
"291064c53",
"26c10e941",
"2a2f1d577",
"38f915ad2",
"2009e8dfa",
"30d2dfff1",
"2e900422c",
"39e88d94b",
"202ebed58",
"2f5b68a22",
"26bf85069",
"3ed7cacad",
"2dafa78a3",
"36666dbf8",
"201adfd1d",
"3fdac9156",
"304619f19",
"374d9ddfa",
"375dc58d0",
"374351d91",
"30514d9a8",
"3fd805b2a"
]
},
{
"version": 4,
"text": "{\"code\":\"abc_DEF-123_4567890123\",\"urls\":[\"https://r1.tunnel.opencode.ai\"]}",
"rows": [
"3fc883f7f",
"305be0841",
"374e3495d",
"375bca25d",
"374593f5d",
"3054f0941",
"3fd55557f",
"200076d00",
"3f7ee82aa",
"37aa52f0d",
"3c7825086",
"3a1c16a4f",
"32cacb023",
"3a1411f4d",
"2cc96c1ee",
"263752e87",
"3769cf020",
"36bf9abc5",
"3850fc012",
"253d36895",
"367bfc008",
"3f8a1e3c1",
"345ca9d10",
"31a572867",
"33f1fd1f1",
"201e9fb17",
"3fd5e8352",
"304e5191d",
"3757ca5f1",
"375703e34",
"375c7cece",
"305826f3c",
"3fd9fd7e2"
]
},
{
"version": 8,
"text": "{\"code\":\"abc_DEF-123_4567890123\",\"urls\":[\"http://192.168.1.20:4096\",\"http://10.0.0.5:4096\",\"http://100.64.12.34:4096\",\"https://alex-macbook-pro.tail1234.ts.net:4096\"]}",
"rows": [
"3fc4e8bbc197f",
"3058074d8ef41",
"3745bfaff435d",
"375624966925d",
"37409ffec885d",
"3059b5c7adc41",
"3fd555555557f",
"200d5f4770d00",
"3f72157e233aa",
"20996812e3256",
"32e6ebfc357b5",
"2d1563a978aa3",
"36cbcad26d0ad",
"2c9824db420f8",
"364d061c95d4b",
"2fb464a19c25a",
"35c7155a8b785",
"211c60817aa52",
"3e6c25c61e789",
"333d8da1128d2",
"30c624de2196e",
"25a69f10502d8",
"27f7f97e05ffb",
"351713c594518",
"235f17d62135d",
"2d1a68c7f2b10",
"35fbf87ebedf1",
"2f1470359a84a",
"23d3caee8d7fe",
"313e249773244",
"26d506111dd6d",
"348b64bdd488a",
"2f40972e613d4",
"291b6294f0ae8",
"3ac904ca3cfed",
"358e8ec7a6848",
"2e592422a9e34",
"2e8a9fb751202",
"28c8797295f5f",
"2e26133d5a8ba",
"3c6a977ee13f7",
"2017eac6d291e",
"3fd1f8550df5d",
"304f52c57a519",
"3753c87ec51fd",
"3759246c5b1d0",
"375786d78ce28",
"3051279b1a259",
"3fd6b714e700f"
]
},
{
"version": 9,
"text": "{\"code\":\"abc_DEF-123_4567890123\",\"urls\":[\"http://192.168.1.20:4096\",\"http://10.0.0.5:4096\",\"http://100.64.12.34:4096\",\"https://alex-macbook-pro.tail1234.ts.net:4096\",\"http://172.16.0.9:4096\",\"http://192.168.64.1:4096\"]}",
"rows": [
"3fc2a8595abc7f",
"305a0126966e41",
"3743e12298925d",
"375e17a60d255d",
"3742e05f581c5d",
"3052fc918f6441",
"3fd5555555557f",
"20065411f8dd00",
"3f74e7bf4103aa",
"3fb118654a8417",
"3ec4bac686cab0",
"2b0e18d076da09",
"21c037a7c96d3d",
"3f36e2617bbe1b",
"2c4f34c4aceb60",
"249ea6127c1f1b",
"36dc079441cb36",
"2e87a85d58342b",
"2c78490e8468a0",
"3d9d6b92fa96c9",
"3c7017860d25be",
"39bdea4d618ebb",
"3ad3c0a00debda",
"261d6ed23a3332",
"37f2e59fcd67f5",
"251e18f159271b",
"215eb4b5bdd950",
"311a1eb1be151b",
"33fd17bf09e3fe",
"211cea74712ff3",
"26c53c5db752a6",
"258591ded673d3",
"326007920f6c4e",
"2f32a07c69b633",
"385905d107a922",
"383b261452f9e9",
"20ec358a49494f",
"2ca1ea67708637",
"32c1e591347086",
"2e1a400a7a7753",
"2561ef8303e544",
"2a901c7f591611",
"3bf9351737692c",
"2c3a17e092fae1",
"225c95bf0941ff",
"201220f1518d11",
"3fd03835976b56",
"30430ff1921518",
"375a6d9f4f6df7",
"3757285b49b584",
"3753c8be146a35",
"305bd1c13abeb2",
"3fd15592cb8454"
]
},
{
"version": 10,
"text": "{\"code\":\"abc_DEF-123_4567890123\",\"urls\":[\"http://192.168.1.20:4096\",\"http://10.0.0.5:4096\",\"http://100.64.12.34:4096\",\"https://alex-macbook-pro.tail1234.ts.net:4096\",\"http://172.16.0.9:4096\",\"http://192.168.64.1:4096\",\"http://192.168.100.200:4096\"]}",
"rows": [
"3fc1e0536ba4e7f",
"305ecab485fa241",
"3744b8cd7c9fe5d",
"3753843ac92425d",
"374463d7d01e25d",
"305f4db445c8c41",
"3fd55555555557f",
"2006a7947af7000",
"3f701fa7c966eaa",
"3f09ea5b4934933",
"3563c453adeac78",
"25376df97457a35",
"2851f54c4b22462",
"25a90c9a7835385",
"29e80990bdfb042",
"343a51e13ab791d",
"2ef517ac0904aa0",
"3b1868dcdab5939",
"2ccc29f5f6e9fd8",
"360bab91501741c",
"31792e1e0928843",
"2486906c7236b23",
"33e9fec605c37e6",
"3b25542df6139d6",
"35c55da28da2ce1",
"27ac2a525b04927",
"25fdd987d5f2df0",
"231f1004585551e",
"2f543b854da8550",
"3f18a414712c111",
"31f7a74ff6ce9f6",
"292270d498b366f",
"20fe95a1092ccf8",
"3f9ee850caa69c9",
"2074a42a6eda428",
"30b50da7b259b4d",
"30e78c3029a0170",
"36026350800d96b",
"2a41c53b75c2523",
"33b8a7d23a9b6c4",
"2fd1f5a18d6a958",
"2a3628d60804847",
"225dd47346e1c08",
"2302dfcd901b35d",
"22c7d74941088d3",
"3826ce9210071cd",
"34f4848955c8926",
"3f16843a343face",
"20721787cd209f8",
"201728545835911",
"3fdc392d64e3f58",
"304576a45a98915",
"3750ae17e962df2",
"3758906fdb2eafc",
"375ab28c0542540",
"305150e77859994",
"3fdcf5a2092afda"
]
},
{
"version": 5,
"text": "http://192.168.1.2:4096/auth/connect/abc?path=/Users/zoë/プロジェクト/😀",
"rows": [
"3fc4452e7f",
"3048813641",
"37522f465d",
"374992645d",
"374996265d",
"3047743f41",
"3fd555557f",
"201226cd00",
"3df11acbc4",
"3333b81967",
"30c77f8c09",
"3315d4fceb",
"3cca658960",
"3a366a7d65",
"35d08ec0c9",
"3ebe20999a",
"20c91dbf49",
"2b17b10069",
"2c437a897f",
"28a5d77eaa",
"28da7b2b6b",
"34b668896d",
"2fe89ed50f",
"2ba6246f1a",
"344515f84b",
"22afaa1969",
"37e77edcd3",
"299de4fbfa",
"37c665c9f0",
"201e611917",
"3fd8a68f53",
"3052196d18",
"37592c9df9",
"374fa29498",
"375f7ad91f",
"3051e46f7a",
"3fd6494bbb"
]
},
{
"version": 23,
"text": "opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode ",
"rows": [
"3fc2b4e8632832b98b986584597f",
"3053514bd8bf8bd0bf0bd23d2d41",
"3740e0b62f42f42f42f42f42ff5d",
"375d9ca8e0e70e88a78ac6fc665d",
"374d17a9f2c72df68769f656675d",
"305170e310bd0b103d0310bd0a41",
"3fd555555555555555555555557f",
"200a61371f42f71d42d717427700",
"3f7a02edf3d83df72f71f62e62aa",
"25985b24ebd0bc872c70c62e6054",
"2c46aa4bd09d09f0bd0ad0bd086d",
"2ca103350fc2fdaf42f62f40f7d3",
"3bf43163e91d9177dc7fb91e903f",
"3b33ca90ebdfbcb7547439ee9f58",
"23f4354a50b509d0bd09f095098b",
"3dbe32362f40f52542552f42f7e3",
"2455371f91da1f7ba7bbf1d91c2f",
"27aaa95439e29c47a478fbd3be94",
"2bd71d7dd0bd09d03f0250bd0aa7",
"351934122fc2fe2f42f62fc2ffe1",
"34f3683c42e62f48ab8bf2262027",
"3e2225de42e62c8a8ba8f22a2198",
"36eabe4852bd2ad0bd0adabdaa87",
"363e01b64f42f62f42f42f42f5e3",
"2d7e02afa23423958b5bc2f42caf",
"2d21619ea21a20bd0bd042d62d58",
"3b58ce88d8bd8bd0bd08d0bd09a9",
"3f3b1dfcaf42f58f6af42f62f7d3",
"29fe4b93f6aa6bfa88abf2c82dff",
"311456dd1e2ae119889919c09f14",
"39512d0950bd0950bf0b50bd0b5f",
"291852371f48f71f42f71fc2ff10",
"35f420c1f9969bf8a789f90f93fe",
"393a432365a65aa6876b49cf9e30",
"3cf8984aa01d02a03d0260b70953",
"3eb35515df42f5dd42d5df42f669",
"3dd261fe29999b27ef7e242f41cc",
"3200cc774b9bb8576477e60f6004",
"3cd1544a60bd0b70bd0a60bd089d",
"2499d535df42f5d74275df42f723",
"2ce733c227987b271c72290f93ff",
"358a11628f18f1c71c72c9cf9e08",
"3cdfc6ca00bd08c09d0ac0b5095b",
"2abfc375df48f5df42f5df62f763",
"3ae263d221161321d91f22c82fdf",
"3c3df9dff12a11fbd3bdf9c09e38",
"28c9c54af8bd8840bd09f2bd2957",
"3aa3cef55f42f5ffc0fd7fc2fe81",
"30d36caf22e62f291e9322f42fef",
"3ab0c0df82e62c79ee9db2d62e24",
"247744aa02bd2a70950830bd0997",
"39969bbcdf42f5df42f5df42f7a1",
"2df7b5e3f23423f51d53f22623f5",
"3b1a03e9121a23171f71122a2118",
"3950d95358bd8b50bd0958bd8b5b",
"2513dee91f62f71f42f71f62f712",
"33f70f8bf6886bf7dc7ffa88a9fe",
"3fbf61232d08d327547529889ae4",
"224eacc840bd0880bd0870bd08a7",
"2bac8a376f4af78542558f42f491",
"3c514181d98b99dba7b9d58b5bad",
"3b0c8e62298b9b27a47b2d0bd0c4",
"37691b8960bd0a903f0040bf0b27",
"373f7e97af62f4bf42f54f4af5d1",
"3d5c0fbdda84a8d4ef4dd0270367",
"26b21d7f05845b864767220b20c8",
"2f452fc8a23d20a0bd08b8bd8a07",
"349e8ab62f42f4a742760d42d653",
"3155b8fc963c60d71c72f3e73f6f",
"219c98ff661e63271c7323e43eca",
"2fc4454840bd0a609d08f0bd0829",
"3a3912fe6f42f46f42f6ff42f491",
"257b4452d6ee6cd91d9053d83faf",
"253677ff2666672bdfbf2bd0bce8",
"21f45cc880bd08c0b508f0bd0827",
"39036b358740776f40f5fdc2dc91",
"21f1773df99699f1da1df3e73fff",
"2f174e7d15a65919e29d13e43d14",
"2d57bec9523d2350bd0950bf0957",
"35159e5d1f42f51fc2fd1f42f711",
"35fbf7fdfab8a9f22621f02703f5",
"300b1dffda9aaad22a23d20b2384",
"2ff0d0c920bd0a2abda928bd8ae7",
"209c51f4bf62f6ef42f4cf42f5a2",
"21472501b68869e2f42dc58b5a5e",
"272ebd23fd08d3d2d62f7d0bd2aa",
"24d56fc9a0bd0800bd0820bd0adb",
"28aec1f47f4af6af42f6bf6af532",
"324f22ddb98b9922ea2d3a88ab9e",
"28985116d98b9adae2aed98899a0",
"3e47927e20bf0920bd0a20bf08e3",
"3e974e527f42f4dfc0fe7f42f5a2",
"2a46f93948a788f91e9144ef4e9d",
"342a4b5af6876b59ee9ed64766a2",
"24caa0c3203d0320950920bd0acb",
"248b7c367d42d6cf42f445425522",
"3768a579b7ef7dc51d518ba7ba5e",
"36b16fbbd76477d71f72d7a47b68",
"2f7ee9cb20bf0b00bd0a203d02cb",
"2790fdf7b74276ef42f69f42f502",
"3d72fa5bf42f41fb1cb1fb98bbfe",
"20149f33160f611bdcbd1b98b914",
"3fd302cb50bd0b50b50b509d0b57",
"304270351f42f71f40f51f42f511",
"375376d3f1cb1ff1da1df5995bfd",
"375ef9d2b9c39c69e29ebf1bf3d5",
"375b268bd0bd0bd0bd09d0bd0a65",
"305b81b4afe2fc2fc2fc0f48f4f1",
"3fdb99ad8a0ca262e62da1161037"
]
},
{
"version": 40,
"text": "opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode opencode ",
"rows": [
"3fc110086dfd4f9dde95e53f19e1bbfc2f4211901947f",
"3056fa6e304ef427bd2bd00bdd2d5d11b19ad0a302d41",
"374e1a55d0ac18c7c2f42f8d2b923a11012249c19e45d",
"37512432c460d4002820864c3823c40bd2bd558d5235d",
"374fe6cdf2fb43bf202c07fe2ac29ff42f43fb56b505d",
"3057e9b512c27cb1bf0bf312e60e717a66c5184f8ad41",
"3fd55555555555555555555555555555555555555557f",
"20076ce51eaf1af162f42d154378d1faaec91ae2ac300",
"3f7a0b03f063d61f875f73fafea7ff2bf09df62e60faa",
"2134273829b50df78bddbb9c18f1b4f62f403f98fbbad",
"39ed7e343e8a1cf1bd0bd082d43d4dacb99b76276a466",
"3412cf916c2cb95342d42e9d8b90a064b51a88c18f39f",
"23de7b6c0240f520e8ba805650a92e0bd0bfb42d43d83",
"229feb946eb94b8f487283ff3ac39ed40d631394fd59d",
"31eb77cf7308708abd0bd963f40f4d72e78dfca5c665e",
"3281821c379e1ad642f40d8543d8927c67860af3ac337",
"3b772d9ddb5276801c5ae846704b980bd03c343f40ec0",
"3ba551bcbfbd8befd47a6b3f19e132f42f402fd0bf2a5",
"32c20db4cc86bae2bd03d1c0f52f4d2df11deceb8e066",
"2d97f2f6a81c20d942f42d9d03d822adfb1c497117bbd",
"3bead1134662e4209c22c45eb0eb270b50b6f50f53d23",
"3da2129b643d434f2ea0e7f52942bbf42f422b90f5585",
"276b9d69710e7068b50b51c3e62e6d0952d5d0a1c4446",
"2601e8cd8bb72ada42f427b50f54c22272e689db9e13e",
"2d70bbc9c453542037ad78563c6fcb8bd8bc76bfe2702",
"2d948b5547f58fff149e7bbeabf2b8f42fc20bd8b93a9",
"26e77aebfdc6bcf3bd0bd202dd3dcd9baa39e8eb8a042",
"2718cef9648f09d342fc2da983902299a91a41e2bf33c",
"2dfe282ff9c0ed3f0a8071fab0a33f23d0bff52e5bdf0",
"2d150b6b1f3903910aa0431c08c0b1f4af4b13543591b",
"2752865b560a3035bf0bd358e4ae756c44875067c675a",
"2712e3a915ad39f14af42d174b54916c46871bc08d11f",
"29f01cadf46bf61fe77a95f6786f9f0bf0bdf50643df3",
"30bba402bc954b4867fa3e6f2af3af7c27437ff8fd20d",
"2de46ffe2f86f8bf9d09d393560f779af3ed3ceb4e89e",
"32023f309c9e19c942f42e8d8dd8ed9af36efaf1ac254",
"3167d6c98952d539dc5da81eb4e75209d09c0e3d60ff0",
"30b9935bccfdafa2dc55a45c39e1bdd42f43b39019649",
"327a8fdd504e74bfbd2bd130f52d4f3189141023c2498",
"3b3e9056c0ad2bd640f42e0923b6253d05dbdac19c36e",
"36543ec3a663e5162a22c5a03c27d20bd2be260d50dba",
"3738f33cd33943c0e22ec56e2af2b4f40f433b54f5281",
"3a6e269940e27c77bd8b70f2e63e745a62eda88fc89b6",
"289b0a240f8c1ac962f62ee54f50fdf2aae5eae1ae25c",
"277405539040d609c41cb08ade8bd20bd0bc842f62ff2",
"20193b85f8f90fd0ca9cbacc38e1b8f62f63bf98b9249",
"31fc3b2d6eca9cfdbd0bd332f42d58a0898414a72a08a",
"3c0efadd2cac3a5342d4ae098b908d60a904d9c28c84f",
"21f46061e040f633ab3a41aa70a10a0bd2bc862e42d3b",
"25ab9114cfbd0b600b30414f2afbb4f42f4393d4bdac5",
"23f96189110e387fbd8bd9b3e43f766afaa53c67869a6",
"35a5bed2159492c542f40f8d0bd8ad587a85fbf22f16f",
"2c5026d9b852f40f9f99a896b86b9203d03d453d43cd0",
"38175ab0deb98f8094b9a85f01f12ff62d43ffd03d6cd",
"2a5603bfac8abcaf3d03d1f0e50f7c00f7191ceb0e49e",
"38a3f5f1da1f29da42f4ae4d83542da0c713d8709725d",
"2ff1d21ff468ee9f1d6145feb0671f0b50bff40e507f3",
"39101d93153d4b712f62451539d291f427411b50f5919",
"235f9669510e7855b50bd153f636753d5adf5863c6b52",
"2f1be0c319be2bd1427427158f58113d7aed12db9d31e",
"2ffbbbc7f652553ff69fc9f6bc6b1f8bd0bdf63fe3df2",
"30818f5f44f583e6d6bde89e1be292fc2fc24d94391b5",
"297577ed47cabcc0bd0bd362dd2d7993a03a7ca70a10a",
"31afc7f4a88c08efc2fc2e396390c391a21119e1be0ac",
"3240252d20c0e4161820b2caf0235d2bd0befda55ad60",
"249c0269a13b0ba5281000bc28c0a2f4af430b52bd7bb",
"3c6a825a23023030bf0bd358e40e766a458738638efaa",
"3aa6efa8bbaf3aff4af42ef74bb4ca6a4d89aac28c00f",
"23e51daae963f610877a65c678affd0bf0bed60e40f43",
"3d11ae003b954d6a87faac8f1af392742f4143f8f793d",
"247c6bfdb58afcb09d29f343d63f789ef9eb7ee74a706",
"2fbd343a099c19cf42f42cbd8dd88896f56938f1bf0ac",
"24f3d7cb0750d513d8adba0e54e78f0bd09e743d53f40",
"31b7915ab8f9aba35065b4ac394102d42d40ab903d6fc",
"35738fdcb8487098bd0bd360f50d7f32871cb029c66cd",
"2997915f5cae2aff60d40f9943d4cf3c07d72ac3ac097",
"21cd37c7fe62e6190a7ee7807c47e50bd0bee40f60fd1",
"2ab4f73c8f3d4be9ca7e641e1ae3a2f40f434f58f5235",
"30f92a9504e67a62bd0bd352f62fd89d61ef5cabc4906",
"353807252f8c10ef42f62d350dd2087dabed39c1ae09c",
"39e90b5ce442d416dc28c10abceb1d0bd0b6750f62f60",
"3425348778fd03e6dea8e2ac294298f42f608b90b52b9",
"3bc13b246ece90c0b5035b62e52e7e29c284b0a30628a",
"2f92f2df20a73a4742f42ef90b50cea2620519caac8bc",
"29fe6651f043f43fbb2e77f83261ff0bd8bdf62e42ffa",
"3d1b9c2f1bb50f711b1c771ea8fbb1f42f411bd8b9915",
"2b56279759063c75bd8bd152d43ff5ab7a2b586b8a956",
"291734fd1d9f99d14afc271d8b983199790913f21f31f",
"21f3d5e3fc50dd3f8f89b3f6b8ab3f03d0bff50d6bff0",
"2e0558963eb983b98ca981ef01f28df42f48f3d43575d",
"24449aa3348ab0a93d0bd183e58e7280741700e7066fe",
"3295f8fdc22d19f042f4adcd4354b4a0461438608f14e",
"3d514a038c4bf68d25715b3e7067aa0bd0bd242640582",
"2394a7ba8df54b43277e74053bd09df42740ef78fd94b",
"27d6f544f1c678439d0bd0a37536627e9bdf4c6bcea32",
"3d29db8b6d9e2bf042742d458fd8fffe3b6dfaf3ad1cf",
"3c60466b56e2550fde9fe2b6bceb6709d0bf2e3fe3da3",
"2bad95e008bd0fc5de95e98c1ae19dfc2f42d19019165",
"28f58901338e34cfbd2bd090de2d7211a1db80a3022f8",
"3b9e5fd74c8c38f0c2f42d5523921b11a1d179c19e1ce",
"336159119840f40d282086363023c82bd2be058d52c82",
"3aaf7e64851ba35b202c04cc2ac29df42f40eb56b5549",
"3448c2ae2fc27c87bf0bf290e60e726a56b7484f8ae9a",
"3da55f5b979f2af062f42f694f74cdeabe987ae2ac00c",
"22451d20ad53e61f875e72b2f6afe70bd03f362e60f92",
"2231ced5bfb50de68bdeb80c18f19df62f42ffd8fb95d",
"27fc0bde952a9c46bd0bd3b2d43d629c89ab06e76a6b6",
"2fbdc47be18c39f042d42d598f949e548529b8c18f1af",
"22e3276bdb40f51fe8b8823a5ca50e0bd0bd142d43e03",
"332741d850398b504870810f3ac395f42d40d3543d74d",
"3173afbe2808b05abf09da83f40f7252c7af2ca5c663e",
"2c1751dcb01e1af042f40f4d4bd0b45c47a43af3ac007",
"21fd7787fa12f61f1f59ebfe7843bf0bd03df43f40df0",
"2b14771d173d8b71d7796b1711e931f42f411fd0bd315",
"31596a675d06ba75bd03d150f52f551dc12d5c6b0c956",
"3518b727159c20f140f62f190fd411bdeb0f19f19611d",
"39f93bcdf162e43fde20c7fabce73f0b50b7f50f52ff3",
"3595b427bb3d439feea0e53d2942a4f42f42ab5035b99",
"3af11b34c70e7021b50b500bee266f2972d7e0630695a",
"2f92125d71b72ac142f427a50b50d21242e789da9c1fe",
"296e6641b1535408b72d7b663863cb8bd8bdb63e60eca",
"3dbb9c5c56f58fdf971d7bdeabf29bf42fc21b5839bbd",
"2bc627977046bcc93d8bd242d53dddbbaa38d86b0aade",
"289774dc4d8f09e942fc2f9983983ab9a91843e1bc23c",
"20f31503cb40ed20c943b25ab0a70423d0bd552f5ac00",
"2f15e8f42cf9039fcb6182bc08f0a4f4af483394f57a7",
"25e46a33e7ca3029bd0bd088e48e6d604486702746d6a",
"3235684ef2ad39cbca74ada74b54b060468689c28f19f",
"3dc16a92af6bf620a43ad766786fae0bf0bdb60540c83",
"238407b9ac954b5fa43affff3af38e7427421ff8fd59d",
"26467574f386f8a69d09d043763f4d92fbecfcebce756",
"3c19abe83d1e1bfe42f42fad8dd8f292fb6e08f3ae3fc",
"3cd0a6bbc552d5301c9d6a6eb4e74809d09d3c3f62c40",
"2b0d653289fdaf9fdc55a63c09d1a2d42f4221921b3a5",
"29f14990804e74a6bd2bd0c0e53d4d3d851be023c2164",
"3b96bf85feec29f540f42fa9039632350dd349c29fbfe",
"3351d9636a20e600aa22c4403c27e70bd2bdf50e53ce2",
"3a37dc5454f943df22ae46fe1ac28bf40f422b54f5385",
"35c8983d0ee27c60bd0bf0c2d60e4d5e66ebda8dc8a46",
"3c99fbb9354e18d262f62f954f50f2fea6e888e3ae2bc",
"23f7fe43ffc2d43fc49c73fafeabdf0bd0bdf42f62df2",
"2310bf671ef70df1ca9c791c18c1b1f62f631f98b9319",
"375e8947568a9cd5bd0bd352d40d55a8818f54a72a35a",
"3f1aada712af397142d42d198b909168a10d19c28c91f",
"23fca8f5fb43f53fab3a41fa70a11f03d8b5f52e42ffb",
"2b98492843fd0b580b30426f1acb8bf42d4373d4bdb85",
"217595199bc4325fbd8bd9b3f42f586ef7aa0c6786926",
"34808772819e98c742f40d0d4bd8ad5c778bebf22f24f",
"304125cb2852f4379f99a886786b920bd03d453d43df0",
"37adae9196f5838094b9aa4f21f10dfc27497fd03d6cd",
"204379e8af46b0873d03d090d50f5701f11c3ceb0e79e",
"363cb866b61f29d942f4ae8d03d42da1c11ff870972dd",
"21f036c990a3e5b91d61471e30e7220b50bc040e504b3",
"39bab6efe83543522f626655b952aff42f40bb50f5889",
"2ae41d32d7c6707fb50bd133f6365c3e59d51063c6992",
"2d27145b507d2be6427405054f98e53c7bebdadb9d26e",
"3b71664922d35536f6afe9a67cabe28bd0bd263fe3d32",
"32169953b6398fc0d69deb6e1be2a6fc2fc33198391c5",
"30542896b142b4c7bd0bd0f25dadd691a23aa0ab0a0ba",
"35b173d9cc0c08c9c2fc2ce923d0af92a113e9e1be25c",
"24db180249c0e4290820b18a30e3b22bd0bc85ad5acf0",
"2ca6e2fcaafb0f90082032cc28c0abf4af40b35ab564b",
"2dfa6339404a3c1dbf0bd138e40e5b684784106b86f8a",
"3f85644376af3ac34af42c078b743d684f8bdac28c24f",
"35dd659e4163f633b74a55a678afd809f2bc460e40d33",
"2ebc09beeb55494097eabe4f1af386742f405ff4fb9cd",
"3cdb737e0dcaf89fbd09d0b3d63f569dfae836ef425a6",
"2225a1e8ec9c11c542f42e8d0dd8bd95f66b78f1af36c",
"2affa4bffed0d53fec9d8bfe94671f0bd09df43d63df0",
"201d65351c79abb16c55851c39c1b1d62d411f943971d",
"3fd74f9953087095bd0bd350f50d7530871d5c25ca55c",
"304bba811aae2af140f42f194356313c07d71ac38c316",
"375cde6df6a2e63f2652c5f07c471f0bd0bdf40f40df2",
"375fd558c8bd4bffea5e469e1ae282f40f410b70dd0d4",
"375b943d9fe67a50bd0bd152f62e7f5d61ee38a3ccbc4",
"305bffb7bfcc10ef42f62cf50fd01ffdabef11f99619c",
"3fdbf34ec2c2d43aec18c34abeeb3d0bd0b4f53f52f52"
]
}
]
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test"
import { randomBytes } from "node:crypto"
import jsQR from "jsqr"
import { encode, renderSVG, renderUnicodeCompact } from "../src/qr.js"
import fixtures from "./fixtures/qr.json" with { type: "json" }
// Fixtures were generated once with the uqr package, a port of Nayuki's reference encoder. Each row is hex with a
// leading 1 bit so leading light modules survive.
const golden = fixtures.map((fixture) => ({
...fixture,
modules: fixture.rows.map((row) =>
[
...BigInt("0x" + row)
.toString(2)
.slice(1),
].map((bit) => bit === "1"),
),
}))
describe("qr", () => {
test.each(golden.map((fixture) => [fixture.version, fixture.text.slice(0, 40), fixture] as const))(
"matches the reference encoder at version %i: %s",
(version, _, fixture) => {
const modules = encode(fixture.text)
expect(modules.length).toBe(version * 4 + 17)
expect(modules).toEqual(fixture.modules)
},
)
// jsQR 1.4.0 places version 23's alignment patterns at 74 instead of 78, so it can't read that version.
test.each(golden.filter((fixture) => fixture.version !== 23).map((fixture) => [fixture.version, fixture] as const))(
"SVG and terminal output decode at version %i",
(_, fixture) => {
expect(decode(fromSVG(renderSVG(fixture.text, { border: 4 })))).toBe(fixture.text)
expect(decode(fromUnicode(renderUnicodeCompact(fixture.text, { border: 1 })))).toBe(fixture.text)
},
)
test("random pairing payloads decode", () => {
Array.from({ length: 30 }, (_, i) => {
const text = JSON.stringify({
code: randomBytes(16).toString("base64url"),
urls: Array.from({ length: 1 + (i % 5) }, (_, j) => `http://192.168.${i}.${j + 1}:4096`),
})
expect(decode(fromUnicode(renderUnicodeCompact(text, { border: (i % 3) + 1 })))).toBe(text)
})
})
test("renders the SVG as uqr did", () => {
const svg = renderSVG("hello", { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
expect(svg).toStartWith(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 290 290"><rect fill="transparent" width="290" height="290"/><path fill="currentColor" d="M40,40h10v10h-10zM50,40h10v10h-10z',
)
expect(fromSVG(svg)).toEqual(withBorder(golden[0].modules, 4))
})
test("renders terminal rows in pairs and shows the background below the last odd row", () => {
const lines = renderUnicodeCompact("hello", { border: 2 }).split("\n")
expect(lines).toHaveLength(13)
expect(lines.every((line) => line.length === 25)).toBe(true)
expect(lines[0]).toBe("\u2588".repeat(25))
expect(lines.at(-1)).toBe("\u2580".repeat(25))
expect(fromUnicode(lines.join("\n"))).toEqual(withBorder(golden[0].modules, 2))
})
test("rejects text longer than version 40 holds", () => {
expect(encode("a".repeat(2953))).toHaveLength(177)
expect(() => encode("a".repeat(2954))).toThrow(RangeError)
})
})
function withBorder(modules: boolean[][], border: number) {
const width = modules.length + border * 2
const light = Array<boolean>(border).fill(false)
const edge = Array.from({ length: border }, () => Array<boolean>(width).fill(false))
return [...edge, ...modules.map((row) => [...light, ...row, ...light]), ...edge]
}
function fromSVG(svg: string) {
const width = Number(svg.match(/viewBox="0 0 (\d+)/)?.[1]) / 10
const modules = Array.from({ length: width }, () => Array<boolean>(width).fill(false))
for (const match of svg.matchAll(/M(\d+),(\d+)h10v10h-10z/g))
modules[Number(match[2]) / 10][Number(match[1]) / 10] = true
return modules
}
// Reverses the half-block encoding and drops the padding row under the last line.
function fromUnicode(text: string) {
const lines = text.split("\n")
const halves = { "\u2588": [false, false], "\u2580": [false, true], "\u2584": [true, false], " ": [true, true] }
return lines
.flatMap((line) => {
const pairs = [...line].map((char) => halves[char as keyof typeof halves])
return [pairs.map((pair) => pair[0]), pairs.map((pair) => pair[1])]
})
.slice(0, lines[0].length)
}
// Draws the modules 4 pixels each with an extra quiet zone, then reads them back with jsQR.
function decode(modules: boolean[][]) {
const scale = 4
const padded = withBorder(modules, 4)
const width = padded.length * scale
const pixels = new Uint8ClampedArray(width * width * 4).map((_, i) => {
if (i % 4 === 3) return 255
const pixel = Math.floor(i / 4)
return padded[Math.floor(pixel / width / scale)][Math.floor((pixel % width) / scale)] ? 0 : 255
})
// jsqr is CommonJS, so under NodeNext its function is the module's `default` property.
const result = jsQR.default(pixels, width, width)
if (!result) throw new Error("jsQR could not read the QR Code")
return new TextDecoder().decode(new Uint8Array(result.binaryData))
}