From b207bf6d7cd1d1fd008f084d62c2eb7842e708b1 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 15 Sep 2026 23:07:21 -0600 Subject: [PATCH 1/5] barcodes: pure-Rip QR encoder, decoder and camera plumbing Port of paulmillr/qr 0.7.0 (MIT OR Apache-2.0, ZXing-derived) as packages/barcodes: spec.rip (ISO/IEC 18004 tables, GF(2^8), BCH and Golay words), barcodes.rip (encodeQR, packed-bitmap encoder with the word-parallel mask race), decode.rip (decodeQR, decodeQRBatch, QRScanner with the zero-allocation frame path) and dom.rip (QRCanvas, QRCamera, frameLoop, svgToPng, gifToPng, BarcodeDetector). Labeled blocks became named methods and delegated generators; the shared scratch arena became named arrays. Encoder output is byte-identical to the reference on 1452 checks across every version, level, mask and output; the decoder matches on 192 synthetic checks. Under Bun the encoder is equal or faster and the decoder within noise. test.rip pins spec tables, codewords, every version and mask against vectors generated from the reference, then round-trips the decoder over rotations, inverted symbols, input formats, batch decoding and scanner reuse. --- packages/barcodes/README.md | 146 +++ packages/barcodes/barcodes.rip | 711 +++++++++++ packages/barcodes/decode.rip | 2068 ++++++++++++++++++++++++++++++++ packages/barcodes/dom.rip | 938 +++++++++++++++ packages/barcodes/package.json | 25 + packages/barcodes/spec.rip | 125 ++ packages/barcodes/test.rip | 290 +++++ 7 files changed, 4303 insertions(+) create mode 100644 packages/barcodes/README.md create mode 100644 packages/barcodes/barcodes.rip create mode 100644 packages/barcodes/decode.rip create mode 100644 packages/barcodes/dom.rip create mode 100644 packages/barcodes/package.json create mode 100644 packages/barcodes/spec.rip create mode 100644 packages/barcodes/test.rip diff --git a/packages/barcodes/README.md b/packages/barcodes/README.md new file mode 100644 index 00000000..e798abc8 --- /dev/null +++ b/packages/barcodes/README.md @@ -0,0 +1,146 @@ +Rip + +# Rip Barcodes + +> **QR code generator and reader — packed-bitmap encoder, camera-budgeted decoder, zero dependencies.** + +The encoder keeps a symbol as one `Uint32Array` with 32 modules per word, +builds the function-pattern template, placement order and the eight mask +planes once per version, and chooses a mask by XORing whole words and +scoring the penalty rules word-parallel. The decoder binarizes a four-level +image pyramid against 8x8 block thresholds, finds finder patterns with +run-length windows that consume a word at a time, projects the best triple +through a homography, and corrects with Reed-Solomon, all inside buffers +allocated once per scanner so a camera frame never allocates. + +**Runtime:** browser-safe (`rip.browser: true`). Four `.rip` files: the +encoder entry, the decoder entry, the camera and canvas plumbing, and the +ISO/IEC 18004 tables the first two share. + +## Quick Start + +```coffee +import encodeQR from 'rip/barcodes' +import decodeQR from 'rip/barcodes/decode' + +text = 'Hello world' +console.log encodeQR(text, 'term') # print to any terminal +svg = encodeQR text, 'svg' # markup for a page +gif = encodeQR text, 'gif', scale: 4 # Uint8Array, a GIF file +url = encodeQR text, 'data-url', scale: 4 # 'data:image/gif;base64,...' +matrix = encodeQR text, 'raw' # boolean[][] with the quiet zone +ascii = encodeQR text, 'ascii' # half-height block characters + +# decode any RGBA raster, the shape a canvas ImageData already has +decodeQR { width, height, data } # the text, or throws +``` + +## Features + +- Every version 1..40, every error-correction level, numeric, alphanumeric + and byte modes, automatic version and mask selection, explicit overrides +- Six outputs: `raw`, `ascii`, `term`, `svg`, `gif`, `data-url` +- Decoding from RGB, RGBA, packed BGRA/X variants, and planar luma formats + including 10- and 12-bit I420 +- ECI-aware byte segments, inverted symbols, arbitrary rotation +- A reusable `QRScanner` for camera loops, with an `effort` tier, a + `timeLimit` budget, and a cooperative `decodeAsync` that yields between + bounded work units +- `decodeQRBatch` finds every symbol in each image + +## Encoding + +```coffee +encodeQR text, output, opts +``` + +| option | meaning | default | +| --- | --- | --- | +| `ecc` | `'low'` 7%, `'medium'` 15%, `'quartile'` 25%, `'high'` 30% | `'medium'` | +| `encoding` | `'numeric'`, `'alphanumeric'`, `'byte'` | smallest fit | +| `version` | 1..40 | smallest fit | +| `mask` | 0..7 | lowest penalty, first on ties | +| `border` | quiet zone in modules, at least 1 | 2 | +| `scale` | pixels per module | 1 | +| `optimize` | `svg` only: merge modules into one path | `true` | +| `textEncoder` | custom text-to-bytes for `byte` mode | UTF-8 | + +Single-segment encoding is always used, and penalty scoring runs on the +reserved test form, so output matches python-qrcode module for module. + +## Decoding + +`decodeQR` takes `{ width, height, data }` and returns the decoded string. It +throws when no symbol decodes; in a camera loop that is a frame miss, feed +the next frame. Clean one-pixel-per-module rasters are too small for +run-length finder detection, so upscale the encoder's `raw` output at least +twice before decoding it. + +| option | meaning | default | +| --- | --- | --- | +| `format` | `'RGB'`, `'RGBA'`, `'RGBX'`, `'BGRA'`, `'BGRX'`, `'I420'`, `'I420A'`, `'I422'`, `'I444'`, `'NV12'`, `'I420P10'`, `'I420P12'` | detected from length | +| `effort` | retry tier: 1 runs only the mandatory pass, `Infinity` runs every retry | 1 | +| `timeLimit` | milliseconds available to retries | one 60 FPS frame | +| `textDecoder` | `(bytes, eci) -> string` for byte segments | `TextDecoder` | +| `pointsOnDetect` | `(points, result) ->` finder, alignment and outline geometry | | +| `imageOnResult` | `(image) ->` the sampled module grid as RGBA | | +| `imageOnBitmap` | `(image) ->` each binarized plane before detection | | + +For photos and uploads pass `effort: Infinity, timeLimit: Infinity`. +Successful decodes cost the same in every tier; retries only run after a +failed strict pass. + +## Scanner + +```coffee +import { QRScanner } from 'rip/barcodes/decode' + +scanner = QRScanner.new maxSize: { width: 1920, height: 1080 }, effort: 2 +scanner.addImage frame # any supported format, up to maxSize +results = scanner.decode() # [string] or [Error] +results = scanner.decodeAsync! # same, yielding to the host between chunks +scanner.clean() # zero every buffer when the source is released +``` + +One scanner serves a whole camera session: its luma arena, pyramid, threshold +grids, bitmaps and finder tables are allocated in the constructor and reused +for every frame. Operations are exclusive; a call made while `decodeAsync` +is pending throws. + +## Camera + +```coffee +import { QRCanvas, frameLoop, rearCamera } from 'rip/barcodes/dom' + +video = document.querySelector 'video' +overlay = document.querySelector 'canvas' # positioned over the video +canvas = QRCanvas.new { overlay } +camera = rearCamera! video +cancel = frameLoop -> + decoded = camera.readFrame canvas # undefined until a frame decodes + if decoded isnt undefined + console.log decoded + cancel() + camera.stop() +``` + +`QRCanvas` decodes frames through one reusable scanner and paints a finder +overlay, the decoded symbol, or the binarized plane onto the canvases it is +given. `rearCamera` and `selfieCamera` open a stream into a video element; +`camera.listDevices()` and `camera.setDevice(id)` switch cameras. When the +browser exposes `VideoFrame`, frames are copied plane-for-plane into the +scanner arena without a canvas round trip. `svgToPng` and `gifToPng` +rasterize the encoder's output, and `BarcodeDetector` is a Shape Detection +API ponyfill over `decodeQR`. Camera access needs a secure context. + +## Test + +```bash +bun run test +``` + +The suite pins spec tables, encoded codewords, every output format, every +version and every mask against vectors generated from the reference +implementation, then round-trips synthetic rasters through the decoder +across versions, levels, rotations, inverted symbols, input formats, batch +decoding and scanner reuse. diff --git a/packages/barcodes/barcodes.rip b/packages/barcodes/barcodes.rip new file mode 100644 index 00000000..dc7b4839 --- /dev/null +++ b/packages/barcodes/barcodes.rip @@ -0,0 +1,711 @@ +# ============================================================================== +# rip/barcodes — QR encoder +# +# A symbol is a packed bit matrix: one Uint32Array, 32 modules per word, +# LSB-first, with bits at x >= size held zero (the penalty scanners depend on +# it). Everything the version alone determines is built once and cached in a +# single slot: the function-pattern template, the zigzag placement order, and +# the eight mask XOR planes with their transposes. Mask selection XORs whole +# words and scores the test form word-parallel, so the eight-mask race costs +# one transpose per encode. +# ============================================================================== + +import { + ALPHANUMERIC, BYTES, ECC_BLOCKS, ECC_LEVELS, GF256, WORDS_PER_BLOCK, + alignmentPatterns, formatBits, maskBits, popcnt, versionBits, +} from './spec.rip' + +MAX_OUTPUT_SIZE =! 1024 +MAX_COMPACT_OUTPUT_SIZE =! 4096 + +MODE_BITS =! { numeric: 1, alphanumeric: 2, byte: 4 } +LENGTH_BITS =! { numeric: [10, 12, 14], alphanumeric: [9, 11, 13], byte: [8, 16, 16] } +NUMERIC_BITS =! [0, 4, 7, 10] + +fail =! (msg) -> throw Error.new msg + +# charCode -> Table 5 value; -1 outside the alphabet. +ALNUM_VAL =! do -> + t = new Int8Array(128).fill(-1) + for i in [0...ALPHANUMERIC.length] + t[ALPHANUMERIC.charCodeAt(i)] = i + t + +# ==[ Reed-Solomon ]== + +# Generator polynomial (leading 1 dropped) and every coefficient*feedback +# product, cached per parity length. +RS_CACHE =! [] + +rsGenerator =! (n) -> + {exp, log} = GF256 + gen = new Uint8Array(n) + gen[n - 1] = 1 + root = 1 + for i in [0...n] + for j in [0...n] + c = gen[j] + gen[j] = (if c then exp[log[c] + log[root]] else 0) ^ (if j + 1 < n then gen[j + 1] else 0) + root = exp[log[root] + 1] + gen + +rsCached =! (n) -> + return RS_CACHE[n] if RS_CACHE[n] + {exp, log} = GF256 + gen = rsGenerator n + mul = new Uint8Array(256 * n) + for f in [1...256] + lf = log[f] + base = f * n + for j in [0...n] + c = gen[j] + mul[base + j] = exp[log[c] + lf] if c + RS_CACHE[n] = { gen, mul } + +# Parity via LFSR remainder. +rsEcc =! (data, rs) -> + {gen, mul} = rs + n = gen.length + last = n - 1 + res = new Uint8Array(n) + for i in [0...data.length] + base = (data[i] ^ res[0]) * n + for j in [0...last] + res[j] = res[j + 1] ^ mul[base + j] + res[last] = mul[base + last] + res + +capacity =! (ver, ecc) -> + bytes = BYTES[ver - 1] + words = WORDS_PER_BLOCK[ecc][ver - 1] + numBlocks = ECC_BLOCKS[ecc][ver - 1] + blockLen = (bytes // numBlocks) - words + shortBlocks = numBlocks - bytes % numBlocks + { words, numBlocks, shortBlocks, blockLen, capacity: (bytes - words * numBlocks) * 8 } + +# ==[ Data codewords ]== + +detectType =! (str) -> + type = 'numeric' + for i in [0...str.length] + v = ALNUM_VAL[str.charCodeAt(i)] + return 'byte' unless v >= 0 + type = 'alphanumeric' if v > 9 + type + +# Segment bits, terminator, padding, then RS blocks interleaved. +encodeData =! (ver, ecc, text, type, utf8) -> + cap = capacity ver, ecc + lengthBits = LENGTH_BITS[type][(ver + 7) // 17] + dataLen = if type is 'byte' then utf8.length else text.length + fail 'Capacity overflow' if dataLen >= 1 << lengthBits + bytes = new Uint8Array(cap.capacity >>> 3) + # MSB-first accumulator flushed a byte at a time; pushes are <= 16 bits and + # a flush keeps it below 8, so it never nears 32. + acc = 0 + accBits = 0 + bytePos = 0 + push = (value, len) -> + acc = (acc << len) | value + accBits += len + while accBits >= 8 + accBits -= 8 + bytes[bytePos++] = (acc >>> accBits) & 0xff + return + push MODE_BITS[type], 4 + push dataLen, lengthBits + if type is 'numeric' + i = 0 + while i < dataLen + n = Math.min(3, dataLen - i) + push Number(text.slice(i, i + n)), NUMERIC_BITS[n] + i += 3 + else if type is 'alphanumeric' + i = 0 + while i + 1 < dataLen + push ALNUM_VAL[text.charCodeAt(i)] * 45 + ALNUM_VAL[text.charCodeAt(i + 1)], 11 + i += 2 + push ALNUM_VAL[text.charCodeAt(dataLen - 1)], 6 if dataLen & 1 + else + for i in [0...utf8.length] + push utf8[i], 8 + bitPos = bytePos * 8 + accBits + fail 'Capacity overflow' if bitPos > cap.capacity + bytes[bytePos] = (acc << (8 - accBits)) & 0xff if accBits + bitPos += Math.min(4, cap.capacity - bitPos) + bitPos += 8 - (bitPos & 7) if bitPos & 7 + pad = 0 + start = bitPos >>> 3 + for i in [start...bytes.length] + bytes[i] = if pad then 0x11 else 0xec + pad ^= 1 + {words, numBlocks, shortBlocks, blockLen} = cap + rs = rsCached words + blocks = [] + eccs = [] + pos = 0 + for i in [0...numBlocks] + len = blockLen + (if i < shortBlocks then 0 else 1) + block = bytes.subarray pos, pos + len + blocks.push block + eccs.push rsEcc(block, rs) + pos += len + res = new Uint8Array(bytes.length + words * numBlocks) + out = 0 + for i in [0..blockLen] + for b in blocks + res[out++] = b[i] if i < b.length + for i in [0...words] + for e in eccs + res[out++] = e[i] + res + +# ==[ Packed bit matrix ]== + +mat =! (size) -> + words = (size + 31) >>> 5 + { size, words, v: new Uint32Array(words * size) } + +matGet =! (m, x, y) -> (m.v[y * m.words + (x >>> 5)] >>> (x & 31)) & 1 + +matSet =! (m, x, y, bit) -> + i = y * m.words + (x >>> 5) + b = 1 << (x & 31) + m.v[i] = if bit then m.v[i] | b else m.v[i] & ~b + return + +TRANSPOSE_MASKS =! [0x55555555, 0x33333333, 0x0f0f0f0f, 0x00ff00ff, 0x0000ffff] +TRANSPOSE_TMP =! new Uint32Array(32) + +# 32x32 in-place bit-matrix transpose (butterfly network). +transpose32 =! (a) -> + for stage in [0...5] + m = TRANSPOSE_MASKS[stage] >>> 0 + s = 1 << stage + i = 0 + while i < 32 + for k in [0...s] + x = a[i + k] >>> 0 + y = a[i + k + s] >>> 0 + t = ((x >>> s) ^ y) & m + a[i + k] = (x ^ (t << s)) >>> 0 + a[i + k + s] = (y ^ t) >>> 0 + i += s << 1 + return + +transposeMat =! (src, dst) -> + {size, words, v} = src + tmp = TRANSPOSE_TMP + y0 = 0 + while y0 < size + for bx in [0...words] + rows = Math.min(32, size - y0) + for r in [0...rows] + tmp[r] = v[(y0 + r) * words + bx] + tmp.fill 0, rows + transpose32 tmp + dstY = bx * 32 + i = 0 + while i < 32 and dstY < size + dst.v[dstY * dst.words + (y0 >>> 5)] = tmp[i] + i++ + dstY++ + y0 += 32 + return + +# ==[ Mask penalty ]== + +# N1, all columns of a 32-wide stripe at once: D = row ^ next row flags +# changes, a monochrome 5-window is four clear D bits, and a run of length L +# contributes L-4 windows plus one run-start window counted twice. +runsPenaltyVertical =! (m) -> + {size, words, v} = m + tail = if size & 31 then ((1 << (size & 31)) - 1) >>> 0 else 0xffffffff + score = 0 + for wi in [0...words] + valid = if wi is words - 1 then tail else 0xffffffff + r3 = v[3 * words + wi] + dPrev = 0xffffffff + d0 = v[wi] ^ v[words + wi] + d1 = v[words + wi] ^ v[2 * words + wi] + d2 = v[2 * words + wi] ^ r3 + idx = 4 * words + wi + for y in [0..size - 5] + r4 = v[idx] + d3 = r3 ^ r4 + w = ~(d0 | d1 | d2 | d3) & valid + score += popcnt(w >>> 0) + 2 * popcnt((w & dPrev) >>> 0) if w + dPrev = d0 + d0 = d1 + d1 = d2 + d2 = d3 + r3 = r4 + idx += words + score + +# N3: 1011101 with four light modules before or after, both orientations of +# the pattern across a 32-wide stripe at once. +finderPenaltyVertical =! (m) -> + {size, words, v} = m + tail = if size & 31 then ((1 << (size & 31)) - 1) >>> 0 else 0xffffffff + count = 0 + for wi in [0...words] + valid = if wi is words - 1 then tail else 0xffffffff + for y in [0..size - 11] + i = y * words + wi + r0 = v[i] + r1 = v[i + words] + r2 = v[i + 2 * words] + r3 = v[i + 3 * words] + r4 = v[i + 4 * words] + r5 = v[i + 5 * words] + r6 = v[i + 6 * words] + r7 = v[i + 7 * words] + r8 = v[i + 8 * words] + r9 = v[i + 9 * words] + r10 = v[i + 10 * words] + m0 = valid & r0 & ~r1 & r2 & r3 & r4 & ~r5 & r6 & ~(r7 | r8 | r9 | r10) + m1 = valid & ~(r0 | r1 | r2 | r3) & r4 & ~r5 & r6 & r7 & r8 & ~r9 & r10 + count += popcnt(m0 >>> 0) + popcnt(m1 >>> 0) + count + +# Score a symbol given both orientations. `limit` is the best score so far +# in the mask race: every term is non-negative, so a partial sum that reaches +# it can no longer win and the expensive N3 search is skipped. +penaltyScore =! (m, t, limit = Infinity) -> + {size, words, v} = m + adjacent = runsPenaltyVertical(m) + runsPenaltyVertical(t) + return adjacent if adjacent >= limit + # N2: three points per overlapping 2x2 same-color box. Valid left edges in + # the last word: one fewer than the bits it holds. + tail2 = ((1 << (size - 32 * (words - 1) - 1)) - 1) >>> 0 + boxes = 0 + dark = 0 + for y in [0...size] + for wi in [0...words] + a0 = v[y * words + wi] + dark += popcnt(a0 >>> 0) + continue if y is size - 1 + a1 = v[(y + 1) * words + wi] + n0 = if wi + 1 < words then v[y * words + wi + 1] else 0 + n1 = if wi + 1 < words then v[(y + 1) * words + wi + 1] else 0 + eqV = ~(a0 ^ a1) + eqH0 = ~(a0 ^ ((a0 >>> 1) | (n0 << 31))) + eqH1 = ~(a1 ^ ((a1 >>> 1) | (n1 << 31))) + w = eqV & eqH0 & eqH1 + w &= tail2 if wi is words - 1 + boxes += popcnt(w >>> 0) + total = size * size + darkSteps = Math.ceil(Math.max(0, Math.abs(dark * 100 - total * 50) - total * 5) / (total * 5)) + partial = adjacent + 3 * boxes + 10 * darkSteps + return partial if partial >= limit + partial + 40 * (finderPenaltyVertical(m) + finderPenaltyVertical(t)) + +penalty =! (m, t) -> + transposeMat m, t + penaltyScore m, t + +# ==[ Symbol layout ]== + +drawInfo =! (m, ver, ecc, mask) -> + size = m.size + bits = formatBits ecc, mask + for i in [0...15] + bit = (bits >> i) & 1 + if i < 6 then matSet m, 8, i, bit + else if i < 8 then matSet m, 8, i + 1, bit + else if i is 8 then matSet m, 7, 8, bit + else matSet m, 14 - i, 8, bit + if i < 8 then matSet m, size - 1 - i, 8, bit + else matSet m, 8, size - 15 + i, bit + matSet m, 8, size - 8, 1 + if ver >= 7 + vbits = versionBits ver + for i in [0...18] + bit = (vbits >> i) & 1 + x = size - 11 + i % 3 + y = i // 3 + matSet m, x, y, bit + matSet m, y, x, bit + return + +# Everything the layout alone determines, built once per version: the +# function-pattern template with the data region zero, the zigzag placement +# order as packed (wordIndex << 5 | bitOffset) positions, the eight mask XOR +# planes and their transposes, and four scratch matrices. Single slot: +# workloads overwhelmingly encode one version repeatedly. +symCache = null + +buildSymCache =! (ver) -> + size = 21 + 4 * (ver - 1) + m = mat size + fun = new Uint8Array(size * size) + setF = (x, y, bit) -> + matSet m, x, y, bit + fun[y * size + x] = 1 + return + for [fx, fy] in [[0, 0], [size - 7, 0], [0, size - 7]] + for dy in [-1...8] + for dx in [-1...8] + x = fx + dx + y = fy + dy + continue if x < 0 or y < 0 or x >= size or y >= size + dark = dx >= 0 and dx < 7 and dy >= 0 and dy < 7 and + (dx is 0 or dx is 6 or dy is 0 or dy is 6 or (dx > 1 and dx < 5 and dy > 1 and dy < 5)) + setF x, y, (if dark then 1 else 0) + align = alignmentPatterns ver + for ay in align + for ax in align + continue if fun[ay * size + ax] + for dy in [-2..2] + for dx in [-2..2] + dark = Math.max(Math.abs(dx), Math.abs(dy)) isnt 1 + setF ax + dx, ay + dy, (if dark then 1 else 0) + for i in [0...size] + setF i, 6, (if i % 2 is 0 then 1 else 0) unless fun[6 * size + i] + setF 6, i, (if i % 2 is 0 then 1 else 0) unless fun[i * size + 6] + # Format, version and dark-module cells are reserved at zero: the "test + # form" the mask penalties are scored on. + for i in [0...9] + if i isnt 6 + setF 8, i, 0 + setF i, 8, 0 + if i < 8 + setF size - 1 - i, 8, 0 + setF 8, size - 1 - i, 0 + if ver >= 7 + for i in [0...18] + x = size - 11 + i % 3 + y = i // 3 + setF x, y, 0 + setF y, x, 0 + planes = (mat(size) for i in [0...8]) + posBuf = new Uint16Array(size * size) + n = 0 + xOffset = size - 1 + dir = -1 + y = size - 1 + while xOffset > 0 + xOffset = 5 if xOffset is 6 + loop + for j in [0...2] + x = xOffset - j + continue if fun[y * size + x] + wi = y * m.words + (x >>> 5) + posBuf[n++] = (wi << 5) | (x & 31) + mb = maskBits x, y + pl = 0 + while mb + planes[pl].v[wi] |= 1 << (x & 31) if mb & 1 + pl++ + mb >>= 1 + break if y + dir < 0 or y + dir >= size + y += dir + xOffset -= 2 + dir = -dir + planesT = planes.map (pl) -> + t = mat size + transposeMat pl, t + t.v + { + ver + tpl: m.v + pos: posBuf.slice(0, n) + planes: planes.map((pl) -> pl.v) + planesT + work: [mat(size), mat(size), mat(size), mat(size)] + } + +# Template copy, data-bit scatter along the cached zigzag order, then mask +# selection over XOR candidates; the first lowest score wins. +drawSymbol =! (ver, ecc, data, maskIdx, test = false) -> + symCache = buildSymCache ver unless symCache?.ver is ver + {tpl, pos, planes, planesT, work} = symCache + [m, t, cand, candT] = work + m.v.set tpl + need = Math.min(8 * data.length, pos.length) + for i in [0...need] + if data[i >>> 3] & (0x80 >>> (i & 7)) + q = pos[i] + m.v[q >>> 5] |= 1 << (q & 31) + mask = maskIdx + unless mask? + transposeMat m, t + bestScore = Infinity + for pl in [0...8] + pv = planes[pl] + ptv = planesT[pl] + for i in [0...cand.v.length] + cand.v[i] = m.v[i] ^ pv[i] + candT.v[i] = t.v[i] ^ ptv[i] + score = penaltyScore cand, candT, bestScore + if score < bestScore + bestScore = score + mask = pl + chosen = planes[mask] + for i in [0...m.v.length] + m.v[i] ^= chosen[i] + drawInfo m, ver, ecc, mask unless test + m + +# ==[ Validation ]== + +asVersion =! (ver) -> + throw TypeError.new "\"version\" expected number, got type=#{typeof ver}" unless typeof ver is 'number' + throw RangeError.new "\"version\" expected safe integer, got #{ver}" unless Number.isSafeInteger ver + throw RangeError.new "Invalid version=#{ver}. Expected number [1..40]" if ver < 1 or ver > 40 + ver + +asNum =! (n, title) -> + throw TypeError.new "\"#{title}\" expected number, got type=#{typeof n}" unless typeof n is 'number' + throw RangeError.new "\"#{title}\" expected safe integer, got #{n}" unless Number.isSafeInteger n + n + +asString =! (s, title) -> + throw TypeError.new "\"#{title}\" expected string, got type=#{typeof s}" unless typeof s is 'string' + s + +# Exact WHATWG TextEncoder byte count without encoding; lone surrogates cost +# the three-byte replacement character. +utf8Length =! (str) -> + length = 0 + i = 0 + while i < str.length + c = str.charCodeAt i + if c < 0x80 then length++ + else if c < 0x800 then length += 2 + else if c < 0xd800 or c > 0xdfff then length += 3 + else if c <= 0xdbff and i + 1 < str.length + next = str.charCodeAt(i + 1) + if next >= 0xdc00 and next <= 0xdfff + length += 4 + i++ + else length += 3 + else length += 3 + i++ + length + +byteCapacity =! (ver, ecc) -> + lengthBits = LENGTH_BITS.byte[(ver + 7) // 17] + Math.min((1 << lengthBits) - 1, (capacity(ver, ecc).capacity - 4 - lengthBits) // 8) + +# Cross-realm and Buffer views pass; JSON-shaped spoofs do not. +isBytes =! (a) -> + a instanceof Uint8Array or + (ArrayBuffer.isView(a) and a.constructor.name is 'Uint8Array' and a.BYTES_PER_ELEMENT is 1) + +# ==[ Renderers ]== +# A raster is the finished matrix plus output geometry; `map` takes an output +# coordinate to its module index, -1 in the border. + +darkAt =! (r, x, y) -> r.map[x] >= 0 and r.map[y] >= 0 and matGet(r.m, r.map[x], r.map[y]) is 1 + +renderRaw =! (r) -> + W = r.W + res = Array.new(W) + for y in [0...W] + row = Array.new(W) + for x in [0...W] + row[x] = darkAt(r, x, y) + res[y] = row + res + +renderAscii =! (r) -> + W = r.W + out = '' + y = 0 + while y < W + for x in [0...W] + first = darkAt r, x, y + second = if y + 1 >= W then true else darkAt(r, x, y + 1) + out += if not first and not second then '█' else if not first and second then '▀' else if first and not second then '▄' else ' ' + out += '\n' + y += 2 + out + +renderTerm =! (r) -> + W = r.W + black = '\x1b[40m \x1b[0m' + white = '\x1b[1;47m \x1b[0m' + out = '' + for y in [0...W] + for x in [0...W] + out += (if darkAt(r, x, y) then black else white) + out += '\n' + out + +renderSvg =! (r, optimize) -> + W = r.W + out = '' + path = '' + prevX = 0 + prevY = 0 + hasPrev = false + for y in [0...W] + for x in [0...W] + continue unless darkAt(r, x, y) + unless optimize + out += '' + continue + mv = "M#{x} #{y}" + if hasPrev + rel = "m#{x - prevX} #{y - prevY}" + mv = rel if rel.length <= mv.length + back = if x < 10 then "H#{x}" else 'h-1' + path += "#{mv}h1v1#{back}Z" + prevX = x + prevY = y + hasPrev = true + out += '' if optimize + out + '' + +# GIF87a with an uncompressed LZW stream: 8-bit codes and a clear code every +# 126 pixels, so no dictionary state exists. Pixels come from a per-module-row +# 0/1 buffer rebuilt only when the module row changes and block-copied in +# spans bounded by the chunk boundaries. +renderGif =! (r) -> + W = r.W + pixels = W * W + N = 126 + fullChunks = pixels // N + tail = pixels % N + out = new Uint8Array(408 + fullChunks * (N + 2) + 2 + tail + 4) + pos = 0 + u16 = (v) -> + out[pos++] = v & 0xff + out[pos++] = v >>> 8 + return + for b in [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] + out[pos++] = b + u16 W + u16 W + out[pos++] = 0xf6 + pos += 2 + out[pos++] = 0xff + out[pos++] = 0xff + out[pos++] = 0xff + pos += 3 * 127 + out[pos++] = 0x2c + pos += 4 + u16 W + u16 W + out[pos++] = 0x00 + out[pos++] = 0x07 + {m, map} = r + row = new Uint8Array(W) + prevMy = -2 + i = 0 + for y in [0...W] + my = map[y] + if my isnt prevMy + prevMy = my + row.fill 0 + if my >= 0 + for x in [0...W] + row[x] = matGet(m, map[x], my) if map[x] >= 0 + x = 0 + while x < W + if i % N is 0 + rem = pixels - i + out[pos++] = (if rem < N then rem else N) + 1 + out[pos++] = 0x80 + n = Math.min(N - i % N, W - x) + out.set row.subarray(x, x + n), pos + pos += n + x += n + i += n + if tail is 0 + out[pos++] = 1 + out[pos++] = 0x80 + out[pos++] = 0x01 + out[pos++] = 0x81 + out[pos++] = 0x00 + out[pos++] = 0x3b + out + +gifDataUrl =! (gif) -> + b64 = if typeof gif.toBase64 is 'function' + gif.toBase64() + else + bin = '' + i = 0 + while i < gif.length + bin += String.fromCharCode(...gif.subarray(i, i + 8192)) + i += 8192 + btoa bin + 'data:image/gif;base64,' + b64 + +# ==[ Public API ]== + +export def encodeQR(text, output = 'raw', opts = {}) + asString text, 'text' + asString output, 'output' + throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if typeof opts isnt 'object' or opts is null or Array.isArray(opts) + ver = opts.version + ver = asVersion ver if ver isnt undefined + ecc = if opts.ecc is undefined then 'medium' else opts.ecc + fail "invalid ecc=#{ecc}" unless ECC_LEVELS.includes ecc + encoding = if opts.encoding is undefined then detectType(text) else opts.encoding + fail "invalid encoding=#{encoding}" unless LENGTH_BITS[encoding] + if encoding isnt 'byte' + alpha = if encoding is 'numeric' then ALPHANUMERIC.slice(0, 10) else ALPHANUMERIC + for ch in text + fail "Unknown letter: \"#{ch}\". Allowed: #{alpha}" unless alpha.includes ch + if opts.mask isnt undefined and (asNum(opts.mask, 'opts.mask') < 0 or opts.mask > 7) + fail "invalid mask=#{opts.mask}" + textEncoder = opts.textEncoder + # Reject impossible built-in UTF-8 payloads before encoding duplicates a + # huge input; a custom encoder's output is its own source of truth. + if encoding is 'byte' and textEncoder is undefined + maxBytes = byteCapacity (if ver is undefined then 40 else ver), ecc + fail 'Capacity overflow' if text.length > maxBytes or utf8Length(text) > maxBytes + utf8 = if encoding is 'byte' + if textEncoder isnt undefined then textEncoder(text) else TextEncoder.new().encode(text) + else + undefined + if utf8 isnt undefined and not isBytes(utf8) + throw TypeError.new "\"opts.textEncoder\" expected Uint8Array, got type=#{typeof utf8}" + dataLen = if encoding is 'byte' then utf8.length else text.length + encodedBits = switch encoding + when 'numeric' then (dataLen // 3) * 10 + NUMERIC_BITS[dataLen % 3] + when 'alphanumeric' then (dataLen // 2) * 11 + (dataLen % 2) * 6 + else dataLen * 8 + if ver is undefined + ver = 1 + while ver <= 40 + lengthBits = LENGTH_BITS[encoding][(ver + 7) // 17] + break if dataLen < 1 << lengthBits and 4 + lengthBits + encodedBits <= capacity(ver, ecc).capacity + ver++ + fail 'Capacity overflow' if ver > 40 + else + lengthBits = LENGTH_BITS[encoding][(ver + 7) // 17] + fail 'Capacity overflow' if dataLen >= 1 << lengthBits or 4 + lengthBits + encodedBits > capacity(ver, ecc).capacity + data = encodeData ver, ecc, text, encoding, utf8 + m = drawSymbol ver, ecc, data, opts.mask + # A quiet zone is required (§5.3.8); custom renderers wanting the borderless + # matrix request border 1 and slice the ring off. + border = if opts.border is undefined then 2 else asNum(opts.border, 'opts.border') + throw RangeError.new "invalid border=#{border}" if border <= 0 + scale = if opts.scale is undefined then 1 else asNum(opts.scale, 'opts.scale') + throw RangeError.new "invalid scale factor: #{scale}" if scale <= 0 or scale > 1024 + W = (m.size + 2 * border) * scale + maxOutputSize = if output is 'ascii' or output is 'gif' or output is 'data-url' then MAX_COMPACT_OUTPUT_SIZE else MAX_OUTPUT_SIZE + throw RangeError.new "invalid opts: output is #{W}x#{W} (max #{maxOutputSize}), reduce border/scale" if W > maxOutputSize + map = new Int32Array(W) + for i in [0...W] + f = i // scale - border + map[i] = if f >= 0 and f < m.size then f else -1 + r = { m, W, map } + switch output + when 'raw' then renderRaw r + when 'ascii' then renderAscii r + when 'term' then renderTerm r + when 'svg' then renderSvg r, (if opts.optimize is undefined then true else opts.optimize) + when 'gif' then renderGif r + when 'data-url' then gifDataUrl renderGif(r) + else fail "Unknown output: #{output}" + +export default encodeQR + +# Internals for the test suite. +export _tests =! { mat, matGet, penalty, drawSymbol, encodeData, rsEcc, detectType } diff --git a/packages/barcodes/decode.rip b/packages/barcodes/decode.rip new file mode 100644 index 00000000..14136f2c --- /dev/null +++ b/packages/barcodes/decode.rip @@ -0,0 +1,2068 @@ +# ============================================================================== +# rip/barcodes — QR decoder, budgeted for live camera frames +# +# A scanner owns a luma arena sized to its maximum frame and a four-level +# 2x2 box-filter pyramid. Each layer is binarized lazily against 8x8 block +# thresholds into a packed one-bit bitmap, finder patterns are found by +# 1:1:3:1:1 run windows on every second row, and the best three are projected +# through a homography onto a module grid, corrected with Reed-Solomon, and +# parsed. Nothing allocates on the frame path: every buffer is created once +# per scanner and reused. Coarse layers run first; native resolution last. +# ============================================================================== + +import { + ALPHANUMERIC, BYTES, ECC_BLOCKS, ECC_LEVELS, GF256, WORDS_PER_BLOCK, + formatBits, maskBits, popcnt, versionBits, +} from './spec.rip' + +MAX_IMAGE_SIDE =! 4096 +MAX_ARENA_BYTES =! 64 * 1024 * 1024 + +# Failure values shared by every attempt; a decode returns a string or one +# of these, never throws on the frame path. +FAIL =! Object.freeze + data: Object.freeze(Error.new 'data') + dimension: Object.freeze(Error.new 'dimension') + finder: Object.freeze(Error.new 'finder') + format: Object.freeze(Error.new 'format') + alignment: Object.freeze(Error.new 'alignment') + rs: Object.freeze(Error.new 'rs') + timing: Object.freeze(Error.new 'timing') + version: Object.freeze(Error.new 'version') + +{exp: EXP, log: LOG} = GF256 +mul = (a, b) -> if a and b then EXP[LOG[a] + LOG[b]] else 0 +inv = (a) -> EXP[255 - LOG[a]] + +clamp = (value, lo, hi) -> Math.max(lo, Math.min(hi, value)) + +# ==[ Payload ]== + +# §7.4.3: six-digit ECI designators; an ECI stays active until another one +# replaces it. +ECI_ENCODINGS =! + 1: 'iso-8859-1', 2: 'ibm437', 3: 'iso-8859-1', 4: 'iso-8859-2', 5: 'iso-8859-3' + 6: 'iso-8859-4', 7: 'iso-8859-5', 8: 'iso-8859-6', 9: 'iso-8859-7', 10: 'iso-8859-8' + 11: 'iso-8859-9', 13: 'iso-8859-11', 15: 'iso-8859-13', 16: 'iso-8859-14' + 17: 'iso-8859-15', 18: 'iso-8859-16', 20: 'shift-jis', 21: 'windows-1250' + 22: 'windows-1251', 23: 'windows-1252', 24: 'windows-1256', 25: 'utf-16be' + 26: 'utf-8', 28: 'big5', 29: 'gbk', 30: 'euc-kr' + +ECI_DECODERS =! {} +for id, name of ECI_ENCODINGS + ECI_DECODERS[id] = try TextDecoder.new(name) catch then undefined + +NUMERIC_LENGTH_BITS =! [10, 12, 14] +ALPHANUMERIC_LENGTH_BITS =! [9, 11, 13] +BYTE_LENGTH_BITS =! [8, 16, 16] + +# Bit reader over corrected data codewords plus a scratch byte buffer with +# one prefix view per length, so byte segments decode without allocating. +class Payload + constructor: (capacity) -> + @position = 0 + @data = new Uint8Array(0) + @dataLen = 0 + @bytes = new Uint8Array(capacity) + @views = (new Uint8Array(@bytes.buffer, 0, i) for i in [0..capacity]) + + read: (bits) -> + start = @position + return -1 if start + bits > @dataLen * 8 + value = 0 + pos = start + for i in [0...bits] + value = (value << 1) | ((@data[pos >> 3] >> (7 - (pos & 7))) & 1) + pos++ + @position = pos + value + + # Returns a string, or with deferText an array of strings and [bytes, eci] + # parts for a caller-supplied text decoder, or FAIL.data. + decode: (data, dataLen, version, deferText = false) -> + @position = 0 + @data = data + @dataLen = dataLen + cls = if version < 10 then 0 else if version < 27 then 1 else 2 + eci = 26 + res = '' + parts = if deferText then [] else null + while @position + 4 <= dataLen * 8 + mode = @read 4 + break unless mode + if mode is 7 + b0 = @read 8 + return FAIL.data if b0 < 0 + if (b0 & 0x80) is 0 + eci = b0 + else + len = if (b0 & 0xc0) is 0x80 then 8 else 16 + value = @read len + return FAIL.data if value < 0 + eci = ((b0 & (if len is 8 then 0x3f else 0x1f)) << len) | value + continue + if mode is 1 + length = @read NUMERIC_LENGTH_BITS[cls] + return FAIL.data if length < 0 + while length >= 3 + value = @read 10 + return FAIL.data if value < 0 or value >= 1000 + res += String(value).padStart(3, '0') + length -= 3 + if length + value = @read(if length is 2 then 7 else 4) + return FAIL.data if value < 0 or value >= 10 ** length + res += String(value).padStart(length, '0') + else if mode is 2 + length = @read ALPHANUMERIC_LENGTH_BITS[cls] + return FAIL.data if length < 0 + while length >= 2 + value = @read 11 + return FAIL.data if value < 0 or value >= 45 * 45 + res += ALPHANUMERIC[value // 45] + ALPHANUMERIC[value % 45] + length -= 2 + if length + value = @read 6 + return FAIL.data if value < 0 or value >= 45 + res += ALPHANUMERIC[value] + else if mode is 4 + length = @read BYTE_LENGTH_BITS[cls] + return FAIL.data if length < 0 or @position + 8 * length > dataLen * 8 + if parts + segment = new Uint8Array(length) + for i in [0...length] + segment[i] = @read(8) + parts.push res if res + parts.push [segment, eci] + res = '' + else + encoding = ECI_ENCODINGS[eci] + return FAIL.data if not encoding or length >= @views.length + decoder = ECI_DECODERS[eci] or TextDecoder.new(encoding) + for i in [0...length] + @bytes[i] = @read(8) + res += decoder.decode @views[length] + else + return FAIL.data + return res unless parts + parts.push res if res + parts + +finishPayload =! (decoded, textDecoder) -> + return decoded if typeof decoded is 'string' + throw Error.new 'text decoder' unless textDecoder + res = '' + for part in decoded + res += if typeof part is 'string' then part else textDecoder(part[0], part[1]) + res + +# ==[ Geometry ]== + +# Finder records are stride-4 Float64Array slots: x, y, module size, row-hit +# confidence. `a` and `b` are element offsets. +dist2 = (pts, a, b) -> (pts[a] - pts[b]) ** 2 + (pts[a + 1] - pts[b + 1]) ** 2 +distance = (first, second) -> Math.hypot(second.x - first.x, second.y - first.y) + +# Version 7+ carries two BCH version words; one must match the sampled +# dimension within radius three. +checkVersion =! (m, size) -> + ver = (size - 17) / 4 + return true if ver < 7 + v1 = 0 + v2 = 0 + for i in [0...18] + x = size - 11 + i % 3 + y = i // 3 + v1 |= m[y * size + x] << i + v2 |= m[x * size + y] << i + expected = versionBits ver + popcnt(expected ^ v1) <= 3 or popcnt(expected ^ v2) <= 3 + +# 3x3 projective transforms, row-major in a Float64Array, applied to column +# vectors [u, v, 1] with a perspective divide. +def squareToQuad!(out, points) + [x1, y1, x2, y2, x3, y3, x4, y4] = points + dx3 = x1 - x2 + x3 - x4 + dy3 = y1 - y2 + y3 - y4 + if dx3 is 0 and dy3 is 0 + out[0] = x2 - x1 + out[1] = x3 - x2 + out[2] = x1 + out[3] = y2 - y1 + out[4] = y3 - y2 + out[5] = y1 + out[6] = 0 + out[7] = 0 + out[8] = 1 + return + dx1 = x2 - x3 + dx2 = x4 - x3 + dy1 = y2 - y3 + dy2 = y4 - y3 + den = dx1 * dy2 - dx2 * dy1 + a31 = (dx3 * dy2 - dx2 * dy3) / den + a32 = (dx1 * dy3 - dx3 * dy1) / den + out[0] = x2 - x1 + a31 * x2 + out[1] = x4 - x1 + a32 * x4 + out[2] = x1 + out[3] = y2 - y1 + a31 * y2 + out[4] = y4 - y1 + a32 * y4 + out[5] = y1 + out[6] = a31 + out[7] = a32 + out[8] = 1 + +# adj[r][c] is the (c, r) cofactor. +def adjugate!(o, m) + for i in [0...9] + r = i // 3 + c = i % 3 + r1 = ((c + 1) % 3) * 3 + r2 = ((c + 2) % 3) * 3 + c1 = (r + 1) % 3 + c2 = (r + 2) % 3 + o[i] = m[r1 + c1] * m[r2 + c2] - m[r1 + c2] * m[r2 + c1] + +# One input row is cached so the product may replace its left operand. +def ptMul!(o, a, b) + for r in [0, 3, 6] + a0 = a[r] + a1 = a[r + 1] + a2 = a[r + 2] + for c in [0...3] + o[r + c] = a0 * b[c] + a1 * b[c + 3] + a2 * b[c + 6] + +def packQuad!(quad, x0, y0, x1, y1, x2, y2, x3, y3) + quad[0] = x0 + quad[1] = y0 + quad[2] = x1 + quad[3] = y1 + quad[4] = x2 + quad[5] = y2 + quad[6] = x3 + quad[7] = y3 + +mapPoint =! (map, x, y) -> + den = map[6] * x + map[7] * y + map[8] + { x: (map[0] * x + map[1] * y + map[2]) / den, y: (map[3] * x + map[4] * y + map[5]) / den } + +# ==[ Input ]== + +LUMA8 =! { step: 1, bits: 8 } +LUMA10 =! { step: 2, bits: 10 } +LUMA12 =! { step: 2, bits: 12 } +RGB =! { step: 3, bits: 8 } +# Four-byte inputs ignore the fourth byte: alpha is never composited and the +# X formats share the storage shape. +RGBA =! { step: 4, bits: 8 } +FORMATS =! + RGB: RGB, RGBA: RGBA, RGBX: RGBA, BGRA: RGBA, BGRX: RGBA + I420: LUMA8, I420A: LUMA8, I422: LUMA8, I444: LUMA8, NV12: LUMA8 + I420P10: LUMA10, I420P12: LUMA12 + +validateSize =! (size, name) -> + throw TypeError.new "#{name} expected safe integer width and height" unless Number.isSafeInteger(size.width) and Number.isSafeInteger(size.height) + throw RangeError.new "#{name} expected positive width and height" if size.width <= 0 or size.height <= 0 + throw RangeError.new "#{name} expected width and height <= #{MAX_IMAGE_SIDE}, got #{size.width}x#{size.height}" if size.width > MAX_IMAGE_SIDE or size.height > MAX_IMAGE_SIDE + size.width * size.height + +def validateOpts!(opts) + throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if opts is null or typeof opts isnt 'object' or Array.isArray(opts) + throw TypeError.new "invalid opts.format=#{opts.format} (#{typeof opts.format})" if opts.format isnt undefined and not FORMATS[opts.format] + if opts.effort isnt undefined and opts.effort isnt Infinity and (not Number.isSafeInteger(opts.effort) or opts.effort < 1) + throw TypeError.new "invalid opts.effort=#{opts.effort} (#{typeof opts.effort})" + if opts.timeLimit isnt undefined and opts.timeLimit isnt Infinity and (typeof opts.timeLimit isnt 'number' or not Number.isFinite(opts.timeLimit) or opts.timeLimit < 0) + throw TypeError.new "invalid opts.timeLimit=#{opts.timeLimit} (#{typeof opts.timeLimit})" + for name in ['textDecoder', 'pointsOnDetect', 'imageOnResult', 'imageOnBitmap'] + throw TypeError.new "invalid opts.#{name}=#{opts[name]} (#{typeof opts[name]})" if opts[name] isnt undefined and typeof opts[name] isnt 'function' + +# Returns the input format; RGB/RGBA is detected by exact length when unnamed. +validateImage =! (img, named, layout, capacity) -> + px = validateSize img, '"img"' + if capacity and (img.width > capacity.width or img.height > capacity.height) + throw RangeError.new "\"img\" expected dimensions <= #{capacity.width}x#{capacity.height}, got #{img.width}x#{img.height}" + bytes = img.data + throw TypeError.new "\"img.data\" expected Uint8Array or Uint8ClampedArray, got #{typeof bytes}" unless bytes instanceof Uint8Array or bytes instanceof Uint8ClampedArray + if named isnt undefined + format = FORMATS[named] + throw TypeError.new "invalid opts.format=#{named} (#{typeof named})" unless format + if layout + {offset, stride} = layout + row = format.step * img.width + end = offset + (img.height - 1) * stride + row + if not Number.isSafeInteger(offset) or not Number.isSafeInteger(stride) or offset < 0 or stride < row or end > bytes.length + throw RangeError.new "\"img.data\" expected valid offset/stride for format=#{named}, got offset=#{offset}, stride=#{stride}, length=#{bytes.length}" + else + expected = format.step * px + planar = format.step <= 2 + if (planar and bytes.length < expected) or (not planar and bytes.length isnt expected) + throw RangeError.new "\"img.data\" expected #{if planar then 'at least ' else ''}#{expected} bytes for format=#{named}, got #{bytes.length}" + return format + return RGB if bytes.length is 3 * px + return RGBA if bytes.length is 4 * px + throw RangeError.new "\"img.data\" expected #{3 * px} or #{4 * px} bytes without opts.format, got #{bytes.length}" + +# Convert any input into the tight native luma plane. +def copyLuma!(out, maxSize, img, named, layout) + {step, bits} = validateImage img, named, layout, maxSize + {width, height, data} = img + stride = layout?.stride or width * step + offset = layout?.offset or 0 + return if data is out and not offset and stride is width and step is 1 + for y in [0...height] + src = offset + y * stride + dst = y * width + if step is 1 + for x in [0...width] + out[dst++] = data[src++] + else if step is 2 + for x in [0...width] + out[dst++] = (data[src] | (data[src + 1] << 8)) >>> (bits - 8) + src += 2 + else + for x in [0...width] + out[dst++] = (data[src] + 2 * data[src + 1] + data[src + 2]) >> 2 + src += step + +# RGBA image from a per-pixel dark predicate. +darkToImage =! (width, height, isDark) -> + data = new Uint8Array(width * height * 4) + i = 0 + for y in [0...height] + for x in [0...width] + color = if isDark(x, y) then 0 else 255 + data[i] = color + data[i + 1] = color + data[i + 2] = color + data[i + 3] = 255 + i += 4 + { width, height, data } + +# ==[ Bitmap primitives ]== +# A layer's bitmap is packed 32 pixels per word; a set bit is a dark pixel. + +# -1 marks out of bounds, distinct from either polarity. +bit =! (layer, x, y) -> + return -1 if x < 0 or y < 0 or x >= layer.width or y >= layer.height + (layer.bitmap[y * layer.words + (x >>> 5)] >>> (x & 31)) & 1 + +# Module pitch of a tolerant 1:1:3:1:1 run, or zero when it does not fit. +ratio =! (a, b, c, d, e) -> + total = a + b + c + d + e + return 0 if total < 7 + ms = total / 7 + tol = ms * 0.5 + fits = Math.abs(ms - a) < tol and Math.abs(ms - b) < tol and Math.abs(3 * ms - c) < 3 * tol and + Math.abs(ms - d) < tol and Math.abs(ms - e) < tol + if fits then ms else 0 + +# Consecutive `color` pixels from (x, y) inclusive stepping (dx, dy); stops on +# mismatch, border, or once the count passes `cap` (at most floor(cap)+1). +# Horizontal runs consume whole words through clz32; vertical runs step. +run =! (layer, x, y, dx, dy, color, cap) -> + n = 0 + if dy + while bit(layer, x, y) is color and n <= cap + n++ + y += dy + return n + return 0 if y < 0 or y >= layer.height + row = y * layer.words + while x >= 0 and x < layer.width and n <= cap + shift = x & 31 + word = layer.bitmap[row + (x >>> 5)] + stops = (if color then ~word else word) >>> 0 + w = if dx > 0 then stops >>> shift else (stops << (31 - shift)) >>> 0 + span = if dx > 0 then Math.min(32 - shift, layer.width - x) else shift + 1 + first = if not w then 32 else if dx > 0 then 31 - Math.clz32((w & -w) >>> 0) else Math.clz32(w) + len = Math.min first, span + n += len + x += dx * len + break if first < span + Math.min n, Math.floor(cap) + 1 + +# 1:1:3:1:1 cross-check from (cx, cy) along (dx, dy): the refined center +# coordinate, the measured pitch when asked, or -1 on ratio failure. +cross =! (layer, cx, cy, dx, dy, maxMs, inverted, measure = false) -> + center = if inverted then 0 else 1 + side = if inverted then 1 else 0 + r2 = run layer, cx, cy, -dx, -dy, center, Infinity + back = r2 + r1 = run layer, cx - dx * back, cy - dy * back, -dx, -dy, side, maxMs + back += r1 + r0 = run layer, cx - dx * back, cy - dy * back, -dx, -dy, center, maxMs + back += r0 + start = (if dx then cx else cy) - back + forward = run layer, cx + dx, cy + dy, dx, dy, center, Infinity + r2 += forward + ahead = 1 + forward + r3 = run layer, cx + dx * ahead, cy + dy * ahead, dx, dy, side, maxMs + ahead += r3 + r4 = run layer, cx + dx * ahead, cy + dy * ahead, dx, dy, center, maxMs + return -1 unless ratio(r0, r1, r2, r3, r4) + if measure then (r0 + r1 + r2 + r3 + r4) / 7 else start + 1 + r0 + r1 + r2 / 2 + +vertical =! (layer, pattern, inverted, measure = false) -> + cross layer, Math.round(pattern.x), Math.round(pattern.y), 0, 1, 3 * pattern.ms, inverted, measure + +# Recenter vertically then horizontally; total movement, or -1 on failure. +refinePattern =! (layer, pattern, inverted) -> + y = vertical layer, pattern, inverted + return -1 if y < 0 + x = cross layer, Math.round(pattern.x), Math.round(y), 1, 0, 3 * pattern.ms, inverted + return -1 if x < 0 + movement = Math.abs(x - pattern.x) + Math.abs(y - pattern.y) + pattern.x = x + pattern.y = y + movement + +refineTriple =! (layer, triple) -> + tl = refinePattern layer, triple.tl, triple.inverted + return false if tl < 0 + tr = refinePattern layer, triple.tr, triple.inverted + return false if tr < 0 + bl = refinePattern layer, triple.bl, triple.inverted + bl >= 0 and tl + tr + bl > 0 + +# Grayscale finder-template fit, one axis at a time, over pitch scales of +# 0.5..1.5 in tenths so measured perspective squeeze is covered. +def fitPattern!(layer, pattern, inverted) + luma = layer.luma + fit = (axis) -> + center = if axis then pattern.y else pattern.x + other = if axis then pattern.x else pattern.y + radius = Math.ceil pattern.ms + lo = Math.round(center) - radius + hi = Math.round(center) + radius + best = center + bestScore = -Infinity + for candidate in [lo..hi] + for scale in [5..15] by 2 + pitch = pattern.ms * scale / 10 + crossRadius = Math.round(pitch / 2) + side = Math.ceil(3.5 * pitch) + dark = 0 + darkCount = 0 + light = 0 + lightCount = 0 + for along in [-side..side] + module = Math.abs(along / pitch) + expectedDark = module < 1.5 or module >= 2.5 + for across in [-crossRadius..crossRadius] + x = Math.round(if axis then other + across else candidate + along) + y = Math.round(if axis then candidate + along else other + across) + continue if x < 0 or y < 0 or x >= layer.width or y >= layer.height + value = luma[y * layer.width + x] + if expectedDark + dark += value + darkCount++ + else + light += value + lightCount++ + score = light / lightCount - dark / darkCount + score = -score if inverted + if score > bestScore + best = candidate + bestScore = score + best + pattern.x = fit 0 + pattern.y = fit 1 + +crossPitch =! (layer, pattern, inverted) -> + pitch = vertical layer, pattern, inverted, true + if pitch < 0 then 0 else pitch + +# Horizontal and vertical runs combined into one perspective-tolerant pitch. +finderPitch =! (layer, pattern) -> + v = crossPitch layer, pattern, false + if v then Math.sqrt(pattern.ms * v) else 0 + +# Endpoint pitches averaged after projecting both run directions onto the edge. +edgePitch =! (layer, first, second, inverted) -> + dx = second.x - first.x + dy = second.y - first.y + length = distance first, second + return 0 unless length + pitch = (pattern) -> + v = crossPitch layer, pattern, inverted + return 0 unless v + Math.sqrt(((dx / length) * pattern.ms) ** 2 + ((dy / length) * v) ** 2) + a = pitch first + b = pitch second + if a and b then (a + b) / 2 else 0 + +def copyPattern!(layer, index, out) + pos = index * 4 + out.x = layer.patterns[pos] + out.y = layer.patterns[pos + 1] + out.ms = layer.patterns[pos + 2] + +# Retry sets are stride-5 records: rank, inverted, and three finder indices, +# kept as a max-heap of 256 then heapsorted ascending. +def swapSet!(sets, a, b) + ap = a * 5 + bp = b * 5 + for i in [0...5] + value = sets[ap + i] + sets[ap + i] = sets[bp + i] + sets[bp + i] = value + +def siftDown!(sets, end) + index = 0 + loop + left = index * 2 + 1 + return if left >= end + right = left + 1 + child = if right < end and sets[right * 5] > sets[left * 5] then right else left + return if sets[index * 5] >= sets[child * 5] + swapSet sets, index, child + index = child + +# Horner evaluation of a low-degree-first GF(256) polynomial. +evalLow =! (poly, length, x) -> + value = 0 + for i in [length - 1..0] by -1 + value = mul(value, x) ^ poly[i] + value + +# ==[ Row stages ]== +# Row-ranged so the cooperative scanner can yield between bounded chunks. + +# 2x2 box filter into the next pyramid layer. +def resizeRows!(src, dst, width, dstWidth, from, to) + for y in [from...to] + srcPos = (y << 1) * width + dstPos = y * dstWidth + for x in [0...dstWidth] + dst[dstPos++] = (src[srcPos] + src[srcPos + 1] + src[srcPos + width] + src[srcPos + width + 1] + 2) >> 2 + srcPos += 2 + +# One threshold per 8x8 block. Low-contrast blocks take the darkest sample so +# faint modules survive, propagated from the neighbors above and left. +def blockRows!(layer, from, to) + luma = layer.luma + bWidth = layer.blockWidth + maxY = layer.height - 8 + maxX = layer.width - 8 + blocks = layer.blocks + for y in [from...to] + yPos = clamp y * 8, 0, maxY + for x in [0...bWidth] + xPos = clamp x * 8, 0, maxX + sum = 0 + min = 0xff + max = 0 + pos = yPos * layer.width + xPos + for yy in [0...8] + for xx in [0...8] + pixel = luma[pos + xx] + sum += pixel + min = pixel if pixel < min + max = pixel if pixel > max + pos += layer.width + average = sum >> 6 + if max - min <= 24 + average = min / 2 + if y > 0 and x > 0 + top = blocks[(y - 1) * bWidth + x] + left = blocks[y * bWidth + x - 1] + topLeft = blocks[(y - 1) * bWidth + x - 1] + previous = (2 * top + left + topLeft) / 4 + average = previous if min < previous + blocks[bWidth * y + x] = average >>> 0 + +# Smooth thresholds over 5x5 blocks and write each block's binary pixels. +def bitmapRows!(layer, from, to) + luma = layer.luma + bWidth = layer.blockWidth + bHeight = layer.blockHeight + maxY = layer.height - 8 + maxX = layer.width - 8 + blocks = layer.blocks + for y in [from...to] + yPos = clamp y * 8, 0, maxY + top = clamp y, 2, bHeight - 3 + for x in [0...bWidth] + xPos = clamp x * 8, 0, maxX + left = clamp x, 2, bWidth - 3 + sum = 0 + for yy in [-2..2] + row = bWidth * (top + yy) + left + for xx in [-2..2] + sum += blocks[row + xx] + average = sum / 25 + layer.cuts[y * bWidth + x] = Math.floor average + pos = yPos * layer.width + xPos + for yy in [0...8] + value = 0 + for xx in [0...8] + value |= 1 << xx if luma[pos + xx] <= average + shift = xPos & 31 + word = (yPos + yy) * layer.words + (xPos >>> 5) + lowMask = (0xff << shift) >>> 0 + layer.bitmap[word] = ((layer.bitmap[word] & ~lowMask) | ((value << shift) >>> 0)) >>> 0 + if shift > 24 + highMask = (1 << (shift - 24)) - 1 + layer.bitmap[word + 1] = ((layer.bitmap[word + 1] & ~highMask) | (value >>> (32 - shift))) >>> 0 + pos += layer.width + +# A five-run window that starts, centers and ends on a dark run: cross-check +# it and merge into a finder record within two modules of an existing center. +def recordFinder!(layer, y, x, r0, r1, r2, r3, r4, inverted) + ms = ratio r0, r1, r2, r3, r4 + return unless ms + start = x - r0 - r1 - r2 - r3 - r4 + cx = Math.round(start + r0 + r1 + r2 / 2) + limit = ms * 3 + cy = cross layer, cx, y, 0, 1, limit, inverted + return if cy < 0 + refinedX = cross layer, cx, Math.round(cy), 1, 0, limit, inverted + return if refinedX < 0 + patterns = layer.patterns + polarity = if inverted then 1 else 0 + for i in [0...layer.patternCount] + pos = i * 4 + continue if (layer.inverted[i] & 1) isnt polarity + continue if Math.abs(patterns[pos] - refinedX) >= 2 * ms or Math.abs(patterns[pos + 1] - cy) >= 2 * ms + count = patterns[pos + 3] + 1 + patterns[pos] = (patterns[pos] * patterns[pos + 3] + refinedX) / count + patterns[pos + 1] = (patterns[pos + 1] * patterns[pos + 3] + cy) / count + patterns[pos + 2] = (patterns[pos + 2] * patterns[pos + 3] + ms) / count + patterns[pos + 3] = count + return + index = layer.patternCount++ + pos = index * 4 + throw Error.new "finder storage exhausted at #{layer.width}x#{layer.height}" if pos + 3 >= patterns.length + patterns[pos] = refinedX + patterns[pos + 1] = cy + patterns[pos + 2] = ms + patterns[pos + 3] = 1 + layer.inverted[index] = polarity + +# Rolling run-length window over every second row; run() always advances. +def findRows!(layer, from, to) + for y in [from...to] by 2 + r0 = r1 = r2 = r3 = r4 = 0 + runs = 0 + previous = bit(layer, 0, y) is 1 + x = 0 + while x < layer.width + length = run layer, x, y, 1, 0, (if previous then 1 else 0), Infinity + x += length + r0 = r1 + r1 = r2 + r2 = r3 + r3 = r4 + r4 = length + runs++ + dark = previous + previous = not previous + recordFinder layer, y, x, r0, r1, r2, r3, r4, (not dark) if runs >= 5 + +# Legal sides are 17 + 4 * version; a non-finite estimate coerces to zero and +# fails the dimension bounds downstream. +snapSize = (estimate) -> (Math.round((estimate - 17) / 4) * 4 + 17) | 0 + +runDecode =! (walk) -> + step = walk.next() + step = walk.next(0) until step.done + step.value + +nextTick =! -> + return scheduler.yield() if typeof scheduler isnt 'undefined' and typeof scheduler.yield is 'function' + Promise.new (resolve) -> setTimeout resolve, 0 + +# Drain generator work, yielding to the host after each scheduling quantum; +# the generator receives the milliseconds spent waiting. +runDecodeAsync =! (walk, timeLimit) -> + tick = Math.min timeLimit, 8 + started = Date.now() + try + step = walk.next() + loop + return step.value if step.done + waited = 0 + if Date.now() - started >= tick + waiting = Date.now() + nextTick! + waited = Date.now() - waiting + started = Date.now() + step = walk.next(waited) + catch error + walk.return undefined + throw error + +# ==[ Scanner ]== + +# A fractional initial value keeps the fields unboxed doubles. +makePattern = -> { x: 0.1, y: 0.1, ms: 0.1 } + +# Reusable decode state. Operations are exclusive: while decodeAsync is +# pending, staging, decoding and cleaning throw until it settles. +export class QRScanner + constructor: (init) -> + validateOpts init + maxPixels = validateSize init.maxSize, '"maxSize"' + maxSize = Object.freeze { ...init.maxSize } + stride = if init.stride is undefined then 1 else init.stride + throw RangeError.new "\"stride\" expected positive safe integer, got #{stride}" unless Number.isSafeInteger(stride) and stride >= 1 + bytes = maxPixels * stride + throw RangeError.new "input arena expected <= #{MAX_ARENA_BYTES} bytes, got #{maxPixels}*#{stride}" unless Number.isSafeInteger(bytes) and bytes <= MAX_ARENA_BYTES + @effort = if init.effort is undefined then 1 else init.effort + @timeLimit = if init.timeLimit is undefined then 1000 / 60 else init.timeLimit + @opts = Object.freeze { ...init, effort: @effort, maxSize, timeLimit: @timeLimit } + @width = 0 + @height = 0 + @luma = new Uint8Array(bytes) + @image = { data: @luma, height: 0, width: 0 } + @input = { data: @luma, height: 0, width: 0 } + @grid = new Uint8Array(177 * 177) + @fun = new Uint8Array(177 * 177) + @alignPos = new Uint8Array(7) + @codewords = new Uint8Array(BYTES[39]) + @blockBytes = new Uint8Array(BYTES[39]) + @syndromes = new Uint8Array(31) + @sigma = new Uint8Array(31) + @previous = new Uint8Array(31) + @next = new Uint8Array(31) + @candidates = new Uint32Array(16) + @ranks = new Float64Array(16) + @found = new Uint32Array(4 * 16 * 3) + @pick = new Float64Array(8) + @nodes = new Float64Array(7 * 7 * 2) + @located = new Float64Array((7 * 7 - 3) * 4) + @payload = Payload.new BYTES[39] + @inFlight = false + @staged = false + @resized = false + @triple = { tl: makePattern(), tr: makePattern(), bl: makePattern(), inverted: false, tlIndex: 0, trIndex: 0, blIndex: 0 } + @blocked = 0 + @retryStart = 0 + @retries = 0 + @points = undefined + # `map` holds the active homography; `from` and `to` are scratch, and `to` + # doubles as the fine-plane map until the next build. + @map = new Float64Array(9) + @from = new Float64Array(9) + @to = new Float64Array(9) + @alignPoint = { x: 0.1, y: 0.1 } + @finePlane = { d: new Uint8Array(0), cut: new Int16Array(0), W: 0, H: 0, bw: 0, sh: 0 } + @invertedProjection = false + @decodedSize = 0 + layers = [] + width = maxSize.width + height = maxSize.height + for i in [0...4] + break if i and Math.min(width, height) < 64 + blockWidth = Math.ceil(width / 8) + blockHeight = Math.ceil(height / 8) + centers = Math.ceil(width / 7) * Math.ceil(height / 7) + luma = if i then new Uint8Array(width * height) else @luma + cuts = new Int16Array(blockWidth * blockHeight) + layers.push + bitmap: new Uint32Array(Math.ceil(width / 32) * height) + blockHeight: 0 + blockWidth: 0 + blocks: new Uint8Array(blockWidth * blockHeight) + cuts: cuts + height: 0 + luma: luma + patternCount: 0 + patterns: new Float64Array(centers * 4) + used: false + width: 0 + words: 0 + # Projection reads luma through the threshold grid; `sh` is 3 for the + # owning layer and grows when native luma reuses a coarser grid. + plane: { d: luma, cut: cuts, W: width, H: height, bw: blockWidth, sh: 3 } + # Native luma offered to downscaled sampling; `r` is the layer index. + context: { opts: @opts, scale: 1 << i, ox: 0, oy: 0, fine: (if i then { luma: @image, r: i } else undefined) } + found: false + inverted: new Uint8Array(centers) + setCount: 0 + setCursor: 0 + sets: new Float64Array(256 * 5) + setsReady: false + # Round-0 pick as an id bag (sum, min, max); retries skip a matching set. + pickSum: -1 + pickLo: 0 + pickHi: 0 + width >>= 1 + height >>= 1 + @layers = layers + + beginOperation!: -> + throw Error.new 'scanner operation already in flight' if @inFlight + @inFlight = true + + endOperation!: -> + @inFlight = false + + decodePayload: (data, dataLen, version) -> + decoded = @payload.decode data, dataLen, version, !!@opts.textDecoder + if decoded instanceof Error then decoded else finishPayload(decoded, @opts.textDecoder) + + # Square-to-output composed with input-to-square, from `from` and `to`. + mapQuad!: (out) -> + squareToQuad out, @to + squareToQuad @to, @from + adjugate @from, @to + ptMul out, out, @from + + # Module-to-plane map from the finder-center square; BR is the alignment inset. + mapFinderQuad!: (out, size, t, brX, brY) -> + c = 3.5 + packQuad @from, c, c, size - c, c, size - 6.5, size - 6.5, c, size - c + packQuad @to, t.tl.x, t.tl.y, t.tr.x, t.tr.y, brX, brY, t.bl.x, t.bl.y + @mapQuad out + + setAlignments: (ver) -> + return 0 if ver is 1 + last = 17 + 4 * ver - 7 + count = Math.ceil((last - 6) / 28) + interval = (last - 6) // count + if interval & 1 + interval++ + else if ((last - 6) % count) * 2 >= count + interval += 2 + positions = @alignPos + positions[0] = 6 + for i in [1...count] + positions[i] = last - (count - i) * interval + positions[count] = last + count + 1 + + addImage!: (img, format = @opts.format) -> + @beginOperation() + try + copyLuma @luma, @opts.maxSize, img, format + @stage img + finally + @endOperation() + + stage!: (size) -> + {width, height} = size + @width = width + @image.width = width + @height = height + @image.height = height + aw = width + ah = height + for layer, i in @layers + used = not i or Math.min(aw, ah) >= 64 + layer.used = used + layer.width = if used then aw else 0 + layer.height = if used then ah else 0 + layer.words = if used then Math.ceil(aw / 32) else 0 + layer.blockWidth = if used then Math.ceil(aw / 8) else 0 + layer.blockHeight = if used then Math.ceil(ah / 8) else 0 + layer.patternCount = 0 + layer.plane.W = layer.width + layer.plane.H = layer.height + layer.plane.bw = layer.blockWidth + layer.context.ox = 0 + layer.context.oy = 0 + layer.found = false + layer.setCount = 0 + layer.setCursor = 0 + layer.setsReady = false + layer.pickSum = -1 + aw >>= 1 + ah >>= 1 + @staged = true + @resized = false + @blocked = 0 + @retryStart = 0 + @retries = 0 + + # Lifecycle wipe: every typed array on the scanner and its layers is zeroed. + clean!: -> + @beginOperation() + try + @payload.position = 0 + @payload.bytes.fill 0 + for own k, v of this + v.fill 0 if ArrayBuffer.isView v + for layer in @layers + for own k, v of layer + v.fill 0 if ArrayBuffer.isView v + layer.blockHeight = 0 + layer.blockWidth = 0 + layer.height = 0 + layer.width = 0 + layer.words = 0 + layer.patternCount = 0 + layer.setCount = 0 + layer.setCursor = 0 + layer.used = false + layer.found = false + layer.setsReady = false + @width = 0 + @image.width = 0 + @height = 0 + @image.height = 0 + @staged = false + @resized = false + @blocked = 0 + @points = undefined + finally + @endOperation() + + # Pixels an integration wrote directly into `luma`. + processImage!: (size, format = 'I420', layout = { offset: 0, stride: size.width * (FORMATS[format]?.step or 0) }) -> + @beginOperation() + try + @input.width = size.width + @input.height = size.height + copyLuma @luma, @opts.maxSize, @input, format, layout + @stage size + finally + @endOperation() + + # Project a module center and classify its pixel against the owning + # threshold block; floor keeps boundary values in the containing pixel. + read: (s, map, mx, my) -> + point = mapPoint map, mx, my + px = Math.floor point.x + py = Math.floor point.y + return 0 if px < 0 or py < 0 or px >= s.W or py >= s.H + dark = s.d[py * s.W + px] <= s.cut[(py >> s.sh) * s.bw + (px >> s.sh)] + if dark isnt @invertedProjection then 1 else 0 + + # Fourth corner of a projective finder square from its three local pitches: + # pitch varies with the inverse 3/2 power of the homography denominator, so + # the 2/3 power of each pitch ratio recovers the corner weight. + perspective: (t, p0, p1, p2) -> + {tl, tr, bl} = t + wx = Math.cbrt(p0 / p1) ** 2 + wy = Math.cbrt(p0 / p2) ** 2 + cornerDen = wx + wy - 1 + return false unless cornerDen + brX = (wx * tr.x + wy * bl.x - tl.x) / cornerDen + brY = (wx * tr.y + wy * bl.y - tl.y) / cornerDen + dx3 = tl.x - tr.x + brX - bl.x + dy3 = tl.y - tr.y + brY - bl.y + return false if (dx3 or dy3) and not ((tr.x - brX) * (bl.y - brY) - (bl.x - brX) * (tr.y - brY)) + packQuad @to, tl.x, tl.y, tr.x, tr.y, brX, brY, bl.x, bl.y + squareToQuad @map, @to + true + + # Best 5x5 alignment-template placement on a 7x7 offset lattice around + # (ox, oy). Grid tiles use half-module steps with ties toward the smallest + # offset; the perspective search uses unit steps, first best wins. + searchAlign: (s, map, scale, side, ox, oy, stepMul, tie) -> + bestX = 0 + bestY = 0 + bestErrors = if tie then Infinity else 6 + for sy in [-3..3] + for sx in [-3..3] + dx = sx * stepMul + dy = sy * stepMul + cap = bestErrors + (if tie then 1 else 0) + errors = 0 + y = -2 + while y <= 2 and errors < cap + for x in [-2..2] + dark = @read(s, map, scale + (ox + dx + x) / side, scale + (oy + dy + y) / side) is 1 + edge = Math.abs(x) is 2 or Math.abs(y) is 2 + if dark isnt (edge or (x is 0 and y is 0)) + errors++ + break if errors >= cap + y++ + continue if errors > bestErrors or (not tie and errors is bestErrors) + continue if errors is bestErrors and dx * dx + dy * dy >= bestX * bestX + bestY * bestY + bestX = dx + bestY = dy + bestErrors = errors + return false if bestErrors > 5 + pt = mapPoint map, scale + (ox + bestX) / side, scale + (oy + bestY) / side + @alignPoint.x = pt.x + @alignPoint.y = pt.y + true + + # Does a finder lie inside a QR region already decoded on any layer? + excluded: (layer, index) -> + return false unless @blocked + scale = layer.context.scale + offset = (scale - 1) / 2 + pos = 4 * index + x = scale * layer.patterns[pos] + offset + y = scale * layer.patterns[pos + 1] + offset + for source in @layers + continue unless source.found + sourceScale = source.context.scale + sourceOffset = (sourceScale - 1) / 2 + s = source.patterns + for i in [0...source.patternCount] + state = source.inverted[i] + continue unless (state & 2) and (state & 4) + tl = 4 * i + tr = 4 * s[tl + 2] + bl = 4 * s[tl + 3] + ux = sourceScale * (s[tr] - s[tl]) + uy = sourceScale * (s[tr + 1] - s[tl + 1]) + vx = sourceScale * (s[bl] - s[tl]) + vy = sourceScale * (s[bl + 1] - s[tl + 1]) + determinant = ux * vy - uy * vx + dx = x - (sourceScale * s[tl] + sourceOffset) + dy = y - (sourceScale * s[tl + 1] + sourceOffset) + across = (dx * vy - dy * vx) / determinant + down = (dy * ux - dx * uy) / determinant + padding = s[tr + 2] + return true if across >= -padding and across <= 1 + padding and down >= -padding and down <= 1 + padding + false + + exclude!: (layer) -> + for i in [0...layer.patternCount] + continue if layer.inverted[i] & 2 or not @excluded(layer, i) + layer.inverted[i] |= 2 + @blocked++ + + # Bounded candidate retention; equal ranks keep the earlier slot. + retain: (index, rank, count, capacity) -> + ranks = @ranks + if count < capacity + @candidates[count] = index + ranks[count] = rank + return count + 1 + worst = 0 + for i in [1...count] + worst = i if ranks[worst] < ranks[i] + return count if rank >= ranks[worst] + @candidates[worst] = index + ranks[worst] = rank + count + + # Assign three finder records to TL/TR/BL by longest side, clockwise. + makeTriple: (layer, i0, i1, i2) -> + pts = layer.patterns + d01 = dist2 pts, i0 * 4, i1 * 4 + d12 = dist2 pts, i1 * 4, i2 * 4 + d02 = dist2 pts, i0 * 4, i2 * 4 + tl = i2 + bl = i0 + tr = i1 + if d12 >= d01 and d12 >= d02 + tl = i0 + bl = i1 + tr = i2 + else if d02 >= d12 and d02 >= d01 + tl = i1 + bl = i0 + tr = i2 + triple = @triple + copyPattern layer, tl, triple.tl + copyPattern layer, bl, triple.bl + copyPattern layer, tr, triple.tr + {tl: topLeft, tr: topRight, bl: bottomLeft} = triple + if (topRight.x - topLeft.x) * (bottomLeft.y - topLeft.y) - (topRight.y - topLeft.y) * (bottomLeft.x - topLeft.x) < 0 + [tr, bl] = [bl, tr] + copyPattern layer, tr, topRight + copyPattern layer, bl, bottomLeft + triple.tlIndex = tl + triple.trIndex = tr + triple.blIndex = bl + triple + + # Does some upper layer confirm candidate slots i, j, k with three distinct centers? + confirmedAbove: (lowerIndex, i, j, k) -> + found = @found + for upperIndex in [lowerIndex + 1...@layers.length] + first = 0 + second = 0 + matched = true + for role in [0...3] + slot = if role is 0 then i else if role is 1 then j else k + base = (upperIndex * 16 + slot) * 3 + selected = 0 + for rank in [0...3] + candidate = found[base + rank] + continue if not candidate or candidate is first or candidate is second + selected = candidate + break + unless selected + matched = false + break + if role is 0 then first = selected + else if role is 1 then second = selected + return true if matched + false + + # Best triple of one polarity for the mandatory attempt, into pick[polarity*4..] + # as (i0, i1, i2, relative error). Raw error rejects square cross-symbol + # mixes in dense grids; sparse scenes use relative error minus row + # confidence to reject small data pseudo-squares. + pickPolarity: (layer, polarity) -> + pts = layer.patterns + candidates = @candidates + confirmed = 0 + for i in [0...layer.patternCount] + state = layer.inverted[i] + continue if (state & 1) isnt polarity or state & 2 + confirmed++ if pts[i * 4 + 3] >= 2 + # With three confirmed centers, require two row hits to reject one-row data. + minimum = if confirmed >= 3 then 2 else 1 + count = 0 + for i in [0...layer.patternCount] + state = layer.inverted[i] + continue if (state & 1) isnt polarity or state & 2 + confidence = pts[i * 4 + 3] + continue if confidence < minimum + count = @retain i, -confidence, count, 16 + return false if count < 3 + # Nearest scale-consistent upper-layer centers per candidate: without this + # rescue, perspective squeeze lost 46 of 168 perspective decodes. + found = @found + found.fill 0 + lowerIndex = @layers.indexOf layer + for upperIndex in [lowerIndex + 1...@layers.length] + upper = @layers[upperIndex] + continue unless upper.used and upper.found + scale = upper.context.scale / layer.context.scale + offset = (scale - 1) / 2 + for slot in [0...count] + at = 4 * candidates[slot] + ms = pts[at + 2] + b0 = b1 = b2 = -1 + d0 = d1 = d2 = Infinity + for i in [0...upper.patternCount] + continue if (upper.inverted[i] & 1) isnt polarity or upper.inverted[i] & 2 + pos = 4 * i + mappedMs = scale * upper.patterns[pos + 2] + smallest = Math.min ms, mappedMs + largest = Math.max ms, mappedMs + # Unrelated scales above 2:1 are rejected; with the distance gate this added +27/-0 rasters. + continue if largest > 2 * smallest + dx = scale * upper.patterns[pos] + offset - pts[at] + dy = scale * upper.patterns[pos + 1] + offset - pts[at + 1] + d = dx * dx + dy * dy + continue if d >= 4 * largest ** 2 or d >= d2 + if d < d0 + b2 = b1 + d2 = d1 + b1 = b0 + d1 = d0 + b0 = i + d0 = d + else if d < d1 + b2 = b1 + d2 = d1 + b1 = i + d1 = d + else + b2 = i + d2 = d + base = (upperIndex * 16 + slot) * 3 + found[base] = b0 + 1 + found[base + 1] = b1 + 1 + found[base + 2] = b2 + 1 + # 1.4 is the zero-loss ambiguous-set choice; isolated 1.8 retained all 7,178 rasters. + moduleRatioMax = if count is 3 then 1.8 else 1.4 + best0 = best1 = best2 = -1 + bestScale = 0 + bestScore = Infinity + bestConfidence = 0 + rankedScore = Infinity + rankedConfidence = 0 + ranked0 = ranked1 = ranked2 = -1 + rankedRelative = Infinity + for i in [0...count - 2] + i0 = candidates[i] + p0 = i0 * 4 + for j in [i + 1...count - 1] + i1 = candidates[j] + p1 = i1 * 4 + d01 = dist2 pts, p0, p1 + for k in [j + 1...count] + i2 = candidates[k] + p2 = i2 * 4 + minMs = Math.min pts[p0 + 2], pts[p1 + 2], pts[p2 + 2] + maxMs = Math.max pts[p0 + 2], pts[p1 + 2], pts[p2 + 2] + nativeConfidence = Math.min pts[p0 + 3], pts[p1 + 3], pts[p2 + 3] + # The measured sparse ambiguity has eight eligible centers. + if maxMs > moduleRatioMax * minMs and (count isnt 8 or nativeConfidence < 4) + continue unless @confirmedAbove(lowerIndex, i, j, k) + d12 = dist2 pts, p1, p2 + d02 = dist2 pts, p0, p2 + a = Math.min d01, d12, d02 + c = Math.max d01, d12, d02 + b = d01 + d12 + d02 - a - c + geometry = Math.abs(c - 2 * b) + Math.abs(c - 2 * a) + confidence = pts[p0 + 3] + pts[p1 + 3] + pts[p2 + 3] + if geometry < bestScore + bestScore = geometry + bestScale = c + bestConfidence = confidence + best0 = i0 + best1 = i1 + best2 = i2 + continue if count > 8 + # A small row-hit weight breaks near-equal geometry toward stronger evidence. + score = geometry / c - 0.01 * confidence + continue if score >= rankedScore + rankedScore = score + rankedConfidence = confidence + rankedRelative = geometry / c + ranked0 = i0 + ranked1 = i1 + ranked2 = i2 + return false if best0 < 0 + relative = bestScore / bestScale + # Small candidate sets describe one symbol; crowded scenes keep raw geometry + # so adjacent symbols cannot mix. 0.08..0.12 is the measured ambiguous band. + if (count <= 7 and relative >= 0.08 and relative <= 0.12) or + (count > 7 and count <= 8 and rankedConfidence >= 2 * bestConfidence and rankedConfidence <= 4 * bestConfidence) + best0 = ranked0 + best1 = ranked1 + best2 = ranked2 + relative = rankedRelative + base = polarity * 4 + @pick[base] = best0 + @pick[base + 1] = best1 + @pick[base + 2] = best2 + @pick[base + 3] = relative + true + + # Reject wrong triples and dimensions from the timing tracks before full + # sampling: 75% agreement across the horizontal and vertical tracks. + timing: (s, map, size) -> + gh = 0 + gv = 0 + n = 0 + N = size - 16 + track = 6.5 + for i in [8...size - 8] + c = i + 0.5 + e = 1 - (i & 1) + gh++ if @read(s, map, c, track) is e + gv++ if @read(s, map, track, c) is e + n++ + return false if 100 * (gh + gv + 2 * (N - n)) < 150 * N and gh isnt n and gv isnt n + 100 * (gh + gv) >= 150 * N or gh is N or gv is N + + # A dark run of about one module flanked by light near the expected + # bottom-right alignment center, cross-checked vertically. This reads the + # native bitmap even when projection later samples a fine plane. + findBasicAlign: (layer, ex, ey, ms) -> + R = Math.max Math.round(5 * ms), 8 + found = false + bestD = Infinity + yLo = Math.max 1, Math.round(ey - R) + yHi = Math.min layer.height - 2, Math.round(ey + R) + xLo = Math.max 1, Math.round(ex - R) + xHi = Math.min layer.width - 2, Math.round(ex + R) + dark = if @invertedProjection then 0 else 1 + capV = Math.ceil(3 * ms) - 1 + for y in [yLo..yHi] + x = xLo + x += run(layer, x, y, 1, 0, dark, Infinity) if bit(layer, x - 1, y) is dark + while x <= xHi + x += run layer, x, y, 1, 0, 1 - dark, Infinity + break if x > xHi + w = run layer, x, y, 1, 0, dark, xHi + 1 - x + cx = x + w / 2 + cxi = Math.round cx + x += w + 1 + continue if w < 0.4 * ms or w > 2.5 * ms + up = run layer, cxi, y - 1, 0, -1, dark, capV + down = run layer, cxi, y + 1, 0, 1, dark, capV + h = up + down + 1 + continue if h < 0.4 * ms or h > 2.5 * ms + cy = y - up + h / 2 + dist = (cx - ex) * (cx - ex) + (cy - ey) * (cy - ey) + if dist < bestD + bestD = dist + found = true + @alignPoint.x = cx + @alignPoint.y = cy + found + + # Detected geometry in caller coordinates for the terminal callback. + report!: (t, br, aligners, ms, size, ctx) -> + return unless ctx.opts.pointsOnDetect + {tl, tr, bl} = t + {scale, ox, oy} = ctx + pt = (point, moduleSize) -> + mapped = { x: point.x * scale + ox, y: point.y * scale + oy } + mapped.moduleSize = moduleSize * scale if moduleSize isnt undefined + mapped + # The located alignment center is inset from the fourth finder position; + # BR completes the finder-center square through the sampling homography. + map = @map + @mapFinderQuad map, size, t, br.x, br.y + project = (x, y) -> pt mapPoint(map, x, y) + quad = (left, top, right, bottom) -> [project(left, top), project(right, top), project(right, bottom), project(left, bottom)] + marker = (point, moduleSize, x, y, radius) -> + center = pt point, moduleSize + expected = project x, y + corners = quad x - radius, y - radius, x + radius, y + radius + # A tiled search can move a center away from the global homography; + # its marker keeps that local correction. + dx = center.x - expected.x + dy = center.y - expected.y + if dx or dy + for corner in corners + corner.x += dx + corner.y += dy + { ...center, corners } + c = 3.5 + pad = 1 + bounds = quad 0, 0, size, size + xs = bounds.map (pt) -> pt.x + ys = bounds.map (pt) -> pt.y + minX = Math.min ...xs + minY = Math.min ...ys + @points = + tl: marker(tl, tl.ms, c, c, c) + tr: marker(tr, tr.ms, size - c, c, c) + br: project(size - c, size - c) + bl: marker(bl, bl.ms, c, size - c, c) + aligners: aligners.map (a) -> marker(a.point, ms, a.x, a.y, 5 / 2) + bounds: bounds + outline: quad(-pad, -pad, size + pad, size + pad) + boundingBox: { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY } + + # Retarget a coarse layer's homography and threshold grid to native luma. + # A layer pixel x sits at 2^r*x + (2^r-1)/2 in native coordinates. + upgrade: (p, map, ctx) -> + fine = ctx.fine + return 0 unless fine + sc = 1 << fine.r + half = (sc - 1) / 2 + scaled = @to + for i in [0...6] + scaled[i] = sc * map[i] + half * map[6 + i % 3] + scaled[6] = map[6] + scaled[7] = map[7] + scaled[8] = map[8] + plane = @finePlane + sh = p.sh + fine.r + plane.d = fine.luma.data + plane.cut = p.cut + plane.W = Math.min fine.luma.width, p.bw << sh + plane.H = Math.min fine.luma.height, ((p.cut.length / p.bw) | 0) << sh + plane.bw = p.bw + plane.sh = sh + sc + + # Berlekamp-Massey, Chien search and Forney over one block in place. + correctBlock: (offset, length, words) -> + blockBytes = @blockBytes + syndromes = @syndromes + hasError = false + for i in [0...words] + value = 0 + for j in [0...length] + value = mul(value, EXP[i]) ^ blockBytes[offset + j] + syndromes[i] = value + hasError = true if value + return true unless hasError + sigma = @sigma + previous = @previous + next = @next + sigma.fill 0, 0, words + 1 + previous.fill 0, 0, words + 1 + sigma[0] = 1 + previous[0] = 1 + sigmaLength = 1 + previousLength = 1 + degree = 0 + shift = 1 + discrepancy = 1 + for n in [0...words] + delta = syndromes[n] + for i in [1...degree + 1] + delta ^= mul(sigma[i], syndromes[n - i]) + unless delta + shift++ + continue + coefficient = mul delta, inv(discrepancy) + nextLength = Math.max sigmaLength, previousLength + shift + for i in [0...nextLength] + a = if i < sigmaLength then sigma[i] else 0 + b = if i >= shift and i - shift < previousLength then mul(coefficient, previous[i - shift]) else 0 + next[i] = a ^ b + if 2 * degree <= n + for i in [0...sigmaLength] + previous[i] = sigma[i] + previousLength = sigmaLength + degree = n + 1 - degree + discrepancy = delta + shift = 1 + else + shift++ + for i in [0...nextLength] + sigma[i] = next[i] + sigmaLength = nextLength + sigmaLength-- while sigmaLength > 1 and not sigma[sigmaLength - 1] + errors = sigmaLength - 1 + return false if not errors or 2 * errors > words + # previous and next are dead now; reuse them as omega and the locations. + omega = previous + omega.fill 0, 0, words + for i in [0...sigmaLength] + j = 0 + while i + j < words + omega[i + j] ^= mul(sigma[i], syndromes[j]) + j++ + locations = next + locationCount = 0 + i = 1 + while i < 256 and locationCount < errors + locations[locationCount++] = inv(i) unless evalLow(sigma, sigmaLength, i) + i++ + return false if locationCount isnt errors + for i in [0...locationCount] + location = locations[i] + blockPos = length - 1 - LOG[location] + return false if blockPos < 0 + inverse = inv location + denominator = 1 + for j in [0...locationCount] + denominator = mul(denominator, 1 ^ mul(locations[j], inverse)) if i isnt j + blockBytes[offset + blockPos] ^= mul(evalLow(omega, words, inverse), inv(denominator)) + true + + # Function-cell map, zigzag unmask into codewords, deinterleave, correct + # every block, then parse the payload. + decodeFormat: (size, eccIndex, mask) -> + ver = (size - 17) / 4 + fun = @fun + fun.fill 0, 0, size * size + for finder in [0...3] + fx = if finder is 1 then size - 7 else 0 + fy = if finder is 2 then size - 7 else 0 + for dy in [-1...8] + for dx in [-1...8] + x = fx + dx + y = fy + dy + fun[y * size + x] = 1 if x >= 0 and y >= 0 and x < size and y < size + count = @setAlignments ver + align = @alignPos + for yi in [0...count] + for xi in [0...count] + ax = align[xi] + ay = align[yi] + continue if fun[ay * size + ax] + for dy in [-2..2] + for dx in [-2..2] + fun[(ay + dy) * size + ax + dx] = 1 + for i in [0...size] + fun[6 * size + i] = 1 + fun[i * size + 6] = 1 + for i in [0..8] + fun[i * size + 8] = 1 + fun[8 * size + i] = 1 + if i < 8 + fun[8 * size + size - 1 - i] = 1 + fun[(size - 1 - i) * size + 8] = 1 + if ver >= 7 + for i in [0...18] + x = size - 11 + i % 3 + y = i // 3 + fun[y * size + x] = 1 + fun[x * size + y] = 1 + bytes = @codewords + total = BYTES[ver - 1] + bytes.fill 0, 0, total + grid = @grid + bitIndex = 0 + dir = -1 + y = size - 1 + xOffset = size - 1 + while xOffset > 0 + xOffset = 5 if xOffset is 6 + loop + for j in [0...2] + x = xOffset - j + continue if fun[y * size + x] + if bitIndex < 8 * total and (grid[y * size + x] ^ ((maskBits(x, y) >> mask) & 1)) is 1 + bytes[bitIndex >> 3] |= 0x80 >> (bitIndex & 7) + bitIndex++ + break if y + dir < 0 or y + dir >= size + y += dir + dir = -dir + xOffset -= 2 + ecc = ECC_LEVELS[eccIndex] + words = WORDS_PER_BLOCK[ecc][ver - 1] + blocks = ECC_BLOCKS[ecc][ver - 1] + shortLen = (total // blocks) - words + shortBlocks = blocks - total % blocks + blockLen = shortLen + words + blockBytes = @blockBytes + pos = 0 + # Every data column precedes every ECC column; both phases share the walk. + for phase in [0...2] + cols = if phase then words else shortLen + 1 + for i in [0...cols] + for block in [0...blocks] + length = blockLen + (if block >= shortBlocks then 1 else 0) + continue if not phase and i >= length - words + offset = block * blockLen + Math.max(0, block - shortBlocks) + blockBytes[offset + (if phase then length - words else 0) + i] = bytes[pos++] + dataLen = total - words * blocks + data = bytes + pos = 0 + for block in [0...blocks] + offset = block * blockLen + Math.max(0, block - shortBlocks) + length = blockLen + (if block >= shortBlocks then 1 else 0) + return FAIL.rs unless @correctBlock(offset, length, words) + end = offset + length - words + for i in [offset...end] + data[pos++] = blockBytes[i] + @decodePayload data, dataLen, ver + + # Both format copies within Hamming distance three; the last in-radius + # (ecc, mask) wins per copy, the closer copy is tried first. + decodeGrid: (size) -> + return FAIL.version unless checkVersion(@grid, size) + m = @grid + f1 = 0 + f2 = 0 + for i in [0...15] + b1 = if i < 6 then m[i * size + 8] else if i < 8 then m[(i + 1) * size + 8] else if i is 8 then m[8 * size + 7] else m[8 * size + 14 - i] + b2 = if i < 8 then m[8 * size + size - 1 - i] else m[(size - 15 + i) * size + 8] + f1 |= b1 << i + f2 |= b2 << i + aEcc = bEcc = -1 + aMask = bMask = 0 + aDistance = bDistance = 0 + for ecc in [0...ECC_LEVELS.length] + for mask in [0...8] + bits = formatBits ECC_LEVELS[ecc], mask + d1 = popcnt(bits ^ f1) + d2 = popcnt(bits ^ f2) + if d1 <= 3 + aEcc = ecc + aMask = mask + aDistance = d1 + if d2 <= 3 + bEcc = ecc + bMask = mask + bDistance = d2 + same = aEcc is bEcc and aMask is bMask + firstB = bEcc >= 0 and (aEcc < 0 or (not same and bDistance < aDistance)) + aFormat = if aEcc < 0 then -1 else (aEcc << 3) | aMask + bFormat = if bEcc < 0 then -1 else (bEcc << 3) | bMask + first = if firstB then bFormat else aFormat + second = if firstB then aFormat else if bEcc >= 0 and not same then bFormat else -1 + decoded = FAIL.format + formatValue = first + while formatValue >= 0 + decoded = @decodeFormat size, formatValue >> 3, formatValue & 7 + break unless decoded instanceof Error + formatValue = if formatValue is first then second else -1 + decoded + + # One symbol or one alignment tile into the scanner grid; tiles never overlap. + projectQuad!: (s, map, size, left = 0, right = size, top = 0, bottom = size) -> + grid = @grid + for y in [top...bottom] + for x in [left...right] + grid[y * size + x] = @read s, map, x + 0.5, y + 0.5 + + projectMap: (s, map, size) -> + return FAIL.timing unless @timing(s, map, size) + @projectQuad s, map, size + @decodeGrid size + + # Timing plus the redundant version bits alone, to gate Version 7+ tiling. + confirm: (s, map, size) -> + return false unless @timing(s, map, size) + grid = @grid + for i in [0...18] + x = size - 11 + i % 3 + y = i // 3 + grid[y * size + x] = @read s, map, x + 0.5, y + 0.5 + grid[x * size + y] = @read s, map, y + 0.5, x + 0.5 + checkVersion grid, size + + # Version 7+: locate every alignment pattern on the lattice, then project + # each tile through its own homography built from the four surrounding nodes. + projectTiles: (sp, sm, size, version, fine, brPoint, t, ms, ctx) -> + sc = if fine then fine else 1 + half = (sc - 1) / 2 + count = @setAlignments version + positions = @alignPos + nodes = @nodes + located = @located + last = positions[count - 1] + c = 0.5 + locatedCount = 0 + tileBrX = 0 + tileBrY = 0 + hasTileBr = false + for yi in [0...count] + for xi in [0...count] + x = positions[xi] + y = positions[yi] + overlapsFinder = (x is 6 and (y is 6 or y is last)) or (x is last and y is 6) + node = (yi * count + xi) * 2 + if not overlapsFinder and @searchAlign(sp, sm, c, 1, x, y, c, true) + pos = locatedCount * 4 + located[pos] = @alignPoint.x + located[pos + 1] = @alignPoint.y + located[pos + 2] = x + c + located[pos + 3] = y + c + locatedCount++ + if x is last and y is last + hasTileBr = true + tileBrX = @alignPoint.x + tileBrY = @alignPoint.y + nodes[node] = @alignPoint.x + nodes[node + 1] = @alignPoint.y + else + q = mapPoint sm, x + c, y + c + nodes[node] = q.x + nodes[node + 1] = q.y + return FAIL.alignment unless locatedCount + for yi in [0...count - 1] + for xi in [0...count - 1] + left = positions[xi] + right = positions[xi + 1] + top = positions[yi] + bottom = positions[yi + 1] + tlNode = (yi * count + xi) * 2 + trNode = tlNode + 2 + blNode = ((yi + 1) * count + xi) * 2 + brNode = blNode + 2 + tile = @map + packQuad @from, left + c, top + c, right + c, top + c, right + c, bottom + c, left + c, bottom + c + packQuad @to, nodes[tlNode], nodes[tlNode + 1], nodes[trNode], nodes[trNode + 1], nodes[brNode], nodes[brNode + 1], nodes[blNode], nodes[blNode + 1] + @mapQuad tile + @projectQuad sp, tile, size, (if xi then left else 0), (if xi is count - 2 then size else right), (if yi then top else 0), (if yi is count - 2 then size else bottom) + if ctx.opts.pointsOnDetect + reportAligners = [] + for i in [0...locatedCount] + pos = i * 4 + reportAligners.push { point: { x: (located[pos] - half) / sc, y: (located[pos + 1] - half) / sc }, x: located[pos + 2], y: located[pos + 3] } + br = if hasTileBr then { x: (tileBrX - half) / sc, y: (tileBrY - half) / sc } else brPoint + @report t, br, reportAligners, ms, size, ctx + @decodeGrid size + + # One path for both polarities: inverted sampling flips read() and the + # aligner's dark color. + projectWith: (layer, triple, pitch) -> + @invertedProjection = triple.inverted + result = @projectTriple layer, triple, pitch + @invertedProjection = false + result + + projectTriple: (layer, t, pitch) -> + inverted = t.inverted + plane = layer.plane + ctx = layer.context + {tl, tr, bl} = t + ms = (tl.ms + tr.ms + bl.ms) / 3 + minMs = Math.min tl.ms, tr.ms, bl.ms + maxMs = Math.max tl.ms, tr.ms, bl.ms + # Perspective changes apparent pitch across finders; the matching edge's + # endpoint pitches are used only when that variation is material. + ux = tr.x - tl.x + uy = tr.y - tl.y + vx = bl.x - tl.x + vy = bl.y - tl.y + area = Math.abs(ux * vy - uy * vx) + span = Math.max Math.abs(uy), Math.abs(vy) + medianMs = tl.ms + tr.ms + bl.ms - minMs - maxMs + medianEst = if span > 0 and medianMs > 0 then area / (span * medianMs) + 7 else Infinity + length = distance tl, tr + # 10/9 is the best gain plateau among 1.105, 10/9, 1.125 and 1.15. + est = if pitch then length / pitch + 7 else if maxMs > (10 / 9) * minMs then (length * 2) / (tl.ms + tr.ms) + 7 else medianEst + snapped = snapSize est + meanEst = if span > 0 and ms > 0 then area / (span * ms) + 7 else Infinity + mean = snapSize meanEst + failed = FAIL.dimension + # Module-size estimates drift under rotation and blur: try the neighbor + # dimensions before giving up on the triple. + for i in [0...4] + size = if i is 0 then snapped else if i is 1 then mean else if i is 2 then snapped - 4 else snapped + 4 + estimate = if i is 1 then meanEst else est + continue if (i > 0 and size is snapped) or (i > 1 and size is mean) + continue if size < 21 or size > 177 or Math.abs(size - estimate) > 6 + @decodedSize = size + # A located bottom-right alignment pattern upgrades the affine BR + # estimate to perspective. + f = 1 - 3 / (size - 7) + brEstX = tl.x + (tr.x - tl.x + bl.x - tl.x) * f + brEstY = tl.y + (tr.y - tl.y + bl.y - tl.y) * f + found = false + if size >= 25 + if inverted or not @perspective(t, tl.ms, tr.ms, bl.ms) + found = @findBasicAlign layer, brEstX, brEstY, ms + else + cx = tr.x + bl.x - 2 * tl.x + cy = tr.y + bl.y - 2 * tl.y + scale = (cx * (brEstX - tl.x) + cy * (brEstY - tl.y)) / (cx * cx + cy * cy) + map = @map + predictedDen = (map[6] + map[7]) * scale + map[8] + dx = ((map[0] + map[1]) * scale + map[2]) / predictedDen - brEstX + dy = ((map[3] + map[4]) * scale + map[5]) / predictedDen - brEstY + # Disagreement within three modules is measurement noise. + if dx * dx + dy * dy <= 9 * ms * ms + found = @findBasicAlign layer, brEstX, brEstY, ms + else + tlMs = finderPitch layer, tl + trMs = finderPitch layer, tr + blMs = finderPitch layer, bl + if tlMs and trMs and blMs and @perspective(t, tlMs, trMs, blMs) + found = @searchAlign plane, map, scale, 3 / (1 - scale), 0, 0, 1, false + # Data can resemble an aligner; Reed-Solomon also tries the affine estimate. + failed = FAIL.alignment + attempts = if found then 2 else 1 + for attempt in [0...attempts] + align = attempt is 0 and found + brX = if align then @alignPoint.x else brEstX + brY = if align then @alignPoint.y else brEstY + brPoint = undefined + if ctx.opts.pointsOnDetect + brPoint = { x: brX, y: brY } + inset = size - 6.5 + aligners = if align then [{ point: @alignPoint, x: inset, y: inset }] else [] + @report t, brPoint, aligners, ms, size, ctx + map = @map + @mapFinderQuad map, size, t, brX, brY + version = (size - 17) / 4 + if version < 7 + failed = @projectMap plane, map, size + return failed unless failed instanceof Error + # Blurred Versions 1..6 still need fine-plane sampling. + if @upgrade(plane, map, ctx) + failed = @projectMap @finePlane, @to, size + return failed unless failed instanceof Error + else + fine = @upgrade plane, map, ctx + sp = if fine then @finePlane else plane + sm = if fine then @to else map + # The redundant BCH word is confirmed before paying for local searches. + confirmed = @confirm sp, sm, size + confirmed = @confirm(plane, map, size) if not confirmed and fine + unless confirmed + failed = FAIL.version + continue + failed = @projectTiles sp, sm, size, version, fine, brPoint, t, ms, ctx + return failed unless failed instanceof Error + # A false local alignment can corrupt a valid global projection. + failed = @projectMap sp, sm, size + return failed unless failed instanceof Error + if fine + failed = @projectMap plane, map, size + return failed unless failed instanceof Error + failed + + # Threshold, pack the bitmap and collect finders for one layer. + binarize: (layer, cooperative) -> + bHeight = layer.blockHeight + chunk = if cooperative then 16 else bHeight + y = 0 + while y < bHeight + blockRows layer, y, Math.min(bHeight, y + chunk) + @retryStart += yield if cooperative and y + chunk < bHeight + y += chunk + layer.bitmap.fill 0, 0, layer.words * layer.height + y = 0 + while y < bHeight + bitmapRows layer, y, Math.min(bHeight, y + chunk) + @retryStart += yield if cooperative and y + chunk < bHeight + y += chunk + @opts.imageOnBitmap darkToImage(layer.width, layer.height, (x, y) -> bit(layer, x, y) is 1) if @opts.imageOnBitmap + layer.patternCount = 0 + rows = if cooperative then 32 else layer.height + y = 0 + while y < layer.height + findRows layer, y, Math.min(layer.height, y + rows) + @retryStart += yield if cooperative and y + rows < layer.height + y += rows + layer.found = true + return + + # Mandatory attempt: both polarities ranked independently, then the pick + # with the better geometry-times-evidence product. + pickTriple: (layer) -> + @exclude layer + ordinary = @pickPolarity layer, 0 + inverted = @pickPolarity layer, 1 + return undefined unless ordinary or inverted + pick = @pick + pts = layer.patterns + polarity = 0 + if inverted + ordConfidence = if ordinary then pts[pick[0] * 4 + 3] + pts[pick[1] * 4 + 3] + pts[pick[2] * 4 + 3] else 0 + invConfidence = pts[pick[4] * 4 + 3] + pts[pick[5] * 4 + 3] + pts[pick[6] * 4 + 3] + # Geometry alone favors well-formed false crosses; a small evidence + # prior keeps weak true crosses competitive. + polarity = 1 if not ordinary or (pick[7] + 0.1) * ordConfidence < (pick[3] + 0.1) * invConfidence + base = polarity * 4 + w0 = pick[base] | 0 + w1 = pick[base + 1] | 0 + w2 = pick[base + 2] | 0 + layer.pickSum = w0 + w1 + w2 + layer.pickLo = Math.min w0, w1, w2 + layer.pickHi = Math.max w0, w1, w2 + triple = @makeTriple layer, w0, w1, w2 + triple.inverted = polarity is 1 + triple + + # Push one retry set onto the bounded max-heap. + retainSet!: (layer, score, inverted, i, i1, i2) -> + sets = layer.sets + index = layer.setCount + if index < 256 + layer.setCount++ + else + return if score >= sets[0] + index = 0 + pos = index * 5 + sets[pos] = score + sets[pos + 1] = if inverted then 1 else 0 + sets[pos + 2] = i + sets[pos + 3] = i1 + sets[pos + 4] = i2 + while index + parent = (index - 1) >> 1 + return if sets[parent * 5] >= sets[index * 5] + swapSet sets, parent, index + index = parent + siftDown sets, layer.setCount + + # Retry schedule for one layer: every finder's bounded neighborhood, each + # pair scored by compactness over row-hit evidence, heapsorted ascending. + buildSets: (layer, cooperative) -> + layer.setsReady = true + layer.setCount = 0 + layer.setCursor = 0 + eligible = 0 + for i in [0...layer.patternCount] + eligible++ unless layer.inverted[i] & 2 + return if eligible < 3 + pts = layer.patterns + neighbors = @candidates + useFilters = eligible > 5 + for i in [0...layer.patternCount - 2] + @retryStart += yield if cooperative and i and not (i & 7) + state = layer.inverted[i] + continue if state & 2 + inverted = !!(state & 1) + p0 = i * 4 + # A 1.5 scale allowance retains the largest legal perspective dimension. + maxDistance = pts[p0 + 2] * 177 * 1.5 + maxDistance2 = maxDistance * maxDistance + count = 0 + for index in [i + 1...layer.patternCount] + otherState = layer.inverted[index] + continue if otherState & 2 or !!(otherState & 1) isnt inverted + pos = index * 4 + smallest = Math.min pts[p0 + 2], pts[pos + 2] + largest = Math.max pts[p0 + 2], pts[pos + 2] + # 2.4 preserves upper-layer-confirmed perspective triples; two pixels + # of slack keep an exact 2:1 ratio inside floating point. + continue if useFilters and largest > 2.4 * smallest + 2 / 7 + dx = pts[p0] - pts[pos] + dy = pts[p0 + 1] - pts[pos + 1] + d = dx * dx + dy * dy + continue if d > maxDistance2 + # A distant high-confidence finder can be the squeezed leg of a + # perspective symbol: rank by evidence-adjusted distance. + count = @retain index, d / pts[pos + 3], count, 15 + for u in [0...count - 1] + i1 = neighbors[u] + p1 = i1 * 4 + d01 = dist2 pts, p0, p1 + for v in [u + 1...count] + i2 = neighbors[v] + p2 = i2 * 4 + d12 = dist2 pts, p1, p2 + d02 = dist2 pts, p0, p2 + a = Math.min d01, d12, d02 + c = Math.max d01, d12, d02 + b = d01 + d12 + d02 - a - c + continue if not a or not b or (useFilters and (a > 4 * b or b > 4 * a)) + da = Math.sqrt a + db = Math.sqrt b + moduleCount = (da + db) / (2 * ((pts[p0 + 2] + pts[p1 + 2] + pts[p2 + 2]) / 3)) + 7 + # Center-line pitch can overestimate small finders: allow 0.8..1.5 scaling. + continue if moduleCount < 21 * 0.8 or moduleCount > 177 * 1.5 + cosine = (a + b - c) / (2 * Math.sqrt(a * b)) + # Finder legs spanning 60 through 120 degrees. + continue if useFilters and Math.abs(cosine) > 0.5 + confidence = pts[p0 + 3] + pts[p1 + 3] + pts[p2 + 3] + @retainSet layer, (da + db + Math.abs(da - db)) / confidence, inverted, i, i1, i2 + for end in [layer.setCount - 1...0] by -1 + swapSet layer.sets, 0, end + siftDown layer.sets, end + return + + # Next scheduled set that is neither excluded nor the round-0 pick. + nextSet: (layer) -> + sets = layer.sets + while layer.setCursor < layer.setCount + pos = layer.setCursor * 5 + layer.setCursor++ + i0 = sets[pos + 2] | 0 + i1 = sets[pos + 3] | 0 + i2 = sets[pos + 4] | 0 + continue if layer.inverted[i0] & 2 or layer.inverted[i1] & 2 or layer.inverted[i2] & 2 + continue if i0 + i1 + i2 is layer.pickSum and Math.min(i0, i1, i2) is layer.pickLo and Math.max(i0, i1, i2) is layer.pickHi + triple = @makeTriple layer, i0, i1, i2 + triple.inverted = !!sets[pos + 1] + return triple + undefined + + # A success retains its projected region in the dead finder records so + # later attempts skip finders inside it. + markDecoded!: (layer, triple, result) -> + opts = layer.context.opts + if opts.imageOnResult + size = @decodedSize + opts.imageOnResult darkToImage(size, size, (x, y) => @grid[y * size + x]) + padding = 3.5 / (@decodedSize - 7) + pts = layer.patterns + tl = 4 * triple.tlIndex + tr = 4 * triple.trIndex + bl = 4 * triple.blIndex + pts[tl] = triple.tl.x + pts[tl + 1] = triple.tl.y + pts[tl + 2] = triple.trIndex + pts[tl + 3] = triple.blIndex + pts[tr] = triple.tr.x + pts[tr + 1] = triple.tr.y + pts[tr + 2] = padding + pts[bl] = triple.bl.x + pts[bl + 1] = triple.bl.y + @blocked++ unless layer.inverted[triple.tlIndex] & 2 + @blocked++ unless layer.inverted[triple.trIndex] & 2 + @blocked++ unless layer.inverted[triple.blIndex] & 2 + layer.inverted[triple.tlIndex] |= 2 | 4 + layer.inverted[triple.trIndex] |= 2 | 8 + layer.inverted[triple.blIndex] |= 2 | 8 + for source in @layers + @exclude source if source.found + @opts.pointsOnDetect @points, result if @points and @opts.pointsOnDetect + + # Lazily build the pyramid, then one mandatory triple per layer (coarse + # first) followed by scheduled retries while effort and time remain. + scan: (cooperative) -> + throw Error.new 'expected addImage before decode' unless @staged + layers = @layers + @points = undefined + failed = FAIL.finder + unless @resized + for i in [1...layers.length] + layer = layers[i] + break unless layer.used + src = layers[i - 1].luma + width = layers[i - 1].width + rows = if cooperative then 64 else layer.height + y = 0 + while y < layer.height + resizeRows src, layer.luma, width, layer.width, y, Math.min(layer.height, y + rows) + @retryStart += yield if cooperative and y + rows < layer.height + y += rows + @resized = true + mandatory = true + loop + attempted = false + stop = false + for i in [layers.length - 1..0] by -1 + if not mandatory and (not @retries or (@timeLimit isnt Infinity and Date.now() - @retryStart >= @timeLimit)) + stop = true + break + layer = layers[i] + continue unless layer.used + yield from @binarize(layer, cooperative) unless layer.found + if mandatory + triple = @pickTriple layer + else + yield from @buildSets(layer, cooperative) unless layer.setsReady + triple = @nextSet layer + unless triple + @retryStart += yield if cooperative + continue + @retries-- if not mandatory and @retries isnt Infinity + attempted = true + # Large blurred symbols get template-fit refinement first; other + # projection failures retry once after a cross refinement pass. + pitch = if not @blocked and Math.abs(triple.tr.y - triple.tl.y) > Math.abs(triple.tr.x - triple.tl.x) then edgePitch(layer, triple.tl, triple.tr, triple.inverted) else 0 + fit = !!(pitch and distance(triple.tl, triple.tr) / pitch + 7 >= 45) + if fit + fitPattern layer, triple.tl, triple.inverted + fitPattern layer, triple.tr, triple.inverted + fitPattern layer, triple.bl, triple.inverted + refineTriple layer, triple + pitch = edgePitch layer, triple.tl, triple.tr, triple.inverted + result = @projectWith layer, triple, pitch + result = @projectWith(layer, triple, pitch) if result instanceof Error and not fit and refineTriple(layer, triple) + unless result instanceof Error + @markDecoded layer, triple, result + return result + failed = result + @retryStart += yield if cooperative + break if stop or (not mandatory and not attempted) + mandatory = false + @opts.pointsOnDetect @points, failed if @points and @opts.pointsOnDetect + failed + + # Re-run the scan after each success for decode-all under one budget. + walk: (cooperative, all) -> + @retryStart = Date.now() + @retries = if @effort is Infinity then Infinity else @effort - 1 + results = [] + loop + result = yield from @scan(cooperative) + results.push result + return results unless all + return results if result instanceof Error + @retryStart += yield if cooperative + + decode: (all = false) -> + @beginOperation() + results = undefined + try + results = runDecode @walk(false, all) + finally + @endOperation() + results + + decodeAsync: (all = false) -> + @beginOperation() + results = undefined + try + results = runDecodeAsync! @walk(true, all), @timeLimit + finally + @endOperation() + results + +# ==[ Public API ]== + +# First QR in an image through the coarse-to-fine scan; throws when none decodes. +export def decodeQR(img, opts = {}) + validateOpts opts + validateImage img, opts.format + scanner = QRScanner.new { ...opts, maxSize: { height: img.height, width: img.width } } + result = undefined + try + scanner.addImage img, opts.format + result = scanner.decode()[0] + finally + scanner.clean() + throw Error.new result.message if result instanceof Error + result + +export default decodeQR + +# Every QR in each image through one cooperatively scheduled scanner. +export def decodeQRBatch(images, opts = {}) + maxSize = opts.maxSize or { width: 3840, height: 3840 } + scanner = QRScanner.new { ...opts, maxSize } + results = [] + try + for image in images + try + scanner.addImage image, opts.format + results.push scanner.decodeAsync!(true) + catch error + throw error unless error instanceof Error + results.push [error] + finally + scanner.clean() + results diff --git a/packages/barcodes/dom.rip b/packages/barcodes/dom.rip new file mode 100644 index 00000000..f7eb1d0b --- /dev/null +++ b/packages/barcodes/dom.rip @@ -0,0 +1,938 @@ +# ============================================================================== +# rip/barcodes — browser plumbing for camera scanning +# +# QRCanvas owns a reusable scanner and the optional overlay, result and bitmap +# canvases; QRCamera feeds it frames, preferring a zero-copy VideoFrame path +# that writes native planes straight into the scanner arena. Overlay identity +# changes are suppressed for three frames so camera jitter never flickers. +# BarcodeDetector is a ponyfill over decodeQR for the Shape Detection API. +# ============================================================================== + +import { QRScanner, decodeQR } from './decode.rip' + +Y8 =! [0, 0, 1] +UV8 =! [1, 1, 1] +P420 =! [Y8, UV8, UV8] +P420_16 =! [[0, 0, 2], [1, 1, 2], [1, 1, 2]] +P_RGBA =! [[0, 0, 4]] +# Plane descriptors per VideoFrame format: x shift, y shift, bytes per sample. +PLANES =! + RGB: [[0, 0, 3]] + RGBA: P_RGBA + RGBX: P_RGBA + BGRA: P_RGBA + BGRX: P_RGBA + I420: P420 + I420P10: P420_16 + I420P12: P420_16 + I420A: [Y8, UV8, UV8, Y8] + I422: [Y8, [1, 0, 1], [1, 0, 1]] + I444: [Y8, Y8, Y8] + NV12: [Y8, [1, 1, 2]] + +# Rendered element size from computed CSS. +export def getSize(elm) + css = getComputedStyle elm + { width: Math.floor(+css.width.split('px')[0]), height: Math.floor(+css.height.split('px')[0]) } + +# Setting a canvas dimension clears it even at the same size. +def setCanvasSize!(canvas, height, width) + canvas.height = height if canvas.height isnt height + canvas.width = width if canvas.width isnt width + +getCanvasContext = (canvas) -> + context = canvas.getContext '2d' + throw Error.new 'Cannot get canvas context' if context is null + { canvas, context } + +def clearCanvas!(cc) + cc.context.clearRect 0, 0, cc.canvas.width, cc.canvas.height + +def traceQuad!(context, points) + context.beginPath() + context.moveTo points[0].x, points[0].y + for i in [1...points.length] + context.lineTo points[i].x, points[i].y + context.closePath() + +def fillQuad!(context, points) + traceQuad context, points + context.fill() + +toward = (from, to, distance) -> + dx = to.x - from.x + dy = to.y - from.y + scale = Math.min 0.5, distance / Math.hypot(dx, dy) + { x: from.x + scale * dx, y: from.y + scale * dy } + +def traceRoundedQuad!(context, points, radius) + starts = points.map (point, i) -> toward point, points[(i + 1) % 4], radius + ends = points.map (point, i) -> toward point, points[(i + 3) % 4], radius + context.beginPath() + context.moveTo starts[0].x, starts[0].y + for i in [1..points.length] + pos = i % 4 + context.lineTo ends[pos].x, ends[pos].y + context.quadraticCurveTo points[pos].x, points[pos].y, starts[pos].x, starts[pos].y + context.closePath() + +OVERLAY_SWITCH_FRAMES =! 3 + +# Half a QR side of center drift is ordinary frame-to-frame motion. +sameOverlay = (left, right) -> + return false unless left + a = left.boundingBox + b = right.boundingBox + side = Math.max a.width, a.height, b.width, b.height + dx = a.x + a.width / 2 - b.x - b.width / 2 + dy = a.y + a.height / 2 - b.y - b.height / 2 + dx * dx + dy * dy <= (0.5 * side) ** 2 + +sameOverlays = (left, right) -> + return false if left.length isnt right.length + for i in [0...left.length] + match = i + match++ while match < right.length and not sameOverlay(left[i], right[match]) + return false if match is right.length + [right[i], right[match]] = [right[match], right[i]] if match isnt i + true + +def copyOverlays!(target, source) + target.length = source.length + for i in [0...source.length] + target[i] = source[i] + +# Drawing and decode options, with defaults. +canvasDefaults = -> + resultBlockSize: 8 + overlayMainColor: 'green' + overlayFinderColor: 'blue' + overlayAlignerColor: 'yellow' + overlaySideColor: 'black' + overlayTimeout: 500 + cropToSquare: true + decodeAll: false + async: false + drawFailed: false + +export class QRCanvas + constructor: (elements = {}, opts = {}, Scanner = QRScanner) -> + {overlay, resultQR, bitmap} = elements + @opts = { ...canvasDefaults(), ...opts } + @lastDetect = 0 + @pending = undefined + @task = undefined + @cleanPending = false + @generation = 0 + @bitmapDrawn = false + @overlayDrawn = false + @overlayMatches = 0 + @overlayCandidate = undefined + @overlayPoints = undefined + @overlayBatch = [] + @overlayBatchFailed = undefined + @overlaySet = [] + @overlayCandidates = [] + @overlaySetMatches = 0 + @overlayFailed = undefined + @overlayFailedCandidate = undefined + @overlayFailedCandidateSet = false + @overlayFailedMatches = 0 + @overlayDeadline = 0 + @overlayTimer = undefined + @rotate = false + @sourceHeight = 0 + @sourceX = 0 + @sourceY = 0 + @inputWidth = 0 + @inputHeight = 0 + @frameSource = undefined + @main = getCanvasContext document.createElement('canvas') + @overlay = getCanvasContext(overlay) if overlay + if resultQR + @resultQR = getCanvasContext resultQR + @resultQR.context.imageSmoothingEnabled = false + @bitmap = getCanvasContext(bitmap) if bitmap + decoder = { maxSize: { width: 3840, height: 3840 }, stride: 4, textDecoder: @opts.textDecoder } + decoder.effort = @opts.effort if @opts.effort isnt undefined + decoder.timeLimit = @opts.timeLimit if @opts.timeLimit isnt undefined + decoder.pointsOnDetect = (points, result) => @onPoints points, result if @overlay + decoder.imageOnResult = (img) => @drawResultQr img if @resultQR + if @bitmap + decoder.imageOnBitmap = (img) => + # A failing frame emits many retry planes; keep only its first. + return if @bitmapDrawn + @bitmapDrawn = true + @drawBitmap img + @scanner = new Scanner(decoder) + @reader = + clean: => + @generation++ + @task?.abort() + @task = undefined + if @pending then @cleanPending = true else @scanner.clean() + return + crop: @opts.cropToSquare + luma: @scanner.luma + frame: @opts.onVideoFrame + source: (source) => + return if source is @frameSource + @frameSource = source + @opts.onFrameSource source if @opts.onFrameSource + return + read: (frame) => + @rotate = frame.rotate + @sourceHeight = frame.sourceHeight + @sourceX = frame.sourceX + @sourceY = frame.sourceY + @inputWidth = frame.size.width + @inputHeight = frame.size.height + setCanvasSize @overlay.canvas, frame.height, frame.width if @overlay + @decode undefined, frame.format, frame.layout, frame.size + + resetOverlay!: -> + @overlayPoints = undefined + @overlayCandidate = undefined + @overlaySet.length = 0 + @overlayCandidates.length = 0 + @overlaySetMatches = 0 + @overlayFailed = undefined + @overlayFailedCandidate = undefined + @overlayFailedCandidateSet = false + @overlayFailedMatches = 0 + @overlayMatches = 0 + + # Map decoder geometry from cropped, row-contiguous coded pixels into + # presentation coordinates; the pixels themselves were never rotated. + onPoints!: (points, result) -> + @resetOverlay() if Date.now() - @lastDetect > @opts.overlayTimeout + if @rotate or @sourceX or @sourceY + move = (point) => + x = point.x + @sourceX + y = point.y + @sourceY + if @rotate + point.x = @sourceHeight - 1 - y + point.y = x + else + point.x = x + point.y = y + return + move point for point in [points.tl, points.tr, points.br, points.bl, ...points.aligners] + for marker in [points.tl, points.tr, points.bl, ...points.aligners] + move point for point in marker.corners + move point for point in [...points.bounds, ...points.outline] + box = points.boundingBox + xs = points.bounds.map (point) -> point.x + ys = points.bounds.map (point) -> point.y + box.x = Math.min ...xs + box.y = Math.min ...ys + box.width = Math.max(...xs) - box.x + box.height = Math.max(...ys) - box.y + return if result instanceof Error and not @opts.drawFailed + if @opts.decodeAll + if result instanceof Error then @overlayBatchFailed = points else @overlayBatch.push points + return + # Reject one-frame identity changes without delaying motion. + if not @overlayPoints or sameOverlay(@overlayPoints, points) + @overlayPoints = points + @overlayCandidate = undefined + @overlayMatches = 0 + else + if sameOverlay(@overlayCandidate, points) + @overlayMatches++ + else + @overlayCandidate = points + @overlayMatches = 1 + return if @overlayMatches < OVERLAY_SWITCH_FRAMES + @overlayPoints = points + @overlayCandidate = undefined + @overlayMatches = 0 + @drawOverlay points, @overlayDrawn, result instanceof Error + @overlayDrawn = true + now = Date.now() + @lastDetect = now + # This runs inside measured decode time, so it only refreshes the + # deadline; decode() arms the timer afterwards. + @overlayDeadline = now + @opts.overlayTimeout + + drawOverlayBatch!: -> + # No terminal geometry is not evidence the displayed set changed; retain + # it until the ordinary deadline expires. + return unless @overlayBatch.length or @overlayBatchFailed + redraw = false + if (not @overlaySet.length and @overlayBatch.length) or sameOverlays(@overlaySet, @overlayBatch) + copyOverlays @overlaySet, @overlayBatch + @overlayCandidates.length = 0 + @overlaySetMatches = 0 + redraw = !!@overlaySet.length + else + if sameOverlays(@overlayCandidates, @overlayBatch) + @overlaySetMatches++ + else + copyOverlays @overlayCandidates, @overlayBatch + @overlaySetMatches = 1 + if @overlaySetMatches is OVERLAY_SWITCH_FRAMES + copyOverlays @overlaySet, @overlayBatch + @overlayCandidates.length = 0 + @overlaySetMatches = 0 + redraw = true + failed = @overlayBatchFailed + if not @overlayFailed and failed + @overlayFailed = failed + @overlayFailedCandidateSet = false + @overlayFailedMatches = 0 + redraw = true + else if @overlayFailed and failed and sameOverlay(@overlayFailed, failed) + @overlayFailed = failed + @overlayFailedCandidateSet = false + @overlayFailedMatches = 0 + redraw = true + else if @overlayFailed or failed + sameCandidate = (not @overlayFailedCandidate and not failed) or + (!!@overlayFailedCandidate and !!failed and sameOverlay(@overlayFailedCandidate, failed)) + if @overlayFailedCandidateSet and sameCandidate + @overlayFailedMatches++ + else + @overlayFailedCandidate = failed + @overlayFailedCandidateSet = true + @overlayFailedMatches = 1 + if @overlayFailedMatches is OVERLAY_SWITCH_FRAMES + @overlayFailed = failed + @overlayFailedCandidate = undefined + @overlayFailedCandidateSet = false + @overlayFailedMatches = 0 + redraw = true + return unless redraw + @overlayDrawn = false + for points in @overlaySet + @drawOverlay points, @overlayDrawn, false + @overlayDrawn = true + if @overlayFailed + @drawOverlay @overlayFailed, @overlayDrawn, true + @overlayDrawn = true + now = Date.now() + @lastDetect = now + @overlayDeadline = now + @opts.overlayTimeout + + expireOverlay!: -> + remaining = @overlayDeadline - Date.now() + if remaining > 0 + @overlayTimer = setTimeout (=> @expireOverlay()), remaining + return + @overlayTimer = undefined + @overlayDeadline = 0 + @overlayDrawn = false + @resetOverlay() + @drawOverlay() + + # Sized to the plane, often a downscaled rung; CSS scales it over the preview. + drawBitmap!: (img) -> + return unless @bitmap + {data, height, width} = img + if @rotate + out = new Uint8ClampedArray(data.length) + for y in [0...height] + for x in [0...width] + src = 4 * (y * width + x) + dst = 4 * (x * height + height - 1 - y) + for k in [0...4] + out[dst + k] = data[src + k] + [width, height] = [height, width] + else + out = Uint8ClampedArray.from data + setCanvasSize @bitmap.canvas, height, width + @bitmap.context.putImageData ImageData.new(out, width, height), 0, 0 + + drawResultQr!: (img) -> + return unless @resultQR + {data, height, width} = img + blockSize = @opts.resultBlockSize + setCanvasSize @resultQR.canvas, height, width + @resultQR.context.putImageData ImageData.new(Uint8ClampedArray.from(data), width, height), 0, 0 + # Scaled modules blur under default smoothing; the result canvas owns this style. + @resultQR.canvas.style = "image-rendering: pixelated; width: #{blockSize * width}px; height: #{blockSize * height}px" + + drawOverlay!: (points, append = false, failed = false) -> + return unless @overlay + ctx = @overlay.context + height = @overlay.canvas.height + width = @overlay.canvas.width + unless append + if @opts.cropToSquare and height isnt width + cropWidth = @inputWidth or Math.min(height, width) + cropHeight = @inputHeight or Math.min(height, width) + x = if @inputWidth then @sourceX else Math.floor((width - cropWidth) / 2) + y = if @inputHeight then @sourceY else Math.floor((height - cropHeight) / 2) + if @rotate and @inputWidth + # The crop rect was applied to coded pixels; rotate it into player coordinates. + [x, y, cropWidth, cropHeight] = [@sourceHeight - y - cropHeight, x, cropHeight, cropWidth] + ctx.clearRect x, y, cropWidth, cropHeight + ctx.fillStyle = @opts.overlaySideColor + right = x + cropWidth + bottom = y + cropHeight + ctx.fillRect 0, 0, width, y if y + ctx.fillRect 0, bottom, width, height - bottom if bottom < height + ctx.fillRect 0, y, x, cropHeight if x + ctx.fillRect right, y, width - right, cropHeight if right < width + else + ctx.clearRect 0, 0, width, height + return unless points + {tl, tr, br, bl} = points + # The four points are logical finder-center positions, so the polygon + # keeps the decoder's projective geometry. + ctx.fillStyle = if failed then 'red' else @opts.overlayMainColor + fillQuad ctx, [tl, tr, br, bl] + moduleSize = (tl.moduleSize + tr.moduleSize + bl.moduleSize) / 3 + ctx.strokeStyle = @opts.overlayAlignerColor + ctx.lineWidth = moduleSize / 2 + traceRoundedQuad ctx, points.outline, 9 + ctx.stroke() + ctx.fillStyle = @opts.overlayFinderColor + fillQuad ctx, finder.corners for finder in [tl, tr, bl] + ctx.fillStyle = @opts.overlayAlignerColor + fillQuad ctx, aligner.corners for aligner in points.aligners + + # Camera-frame decoding is fail-soft: a failed frame is a miss, never a throw. + finish: (res) -> + failures = undefined + if res and @opts.decodeAll + failures = res + for result in res + return res if typeof result is 'string' + else if res and typeof res[0] is 'string' + return res[0] + @drawOverlay() if @overlay and Date.now() - @lastDetect > @opts.overlayTimeout + failures + + endDecode!: -> + @drawOverlayBatch() if @opts.decodeAll + if @overlayDeadline and @overlayTimer is undefined + @overlayTimer = setTimeout (=> @expireOverlay()), @opts.overlayTimeout + + decode: (image, format, layout, size) -> + return if @pending + @bitmapDrawn = false + @overlayDrawn = false + @overlayBatch.length = 0 + @overlayBatchFailed = undefined + try + if image then @scanner.addImage image, format + else if size and format and layout then @scanner.processImage size, format, layout + else throw Error.new 'expected image or scanner-owned frame' + catch + @endDecode() + return @finish() + unless @opts.async + res = undefined + try + res = @scanner.decode @opts.decodeAll + catch + res = undefined + finally + @endDecode() + return @finish res + decodeAsync = @scanner.decodeAsync + unless decodeAsync + @endDecode() + return @finish() + generation = @generation + task = if typeof TaskController is 'function' and typeof scheduler?.postTask is 'function' then TaskController.new() else undefined + @task = task + work = if task then scheduler.postTask((=> decodeAsync.call @scanner, @opts.decodeAll), { signal: task.signal }) else decodeAsync.call(@scanner, @opts.decodeAll) + onDone = (res) => + return if generation isnt @generation + @endDecode() + @finish res + onFail = => + return if generation isnt @generation + @endDecode() + @finish() + settle = => + @pending = undefined if @pending is pending + @task = undefined if @task is task + if @cleanPending + @cleanPending = false + @scanner.clean() + return + pending = work.then(onDone, onFail).finally(settle) + @pending = pending + pending + + drawImage: (image, height, width) -> + @rotate = false + @sourceHeight = height + side = Math.min width, height + cropped = @opts.cropToSquare and width isnt height + inputWidth = if cropped then side else width + inputHeight = if cropped then side else height + @sourceX = if cropped then (width - side) >> 1 else 0 + @sourceY = if cropped then (height - side) >> 1 else 0 + @inputWidth = inputWidth + @inputHeight = inputHeight + # The working canvas holds only the selected source rect; the overlay + # keeps presentation dimensions. + setCanvasSize @main.canvas, inputHeight, inputWidth + setCanvasSize @overlay.canvas, height, width if @overlay + {context} = @main + context.drawImage image, -@sourceX, -@sourceY, width, height + @decode context.getImageData(0, 0, inputWidth, inputHeight) + + clear!: -> + @reader.clean() + @sourceX = @sourceY = 0 + @inputWidth = @inputHeight = 0 + clearTimeout @overlayTimer if @overlayTimer isnt undefined + @overlayTimer = undefined + @overlayDeadline = 0 + @overlayDrawn = false + @resetOverlay() + @overlayBatch.length = 0 + @overlayBatchFailed = undefined + clearCanvas @main + clearCanvas @overlay if @overlay + clearCanvas @resultQR if @resultQR + clearCanvas @bitmap if @bitmap + +# Reads frames from a video player and optionally owns its camera stream. +# With only a player it replays file-backed video: VideoFrame reads the +# decoded player surface, no captured stream needed. +export class QRCamera + constructor: (player, streamOrOpts = {}, init = {}) -> + @player = player + stream = if 'getTracks' of streamOrOpts then streamOrOpts else undefined + opts = if stream then init else streamOrOpts + @opts = { format: 'auto', ...opts } + @stream = undefined + @reader = undefined + @planes = ({ offset: 0, stride: 0 } for i in [0...4]) + @layouts = (@planes.slice(0, n) for n in [1..4]) + @rect = { x: 0, y: 0, width: 0, height: 0 } + @nativeCopy = { rect: @rect } + @convertedCopy = { format: 'RGBA', rect: @rect } + @scanned = { format: 'I420', layout: { offset: 0, stride: 0 }, rotate: false, size: { width: 0, height: 0 }, sourceHeight: 0, sourceX: 0, sourceY: 0, width: 0, height: 0 } + @videoFrame = undefined + @nativeOnly = false + @reported = false + @reading = false + @source = 0 + @validateFormat @opts.format + @setStream stream if stream + + validateFormat!: (format) -> + return if format is 'auto' or format is 'canvas' + return if format isnt 'RGB' and PLANES[format] + throw TypeError.new "invalid opts.format=#{format} (#{typeof format})" + + # Native auto-detection, forced canvas, or one VideoFrame output format. + setFormat!: (format) -> + @validateFormat format + return if format is @opts.format + @opts.format = format + @source++ + @videoFrame = undefined + @nativeOnly = false + @reported = false + @resetFrame() + + resetFrame!: -> + s = @scanned + s.layout.offset = 0 + s.layout.stride = 0 + s.rotate = false + s.size.width = 0 + s.size.height = 0 + s.sourceHeight = 0 + s.sourceX = 0 + s.sourceY = 0 + s.width = 0 + s.height = 0 + + cleanFrame!: -> + @reader.clean() if @reader + @reader = undefined + @resetFrame() + + # Plane offsets and strides for a copy of the given format; returns the total bytes. + setLayout: (copy, format, width, height) -> + planes = PLANES[format] + throw Error.new "Unsupported VideoFrame format=#{format}" unless planes + offset = 0 + for [xShift, yShift, bytes], i in planes + planeWidth = (width + (1 << xShift) - 1) >> xShift + planeHeight = (height + (1 << yShift) - 1) >> yShift + plane = @planes[i] + plane.offset = offset + plane.stride = planeWidth * bytes + offset += plane.stride * planeHeight + copy.layout = @layouts[planes.length - 1] + offset + + # Copy one frame's coded pixels into the reader's arena; the canvas path + # handles frames whose visible and display dimensions differ. + scan: (frame, reader, rotate) -> + visible = frame.visibleRect + width = visible?.width or frame.displayWidth + height = visible?.height or frame.displayHeight + unless Number.isSafeInteger(width) and Number.isSafeInteger(height) and width > 0 and height > 0 and width is frame.displayWidth and height is frame.displayHeight + throw Error.new 'Unsupported VideoFrame dimensions' + selected = if @opts.format is 'auto' or @nativeOnly then frame.format else @opts.format + throw Error.new "Unsupported VideoFrame format=#{selected}" if not selected or selected is 'canvas' + planes = PLANES[selected] + throw Error.new "Unsupported VideoFrame format=#{selected}" unless planes + sourceWidth = width + sourceHeight = height + sourceX = 0 + sourceY = 0 + if reader.crop and width isnt height + side = Math.min width, height + xShift = 0 + yShift = 0 + for plane in planes + xShift = Math.max xShift, plane[0] + yShift = Math.max yShift, plane[1] + side -= side % (1 << Math.max(xShift, yShift)) + sourceWidth = sourceHeight = side + sourceX = (width - side) >> 1 + sourceY = (height - side) >> 1 + sourceX -= sourceX % (1 << xShift) + sourceY -= sourceY % (1 << yShift) + @rect.x = (visible?.x or 0) + sourceX + @rect.y = (visible?.y or 0) + sourceY + @rect.width = sourceWidth + @rect.height = sourceHeight + # Chromium rejects an explicit non-RGB format even when it equals the + # native one; omitting it requests the native planes directly. + copy = if selected is frame.format then @nativeCopy else @convertedCopy + copy.format = selected if copy is @convertedCopy + required = @setLayout copy, selected, sourceWidth, sourceHeight + size = frame.allocationSize copy + unless Number.isSafeInteger(size) and size >= required and size <= reader.luma.length + throw Error.new "Invalid VideoFrame allocation size=#{size}, expected #{required}..#{reader.luma.length}" + [layout] = frame.copyTo!(reader.luma, copy) + throw Error.new 'Missing VideoFrame plane layout' unless layout + scanned = @scanned + scanned.format = selected + scanned.layout.offset = layout.offset + scanned.layout.stride = layout.stride + scanned.rotate = rotate + scanned.size.width = sourceWidth + scanned.size.height = sourceHeight + scanned.sourceHeight = height + scanned.sourceX = sourceX + scanned.sourceY = sourceY + scanned.width = if rotate then height else width + scanned.height = if rotate then width else height + scanned + + setStream!: (stream) -> + @stream = stream + @source++ + @videoFrame = undefined + @nativeOnly = false + @reported = false + @cleanFrame() + player = @player + # Inline autoplay on mobile must be set before the stream attaches. + player.setAttribute 'autoplay', '' + player.setAttribute 'muted', '' + player.setAttribute 'playsinline', '' + player.srcObject = stream + + # Available only after the first getUserMedia request. + listDevices: -> + throw Error.new 'Media Devices not supported' unless navigator.mediaDevices?.enumerateDevices + devices = navigator.mediaDevices.enumerateDevices! + devices.filter((device) -> device.kind is 'videoinput').map (d) -> { deviceId: d.deviceId, label: d.label or "Camera #{d.deviceId}" } + + # Stop first so constrained camera hardware is released before the + # replacement stream is requested. + setDevice: (deviceId) -> + @stop() + source = @source + stream = navigator.mediaDevices.getUserMedia! video: { deviceId: { exact: deviceId } } + # A later request may resolve first; never attach or leak a stale stream. + if source isnt @source + track.stop() for track in stream.getTracks() + return + @setStream stream + return + + draw: (canvas, fullSize) -> + player = @player + # drawImage throws while a newly attached video has no decoded frame. + return if player.readyState < 2 or not player.videoWidth or not player.videoHeight + canvas.reader.source 'canvas' + # The rendered player box keeps overlay coordinates aligned with the + # preview; fullSize opts into intrinsic frame pixels. + return canvas.drawImage(player, player.videoHeight, player.videoWidth) if fullSize + size = getSize player + canvas.drawImage player, size.height, size.width + + readFrame: (canvas, fullSize = false) -> + reader = canvas.reader + @reader = reader + return @draw(canvas, fullSize) if not fullSize or @opts.format is 'canvas' or @videoFrame is false + if typeof VideoFrame isnt 'function' + @videoFrame = false + return @draw(canvas, fullSize) + return if @reading + @reading = true + source = @source + frame = undefined + try + frame = VideoFrame.new @player + catch + @videoFrame = false + @reading = false + return @draw(canvas, fullSize) + try + if not @reported and reader.frame + @reported = true + reader.frame frame + {videoWidth, videoHeight} = @player + # copyTo exposes coded pixels while the player shows presentation + # geometry; exactly swapped dimensions are the implicit portrait rotation. + aligned = frame.displayWidth is videoWidth and frame.displayHeight is videoHeight + rotate = frame.displayWidth is videoHeight and frame.displayHeight is videoWidth + # An unsupported shape is a per-frame fallback, not a capability failure. + return @draw(canvas, fullSize) if (videoWidth > 0 and videoHeight > 0 and not aligned and not rotate) or frame.rotation or frame.flip + scanned = undefined + try + scanned = @scan! frame, reader, rotate + catch error + converted = not @nativeOnly and @opts.format isnt 'auto' and @opts.format isnt frame.format + throw error unless converted + # A browser may copy native YUV but reject YUV conversion; cache only + # the failed conversion and keep the direct native path. + @nativeOnly = true + @resetFrame() + scanned = @scan! frame, reader, rotate + if source isnt @source + @resetFrame() + return + # Never publish coordinates prepared for stale player geometry. + return if @player.videoWidth isnt videoWidth or @player.videoHeight isnt videoHeight + @videoFrame = true + reader.source 'VideoFrame' + return reader.read(scanned) + catch + @resetFrame() + return if source isnt @source + # A constructor can exist while copyTo support does not; only the + # first frame of a source pays for finding out. + @videoFrame = false + return @draw(canvas, fullSize) + finally + frame.close() + @reading = false + + stop!: -> + @source++ + @videoFrame = undefined + @nativeOnly = false + @reported = false + @cleanFrame() + if @stream + track.stop() for track in @stream.getTracks() + @stream = undefined + +# Media Capture terms: 'environment' is rear-facing, 'user' is the selfie camera. +openCamera = (player, facingMode, opts = {}) -> + stream = navigator.mediaDevices.getUserMedia! video: { height: { ideal: window.screen.height }, width: { ideal: window.screen.width }, facingMode } + QRCamera.new player, stream, opts + +export def rearCamera(player, opts = {}) + openCamera! player, 'environment', opts + +export def selfieCamera(player, opts = {}) + openCamera! player, 'user', opts + +# Run a callback per presented frame; returns a canceller. +export def frameLoop(cb, video) + useVideo = !!video and typeof video.requestVideoFrameCallback is 'function' and typeof video.cancelVideoFrameCallback is 'function' + active = true + handle = undefined + loopFn = (ts) -> + # Check active after the callback: it may cancel the loop from inside. + cb ts + handle = request() if active + return + request = -> if useVideo then video.requestVideoFrameCallback(loopFn) else requestAnimationFrame(loopFn) + cancel = (id) -> if useVideo then video.cancelVideoFrameCallback(id) else cancelAnimationFrame(id) + handle = request() + -> + return unless active + active = false + cancel handle if handle isnt undefined + handle = undefined + return + +# SVG markup to a PNG data URL through an image element and a canvas. +export def svgToPng(svgData, width, height) + Promise.new (resolve, reject) -> + unless Number.isSafeInteger(width) and Number.isSafeInteger(height) and width > 0 and height > 0 and width < 8192 and height < 8192 + return reject(Error.new "invalid width and height: #{width} #{height}") + doc = DOMParser.new().parseFromString svgData, 'image/svg+xml' + svgElement = doc.documentElement + svgElement.setAttribute 'width', String(width) + svgElement.setAttribute 'height', String(height) + rect = doc.createElementNS 'http://www.w3.org/2000/svg', 'rect' + rect.setAttribute 'width', '100%' + rect.setAttribute 'height', '100%' + rect.setAttribute 'fill', 'white' + svgElement.insertBefore rect, svgElement.firstChild + source = XMLSerializer.new().serializeToString doc + img = Image.new() + # Handlers are registered before src so a fast decode cannot complete first. + img.onload = -> + canvas = document.createElement 'canvas' + canvas.width = width + canvas.height = height + ctx = canvas.getContext '2d' + return reject(Error.new 'was not able to create 2d context') unless ctx + ctx.drawImage img, 0, 0, width, height + resolve canvas.toDataURL('image/png') + img.onerror = reject + img.src = 'data:image/svg+xml,' + encodeURIComponent(source) + +export def gifToPng(gifBytes) + blob = Blob.new [gifBytes], { type: 'image/gif' } + bitmap = createImageBitmap! blob + try + canvas = OffscreenCanvas.new bitmap.width, bitmap.height + ctx = canvas.getContext 'bitmaprenderer', { alpha: false } + throw Error.new 'was not able to create bitmaprenderer context' unless ctx + ctx.transferFromImageBitmap bitmap + return canvas.convertToBlob!({ type: 'image/png' }) + finally + bitmap.close() + +# ==[ BarcodeDetector ]== +# Ponyfill: importing has no side effects; assign it to the global name to +# install it. Only qr_code is detected, at most one per image, and +# cornerPoints are the decoder's projected bounds. + +BARCODE_FORMATS =! ['aztec', 'code_128', 'code_39', 'code_93', 'codabar', 'data_matrix', 'ean_13', 'ean_8', 'itf', 'pdf417', 'qr_code', 'unknown', 'upc_a', 'upc_e'] + +# Cross-realm brand check; instanceof fails across iframes. +kindOf = (o) -> Object.prototype.toString.call(o).slice(8, -1) +invalidState = (msg) -> DOMException.new msg, 'InvalidStateError' + +prefixed = (e, prefix) -> + return DOMException.new("#{prefix}: #{e.message}", e.name) if e instanceof DOMException + return new e.constructor("#{prefix}: #{e.message}") if e instanceof Error + Error.new "#{prefix}: #{e}" + +createCanvas = (width, height) -> + return OffscreenCanvas.new(width, height) if typeof OffscreenCanvas isnt 'undefined' + canvas = document.createElement 'canvas' + canvas.width = width + canvas.height = height + canvas + +readPixels = (ctx, width, height) -> + try + ctx.getImageData 0, 0, width, height + catch + throw DOMException.new 'Source would taint origin.', 'SecurityError' + +drawSource = (src, width, height) -> + return null if width is 0 or height is 0 + canvas = createCanvas width, height + ctx = canvas.getContext '2d' + throw DOMException.new('Canvas 2D context unavailable.', 'NotSupportedError') if ctx is null + ctx.drawImage src, 0, 0 + readPixels ctx, width, height + +# Every ImageBitmapSource kind to ImageData; null is a zero-sized source. +toImageData = (image) -> + kind = kindOf image + if kind is 'Blob' + bitmap = undefined + try + bitmap = createImageBitmap! image + catch + throw invalidState('Failed to load or decode Blob.') + try + return drawSource(bitmap, bitmap.width, bitmap.height) + finally + bitmap.close() + if kind is 'ImageData' + throw invalidState('The image data has been detached.') if image.data.buffer.byteLength is 0 + if kindOf(image.data) is 'Float16Array' + # rgba-float16 channels use a 0..1 scale; the decoder wants bytes. + pixels = Uint8ClampedArray.from image.data, (value) -> value * 255 + return { width: image.width, height: image.height, data: pixels } + return image + if kind is 'HTMLCanvasElement' or kind is 'OffscreenCanvas' + {width, height} = image + return null if width is 0 or height is 0 + # A WebGL canvas returns null here; draw it onto a scratch canvas instead. + ctx = image.getContext '2d' + return if ctx isnt null then readPixels(ctx, width, height) else drawSource(image, width, height) + if kind is 'HTMLImageElement' + try + image.decode! + catch + throw invalidState('Failed to load or decode HTMLImageElement.') + return drawSource(image, image.naturalWidth, image.naturalHeight) + if kind is 'SVGImageElement' + try + await image.decode?() + catch + throw invalidState('Failed to load or decode SVGImageElement.') + return drawSource(image, image.width.baseVal.value, image.height.baseVal.value) + if kind is 'HTMLVideoElement' + throw invalidState('Invalid element or state.') if image.readyState < 2 + return drawSource(image, image.videoWidth, image.videoHeight) + if kind is 'ImageBitmap' + throw invalidState('The image source is detached.') if image.width is 0 and image.height is 0 + return drawSource(image, image.width, image.height) + if kind is 'VideoFrame' + throw invalidState('VideoFrame is closed.') if image.format is null + return drawSource(image, image.displayWidth, image.displayHeight) + throw TypeError.new "The provided value is not of type '(Blob or HTMLCanvasElement or HTMLImageElement or HTMLVideoElement or ImageBitmap or ImageData or OffscreenCanvas or SVGImageElement or VideoFrame)'." + +# Top-left, top-right, bottom-right, bottom-left in image space, rotated +# from the decoder's clockwise projected boundary. +symbolCorners = (p) -> + points = p.bounds.map (pt) -> { x: pt.x, y: pt.y } + first = 0 + for i in [1...points.length] + first = i if points[i].y < points[first].y or (points[i].y is points[first].y and points[i].x < points[first].x) + points.map (_, i) -> points[(first + i) % points.length] + +export class BarcodeDetector + constructor: (options = {}) -> + try + # WebIDL dictionaries treat null as empty and reject primitives. + type = typeof options + throw TypeError.new('The provided value is not a dictionary.') if options isnt null and type isnt 'object' and type isnt 'function' + dictionary = if options is null then {} else options + # 'unknown' is dropped rather than rejected, as in Chromium. + formats = if dictionary.formats is undefined then undefined else [...dictionary.formats].filter (f) -> f isnt 'unknown' + throw TypeError.new('Hint option provided, but is empty.') if formats isnt undefined and formats.length is 0 + for format in formats or [] + unless BARCODE_FORMATS.includes format + throw TypeError.new "Failed to read the 'formats' property from 'BarcodeDetectorOptions': The provided value '#{format}' is not a valid enum value of type BarcodeFormat." + @formats = formats or [] + catch e + throw prefixed(e, "Failed to construct 'BarcodeDetector'") + + @getSupportedFormats: -> Promise.resolve ['qr_code'] + + detect: (image) -> + try + data = toImageData! image + return [] if data is null + return [] if @formats.length isnt 0 and not @formats.includes('qr_code') + points = undefined + rawValue = undefined + try + rawValue = decodeQR data, pointsOnDetect: (p, result) -> points = p if typeof result is 'string' + catch + return [] + box = points.boundingBox + [{ boundingBox: DOMRectReadOnly.new(box.x, box.y, box.width, box.height), rawValue, format: 'qr_code', cornerPoints: symbolCorners(points) }] + catch e + throw prefixed(e, "Failed to execute 'detect' on 'BarcodeDetector'") diff --git a/packages/barcodes/package.json b/packages/barcodes/package.json new file mode 100644 index 00000000..3520dcab --- /dev/null +++ b/packages/barcodes/package.json @@ -0,0 +1,25 @@ +{ + "name": "@rip/barcodes", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "QR code generator and reader — packed-bitmap encoder, camera-budgeted decoder, zero dependencies.", + "exports": { + ".": "./barcodes.rip", + "./decode": "./decode.rip", + "./dom": "./dom.rip" + }, + "scripts": { + "test": "rip test.rip" + }, + "rip": { + "browser": true + }, + "files": [ + "barcodes.rip", + "spec.rip", + "decode.rip", + "dom.rip", + "README.md" + ] +} diff --git a/packages/barcodes/spec.rip b/packages/barcodes/spec.rip new file mode 100644 index 00000000..0b3ea3f6 --- /dev/null +++ b/packages/barcodes/spec.rip @@ -0,0 +1,125 @@ +# ============================================================================== +# rip/barcodes — ISO/IEC 18004:2024 tables and bit-level primitives shared by +# the QR encoder and decoder: codeword counts, error-correction block layout, +# the alphanumeric alphabet, alignment-pattern centers, the BCH format and +# Golay version words, the GF(2^8) field, the mask predicates, and popcount. +# ============================================================================== + +# Table 1: total codewords by version, from the data-module area formula. +export BYTES =! do -> + res = [] + for ver in [1..40] + bits = (16 * ver + 128) * ver + 64 + if ver >= 2 + align = ver // 7 + 2 + bits -= (25 * align - 10) * align - 55 + bits -= 36 if ver >= 7 + res.push bits >>> 3 + res + +# Spec table order, also the format-indicator segment order. +export ECC_LEVELS =! ['low', 'medium', 'quartile', 'high'] + +# Table 9: error-correction codewords per block. +export WORDS_PER_BLOCK =! + low: [ + 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, + ] + medium: [ + 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, + ] + quartile: [ + 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, + ] + high: [ + 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, + ] + +# Table 9: error-correction block count. +export ECC_BLOCKS =! + low: [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] + medium: [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] + quartile: [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] + high: [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] + +# Table 12: error-correction-level format indicators. +export EC_CODE =! { low: 1, medium: 0, quartile: 3, high: 2 } + +# Table 5: alphanumeric characters in value order. +export ALPHANUMERIC =! '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' + +# Annex E: alignment-pattern center coordinates, borderless module indices. +export def alignmentPatterns(ver) + return [] if ver is 1 + last = 21 + 4 * (ver - 1) - 7 + count = Math.ceil((last - 6) / 28) + interval = (last - 6) // count + if interval % 2 + interval += 1 + else if ((last - 6) % count) * 2 >= count + interval += 2 + res = [6] + for m in [1...count] + res.push last - (count - m) * interval + res.push last + res + +# §7.9.1 / Annex C.2: BCH-protected, masked 15-bit format word. +export def formatBits(ecc, mask) + data = (EC_CODE[ecc] << 3) | mask + d = data + for i in [0...10] + d = (d << 1) ^ ((d >> 9) * 0b10100110111) + ((data << 10) | d) ^ 0b101010000010010 + +# §7.10 / Annex D.2: Golay-protected 18-bit version word. +export def versionBits(ver) + d = ver + for i in [0...12] + d = (d << 1) ^ ((d >> 11) * 0b1111100100101) + (ver << 12) | d + +# GF(2^8) with primitive polynomial 0x11d. EXP is doubled so the product of +# two logs indexes it without a modulo. +export GF256 =! do -> + exp = new Uint8Array(510) + log = new Uint8Array(256) + x = 1 + for i in [0...255] + exp[i] = exp[i + 255] = x + log[x] = i + x <<= 1 + x ^= 0x11d if x & 0x100 + { exp, log } + +# Table 10 mask predicates as an 8-bit vector: bit m is set when mask m +# fires at (x, y). +export def maskBits(x, y) + x2 = x % 2 + y2 = y % 2 + x3 = x % 3 + xy3 = (x3 * (y % 3)) % 3 + xy2 = x2 & y2 + bits = 0 + bits |= 1 if x2 is y2 + bits |= 2 if y2 is 0 + bits |= 4 if x3 is 0 + bits |= 8 if (x + y) % 3 is 0 + bits |= 16 if (y // 2 + x // 3) % 2 is 0 + bits |= 32 if xy2 + xy3 is 0 + bits |= 64 if (xy2 + xy3) % 2 is 0 + bits |= 128 if ((x2 ^ y2) + xy3) % 2 is 0 + bits + +POP16 =! do -> + t = new Uint8Array(1 << 16) + for i in [1...t.length] + t[i] = t[i >>> 1] + (i & 1) + t + +export def popcnt(n) + POP16[n & 0xffff] + POP16[n >>> 16] diff --git a/packages/barcodes/test.rip b/packages/barcodes/test.rip new file mode 100644 index 00000000..7ad5d0b0 --- /dev/null +++ b/packages/barcodes/test.rip @@ -0,0 +1,290 @@ +# ============================================================================== +# rip/barcodes tests — encoder pins generated once from the reference +# implementation, decoder round trips over synthetic rasters, package surface +# ============================================================================== + +import { test, eq, ok, throws } from 'rip/testing' +import encodeQR, { _tests } from 'rip/barcodes' +import * as enc from 'rip/barcodes' +import decodeQR, { QRScanner, decodeQRBatch } from 'rip/barcodes/decode' +import * as dec from 'rip/barcodes/decode' +import * as dom from 'rip/barcodes/dom' +import { BYTES, alignmentPatterns, formatBits, versionBits, maskBits, popcnt } from './spec.rip' +import { readFileSync } from 'fs' + +rows = (raw) -> raw.map((row) -> row.map((b) -> if b then '1' else '0').join('')).join('\n') + +# ==[ Package surface ]== + +console.log "\nPackage" + +test "exports", -> + eq Object.keys(enc).sort(), ['_tests', 'default', 'encodeQR'] + eq enc.default, encodeQR + eq Object.keys(dec).sort(), ['QRScanner', 'decodeQR', 'decodeQRBatch', 'default'] + eq dec.default, decodeQR + eq Object.keys(dom).sort(), ['BarcodeDetector', 'QRCamera', 'QRCanvas', 'frameLoop', 'getSize', 'gifToPng', 'rearCamera', 'selfieCamera', 'svgToPng'] + +test "no runtime deps, declares browser safety and earns it", -> + pkg = JSON.parse readFileSync("#{import.meta.dir}/package.json", 'utf8') + eq pkg.dependencies, undefined + eq pkg.rip, { browser: true } + for file in ['spec.rip', 'barcodes.rip', 'decode.rip', 'dom.rip'] + source = readFileSync "#{import.meta.dir}/#{file}", 'utf8' + eq /\bBun\.|node:|process\.|globalThis/.test(source), false, file + +# ==[ Spec tables ]== + +console.log "\nSpec" + +test "Table 1 codeword totals", -> + eq BYTES, [ + 26, 44, 70, 100, 134, 172, 196, 242, 292, 346, 404, 466, 532, 581, 655, 733, 815, 901, 991, 1085, + 1156, 1258, 1364, 1474, 1588, 1706, 1828, 1921, 2051, 2185, 2323, 2465, 2611, 2761, 2876, 3034, 3196, 3362, 3532, 3706, + ] + +test "alignment centers", -> + eq alignmentPatterns(1), [] + eq alignmentPatterns(2), [6, 18] + eq alignmentPatterns(7), [6, 22, 38] + eq alignmentPatterns(40), [6, 30, 58, 86, 114, 142, 170] + +test "format and version words", -> + eq formatBits('medium', 0), 0b101010000010010 + eq formatBits('low', 7), 0b110100101110110 + eq versionBits(7), 0x07C94 + eq versionBits(40), 0x28C69 + +test "mask predicates and popcount", -> + eq maskBits(0, 0), 0xff + eq maskBits(1, 0), 0b01110010 + eq popcnt(0), 0 + eq popcnt(0xffffffff), 32 + eq popcnt(0x80000001), 2 + +# ==[ Encoder ]== + +console.log "\nEncoder" + +ENCODE_DATA =! [ + ["12345678", 1, "low", "numeric", [16, 32, 123, 114, 39, 0, 236, 17, 236, 17, 236, 17, 236, 17, 236, 17, 236, 17, 236, 188, 247, 62, 248, 53, 170, 224]] + ["HELLO WORLD", 1, "low", "alphanumeric", [32, 91, 11, 120, 209, 114, 220, 77, 67, 64, 236, 17, 236, 17, 236, 17, 236, 17, 236, 209, 239, 196, 207, 78, 195, 109]] + ["🔻", 1, "low", "byte", [64, 79, 9, 249, 75, 176, 236, 17, 236, 17, 236, 17, 236, 17, 236, 17, 236, 17, 236, 3, 99, 190, 127, 225, 243, 91]] + ["HELLO WORLD!123", 1, "low", "byte", [64, 244, 132, 84, 196, 196, 242, 5, 116, 245, 36, 196, 66, 19, 19, 35, 48, 236, 17, 224, 16, 46, 161, 123, 208, 138]] + ["HELLO", 1, "high", "alphanumeric", [32, 43, 11, 120, 204, 0, 236, 17, 236, 109, 149, 156, 18, 217, 41, 246, 36, 42, 84, 46, 225, 190, 218, 251, 27, 196]] + ["HELLO WORLD", 2, "high", "alphanumeric", [32, 91, 11, 120, 209, 114, 220, 77, 67, 64, 236, 17, 236, 17, 236, 17, 160, 72, 249, 35, 10, 6, 195, 31, 94, 27, 113, 37, 124, 145, 66, 90, 54, 168, 56, 162, 2, 77, 162, 41, 163, 243, 119, 37]] +] + +test "encodeData vectors", -> + for [text, version, ecc, type, bytes] in ENCODE_DATA + utf8 = if type is 'byte' then TextEncoder.new().encode(text) else undefined + eq Array.from(_tests.encodeData(version, ecc, text, type, utf8)), bytes, text + +test "detectType", -> + eq _tests.detectType('12345678'), 'numeric' + eq _tests.detectType('HELLO WORLD'), 'alphanumeric' + eq _tests.detectType('hello'), 'byte' + eq _tests.detectType('🔻'), 'byte' + +test "ascii", -> + eq encodeQR('Hello world', 'ascii'), "█████████████████████████\n██ ▄▄▄▄▄ ██▀ ▀██ ▄▄▄▄▄ ██\n██ █ █ █ ███ █ █ ██\n██ █▄▄▄█ █ █▀▄█ █▄▄▄█ ██\n██▄▄▄▄▄▄▄█ █ █▄█▄▄▄▄▄▄▄██\n██ █▄ ▄ ▄█▀▀ ▀▀ █▀██\n██ ▄▄▄ ▀▄▄▀▀▀▀ ▄█▀ ▄███\n████▄▄█▄▄▄ ▄█ ▄▄█▀▀██▄██\n██ ▄▄▄▄▄ █▀██▄█▀▄▀█▀ ▀██\n██ █ █ █ █▄▀▀ ▀▀██▄██\n██ █▄▄▄█ █▄▀▀█ ▀█▄▀▀ ████\n██▄▄▄▄▄▄▄█▄██▄▄█▄█▄██▄███\n▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n" + +test "term", -> + eq encodeQR('Hi', 'term', border: 1), "\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[40m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\u001b[1;47m \u001b[0m\n" + +test "svg, optimized and plain", -> + eq encodeQR('Hello world', 'svg'), "" + eq encodeQR('Hi', 'svg', optimize: false), "" + +test "gif and data-url", -> + eq Array.from(encodeQR('Hello world', 'gif', scale: 2)), [71, 73, 70, 56, 55, 97, 50, 0, 50, 0, 246, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 44, 0, 0, 0, 0, 50, 0, 50, 0, 0, 7, 127, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 127, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 127, 128, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 127, 128, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 127, 128, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 127, 128, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 127, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 127, 128, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 127, 128, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 127, 128, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 127, 128, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 127, 128, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 127, 128, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 127, 128, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 127, 128, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 127, 128, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 127, 128, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 127, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 127, 128, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 107, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 129, 0, 59] + eq encodeQR('Hello world', 'data-url'), "data:image/gif;base64,R0lGODdhGQAZAPYAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAGQAZAAAHf4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEBAQEBAQAAAAEAAAABAQEBAQEBAAAAAAEAAAAAAAEAAAEBAQAAAQAAAAAAAQAAAAABAAEBAQABAAEBAQAAAAEAAQEBAAEAAAB/gAABAAEBAQABAAEBAQAAAAEAAQEBAAEAAAAAAQABAQEAAQABAQAAAQABAAEBAQABAAAAAAEAAAAAAAEAAQEAAQAAAQAAAAAAAQAAAAABAQEBAQEBAAEAAQABAAEBAQEBAQEAAAAAAAAAAAAAAAABAAEAAAAAAAAAAAAAAAAAAH+AAQABAQEBAQAAAAEBAAABAQEBAQAAAAAAAAEAAAEAAQAAAQEBAQEBAQEBAQEAAQAAAAABAQEBAQABAQAAAAABAQEAAAEBAQAAAAAAAQAAAAEBAAABAQEBAQEAAAEBAQAAAAAAAAAAAQEAAQEBAQEBAAEBAQAAAAAAAQAAAAAAf4AAAAAAAAAAAQEAAAEAAAABAQAAAAAAAAABAQEBAQEBAAAAAAEAAAEAAAABAQAAAAAAAQAAAAAAAQABAAAAAAEAAQABAQEBAAAAAAEAAQEBAAEAAQEAAQAAAQEAAAAAAQAAAAABAAEBAQABAAEBAAABAQEBAQEAAAAAAAAAAQB6gAEBAQABAAEAAAABAAABAAABAAAAAAAAAQAAAAAAAQAAAQEAAQEAAAEBAQAAAAAAAAEBAQEBAQEAAQAAAQEAAQABAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgQA7" + +test "raw", -> + eq encodeQR('Hello world', 'raw', border: 1), [[false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], [false, true, true, true, true, true, true, true, false, false, false, true, false, false, false, true, true, true, true, true, true, true, false], [false, true, false, false, false, false, false, true, false, false, true, true, true, false, false, true, false, false, false, false, false, true, false], [false, true, false, true, true, true, false, true, false, true, true, true, false, false, false, true, false, true, true, true, false, true, false], [false, true, false, true, true, true, false, true, false, true, true, true, false, false, false, true, false, true, true, true, false, true, false], [false, true, false, true, true, true, false, true, false, true, true, false, false, true, false, true, false, true, true, true, false, true, false], [false, true, false, false, false, false, false, true, false, true, true, false, true, false, false, true, false, false, false, false, false, true, false], [false, true, true, true, true, true, true, true, false, true, false, true, false, true, false, true, true, true, true, true, true, true, false], [false, false, false, false, false, false, false, false, false, true, false, true, false, false, false, false, false, false, false, false, false, false, false], [false, true, false, true, true, true, true, true, false, false, false, true, true, false, false, true, true, true, true, true, false, false, false], [false, true, false, false, true, false, true, false, false, true, true, true, true, true, true, true, true, true, true, true, false, true, false], [false, true, true, true, true, true, false, true, true, false, false, false, false, true, true, true, false, false, true, true, true, false, false], [false, true, false, false, false, true, true, false, false, true, true, true, true, true, true, false, false, true, true, true, false, false, false], [false, false, false, true, true, false, true, true, true, true, true, true, false, true, true, true, false, false, false, false, false, true, false], [false, false, false, false, false, false, false, false, false, true, true, false, false, true, false, false, false, true, true, false, false, false, false], [false, true, true, true, true, true, true, true, false, false, false, false, true, false, false, true, false, false, false, true, true, false, false], [false, true, false, false, false, false, false, true, false, true, false, false, false, false, true, false, true, false, true, true, true, true, false], [false, true, false, true, true, true, false, true, false, true, true, false, true, false, false, true, true, false, false, false, false, true, false], [false, true, false, true, true, true, false, true, false, true, true, false, false, true, true, true, true, true, true, false, false, false, false], [false, true, false, true, true, true, false, true, false, true, false, false, false, true, false, false, true, false, false, true, false, false, false], [false, true, false, false, false, false, false, true, false, false, true, true, false, true, true, false, false, true, true, true, false, false, false], [false, true, true, true, true, true, true, true, false, true, false, false, true, true, false, true, false, true, false, false, true, false, false], [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]] + +VERSION_HASHES =! ["15144155502040334453", "11860336336062881808", "9136085086669388644", "1193089572591710977", "7686873670648342784", "5343431842319324110", "17390098021478000837", "3840461960574158097", "4294510183992695906", "15528468463722452699", "6845340642062067951", "2863315931406977110", "1000235694808952747", "4850376682882191310", "170292611262382408", "16193601112266899915", "3021963056225426727", "17063662145875125935", "15921535972440319276", "10460739331697383771", "4104402773850033517", "6767157915294766020", "16623589994970719969", "12747355529232067061", "17235346348443545711", "11264115602249123588", "11370428948163515140", "15807326944508417556", "135482596482876342", "4755772298643999272", "17978155199721953926", "16372105395315088973", "144795419258277521", "14530093731977448862", "18060845299463144063", "3503644178110624366", "6867699886559328586", "10580509781540705", "18052239940279011487", "2543736270194563918"] + +test "every version at medium, auto mask", -> + for hash, i in VERSION_HASHES + ver = i + 1 + eq Bun.hash(rows(encodeQR('RIP'.repeat(ver), 'raw', version: ver, ecc: 'medium', border: 1))).toString(), hash, "v#{ver}" + +MASKS =! [ + "00000000000000000000000\n01111111000101011111110\n01000001011011010000010\n01011101000001010111010\n01011101000101010111010\n01011101010001010111010\n01000001000001010000010\n01111111010101011111110\n00000000000111000000000\n01010101001110000100100\n01001110001000010011100\n00011001100101000100000\n00010110110100010010110\n01101101101101010101100\n00000000011110101010010\n01111111001010111001010\n01000001000111101110110\n01011101010010111001010\n01011101001000010001100\n01011101010001000100010\n01000001000100010001110\n01111111010101010101010\n00000000000000000000000" + "00000000000000000000000\n01111111011111011111110\n01000001000001010000010\n01011101011011010111010\n01011101001111010111010\n01011101001011010111010\n01000001011011010000010\n01111111010101011111110\n00000000001101000000000\n01010001100100001001010\n01100100100010111001000\n00110011001111101110100\n00111100011110111000010\n01000111000111111111000\n00000000010100000000110\n01111111010000010011110\n01000001001101000100010\n01011101001000010011110\n01011101000010111011000\n01011101011011101110110\n01000001001110111011010\n01111111011111111111110\n00000000000000000000000" + "00000000000000000000000\n01111111001001011111110\n01000001001000010000010\n01011101011101010111010\n01011101010110010111010\n01011101011101010111010\n01000001010010010000010\n01111111010101011111110\n00000000010100000000000\n01011111000010011111000\n00101100101011110000000\n00000101111001011000010\n01110100010111110001010\n01110001110001001001110\n00000000011101001001110\n01111111000110100101000\n01000001010100001101010\n01011101011110100101000\n01011101011011110010000\n01011101011101011000000\n01000001000111110010010\n01111111011001001001000\n00000000000000000000000" + "00000000000000000000000\n01111111011001011111110\n01000001010011010000010\n01011101000000010111010\n01011101010110010111010\n01011101000110010111010\n01000001001111010000010\n01111111010101011111110\n00000000011111000000000\n01011011101111010010110\n00101100101011110000000\n01011111100010000011000\n00011000111010011100110\n01110001110001001001110\n00000000010110010010100\n01111111011011001000100\n01000001010100001101010\n01011101000101111110010\n01011101010110011111100\n01011101011101011000000\n01000001001100101001000\n01111111010100100100100\n00000000000000000000000" + "00000000000000000000000\n01111111010001011111110\n01000001000000010000010\n01011101001010010111010\n01011101010001010111010\n01011101010101010111010\n01000001011010010000010\n01111111010101011111110\n00000000010011000000000\n01000101111010111110010\n00010100010011001000110\n01000011111110011111010\n00110010010000110110010\n01001001001001110001000\n00000000010101110001000\n01111111010001100010000\n01000001000011001010010\n01011101010110011101110\n01011101000011001010110\n01011101001010011111000\n01000001000000110101010\n01111111010001110001110\n00000000000000000000000" + "00000000000000000000000\n01111111001111011111110\n01000001010000010000010\n01011101011101010111010\n01011101011010010111010\n01011101001101010111010\n01000001001010010000010\n01111111010101011111110\n00000000011100000000000\n01000001010010110011100\n00110000110111101100010\n00000101111001011000010\n01111100011111111001010\n01000111000111111111000\n00000000010101000001110\n01111111000110100101000\n01000001001000010001000\n01011101001110100101000\n01011101000011111010000\n01011101001011101110110\n01000001001111111010010\n01111111011001001001000\n00000000000000000000000" + "00000000000000000000000\n01111111011111011111110\n01000001010000010000010\n01011101011001010111010\n01011101001010010111010\n01011101011111010111010\n01000001001100010000010\n01111111010101011111110\n00000000001100000000000\n01001111110110100101110\n00110000110111101100010\n00010111101011001010000\n01111010011001111111010\n01000111000111111111000\n00000000010101110001000\n01111111010010000001100\n01000001011000010001000\n01011101011100110111010\n01011101010101111100000\n01011101001011101110110\n01000001001111001010100\n01111111011101101101100\n00000000000000000000000" + "00000000000000000000000\n01111111000101011111110\n01000001001111010000010\n01011101000011010111010\n01011101000101010111010\n01011101000101010111010\n01000001010011010000010\n01111111010101011111110\n00000000000011000000000\n01001011011100101000000\n01001110001000010011100\n00111101000001100000100\n00000100100110000000100\n01101101101101010101100\n00000000011010001110110\n01111111001000101011000\n01000001010111101110110\n01011101000110011101110\n01011101011010000011110\n01011101000001000100010\n01000001000000110101010\n01111111010111000111000\n00000000000000000000000" +] + +test "explicit masks 0..7", -> + for expected, mask in MASKS + eq rows(encodeQR('MASK', 'raw', { mask, border: 1 })), expected, "mask #{mask}" + +test "single-slot symbol cache survives version changes", -> + a = rows encodeQR('MASK', 'raw', border: 1) + encodeQR 'RIP'.repeat(20), 'raw' + eq rows(encodeQR('MASK', 'raw', border: 1)), a + eq rows(encodeQR('MASK', 'raw', mask: 3, border: 1)), MASKS[3] + +test "capacity boundaries", -> + ok encodeQR('x'.repeat(17), 'raw', version: 1, ecc: 'low') + throws (-> encodeQR('x'.repeat(18), 'raw', version: 1, ecc: 'low')), 'Capacity overflow' + ok encodeQR('7'.repeat(7089), 'ascii', ecc: 'low') + throws (-> encodeQR('7'.repeat(7090), 'ascii', ecc: 'low')), 'Capacity overflow' + throws (-> encodeQR('x'.repeat(3000), 'raw')), 'Capacity overflow' + +test "argument validation", -> + throws (-> encodeQR(123, 'raw')), TypeError + throws (-> encodeQR('x', 'nope')), 'Unknown output' + throws (-> encodeQR('x', 'raw', version: 0)), RangeError + throws (-> encodeQR('x', 'raw', version: 41)), RangeError + throws (-> encodeQR('x', 'raw', mask: 9)), 'invalid mask' + throws (-> encodeQR('x', 'raw', ecc: 'zz')), 'invalid ecc' + throws (-> encodeQR('abc', 'raw', encoding: 'numeric')), 'Unknown letter' + throws (-> encodeQR('x', 'raw', border: 0)), RangeError + throws (-> encodeQR('x', 'raw', scale: 2000)), RangeError + throws (-> encodeQR('x', 'raw', version: 1, scale: 50)), 'reduce border/scale' + throws (-> encodeQR('x', 'raw', textEncoder: -> 'no')), TypeError + +test "custom text encoder", -> + bytes = Uint8Array.from [72, 105] + eq rows(encodeQR('anything', 'raw', textEncoder: (-> bytes), border: 1)), rows(encodeQR('Hi', 'raw', border: 1)) + +# ==[ Decoder ]== + +console.log "\nDecoder" + +# RGBA raster with the symbol centered on a gray field. +raster = (text, opts, scale, W, H, invert = false, rotate = 0) -> + raw = encodeQR text, 'raw', { ...opts, scale, border: 4 } + s = raw.length + data = new Uint8Array(W * H * 4).fill(if invert then 40 else 200) + ox = (W - s) >> 1 + oy = (H - s) >> 1 + for y in [0...s] + for x in [0...s] + dark = raw[y][x] + dark = not dark if invert + v = if dark then 0 else 255 + [px, py] = switch rotate + when 90 then [s - 1 - y, x] + when 180 then [s - 1 - x, s - 1 - y] + when 270 then [y, s - 1 - x] + else [x, y] + p = 4 * ((oy + py) * W + ox + px) + data[p] = data[p + 1] = data[p + 2] = v + data[p + 3] = 255 + { width: W, height: H, data } + +toLuma = (img) -> + n = img.width * img.height + luma = new Uint8Array(n) + for i in [0...n] + p = 4 * i + luma[i] = (img.data[p] + 2 * img.data[p + 1] + img.data[p + 2]) >> 2 + { width: img.width, height: img.height, data: luma } + +test "round trip every third version at every level", -> + for ver in [1..40] by 3 + text = 'RIP'.repeat(ver) + for ecc in ['low', 'medium', 'quartile', 'high'] + eq decodeQR(raster(text, { version: ver, ecc }, 3, 700, 700)), text, "v#{ver} #{ecc}" + +test "rotations and inverted symbols", -> + text = 'https://example.com/path?q=1' + for rotate in [0, 90, 180, 270] + eq decodeQR(raster(text, {}, 4, 400, 400, false, rotate)), text, "rot #{rotate}" + eq decodeQR(raster(text, {}, 4, 400, 400, true)), text, 'inverted' + +test "numeric, alphanumeric, byte and unicode payloads", -> + for text in ['7'.repeat(300), 'HELLO WORLD $%*+-./:', 'mixed Case 123', 'Ünïcödé 🔻 text'] + eq decodeQR(raster(text, {}, 4, 500, 500)), text + +test "luma and packed formats", -> + img = raster 'FORMATS', {}, 4, 300, 300 + eq decodeQR(toLuma(img), format: 'I420'), 'FORMATS' + eq decodeQR(img, format: 'RGBA'), 'FORMATS' + rgb = new Uint8Array(img.width * img.height * 3) + for i in [0...img.width * img.height] + rgb[3 * i + k] = img.data[4 * i + k] for k in [0...3] + eq decodeQR({ width: img.width, height: img.height, data: rgb }), 'FORMATS' + +test "throws when nothing decodes", -> + blank = { width: 200, height: 200, data: new Uint8Array(200 * 200 * 4).fill(128) } + throws (-> decodeQR blank), Error, 'finder' + eq decodeQR(raster('EFFORT', {}, 4, 300, 300), effort: Infinity, timeLimit: Infinity), 'EFFORT' + +test "option and image validation", -> + img = raster 'V', {}, 4, 200, 200 + throws (-> decodeQR(img, effort: 0)), TypeError + throws (-> decodeQR(img, timeLimit: -1)), TypeError + throws (-> decodeQR(img, format: 'nope')), TypeError + throws (-> decodeQR(img, pointsOnDetect: 1)), TypeError + throws (-> decodeQR({ width: 0, height: 1, data: new Uint8Array(0) })), RangeError + throws (-> decodeQR({ width: 2, height: 2, data: new Uint8Array(5) })), RangeError + throws (-> decodeQR({ width: 2, height: 2, data: [1, 2, 3, 4] })), TypeError + +test "pointsOnDetect reports geometry in image coordinates", -> + scale = 5 + img = raster 'POINTS', {}, scale, 400, 400 + points = null + result = null + eq decodeQR(img, pointsOnDetect: (p, r) -> [points, result] = [p, r]), 'POINTS' + eq result, 'POINTS' + ok points.tl.x < points.tr.x and points.tl.y < points.bl.y + for f in [points.tl, points.tr, points.bl] + ok Math.abs(f.moduleSize - scale) < 1, "module size #{f.moduleSize}" + eq f.corners.length, 4 + box = points.boundingBox + size = 21 * scale + ok Math.abs(box.width - size) < scale and Math.abs(box.height - size) < scale, "box #{box.width}x#{box.height}" + ok Math.abs(box.x + box.width / 2 - 200) < scale and Math.abs(box.y + box.height / 2 - 200) < scale + +test "imageOnResult delivers the sampled module grid", -> + img = raster 'GRID', {}, 4, 300, 300 + grid = null + decodeQR img, imageOnResult: (g) -> grid = g + eq [grid.width, grid.height], [21, 21] + raw = encodeQR 'GRID', 'raw', border: 1 + dark = (x, y) -> grid.data[4 * (y * 21 + x)] is 0 + eq (dark(x, y) for x in [0...21] for y in [0...21]), ((raw[y + 1][x + 1] for x in [0...21]) for y in [0...21]) + +test! "decodeQRBatch finds every symbol", -> + two = raster 'LEFT', {}, 4, 700, 300 + right = raster 'RIGHT', {}, 4, 300, 300 + for y in [0...300] + for x in [0...300] + src = 4 * (y * 300 + x) + dst = 4 * (y * 700 + 400 + x) + two.data[dst + k] = right.data[src + k] for k in [0...4] + [results] = decodeQRBatch! [two] + eq results.filter((r) -> typeof r is 'string').sort(), ['LEFT', 'RIGHT'] + eq results.at(-1) instanceof Error, true + +test! "scanner reuse, exclusive operations and clean", -> + scanner = QRScanner.new maxSize: { width: 300, height: 300 } + scanner.addImage raster('ONE', {}, 4, 300, 300) + eq scanner.decode(), ['ONE'] + scanner.addImage raster('TWO', {}, 4, 250, 250) + eq scanner.decode(), ['TWO'] + # A success excludes its region, so a staged frame decodes once; re-stage. + scanner.addImage raster('TWO', {}, 4, 250, 250) + eq scanner.decodeAsync!(), ['TWO'] + throws (-> scanner.addImage raster('BIG', {}, 4, 400, 400)), RangeError + reentered = null + reenter = -> + try + guard.decode() + catch e + reentered = e.message + guard = QRScanner.new maxSize: { width: 300, height: 300 }, pointsOnDetect: reenter + guard.addImage raster('RE', {}, 4, 300, 300) + eq guard.decode(), ['RE'] + eq reentered, 'scanner operation already in flight' + scanner.clean() + throws (-> scanner.decode()), 'expected addImage before decode' From 31681b3f9383dc8cef4a48ad59eb932ef438730a Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 15 Sep 2026 23:35:09 -0600 Subject: [PATCH 2/5] barcodes: Code 128 encoder and reader, arrow-form declarations Add code128.rip with both sides of ISO/IEC 15417 in one file: an encoder that picks the shortest codeword sequence over subsets A, B and C by dynamic programming, with GS1-128 FNC1 and the six QR outputs, and a scan-line reader that matches each eleven-module group to its nearest codeword in both directions, on both axes and in both polarities, then verifies stop, termination bar and the mod-103 check. Lift the GIF87a writer into gif.rip and the image validation and luma conversion into image.rip so both symbologies share them. Re-export the Code 128 entry points from the encoder and decoder entries. Replace every def declaration with the standard name =! (args) -> form; void procedures ending in a loop return explicitly so the arrow form does not collect. Emitted JS, parity oracles and benchmarks are unchanged. Tests: 33 passing, with a Code 128 section pinning the published vector, subset choices, every output, and round trips across scales, rotations, inversion, luma input and GS1-128. --- packages/barcodes/README.md | 62 ++++- packages/barcodes/barcodes.rip | 71 +---- packages/barcodes/code128.rip | 455 +++++++++++++++++++++++++++++++++ packages/barcodes/decode.rip | 124 ++------- packages/barcodes/dom.rip | 25 +- packages/barcodes/gif.rip | 71 +++++ packages/barcodes/image.rip | 91 +++++++ packages/barcodes/package.json | 6 +- packages/barcodes/spec.rip | 10 +- packages/barcodes/test.rip | 127 ++++++++- 10 files changed, 853 insertions(+), 189 deletions(-) create mode 100644 packages/barcodes/code128.rip create mode 100644 packages/barcodes/gif.rip create mode 100644 packages/barcodes/image.rip diff --git a/packages/barcodes/README.md b/packages/barcodes/README.md index e798abc8..0c2045e5 100644 --- a/packages/barcodes/README.md +++ b/packages/barcodes/README.md @@ -2,7 +2,7 @@ # Rip Barcodes -> **QR code generator and reader — packed-bitmap encoder, camera-budgeted decoder, zero dependencies.** +> **QR and Code 128 generator and reader — packed-bitmap QR encoder, camera-budgeted decoder, scan-line Code 128, zero dependencies.** The encoder keeps a symbol as one `Uint32Array` with 32 modules per word, builds the function-pattern template, placement order and the eight mask @@ -11,11 +11,16 @@ scoring the penalty rules word-parallel. The decoder binarizes a four-level image pyramid against 8x8 block thresholds, finds finder patterns with run-length windows that consume a word at a time, projects the best triple through a homography, and corrects with Reed-Solomon, all inside buffers -allocated once per scanner so a camera frame never allocates. +allocated once per scanner so a camera frame never allocates. Code 128 +lives in one file: the encoder chooses the shortest subset sequence by +dynamic programming, and the reader walks scan lines middle-out, matching +each eleven-module group to its nearest codeword in both directions and on +both axes. -**Runtime:** browser-safe (`rip.browser: true`). Four `.rip` files: the -encoder entry, the decoder entry, the camera and canvas plumbing, and the -ISO/IEC 18004 tables the first two share. +**Runtime:** browser-safe (`rip.browser: true`). Seven `.rip` files: the +encoder entry, the decoder entry, Code 128, the camera and canvas plumbing, +the ISO/IEC 18004 tables, and the GIF writer and image-input helpers the +encoders and decoders share. ## Quick Start @@ -33,6 +38,12 @@ ascii = encodeQR text, 'ascii' # half-height block characters # decode any RGBA raster, the shape a canvas ImageData already has decodeQR { width, height, data } # the text, or throws + +import { encodeCode128 } from 'rip/barcodes' +import { decodeCode128 } from 'rip/barcodes/decode' + +encodeCode128 'L2602852147', 'svg', scale: 2 # a Code 128 label +decodeCode128 { width, height, data } # the text, or throws ``` ## Features @@ -47,6 +58,9 @@ decodeQR { width, height, data } # the text, or throws `timeLimit` budget, and a cooperative `decodeAsync` that yields between bounded work units - `decodeQRBatch` finds every symbol in each image +- Code 128 with subsets A, B and C, shortest-sequence subset selection, + GS1-128 FNC1, the same six outputs, and a reader that handles both + directions, both axes and inverted symbols ## Encoding @@ -90,6 +104,40 @@ For photos and uploads pass `effort: Infinity, timeLimit: Infinity`. Successful decodes cost the same in every tier; retries only run after a failed strict pass. +## Code 128 + +```coffee +import { encodeCode128 } from 'rip/barcodes' +import { decodeCode128, readCode128 } from 'rip/barcodes/decode' + +encodeCode128 text, output, opts +``` + +Every ASCII character encodes; the ASCII group separator (`'\x1d'`) becomes +an FNC1 separator, and `gs1: true` opens the symbol with FNC1 for GS1-128 +application identifiers. The codeword sequence is the shortest over the +three subsets, so `'A1234'` latches to subset C for the digit pairs while +`'12345'` does not pay for a latch it cannot amortize. The outputs are the +QR six with one row of modules: `raw` is a `boolean[]` including the quiet +zone, `ascii` and `term` are one line, and `svg`, `gif` and `data-url` draw +`height` modules of bar. + +| option | meaning | default | +| --- | --- | --- | +| `scale` | pixels per module | `1` | +| `border` | quiet-zone modules on each side | `10` | +| `height` | bar height in modules for `svg`, `gif`, `data-url` | `40` | +| `gs1` | open with FNC1 for GS1-128 | `false` | +| `optimize` | one `` instead of one `` per bar | `true` | + +`decodeCode128` takes the same `{ width, height, data }` as `decodeQR`, +with the same `format` option, and returns the text or throws. +`readCode128` returns `null` on a miss and otherwise +`{ text, gs1, codes, line, vertical, reversed, inverted }`: the verified +codewords and which scan line, axis, direction and polarity produced them. +Modules must be at least one pixel wide; a printed label filling a quarter +of a camera frame is plenty. + ## Scanner ```coffee @@ -143,4 +191,6 @@ The suite pins spec tables, encoded codewords, every output format, every version and every mask against vectors generated from the reference implementation, then round-trips synthetic rasters through the decoder across versions, levels, rotations, inverted symbols, input formats, batch -decoding and scanner reuse. +decoding and scanner reuse. Code 128 is pinned against the published +`Wikipedia` vector, shortest-subset choices, every output, and round trips +at four scales, four rotations, inverted, luma input and GS1-128. diff --git a/packages/barcodes/barcodes.rip b/packages/barcodes/barcodes.rip index dc7b4839..890824ac 100644 --- a/packages/barcodes/barcodes.rip +++ b/packages/barcodes/barcodes.rip @@ -14,6 +14,7 @@ import { ALPHANUMERIC, BYTES, ECC_BLOCKS, ECC_LEVELS, GF256, WORDS_PER_BLOCK, alignmentPatterns, formatBits, maskBits, popcnt, versionBits, } from './spec.rip' +import { gifDataUrl, writeGif } from './gif.rip' MAX_OUTPUT_SIZE =! 1024 MAX_COMPACT_OUTPUT_SIZE =! 4096 @@ -558,44 +559,12 @@ renderSvg =! (r, optimize) -> hasPrev = true out += '' if optimize out + '' - -# GIF87a with an uncompressed LZW stream: 8-bit codes and a clear code every -# 126 pixels, so no dictionary state exists. Pixels come from a per-module-row -# 0/1 buffer rebuilt only when the module row changes and block-copied in -# spans bounded by the chunk boundaries. renderGif =! (r) -> W = r.W - pixels = W * W - N = 126 - fullChunks = pixels // N - tail = pixels % N - out = new Uint8Array(408 + fullChunks * (N + 2) + 2 + tail + 4) - pos = 0 - u16 = (v) -> - out[pos++] = v & 0xff - out[pos++] = v >>> 8 - return - for b in [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] - out[pos++] = b - u16 W - u16 W - out[pos++] = 0xf6 - pos += 2 - out[pos++] = 0xff - out[pos++] = 0xff - out[pos++] = 0xff - pos += 3 * 127 - out[pos++] = 0x2c - pos += 4 - u16 W - u16 W - out[pos++] = 0x00 - out[pos++] = 0x07 {m, map} = r row = new Uint8Array(W) prevMy = -2 - i = 0 - for y in [0...W] + writeGif W, W, (y) -> my = map[y] if my isnt prevMy prevMy = my @@ -603,41 +572,11 @@ renderGif =! (r) -> if my >= 0 for x in [0...W] row[x] = matGet(m, map[x], my) if map[x] >= 0 - x = 0 - while x < W - if i % N is 0 - rem = pixels - i - out[pos++] = (if rem < N then rem else N) + 1 - out[pos++] = 0x80 - n = Math.min(N - i % N, W - x) - out.set row.subarray(x, x + n), pos - pos += n - x += n - i += n - if tail is 0 - out[pos++] = 1 - out[pos++] = 0x80 - out[pos++] = 0x01 - out[pos++] = 0x81 - out[pos++] = 0x00 - out[pos++] = 0x3b - out - -gifDataUrl =! (gif) -> - b64 = if typeof gif.toBase64 is 'function' - gif.toBase64() - else - bin = '' - i = 0 - while i < gif.length - bin += String.fromCharCode(...gif.subarray(i, i + 8192)) - i += 8192 - btoa bin - 'data:image/gif;base64,' + b64 + row # ==[ Public API ]== -export def encodeQR(text, output = 'raw', opts = {}) +export encodeQR =! (text, output = 'raw', opts = {}) -> asString text, 'text' asString output, 'output' throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if typeof opts isnt 'object' or opts is null or Array.isArray(opts) @@ -707,5 +646,7 @@ export def encodeQR(text, output = 'raw', opts = {}) export default encodeQR +export { encodeCode128 } from './code128.rip' + # Internals for the test suite. export _tests =! { mat, matGet, penalty, drawSymbol, encodeData, rsEcc, detectType } diff --git a/packages/barcodes/code128.rip b/packages/barcodes/code128.rip new file mode 100644 index 00000000..446c17b7 --- /dev/null +++ b/packages/barcodes/code128.rip @@ -0,0 +1,455 @@ +# ============================================================================== +# rip/barcodes — Code 128 (ISO/IEC 15417) +# +# A symbol is a start code, data codewords, a mod-103 check and the stop +# pattern; every codeword is three bars and three spaces spanning eleven +# modules, and the stop adds a two-module termination bar. The encoder picks +# the shortest codeword sequence over the three subsets by dynamic +# programming. The reader walks scan lines: a per-line threshold, run +# lengths, a start code behind a quiet zone, nearest-pattern matching of each +# eleven-module group, then stop, check and the subset state machine. Both +# reading directions and both axes are tried. +# ============================================================================== + +import { gifDataUrl, writeGif } from './gif.rip' +import { MAX_IMAGE_SIDE, copyLuma, validateImage } from './image.rip' + +# Bar/space widths for values 0..106; 106 is Stop with its termination bar. +PATTERNS =! %w[ + 212222 222122 222221 121223 121322 131222 122213 122312 132212 221213 + 221312 231212 112232 122132 122231 113222 123122 123221 223211 221132 + 221231 213212 223112 312131 311222 321122 321221 312212 322112 322211 + 212123 212321 232121 111323 131123 131321 112313 132113 132311 211313 + 231113 231311 112133 112331 132131 113123 113321 133121 313121 211331 + 231131 213113 213311 213131 311123 311321 331121 312113 312311 332111 + 314111 221411 431111 111224 111422 121124 121421 141122 141221 112214 + 112412 122114 122411 142112 142211 241211 221114 413111 241112 134111 + 111242 121142 121241 114212 124112 124211 411212 421112 421211 212141 + 214121 412121 111143 111341 131141 114113 114311 411113 411311 113141 + 114131 311141 411131 211412 211214 211232 2331112 +] + +CODE_C =! 99 +CODE_B =! 100 +CODE_A =! 101 +SHIFT =! 98 +FNC1 =! 102 +START_A =! 103 +STOP =! 106 +GS =! 29 + +QUIET_ZONE =! 10 +DEFAULT_HEIGHT =! 40 + +fail =! (msg) -> throw Error.new msg + +# ==[ Codewords ]== + +# Subset membership by char code: A takes 0..95, B takes 32..127; the ASCII +# group separator is FNC1 in every subset. +inA =! (c) -> c < 96 and c isnt GS +inB =! (c) -> c >= 32 and c < 128 +isDigit =! (c) -> c >= 48 and c <= 57 + +charValue =! (c, subset) -> + return FNC1 if c is GS + if c < 32 then c + 64 else c - 32 + +# Shortest codeword sequence. cost[i][s] is the cheapest tail from position i +# while latched in subset s (0 A, 1 B, 2 C); a switch is always paired with +# the codeword it enables, so no move is free. Ties keep the earliest move: +# stay, pair in C, latch C, shift, latch across. +codewords =! (text, gs1) -> + n = text.length + fail 'Code 128 text is empty' unless n + cost = new Int32Array(3 * (n + 1)) + move = new Uint8Array(3 * (n + 1)) + i = n - 1 + while i >= 0 + c = text.charCodeAt i + fnc = c is GS + pair = i + 1 < n and isDigit(c) and isDigit(text.charCodeAt(i + 1)) + a = inA c + b = inB c + next = 3 * (i + 1) + skip = 3 * (i + 2) + for s in [0...3] + best = 0x7fffffff + pick = 0 + if s is 2 + if fnc + best = 1 + cost[next + 2] + if pair and 1 + cost[skip + 2] < best + best = 1 + cost[skip + 2] + pick = 1 + if a and 2 + cost[next] < best + best = 2 + cost[next] + pick = 2 + if b and 2 + cost[next + 1] < best + best = 2 + cost[next + 1] + pick = 3 + else + inside = if s is 0 then a else b + other = if s is 0 then b else a + if fnc or inside + best = 1 + cost[next + s] + if pair and 2 + cost[skip + 2] < best + best = 2 + cost[skip + 2] + pick = 4 + if other and not inside and 2 + cost[next + s] < best + best = 2 + cost[next + s] + pick = 5 + if other and 2 + cost[next + 1 - s] < best + best = 2 + cost[next + 1 - s] + pick = 3 - s + fail "Code 128 cannot encode #{JSON.stringify text[i]}" if best is 0x7fffffff + cost[3 * i + s] = best + move[3 * i + s] = pick + i-- + s = 1 + s = 2 if cost[2] < cost[s] + s = 0 if cost[0] < cost[s] + codes = [START_A + s] + codes.push FNC1 if gs1 + i = 0 + while i < n + c = text.charCodeAt i + switch move[3 * i + s] + when 0 + codes.push (if s is 2 then FNC1 else charValue(c, s)) + i++ + when 1 + codes.push 10 * (c - 48) + text.charCodeAt(i + 1) - 48 + i += 2 + when 2 + s = 0 + codes.push CODE_A, charValue(c, s) + i++ + when 3 + s = 1 + codes.push CODE_B, charValue(c, s) + i++ + when 4 + s = 2 + codes.push CODE_C, 10 * (c - 48) + text.charCodeAt(i + 1) - 48 + i += 2 + else + codes.push SHIFT, charValue(c, 1 - s) + i++ + sum = codes[0] + for k in [1...codes.length] + sum += k * codes[k] + codes.push sum % 103, STOP + codes + +# Module row: 1 dark, 0 light, bars first within each codeword. +modules =! (codes) -> + out = new Uint8Array(11 * codes.length + 2) + x = 0 + for code in codes + pattern = PATTERNS[code] + for k in [0...pattern.length] + w = pattern.charCodeAt(k) - 48 + out.fill 1, x, x + w if (k & 1) is 0 + x += w + out + +# ==[ Renderers ]== + +asInt =! (n, title, min) -> + throw TypeError.new "\"#{title}\" expected number, got type=#{typeof n}" unless typeof n is 'number' + throw RangeError.new "\"#{title}\" expected integer >= #{min}, got #{n}" unless Number.isSafeInteger(n) and n >= min + n + +# A raster is the module row plus output geometry; `bars` is the dark-run +# list [x, width, ...] in output pixels, the shape every output consumes. +raster =! (mods, scale, border, height) -> + W = (mods.length + 2 * border) * scale + H = height * scale + fail "Code 128 output #{W}x#{H} exceeds #{MAX_IMAGE_SIDE}" if W > MAX_IMAGE_SIDE or H > MAX_IMAGE_SIDE + bars = [] + x = 0 + while x < mods.length + x++ while x < mods.length and mods[x] is 0 + break if x is mods.length + start = x + x++ while x < mods.length and mods[x] is 1 + bars.push (border + start) * scale, (x - start) * scale + { W, H, bars } + +pixelRow =! (r) -> + row = new Uint8Array(r.W) + for k in [0...r.bars.length] by 2 + row.fill 1, r.bars[k], r.bars[k] + r.bars[k + 1] + row + +renderRaw =! (r) -> + row = pixelRow r + out = Array.new(r.W) + for x in [0...r.W] + out[x] = row[x] is 1 + out + +renderAscii =! (r) -> + row = pixelRow r + out = '' + for x in [0...r.W] + out += (if row[x] then ' ' else '█') + out + '\n' + +renderTerm =! (r) -> + row = pixelRow r + black = '\x1b[40m \x1b[0m' + white = '\x1b[1;47m \x1b[0m' + out = '' + for x in [0...r.W] + out += (if row[x] then black else white) + out + '\n' + +renderSvg =! (r, optimize) -> + out = '' + path = '' + for k in [0...r.bars.length] by 2 + x = r.bars[k] + w = r.bars[k + 1] + if optimize + path += "M#{x} 0h#{w}v#{r.H}h-#{w}Z" + else + out += '' + out += '' if optimize + out + '' + +renderGif =! (r) -> + row = pixelRow r + writeGif r.W, r.H, (y) -> row + +# ==[ Encoder ]== + +export encodeCode128 =! (text, output = 'raw', opts = {}) -> + throw TypeError.new "\"text\" expected string, got type=#{typeof text}" unless typeof text is 'string' + throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if opts is null or typeof opts isnt 'object' + for name in ['scale', 'border', 'height'] + throw TypeError.new "invalid opts.#{name}=#{opts[name]} (#{typeof opts[name]})" if opts[name] isnt undefined and typeof opts[name] isnt 'number' + scale = if opts.scale is undefined then 1 else asInt(opts.scale, 'scale', 1) + border = if opts.border is undefined then QUIET_ZONE else asInt(opts.border, 'border', 0) + height = if opts.height is undefined then DEFAULT_HEIGHT else asInt(opts.height, 'height', 1) + codes = codewords text, opts.gs1 is true + r = raster modules(codes), scale, border, height + switch output + when 'raw' then renderRaw r + when 'ascii' then renderAscii r + when 'term' then renderTerm r + when 'svg' then renderSvg r, (if opts.optimize is undefined then true else opts.optimize) + when 'gif' then renderGif r + when 'data-url' then gifDataUrl renderGif(r) + else fail "Unknown output: #{output}" + +# ==[ Reader ]== + +# Widths flattened for the matcher, seven slots per value. +WIDTHS =! do -> + out = new Uint8Array(7 * PATTERNS.length) + for pattern, code in PATTERNS + for k in [0...pattern.length] + out[7 * code + k] = pattern.charCodeAt(k) - 48 + out + +# Nearest value in lo..hi to the six runs at `at`, or -1: every run within +# 0.7 module of its ideal and the summed error under a quarter of the width. +nearest =! (runs, at, lo, hi) -> + total = runs[at] + runs[at + 1] + runs[at + 2] + runs[at + 3] + runs[at + 4] + runs[at + 5] + return -1 if total < 11 + unit = total / 11 + limit = unit * 0.7 + best = total * 0.25 + code = -1 + for c in [lo..hi] + base = 7 * c + v = 0 + for k in [0...6] + d = runs[at + k] - WIDTHS[base + k] * unit + d = -d if d < 0 + if d > limit + v = best + break + v += d + if v < best + best = v + code = c + code + +# Run lengths of one thresholded scan line: even slots light, odd dark, the +# first possibly zero. Returns the run count, zero for a flat line. +runLengths =! (luma, start, step, count, runs) -> + lo = 255 + hi = 0 + p = start + for k in [0...count] + v = luma[p] + lo = v if v < lo + hi = v if v > hi + p += step + return 0 if hi - lo < 32 + threshold = (lo + hi) >> 1 + n = 0 + dark = false + len = 0 + p = start + for k in [0...count] + isDark = luma[p] < threshold + if isDark isnt dark + runs[n++] = len + len = 0 + dark = isDark + len++ + p += step + runs[n++] = len + n + +# The same line read right to left, light run first. +reverseRuns =! (runs, n, out) -> + m = 0 + out[m++] = 0 if (n & 1) is 0 + k = n - 1 + while k >= 0 + out[m++] = runs[k--] + m + +# Codewords from a start at `at` through stop, check verified; the count, or 0. +readFrom =! (runs, n, at, start, codes) -> + len = 0 + codes[len++] = start + at += 6 + loop + return 0 if at + 6 >= n + code = nearest runs, at, 0, STOP + return 0 if code < 0 or (code >= START_A and code < STOP) + codes[len++] = code + if code is STOP + width = runs[at] + runs[at + 1] + runs[at + 2] + runs[at + 3] + runs[at + 4] + runs[at + 5] + bar = runs[at + 6] * 11 + return 0 if bar < width or bar > 4 * width + return 0 unless at + 7 >= n or 2 * runs[at + 7] >= width + break + at += 6 + return 0 if len < 4 + sum = codes[0] + for k in [1...len - 2] + sum += k * codes[k] + return 0 unless sum % 103 is codes[len - 2] + len + +# Every start code behind a quiet zone, left to right; `first` is the slot +# of the first dark run, 1 as recorded and 0 to read the line inverted. +readRuns =! (runs, n, codes, first) -> + at = first + while at + 5 < n + start = nearest runs, at, START_A, STOP - 1 + if start >= 0 + width = runs[at] + runs[at + 1] + runs[at + 2] + runs[at + 3] + runs[at + 4] + runs[at + 5] + if at is first or 2 * runs[at - 1] >= width + len = readFrom runs, n, at, start, codes + return len if len + at += 2 + 0 + +# Text from verified codewords, tracking latches, shifts, FNC1 and FNC4. +decodeText =! (codes, len) -> + subset = codes[0] - START_A + shifted = -1 + high = false + highNext = false + lastFnc4 = false + gs1 = false + out = '' + k = 1 + while k < len - 2 + code = codes[k++] + current = if shifted >= 0 then shifted else subset + shifted = -1 + fnc4 = false + if current is 2 + if code < 100 + out += (if code < 10 then '0' else '') + code + else if code is CODE_B then subset = 1 + else if code is CODE_A then subset = 0 + else if k is 2 then gs1 = true + else out += '\x1d' + else if code < 96 + c = if code < 64 then code + 32 else if current is 0 then code - 64 else code + 32 + c += 128 if high isnt highNext + highNext = false + out += String.fromCharCode c + else switch code + when 96, 97 then null + when SHIFT then shifted = 1 - current + when CODE_C then subset = 2 + when CODE_B + if current is 0 then subset = 1 else fnc4 = true + when CODE_A + if current is 1 then subset = 0 else fnc4 = true + when FNC1 + if k is 2 then gs1 = true else out += '\x1d' + if fnc4 + if lastFnc4 + high = not high + highNext = false + lastFnc4 = false + else + highNext = true + lastFnc4 = true + else + lastFnc4 = false + { text: out, gs1 } + +# Scan lines middle-out at a stride that covers the axis in about 32 lines. +scanLines =! (luma, width, height, vertical, runs, rev, codes) -> + lines = if vertical then width else height + count = if vertical then height else width + step = if vertical then width else 1 + stride = Math.max 1, lines >> 5 + middle = lines >> 1 + k = 0 + loop + offset = stride * ((k + 1) >> 1) + line = if (k & 1) is 0 then middle + offset else middle - offset + break if line < 0 and middle + offset >= lines + k++ + continue if line < 0 or line >= lines + n = runLengths luma, (if vertical then line else line * width), step, count, runs + continue if n < 13 + m = reverseRuns runs, n, rev + for inverted in [false, true] + first = if inverted then 0 else 1 + len = readRuns runs, n, codes, first + return { line, reversed: false, inverted, len } if len + len = readRuns rev, m, codes, first + return { line, reversed: true, inverted, len } if len + null + +export readCode128 =! (img, opts = {}) -> + throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if opts is null or typeof opts isnt 'object' + validateImage img, opts.format + {width, height} = img + luma = new Uint8Array(width * height) + copyLuma luma, undefined, img, opts.format + side = Math.max(width, height) + 2 + runs = new Int32Array(side) + rev = new Int32Array(side) + codes = [] + vertical = false + hit = scanLines luma, width, height, false, runs, rev, codes + unless hit + vertical = true + hit = scanLines luma, width, height, true, runs, rev, codes + return null unless hit + {text, gs1} = decodeText codes, hit.len + { text, gs1, codes: codes.slice(0, hit.len), line: hit.line, vertical, reversed: hit.reversed, inverted: hit.inverted } + +export decodeCode128 =! (img, opts = {}) -> + hit = readCode128 img, opts + throw Error.new 'Code 128 not found' unless hit + hit.text + +# Internals for the test suite. +export _tests =! { codewords, modules, decodeText, PATTERNS } diff --git a/packages/barcodes/decode.rip b/packages/barcodes/decode.rip index 14136f2c..30aaa6b9 100644 --- a/packages/barcodes/decode.rip +++ b/packages/barcodes/decode.rip @@ -14,8 +14,8 @@ import { ALPHANUMERIC, BYTES, ECC_BLOCKS, ECC_LEVELS, GF256, WORDS_PER_BLOCK, formatBits, maskBits, popcnt, versionBits, } from './spec.rip' +import { FORMATS, copyLuma, darkToImage, validateImage, validateSize } from './image.rip' -MAX_IMAGE_SIDE =! 4096 MAX_ARENA_BYTES =! 64 * 1024 * 1024 # Failure values shared by every attempt; a decode returns a string or one @@ -180,7 +180,7 @@ checkVersion =! (m, size) -> # 3x3 projective transforms, row-major in a Float64Array, applied to column # vectors [u, v, 1] with a perspective divide. -def squareToQuad!(out, points) +squareToQuad =! (out, points) -> [x1, y1, x2, y2, x3, y3, x4, y4] = points dx3 = x1 - x2 + x3 - x4 dy3 = y1 - y2 + y3 - y4 @@ -213,7 +213,7 @@ def squareToQuad!(out, points) out[8] = 1 # adj[r][c] is the (c, r) cofactor. -def adjugate!(o, m) +adjugate =! (o, m) -> for i in [0...9] r = i // 3 c = i % 3 @@ -222,17 +222,19 @@ def adjugate!(o, m) c1 = (r + 1) % 3 c2 = (r + 2) % 3 o[i] = m[r1 + c1] * m[r2 + c2] - m[r1 + c2] * m[r2 + c1] + return # One input row is cached so the product may replace its left operand. -def ptMul!(o, a, b) +ptMul =! (o, a, b) -> for r in [0, 3, 6] a0 = a[r] a1 = a[r + 1] a2 = a[r + 2] for c in [0...3] o[r + c] = a0 * b[c] + a1 * b[c + 3] + a2 * b[c + 6] + return -def packQuad!(quad, x0, y0, x1, y1, x2, y2, x3, y3) +packQuad =! (quad, x0, y0, x1, y1, x2, y2, x3, y3) -> quad[0] = x0 quad[1] = y0 quad[2] = x1 @@ -248,25 +250,7 @@ mapPoint =! (map, x, y) -> # ==[ Input ]== -LUMA8 =! { step: 1, bits: 8 } -LUMA10 =! { step: 2, bits: 10 } -LUMA12 =! { step: 2, bits: 12 } -RGB =! { step: 3, bits: 8 } -# Four-byte inputs ignore the fourth byte: alpha is never composited and the -# X formats share the storage shape. -RGBA =! { step: 4, bits: 8 } -FORMATS =! - RGB: RGB, RGBA: RGBA, RGBX: RGBA, BGRA: RGBA, BGRX: RGBA - I420: LUMA8, I420A: LUMA8, I422: LUMA8, I444: LUMA8, NV12: LUMA8 - I420P10: LUMA10, I420P12: LUMA12 - -validateSize =! (size, name) -> - throw TypeError.new "#{name} expected safe integer width and height" unless Number.isSafeInteger(size.width) and Number.isSafeInteger(size.height) - throw RangeError.new "#{name} expected positive width and height" if size.width <= 0 or size.height <= 0 - throw RangeError.new "#{name} expected width and height <= #{MAX_IMAGE_SIDE}, got #{size.width}x#{size.height}" if size.width > MAX_IMAGE_SIDE or size.height > MAX_IMAGE_SIDE - size.width * size.height - -def validateOpts!(opts) +validateOpts =! (opts) -> throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if opts is null or typeof opts isnt 'object' or Array.isArray(opts) throw TypeError.new "invalid opts.format=#{opts.format} (#{typeof opts.format})" if opts.format isnt undefined and not FORMATS[opts.format] if opts.effort isnt undefined and opts.effort isnt Infinity and (not Number.isSafeInteger(opts.effort) or opts.effort < 1) @@ -275,68 +259,7 @@ def validateOpts!(opts) throw TypeError.new "invalid opts.timeLimit=#{opts.timeLimit} (#{typeof opts.timeLimit})" for name in ['textDecoder', 'pointsOnDetect', 'imageOnResult', 'imageOnBitmap'] throw TypeError.new "invalid opts.#{name}=#{opts[name]} (#{typeof opts[name]})" if opts[name] isnt undefined and typeof opts[name] isnt 'function' - -# Returns the input format; RGB/RGBA is detected by exact length when unnamed. -validateImage =! (img, named, layout, capacity) -> - px = validateSize img, '"img"' - if capacity and (img.width > capacity.width or img.height > capacity.height) - throw RangeError.new "\"img\" expected dimensions <= #{capacity.width}x#{capacity.height}, got #{img.width}x#{img.height}" - bytes = img.data - throw TypeError.new "\"img.data\" expected Uint8Array or Uint8ClampedArray, got #{typeof bytes}" unless bytes instanceof Uint8Array or bytes instanceof Uint8ClampedArray - if named isnt undefined - format = FORMATS[named] - throw TypeError.new "invalid opts.format=#{named} (#{typeof named})" unless format - if layout - {offset, stride} = layout - row = format.step * img.width - end = offset + (img.height - 1) * stride + row - if not Number.isSafeInteger(offset) or not Number.isSafeInteger(stride) or offset < 0 or stride < row or end > bytes.length - throw RangeError.new "\"img.data\" expected valid offset/stride for format=#{named}, got offset=#{offset}, stride=#{stride}, length=#{bytes.length}" - else - expected = format.step * px - planar = format.step <= 2 - if (planar and bytes.length < expected) or (not planar and bytes.length isnt expected) - throw RangeError.new "\"img.data\" expected #{if planar then 'at least ' else ''}#{expected} bytes for format=#{named}, got #{bytes.length}" - return format - return RGB if bytes.length is 3 * px - return RGBA if bytes.length is 4 * px - throw RangeError.new "\"img.data\" expected #{3 * px} or #{4 * px} bytes without opts.format, got #{bytes.length}" - -# Convert any input into the tight native luma plane. -def copyLuma!(out, maxSize, img, named, layout) - {step, bits} = validateImage img, named, layout, maxSize - {width, height, data} = img - stride = layout?.stride or width * step - offset = layout?.offset or 0 - return if data is out and not offset and stride is width and step is 1 - for y in [0...height] - src = offset + y * stride - dst = y * width - if step is 1 - for x in [0...width] - out[dst++] = data[src++] - else if step is 2 - for x in [0...width] - out[dst++] = (data[src] | (data[src + 1] << 8)) >>> (bits - 8) - src += 2 - else - for x in [0...width] - out[dst++] = (data[src] + 2 * data[src + 1] + data[src + 2]) >> 2 - src += step - -# RGBA image from a per-pixel dark predicate. -darkToImage =! (width, height, isDark) -> - data = new Uint8Array(width * height * 4) - i = 0 - for y in [0...height] - for x in [0...width] - color = if isDark(x, y) then 0 else 255 - data[i] = color - data[i + 1] = color - data[i + 2] = color - data[i + 3] = 255 - i += 4 - { width, height, data } + return # ==[ Bitmap primitives ]== # A layer's bitmap is packed 32 pixels per word; a set bit is a dark pixel. @@ -426,7 +349,7 @@ refineTriple =! (layer, triple) -> # Grayscale finder-template fit, one axis at a time, over pitch scales of # 0.5..1.5 in tenths so measured perspective squeeze is covered. -def fitPattern!(layer, pattern, inverted) +fitPattern =! (layer, pattern, inverted) -> luma = layer.luma fit = (axis) -> center = if axis then pattern.y else pattern.x @@ -491,7 +414,7 @@ edgePitch =! (layer, first, second, inverted) -> b = pitch second if a and b then (a + b) / 2 else 0 -def copyPattern!(layer, index, out) +copyPattern =! (layer, index, out) -> pos = index * 4 out.x = layer.patterns[pos] out.y = layer.patterns[pos + 1] @@ -499,15 +422,16 @@ def copyPattern!(layer, index, out) # Retry sets are stride-5 records: rank, inverted, and three finder indices, # kept as a max-heap of 256 then heapsorted ascending. -def swapSet!(sets, a, b) +swapSet =! (sets, a, b) -> ap = a * 5 bp = b * 5 for i in [0...5] value = sets[ap + i] sets[ap + i] = sets[bp + i] sets[bp + i] = value + return -def siftDown!(sets, end) +siftDown =! (sets, end) -> index = 0 loop left = index * 2 + 1 @@ -529,17 +453,18 @@ evalLow =! (poly, length, x) -> # Row-ranged so the cooperative scanner can yield between bounded chunks. # 2x2 box filter into the next pyramid layer. -def resizeRows!(src, dst, width, dstWidth, from, to) +resizeRows =! (src, dst, width, dstWidth, from, to) -> for y in [from...to] srcPos = (y << 1) * width dstPos = y * dstWidth for x in [0...dstWidth] dst[dstPos++] = (src[srcPos] + src[srcPos + 1] + src[srcPos + width] + src[srcPos + width + 1] + 2) >> 2 srcPos += 2 + return # One threshold per 8x8 block. Low-contrast blocks take the darkest sample so # faint modules survive, propagated from the neighbors above and left. -def blockRows!(layer, from, to) +blockRows =! (layer, from, to) -> luma = layer.luma bWidth = layer.blockWidth maxY = layer.height - 8 @@ -570,9 +495,10 @@ def blockRows!(layer, from, to) previous = (2 * top + left + topLeft) / 4 average = previous if min < previous blocks[bWidth * y + x] = average >>> 0 + return # Smooth thresholds over 5x5 blocks and write each block's binary pixels. -def bitmapRows!(layer, from, to) +bitmapRows =! (layer, from, to) -> luma = layer.luma bWidth = layer.blockWidth bHeight = layer.blockHeight @@ -605,10 +531,11 @@ def bitmapRows!(layer, from, to) highMask = (1 << (shift - 24)) - 1 layer.bitmap[word + 1] = ((layer.bitmap[word + 1] & ~highMask) | (value >>> (32 - shift))) >>> 0 pos += layer.width + return # A five-run window that starts, centers and ends on a dark run: cross-check # it and merge into a finder record within two modules of an existing center. -def recordFinder!(layer, y, x, r0, r1, r2, r3, r4, inverted) +recordFinder =! (layer, y, x, r0, r1, r2, r3, r4, inverted) -> ms = ratio r0, r1, r2, r3, r4 return unless ms start = x - r0 - r1 - r2 - r3 - r4 @@ -640,7 +567,7 @@ def recordFinder!(layer, y, x, r0, r1, r2, r3, r4, inverted) layer.inverted[index] = polarity # Rolling run-length window over every second row; run() always advances. -def findRows!(layer, from, to) +findRows =! (layer, from, to) -> for y in [from...to] by 2 r0 = r1 = r2 = r3 = r4 = 0 runs = 0 @@ -658,6 +585,7 @@ def findRows!(layer, from, to) dark = previous previous = not previous recordFinder layer, y, x, r0, r1, r2, r3, r4, (not dark) if runs >= 5 + return # Legal sides are 17 + 4 * version; a non-finite estimate coerces to zero and # fails the dimension bounds downstream. @@ -2035,7 +1963,7 @@ export class QRScanner # ==[ Public API ]== # First QR in an image through the coarse-to-fine scan; throws when none decodes. -export def decodeQR(img, opts = {}) +export decodeQR =! (img, opts = {}) -> validateOpts opts validateImage img, opts.format scanner = QRScanner.new { ...opts, maxSize: { height: img.height, width: img.width } } @@ -2050,8 +1978,10 @@ export def decodeQR(img, opts = {}) export default decodeQR +export { decodeCode128, readCode128 } from './code128.rip' + # Every QR in each image through one cooperatively scheduled scanner. -export def decodeQRBatch(images, opts = {}) +export decodeQRBatch =! (images, opts = {}) -> maxSize = opts.maxSize or { width: 3840, height: 3840 } scanner = QRScanner.new { ...opts, maxSize } results = [] diff --git a/packages/barcodes/dom.rip b/packages/barcodes/dom.rip index f7eb1d0b..9759054e 100644 --- a/packages/barcodes/dom.rip +++ b/packages/barcodes/dom.rip @@ -31,12 +31,12 @@ PLANES =! NV12: [Y8, [1, 1, 2]] # Rendered element size from computed CSS. -export def getSize(elm) +export getSize =! (elm) -> css = getComputedStyle elm { width: Math.floor(+css.width.split('px')[0]), height: Math.floor(+css.height.split('px')[0]) } # Setting a canvas dimension clears it even at the same size. -def setCanvasSize!(canvas, height, width) +setCanvasSize =! (canvas, height, width) -> canvas.height = height if canvas.height isnt height canvas.width = width if canvas.width isnt width @@ -45,17 +45,17 @@ getCanvasContext = (canvas) -> throw Error.new 'Cannot get canvas context' if context is null { canvas, context } -def clearCanvas!(cc) +clearCanvas =! (cc) -> cc.context.clearRect 0, 0, cc.canvas.width, cc.canvas.height -def traceQuad!(context, points) +traceQuad =! (context, points) -> context.beginPath() context.moveTo points[0].x, points[0].y for i in [1...points.length] context.lineTo points[i].x, points[i].y context.closePath() -def fillQuad!(context, points) +fillQuad =! (context, points) -> traceQuad context, points context.fill() @@ -65,7 +65,7 @@ toward = (from, to, distance) -> scale = Math.min 0.5, distance / Math.hypot(dx, dy) { x: from.x + scale * dx, y: from.y + scale * dy } -def traceRoundedQuad!(context, points, radius) +traceRoundedQuad =! (context, points, radius) -> starts = points.map (point, i) -> toward point, points[(i + 1) % 4], radius ends = points.map (point, i) -> toward point, points[(i + 3) % 4], radius context.beginPath() @@ -97,10 +97,11 @@ sameOverlays = (left, right) -> [right[i], right[match]] = [right[match], right[i]] if match isnt i true -def copyOverlays!(target, source) +copyOverlays =! (target, source) -> target.length = source.length for i in [0...source.length] target[i] = source[i] + return # Drawing and decode options, with defaults. canvasDefaults = -> @@ -741,14 +742,14 @@ openCamera = (player, facingMode, opts = {}) -> stream = navigator.mediaDevices.getUserMedia! video: { height: { ideal: window.screen.height }, width: { ideal: window.screen.width }, facingMode } QRCamera.new player, stream, opts -export def rearCamera(player, opts = {}) +export rearCamera =! (player, opts = {}) -> openCamera! player, 'environment', opts -export def selfieCamera(player, opts = {}) +export selfieCamera =! (player, opts = {}) -> openCamera! player, 'user', opts # Run a callback per presented frame; returns a canceller. -export def frameLoop(cb, video) +export frameLoop =! (cb, video) -> useVideo = !!video and typeof video.requestVideoFrameCallback is 'function' and typeof video.cancelVideoFrameCallback is 'function' active = true handle = undefined @@ -768,7 +769,7 @@ export def frameLoop(cb, video) return # SVG markup to a PNG data URL through an image element and a canvas. -export def svgToPng(svgData, width, height) +export svgToPng =! (svgData, width, height) -> Promise.new (resolve, reject) -> unless Number.isSafeInteger(width) and Number.isSafeInteger(height) and width > 0 and height > 0 and width < 8192 and height < 8192 return reject(Error.new "invalid width and height: #{width} #{height}") @@ -795,7 +796,7 @@ export def svgToPng(svgData, width, height) img.onerror = reject img.src = 'data:image/svg+xml,' + encodeURIComponent(source) -export def gifToPng(gifBytes) +export gifToPng =! (gifBytes) -> blob = Blob.new [gifBytes], { type: 'image/gif' } bitmap = createImageBitmap! blob try diff --git a/packages/barcodes/gif.rip b/packages/barcodes/gif.rip new file mode 100644 index 00000000..4ae4d558 --- /dev/null +++ b/packages/barcodes/gif.rip @@ -0,0 +1,71 @@ +# ============================================================================== +# rip/barcodes — GIF87a writer +# +# Uncompressed LZW stream: 8-bit codes and a clear code every 126 pixels, so +# no dictionary state exists. Palette entry 0 is white, every other entry +# black; `rowFor(y)` returns the 0/1 byte row for output row y (identical rows +# may return the same buffer) and each row is block-copied in spans bounded +# by the chunk boundaries. +# ============================================================================== + +export writeGif =! (W, H, rowFor) -> + pixels = W * H + N = 126 + fullChunks = pixels // N + tail = pixels % N + out = new Uint8Array(408 + fullChunks * (N + 2) + 2 + tail + 4) + pos = 0 + u16 = (v) -> + out[pos++] = v & 0xff + out[pos++] = v >>> 8 + return + for b in [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] + out[pos++] = b + u16 W + u16 H + out[pos++] = 0xf6 + pos += 2 + out[pos++] = 0xff + out[pos++] = 0xff + out[pos++] = 0xff + pos += 3 * 127 + out[pos++] = 0x2c + pos += 4 + u16 W + u16 H + out[pos++] = 0x00 + out[pos++] = 0x07 + i = 0 + for y in [0...H] + row = rowFor y + x = 0 + while x < W + if i % N is 0 + rem = pixels - i + out[pos++] = (if rem < N then rem else N) + 1 + out[pos++] = 0x80 + n = Math.min(N - i % N, W - x) + out.set row.subarray(x, x + n), pos + pos += n + x += n + i += n + if tail is 0 + out[pos++] = 1 + out[pos++] = 0x80 + out[pos++] = 0x01 + out[pos++] = 0x81 + out[pos++] = 0x00 + out[pos++] = 0x3b + out + +export gifDataUrl =! (gif) -> + b64 = if typeof gif.toBase64 is 'function' + gif.toBase64() + else + bin = '' + i = 0 + while i < gif.length + bin += String.fromCharCode(...gif.subarray(i, i + 8192)) + i += 8192 + btoa bin + 'data:image/gif;base64,' + b64 diff --git a/packages/barcodes/image.rip b/packages/barcodes/image.rip new file mode 100644 index 00000000..1bdcfee5 --- /dev/null +++ b/packages/barcodes/image.rip @@ -0,0 +1,91 @@ +# ============================================================================== +# rip/barcodes — image input +# +# Every decoder takes the same picture shapes: an unnamed RGB/RGBA raster +# (detected by exact length), or a named format with an optional plane +# layout. Validation is loud and the conversion lands in a tight 8-bit luma +# plane the readers scan directly. +# ============================================================================== + +export MAX_IMAGE_SIDE =! 4096 + +LUMA8 =! { step: 1, bits: 8 } +LUMA10 =! { step: 2, bits: 10 } +LUMA12 =! { step: 2, bits: 12 } +RGB =! { step: 3, bits: 8 } +# Four-byte inputs ignore the fourth byte: alpha is never composited and the +# X formats share the storage shape. +RGBA =! { step: 4, bits: 8 } +export FORMATS =! + RGB: RGB, RGBA: RGBA, RGBX: RGBA, BGRA: RGBA, BGRX: RGBA + I420: LUMA8, I420A: LUMA8, I422: LUMA8, I444: LUMA8, NV12: LUMA8 + I420P10: LUMA10, I420P12: LUMA12 + +export validateSize =! (size, name) -> + throw TypeError.new "#{name} expected safe integer width and height" unless Number.isSafeInteger(size.width) and Number.isSafeInteger(size.height) + throw RangeError.new "#{name} expected positive width and height" if size.width <= 0 or size.height <= 0 + throw RangeError.new "#{name} expected width and height <= #{MAX_IMAGE_SIDE}, got #{size.width}x#{size.height}" if size.width > MAX_IMAGE_SIDE or size.height > MAX_IMAGE_SIDE + size.width * size.height + +# Returns the input format; RGB/RGBA is detected by exact length when unnamed. +export validateImage =! (img, named, layout, capacity) -> + px = validateSize img, '"img"' + if capacity and (img.width > capacity.width or img.height > capacity.height) + throw RangeError.new "\"img\" expected dimensions <= #{capacity.width}x#{capacity.height}, got #{img.width}x#{img.height}" + bytes = img.data + throw TypeError.new "\"img.data\" expected Uint8Array or Uint8ClampedArray, got #{typeof bytes}" unless bytes instanceof Uint8Array or bytes instanceof Uint8ClampedArray + if named isnt undefined + format = FORMATS[named] + throw TypeError.new "invalid opts.format=#{named} (#{typeof named})" unless format + if layout + {offset, stride} = layout + row = format.step * img.width + end = offset + (img.height - 1) * stride + row + if not Number.isSafeInteger(offset) or not Number.isSafeInteger(stride) or offset < 0 or stride < row or end > bytes.length + throw RangeError.new "\"img.data\" expected valid offset/stride for format=#{named}, got offset=#{offset}, stride=#{stride}, length=#{bytes.length}" + else + expected = format.step * px + planar = format.step <= 2 + if (planar and bytes.length < expected) or (not planar and bytes.length isnt expected) + throw RangeError.new "\"img.data\" expected #{if planar then 'at least ' else ''}#{expected} bytes for format=#{named}, got #{bytes.length}" + return format + return RGB if bytes.length is 3 * px + return RGBA if bytes.length is 4 * px + throw RangeError.new "\"img.data\" expected #{3 * px} or #{4 * px} bytes without opts.format, got #{bytes.length}" + +# Convert any input into the tight native luma plane. +export copyLuma =! (out, maxSize, img, named, layout) -> + {step, bits} = validateImage img, named, layout, maxSize + {width, height, data} = img + stride = layout?.stride or width * step + offset = layout?.offset or 0 + return if data is out and not offset and stride is width and step is 1 + for y in [0...height] + src = offset + y * stride + dst = y * width + if step is 1 + for x in [0...width] + out[dst++] = data[src++] + else if step is 2 + for x in [0...width] + out[dst++] = (data[src] | (data[src + 1] << 8)) >>> (bits - 8) + src += 2 + else + for x in [0...width] + out[dst++] = (data[src] + 2 * data[src + 1] + data[src + 2]) >> 2 + src += step + return + +# RGBA image from a per-pixel dark predicate. +export darkToImage =! (width, height, isDark) -> + data = new Uint8Array(width * height * 4) + i = 0 + for y in [0...height] + for x in [0...width] + color = if isDark(x, y) then 0 else 255 + data[i] = color + data[i + 1] = color + data[i + 2] = color + data[i + 3] = 255 + i += 4 + { width, height, data } diff --git a/packages/barcodes/package.json b/packages/barcodes/package.json index 3520dcab..b9969250 100644 --- a/packages/barcodes/package.json +++ b/packages/barcodes/package.json @@ -3,10 +3,11 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "QR code generator and reader — packed-bitmap encoder, camera-budgeted decoder, zero dependencies.", + "description": "QR and Code 128 generator and reader \u2014 packed-bitmap QR encoder, camera-budgeted decoder, scan-line Code 128, zero dependencies.", "exports": { ".": "./barcodes.rip", "./decode": "./decode.rip", + "./code128": "./code128.rip", "./dom": "./dom.rip" }, "scripts": { @@ -18,7 +19,10 @@ "files": [ "barcodes.rip", "spec.rip", + "gif.rip", + "image.rip", "decode.rip", + "code128.rip", "dom.rip", "README.md" ] diff --git a/packages/barcodes/spec.rip b/packages/barcodes/spec.rip index 0b3ea3f6..37bb5dca 100644 --- a/packages/barcodes/spec.rip +++ b/packages/barcodes/spec.rip @@ -53,7 +53,7 @@ export EC_CODE =! { low: 1, medium: 0, quartile: 3, high: 2 } export ALPHANUMERIC =! '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' # Annex E: alignment-pattern center coordinates, borderless module indices. -export def alignmentPatterns(ver) +export alignmentPatterns =! (ver) -> return [] if ver is 1 last = 21 + 4 * (ver - 1) - 7 count = Math.ceil((last - 6) / 28) @@ -69,7 +69,7 @@ export def alignmentPatterns(ver) res # §7.9.1 / Annex C.2: BCH-protected, masked 15-bit format word. -export def formatBits(ecc, mask) +export formatBits =! (ecc, mask) -> data = (EC_CODE[ecc] << 3) | mask d = data for i in [0...10] @@ -77,7 +77,7 @@ export def formatBits(ecc, mask) ((data << 10) | d) ^ 0b101010000010010 # §7.10 / Annex D.2: Golay-protected 18-bit version word. -export def versionBits(ver) +export versionBits =! (ver) -> d = ver for i in [0...12] d = (d << 1) ^ ((d >> 11) * 0b1111100100101) @@ -98,7 +98,7 @@ export GF256 =! do -> # Table 10 mask predicates as an 8-bit vector: bit m is set when mask m # fires at (x, y). -export def maskBits(x, y) +export maskBits =! (x, y) -> x2 = x % 2 y2 = y % 2 x3 = x % 3 @@ -121,5 +121,5 @@ POP16 =! do -> t[i] = t[i >>> 1] + (i & 1) t -export def popcnt(n) +export popcnt =! (n) -> POP16[n & 0xffff] + POP16[n >>> 16] diff --git a/packages/barcodes/test.rip b/packages/barcodes/test.rip index 7ad5d0b0..d13c9c35 100644 --- a/packages/barcodes/test.rip +++ b/packages/barcodes/test.rip @@ -9,6 +9,8 @@ import * as enc from 'rip/barcodes' import decodeQR, { QRScanner, decodeQRBatch } from 'rip/barcodes/decode' import * as dec from 'rip/barcodes/decode' import * as dom from 'rip/barcodes/dom' +import { encodeCode128, readCode128, decodeCode128, _tests as c128 } from 'rip/barcodes/code128' +import * as code128 from 'rip/barcodes/code128' import { BYTES, alignmentPatterns, formatBits, versionBits, maskBits, popcnt } from './spec.rip' import { readFileSync } from 'fs' @@ -19,17 +21,20 @@ rows = (raw) -> raw.map((row) -> row.map((b) -> if b then '1' else '0').join('') console.log "\nPackage" test "exports", -> - eq Object.keys(enc).sort(), ['_tests', 'default', 'encodeQR'] + eq Object.keys(enc).sort(), ['_tests', 'default', 'encodeCode128', 'encodeQR'] eq enc.default, encodeQR - eq Object.keys(dec).sort(), ['QRScanner', 'decodeQR', 'decodeQRBatch', 'default'] + eq enc.encodeCode128, encodeCode128 + eq Object.keys(dec).sort(), ['QRScanner', 'decodeCode128', 'decodeQR', 'decodeQRBatch', 'default', 'readCode128'] eq dec.default, decodeQR + eq dec.decodeCode128, decodeCode128 + eq Object.keys(code128).sort(), ['_tests', 'decodeCode128', 'encodeCode128', 'readCode128'] eq Object.keys(dom).sort(), ['BarcodeDetector', 'QRCamera', 'QRCanvas', 'frameLoop', 'getSize', 'gifToPng', 'rearCamera', 'selfieCamera', 'svgToPng'] test "no runtime deps, declares browser safety and earns it", -> pkg = JSON.parse readFileSync("#{import.meta.dir}/package.json", 'utf8') eq pkg.dependencies, undefined eq pkg.rip, { browser: true } - for file in ['spec.rip', 'barcodes.rip', 'decode.rip', 'dom.rip'] + for file in ['spec.rip', 'gif.rip', 'image.rip', 'barcodes.rip', 'decode.rip', 'code128.rip', 'dom.rip'] source = readFileSync "#{import.meta.dir}/#{file}", 'utf8' eq /\bBun\.|node:|process\.|globalThis/.test(source), false, file @@ -288,3 +293,119 @@ test! "scanner reuse, exclusive operations and clean", -> eq reentered, 'scanner operation already in flight' scanner.clean() throws (-> scanner.decode()), 'expected addImage before decode' + +# ==[ Code 128 ]== + +console.log "\nCode 128" + +test "Code 128 codewords: published vector, shortest subset choice, FNC1", -> + eq c128.codewords('Wikipedia'), [104, 55, 73, 75, 73, 80, 69, 68, 73, 65, 88, 106] + eq c128.codewords('1234'), [105, 12, 34, 82, 106] + eq c128.codewords('A1234'), [104, 33, 99, 12, 34, 95, 106] + eq c128.codewords('ABC123456'), [104, 33, 34, 35, 99, 12, 34, 56, 23, 106] + eq c128.codewords('\x01\x02'), [103, 65, 66, 94, 106] + eq c128.codewords('a\x01b'), [104, 65, 98, 65, 66, 0, 106] + eq c128.codewords('AB\x1dCD'), [104, 33, 34, 102, 35, 36, 7, 106] + eq c128.codewords('(01)09501101530003', true), [104, 102, 8, 16, 17, 9, 99, 9, 50, 11, 1, 53, 0, 3, 8, 106] + for text in ['Hello, World! 2026', 'L2602852147', '12345', '\x7f~ ', 'x0y1z2', '2026-09-15T12:00'] + codes = c128.codewords text + ok codes.length <= text.length + 3, text + sum = codes[0] + sum += k * codes[k] for k in [1...codes.length - 2] + eq sum % 103, codes[codes.length - 2], text + eq codes[codes.length - 1], 106 + throws (-> c128.codewords 'é'), Error, 'cannot encode' + throws (-> c128.codewords ''), Error, 'empty' + +test "Code 128 modules and outputs", -> + raw = encodeCode128 'Wikipedia', 'raw', border: 0 + eq raw.length, 11 * 12 + 2 + eq raw.slice(0, 11).map((b) -> if b then 1 else 0).join(''), '11010010000' + eq raw.slice(-13).map((b) -> if b then 1 else 0).join(''), '1100011101011' + eq encodeCode128('Wikipedia').length, 11 * 12 + 2 + 20 + eq encodeCode128('Wikipedia', 'raw', scale: 3, border: 1).length, 3 * (11 * 12 + 4) + ascii = encodeCode128 'ok', 'ascii', border: 2 + eq ascii.length, 62 + eq ascii.slice(0, 2), '██' + eq ascii.at(-1), '\n' + eq encodeCode128('ok', 'svg', border: 2, height: 10), '' + eq (encodeCode128('ok', 'svg', border: 2, height: 10, optimize: false).match(/ encodeCode128 'ok', 'png'), Error, 'Unknown output' + throws (-> encodeCode128 42), TypeError, 'expected string' + throws (-> encodeCode128 'ok', 'raw', scale: 0), RangeError, 'scale' + throws (-> encodeCode128 'ok', 'raw', border: -1), RangeError, 'border' + throws (-> encodeCode128 'ok', 'raw', height: '4'), TypeError, 'height' + throws (-> encodeCode128 'ok', 'raw', scale: 100), Error, 'exceeds' + +# RGBA raster with the bar row painted 30 pixels tall on a gray field. +bars = (text, opts, scale, W, H, rotate = 0, invert = false) -> + raw = encodeCode128 text, 'raw', { ...opts, scale, height: 1 } + w = raw.length + h = 30 + data = new Uint8Array(W * H * 4).fill(if invert then 40 else 190) + turned = rotate is 90 or rotate is 270 + ox = (W - (if turned then h else w)) >> 1 + oy = (H - (if turned then w else h)) >> 1 + for y in [0...h] + for x in [0...w] + dark = raw[x] + dark = not dark if invert + v = if dark then 20 else 240 + px = x + py = y + switch rotate + when 90 then (px = h - 1 - y; py = x) + when 180 then (px = w - 1 - x; py = h - 1 - y) + when 270 then (px = y; py = w - 1 - x) + p = 4 * ((oy + py) * W + ox + px) + data[p] = data[p + 1] = data[p + 2] = v + data[p + 3] = 255 + { width: W, height: H, data } + +test "Code 128 round trips: scales, rotations, inversion, GS1", -> + for scale in [1, 2, 3, 5] + eq decodeCode128(bars('Hello, World! 2026', {}, scale, 1400, 200)), 'Hello, World! 2026', "scale #{scale}" + for rotate in [0, 90, 180, 270] + hit = readCode128 bars('Wikipedia', {}, 2, 500, 500, rotate) + eq hit.text, 'Wikipedia', "rotate #{rotate}" + eq hit.vertical, rotate is 90 or rotate is 270 + eq hit.reversed, rotate is 180 or rotate is 270 + eq hit.inverted, false + eq hit.codes, [104, 55, 73, 75, 73, 80, 69, 68, 73, 65, 88, 106] + hit = readCode128 bars('Wikipedia', {}, 2, 500, 300, 0, true) + eq hit.text, 'Wikipedia' + eq hit.inverted, true + eq readCode128(bars('a\x01b\x02', {}, 2, 500, 300)).text, 'a\x01b\x02' + eq readCode128(bars('0123456789', {}, 1, 300, 100)).text, '0123456789' + eq readCode128(bars('ok', {}, 1, 100, 20)).text, 'ok' + hit = readCode128 bars('(01)09501101530003', gs1: true, 2, 600, 300) + eq hit.text, '(01)09501101530003' + eq hit.gs1, true + hit = readCode128 bars('AB\x1dCD', {}, 2, 500, 300) + eq hit.text, 'AB\x1dCD' + eq hit.gs1, false + eq readCode128(toLuma(bars('Wikipedia', {}, 2, 500, 300)), format: 'I420').text, 'Wikipedia' + blank = { width: 50, height: 50, data: new Uint8Array(50 * 50 * 4).fill(200) } + eq readCode128(blank), null + throws (-> decodeCode128 blank), Error, 'not found' + throws (-> readCode128 blank, null), TypeError, 'opts' + throws (-> readCode128 { width: 2, height: 2, data: new Uint8Array(3) }), RangeError, 'img.data' + +test "Code 128 text: shifts, FNC4 high characters, latches", -> + text = (codes) -> c128.decodeText(codes, codes.length).text + eq text([104, 98, 65, 66, 0, 106]), '\x01b' + eq text([104, 100, 33, 34, 0, 106]), 'ÁB' + eq text([104, 100, 100, 33, 34, 0, 106]), 'ÁÂ' + eq text([104, 100, 100, 33, 100, 34, 0, 106]), 'ÁB' + eq text([103, 65, 100, 65, 99, 1, 101, 66, 0, 106]), '\x01a01\x02' + eq text([105, 0, 100, 33, 0, 106]), '00A' + eq c128.decodeText([105, 102, 1, 102, 2, 0, 106], 7), { text: '01\x1d02', gs1: true } From 89763e489b6d7baf718a87e61e702d92dcb2b627 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 15 Sep 2026 23:44:23 -0600 Subject: [PATCH 3/5] barcodes: one file per symbology Merge the QR encoder and decoder into qr.rip beside code128.rip, and reduce barcodes.rip to the root entry that re-exports both symbologies. Subpaths are ./qr, ./code128 and ./dom. Every module-level helper in qr.rip is a const binding: the merge exposed an encoder local silently overwriting the decoder's plain-assigned GF multiply, and a const turns any such collision into a compile error. Code 128 validates its arguments through the same asString, asObject and asInt helpers the QR encoder uses. Tests 33/33, parity oracles unchanged, bench unchanged. --- packages/barcodes/README.md | 20 +- packages/barcodes/barcodes.rip | 652 +--------------------- packages/barcodes/code128.rip | 16 +- packages/barcodes/dom.rip | 2 +- packages/barcodes/package.json | 4 +- packages/barcodes/{decode.rip => qr.rip} | 680 ++++++++++++++++++++++- packages/barcodes/test.rip | 16 +- 7 files changed, 695 insertions(+), 695 deletions(-) rename packages/barcodes/{decode.rip => qr.rip} (76%) diff --git a/packages/barcodes/README.md b/packages/barcodes/README.md index 0c2045e5..3ffed1d2 100644 --- a/packages/barcodes/README.md +++ b/packages/barcodes/README.md @@ -17,16 +17,16 @@ dynamic programming, and the reader walks scan lines middle-out, matching each eleven-module group to its nearest codeword in both directions and on both axes. -**Runtime:** browser-safe (`rip.browser: true`). Seven `.rip` files: the -encoder entry, the decoder entry, Code 128, the camera and canvas plumbing, -the ISO/IEC 18004 tables, and the GIF writer and image-input helpers the -encoders and decoders share. +**Runtime:** browser-safe (`rip.browser: true`). One file per symbology, +`qr.rip` and `code128.rip`, each holding its encoder and reader; a root +entry that re-exports both; the camera and canvas plumbing; and the +ISO/IEC 18004 tables, GIF writer and image-input helpers they share. +`rip/barcodes/qr` and `rip/barcodes/code128` import one symbology alone. ## Quick Start ```coffee -import encodeQR from 'rip/barcodes' -import decodeQR from 'rip/barcodes/decode' +import encodeQR, { decodeQR } from 'rip/barcodes' text = 'Hello world' console.log encodeQR(text, 'term') # print to any terminal @@ -39,8 +39,7 @@ ascii = encodeQR text, 'ascii' # half-height block characters # decode any RGBA raster, the shape a canvas ImageData already has decodeQR { width, height, data } # the text, or throws -import { encodeCode128 } from 'rip/barcodes' -import { decodeCode128 } from 'rip/barcodes/decode' +import { encodeCode128, decodeCode128 } from 'rip/barcodes' encodeCode128 'L2602852147', 'svg', scale: 2 # a Code 128 label decodeCode128 { width, height, data } # the text, or throws @@ -107,8 +106,7 @@ failed strict pass. ## Code 128 ```coffee -import { encodeCode128 } from 'rip/barcodes' -import { decodeCode128, readCode128 } from 'rip/barcodes/decode' +import { encodeCode128, decodeCode128, readCode128 } from 'rip/barcodes' encodeCode128 text, output, opts ``` @@ -141,7 +139,7 @@ of a camera frame is plenty. ## Scanner ```coffee -import { QRScanner } from 'rip/barcodes/decode' +import { QRScanner } from 'rip/barcodes' scanner = QRScanner.new maxSize: { width: 1920, height: 1080 }, effort: 2 scanner.addImage frame # any supported format, up to maxSize diff --git a/packages/barcodes/barcodes.rip b/packages/barcodes/barcodes.rip index 890824ac..eb14a29f 100644 --- a/packages/barcodes/barcodes.rip +++ b/packages/barcodes/barcodes.rip @@ -1,652 +1,6 @@ -# ============================================================================== -# rip/barcodes — QR encoder -# -# A symbol is a packed bit matrix: one Uint32Array, 32 modules per word, -# LSB-first, with bits at x >= size held zero (the penalty scanners depend on -# it). Everything the version alone determines is built once and cached in a -# single slot: the function-pattern template, the zigzag placement order, and -# the eight mask XOR planes with their transposes. Mask selection XORs whole -# words and scores the test form word-parallel, so the eight-mask race costs -# one transpose per encode. -# ============================================================================== +import { encodeQR } from './qr.rip' -import { - ALPHANUMERIC, BYTES, ECC_BLOCKS, ECC_LEVELS, GF256, WORDS_PER_BLOCK, - alignmentPatterns, formatBits, maskBits, popcnt, versionBits, -} from './spec.rip' -import { gifDataUrl, writeGif } from './gif.rip' - -MAX_OUTPUT_SIZE =! 1024 -MAX_COMPACT_OUTPUT_SIZE =! 4096 - -MODE_BITS =! { numeric: 1, alphanumeric: 2, byte: 4 } -LENGTH_BITS =! { numeric: [10, 12, 14], alphanumeric: [9, 11, 13], byte: [8, 16, 16] } -NUMERIC_BITS =! [0, 4, 7, 10] - -fail =! (msg) -> throw Error.new msg - -# charCode -> Table 5 value; -1 outside the alphabet. -ALNUM_VAL =! do -> - t = new Int8Array(128).fill(-1) - for i in [0...ALPHANUMERIC.length] - t[ALPHANUMERIC.charCodeAt(i)] = i - t - -# ==[ Reed-Solomon ]== - -# Generator polynomial (leading 1 dropped) and every coefficient*feedback -# product, cached per parity length. -RS_CACHE =! [] - -rsGenerator =! (n) -> - {exp, log} = GF256 - gen = new Uint8Array(n) - gen[n - 1] = 1 - root = 1 - for i in [0...n] - for j in [0...n] - c = gen[j] - gen[j] = (if c then exp[log[c] + log[root]] else 0) ^ (if j + 1 < n then gen[j + 1] else 0) - root = exp[log[root] + 1] - gen - -rsCached =! (n) -> - return RS_CACHE[n] if RS_CACHE[n] - {exp, log} = GF256 - gen = rsGenerator n - mul = new Uint8Array(256 * n) - for f in [1...256] - lf = log[f] - base = f * n - for j in [0...n] - c = gen[j] - mul[base + j] = exp[log[c] + lf] if c - RS_CACHE[n] = { gen, mul } - -# Parity via LFSR remainder. -rsEcc =! (data, rs) -> - {gen, mul} = rs - n = gen.length - last = n - 1 - res = new Uint8Array(n) - for i in [0...data.length] - base = (data[i] ^ res[0]) * n - for j in [0...last] - res[j] = res[j + 1] ^ mul[base + j] - res[last] = mul[base + last] - res - -capacity =! (ver, ecc) -> - bytes = BYTES[ver - 1] - words = WORDS_PER_BLOCK[ecc][ver - 1] - numBlocks = ECC_BLOCKS[ecc][ver - 1] - blockLen = (bytes // numBlocks) - words - shortBlocks = numBlocks - bytes % numBlocks - { words, numBlocks, shortBlocks, blockLen, capacity: (bytes - words * numBlocks) * 8 } - -# ==[ Data codewords ]== - -detectType =! (str) -> - type = 'numeric' - for i in [0...str.length] - v = ALNUM_VAL[str.charCodeAt(i)] - return 'byte' unless v >= 0 - type = 'alphanumeric' if v > 9 - type - -# Segment bits, terminator, padding, then RS blocks interleaved. -encodeData =! (ver, ecc, text, type, utf8) -> - cap = capacity ver, ecc - lengthBits = LENGTH_BITS[type][(ver + 7) // 17] - dataLen = if type is 'byte' then utf8.length else text.length - fail 'Capacity overflow' if dataLen >= 1 << lengthBits - bytes = new Uint8Array(cap.capacity >>> 3) - # MSB-first accumulator flushed a byte at a time; pushes are <= 16 bits and - # a flush keeps it below 8, so it never nears 32. - acc = 0 - accBits = 0 - bytePos = 0 - push = (value, len) -> - acc = (acc << len) | value - accBits += len - while accBits >= 8 - accBits -= 8 - bytes[bytePos++] = (acc >>> accBits) & 0xff - return - push MODE_BITS[type], 4 - push dataLen, lengthBits - if type is 'numeric' - i = 0 - while i < dataLen - n = Math.min(3, dataLen - i) - push Number(text.slice(i, i + n)), NUMERIC_BITS[n] - i += 3 - else if type is 'alphanumeric' - i = 0 - while i + 1 < dataLen - push ALNUM_VAL[text.charCodeAt(i)] * 45 + ALNUM_VAL[text.charCodeAt(i + 1)], 11 - i += 2 - push ALNUM_VAL[text.charCodeAt(dataLen - 1)], 6 if dataLen & 1 - else - for i in [0...utf8.length] - push utf8[i], 8 - bitPos = bytePos * 8 + accBits - fail 'Capacity overflow' if bitPos > cap.capacity - bytes[bytePos] = (acc << (8 - accBits)) & 0xff if accBits - bitPos += Math.min(4, cap.capacity - bitPos) - bitPos += 8 - (bitPos & 7) if bitPos & 7 - pad = 0 - start = bitPos >>> 3 - for i in [start...bytes.length] - bytes[i] = if pad then 0x11 else 0xec - pad ^= 1 - {words, numBlocks, shortBlocks, blockLen} = cap - rs = rsCached words - blocks = [] - eccs = [] - pos = 0 - for i in [0...numBlocks] - len = blockLen + (if i < shortBlocks then 0 else 1) - block = bytes.subarray pos, pos + len - blocks.push block - eccs.push rsEcc(block, rs) - pos += len - res = new Uint8Array(bytes.length + words * numBlocks) - out = 0 - for i in [0..blockLen] - for b in blocks - res[out++] = b[i] if i < b.length - for i in [0...words] - for e in eccs - res[out++] = e[i] - res - -# ==[ Packed bit matrix ]== - -mat =! (size) -> - words = (size + 31) >>> 5 - { size, words, v: new Uint32Array(words * size) } - -matGet =! (m, x, y) -> (m.v[y * m.words + (x >>> 5)] >>> (x & 31)) & 1 - -matSet =! (m, x, y, bit) -> - i = y * m.words + (x >>> 5) - b = 1 << (x & 31) - m.v[i] = if bit then m.v[i] | b else m.v[i] & ~b - return - -TRANSPOSE_MASKS =! [0x55555555, 0x33333333, 0x0f0f0f0f, 0x00ff00ff, 0x0000ffff] -TRANSPOSE_TMP =! new Uint32Array(32) - -# 32x32 in-place bit-matrix transpose (butterfly network). -transpose32 =! (a) -> - for stage in [0...5] - m = TRANSPOSE_MASKS[stage] >>> 0 - s = 1 << stage - i = 0 - while i < 32 - for k in [0...s] - x = a[i + k] >>> 0 - y = a[i + k + s] >>> 0 - t = ((x >>> s) ^ y) & m - a[i + k] = (x ^ (t << s)) >>> 0 - a[i + k + s] = (y ^ t) >>> 0 - i += s << 1 - return - -transposeMat =! (src, dst) -> - {size, words, v} = src - tmp = TRANSPOSE_TMP - y0 = 0 - while y0 < size - for bx in [0...words] - rows = Math.min(32, size - y0) - for r in [0...rows] - tmp[r] = v[(y0 + r) * words + bx] - tmp.fill 0, rows - transpose32 tmp - dstY = bx * 32 - i = 0 - while i < 32 and dstY < size - dst.v[dstY * dst.words + (y0 >>> 5)] = tmp[i] - i++ - dstY++ - y0 += 32 - return - -# ==[ Mask penalty ]== - -# N1, all columns of a 32-wide stripe at once: D = row ^ next row flags -# changes, a monochrome 5-window is four clear D bits, and a run of length L -# contributes L-4 windows plus one run-start window counted twice. -runsPenaltyVertical =! (m) -> - {size, words, v} = m - tail = if size & 31 then ((1 << (size & 31)) - 1) >>> 0 else 0xffffffff - score = 0 - for wi in [0...words] - valid = if wi is words - 1 then tail else 0xffffffff - r3 = v[3 * words + wi] - dPrev = 0xffffffff - d0 = v[wi] ^ v[words + wi] - d1 = v[words + wi] ^ v[2 * words + wi] - d2 = v[2 * words + wi] ^ r3 - idx = 4 * words + wi - for y in [0..size - 5] - r4 = v[idx] - d3 = r3 ^ r4 - w = ~(d0 | d1 | d2 | d3) & valid - score += popcnt(w >>> 0) + 2 * popcnt((w & dPrev) >>> 0) if w - dPrev = d0 - d0 = d1 - d1 = d2 - d2 = d3 - r3 = r4 - idx += words - score - -# N3: 1011101 with four light modules before or after, both orientations of -# the pattern across a 32-wide stripe at once. -finderPenaltyVertical =! (m) -> - {size, words, v} = m - tail = if size & 31 then ((1 << (size & 31)) - 1) >>> 0 else 0xffffffff - count = 0 - for wi in [0...words] - valid = if wi is words - 1 then tail else 0xffffffff - for y in [0..size - 11] - i = y * words + wi - r0 = v[i] - r1 = v[i + words] - r2 = v[i + 2 * words] - r3 = v[i + 3 * words] - r4 = v[i + 4 * words] - r5 = v[i + 5 * words] - r6 = v[i + 6 * words] - r7 = v[i + 7 * words] - r8 = v[i + 8 * words] - r9 = v[i + 9 * words] - r10 = v[i + 10 * words] - m0 = valid & r0 & ~r1 & r2 & r3 & r4 & ~r5 & r6 & ~(r7 | r8 | r9 | r10) - m1 = valid & ~(r0 | r1 | r2 | r3) & r4 & ~r5 & r6 & r7 & r8 & ~r9 & r10 - count += popcnt(m0 >>> 0) + popcnt(m1 >>> 0) - count - -# Score a symbol given both orientations. `limit` is the best score so far -# in the mask race: every term is non-negative, so a partial sum that reaches -# it can no longer win and the expensive N3 search is skipped. -penaltyScore =! (m, t, limit = Infinity) -> - {size, words, v} = m - adjacent = runsPenaltyVertical(m) + runsPenaltyVertical(t) - return adjacent if adjacent >= limit - # N2: three points per overlapping 2x2 same-color box. Valid left edges in - # the last word: one fewer than the bits it holds. - tail2 = ((1 << (size - 32 * (words - 1) - 1)) - 1) >>> 0 - boxes = 0 - dark = 0 - for y in [0...size] - for wi in [0...words] - a0 = v[y * words + wi] - dark += popcnt(a0 >>> 0) - continue if y is size - 1 - a1 = v[(y + 1) * words + wi] - n0 = if wi + 1 < words then v[y * words + wi + 1] else 0 - n1 = if wi + 1 < words then v[(y + 1) * words + wi + 1] else 0 - eqV = ~(a0 ^ a1) - eqH0 = ~(a0 ^ ((a0 >>> 1) | (n0 << 31))) - eqH1 = ~(a1 ^ ((a1 >>> 1) | (n1 << 31))) - w = eqV & eqH0 & eqH1 - w &= tail2 if wi is words - 1 - boxes += popcnt(w >>> 0) - total = size * size - darkSteps = Math.ceil(Math.max(0, Math.abs(dark * 100 - total * 50) - total * 5) / (total * 5)) - partial = adjacent + 3 * boxes + 10 * darkSteps - return partial if partial >= limit - partial + 40 * (finderPenaltyVertical(m) + finderPenaltyVertical(t)) - -penalty =! (m, t) -> - transposeMat m, t - penaltyScore m, t - -# ==[ Symbol layout ]== - -drawInfo =! (m, ver, ecc, mask) -> - size = m.size - bits = formatBits ecc, mask - for i in [0...15] - bit = (bits >> i) & 1 - if i < 6 then matSet m, 8, i, bit - else if i < 8 then matSet m, 8, i + 1, bit - else if i is 8 then matSet m, 7, 8, bit - else matSet m, 14 - i, 8, bit - if i < 8 then matSet m, size - 1 - i, 8, bit - else matSet m, 8, size - 15 + i, bit - matSet m, 8, size - 8, 1 - if ver >= 7 - vbits = versionBits ver - for i in [0...18] - bit = (vbits >> i) & 1 - x = size - 11 + i % 3 - y = i // 3 - matSet m, x, y, bit - matSet m, y, x, bit - return - -# Everything the layout alone determines, built once per version: the -# function-pattern template with the data region zero, the zigzag placement -# order as packed (wordIndex << 5 | bitOffset) positions, the eight mask XOR -# planes and their transposes, and four scratch matrices. Single slot: -# workloads overwhelmingly encode one version repeatedly. -symCache = null - -buildSymCache =! (ver) -> - size = 21 + 4 * (ver - 1) - m = mat size - fun = new Uint8Array(size * size) - setF = (x, y, bit) -> - matSet m, x, y, bit - fun[y * size + x] = 1 - return - for [fx, fy] in [[0, 0], [size - 7, 0], [0, size - 7]] - for dy in [-1...8] - for dx in [-1...8] - x = fx + dx - y = fy + dy - continue if x < 0 or y < 0 or x >= size or y >= size - dark = dx >= 0 and dx < 7 and dy >= 0 and dy < 7 and - (dx is 0 or dx is 6 or dy is 0 or dy is 6 or (dx > 1 and dx < 5 and dy > 1 and dy < 5)) - setF x, y, (if dark then 1 else 0) - align = alignmentPatterns ver - for ay in align - for ax in align - continue if fun[ay * size + ax] - for dy in [-2..2] - for dx in [-2..2] - dark = Math.max(Math.abs(dx), Math.abs(dy)) isnt 1 - setF ax + dx, ay + dy, (if dark then 1 else 0) - for i in [0...size] - setF i, 6, (if i % 2 is 0 then 1 else 0) unless fun[6 * size + i] - setF 6, i, (if i % 2 is 0 then 1 else 0) unless fun[i * size + 6] - # Format, version and dark-module cells are reserved at zero: the "test - # form" the mask penalties are scored on. - for i in [0...9] - if i isnt 6 - setF 8, i, 0 - setF i, 8, 0 - if i < 8 - setF size - 1 - i, 8, 0 - setF 8, size - 1 - i, 0 - if ver >= 7 - for i in [0...18] - x = size - 11 + i % 3 - y = i // 3 - setF x, y, 0 - setF y, x, 0 - planes = (mat(size) for i in [0...8]) - posBuf = new Uint16Array(size * size) - n = 0 - xOffset = size - 1 - dir = -1 - y = size - 1 - while xOffset > 0 - xOffset = 5 if xOffset is 6 - loop - for j in [0...2] - x = xOffset - j - continue if fun[y * size + x] - wi = y * m.words + (x >>> 5) - posBuf[n++] = (wi << 5) | (x & 31) - mb = maskBits x, y - pl = 0 - while mb - planes[pl].v[wi] |= 1 << (x & 31) if mb & 1 - pl++ - mb >>= 1 - break if y + dir < 0 or y + dir >= size - y += dir - xOffset -= 2 - dir = -dir - planesT = planes.map (pl) -> - t = mat size - transposeMat pl, t - t.v - { - ver - tpl: m.v - pos: posBuf.slice(0, n) - planes: planes.map((pl) -> pl.v) - planesT - work: [mat(size), mat(size), mat(size), mat(size)] - } - -# Template copy, data-bit scatter along the cached zigzag order, then mask -# selection over XOR candidates; the first lowest score wins. -drawSymbol =! (ver, ecc, data, maskIdx, test = false) -> - symCache = buildSymCache ver unless symCache?.ver is ver - {tpl, pos, planes, planesT, work} = symCache - [m, t, cand, candT] = work - m.v.set tpl - need = Math.min(8 * data.length, pos.length) - for i in [0...need] - if data[i >>> 3] & (0x80 >>> (i & 7)) - q = pos[i] - m.v[q >>> 5] |= 1 << (q & 31) - mask = maskIdx - unless mask? - transposeMat m, t - bestScore = Infinity - for pl in [0...8] - pv = planes[pl] - ptv = planesT[pl] - for i in [0...cand.v.length] - cand.v[i] = m.v[i] ^ pv[i] - candT.v[i] = t.v[i] ^ ptv[i] - score = penaltyScore cand, candT, bestScore - if score < bestScore - bestScore = score - mask = pl - chosen = planes[mask] - for i in [0...m.v.length] - m.v[i] ^= chosen[i] - drawInfo m, ver, ecc, mask unless test - m - -# ==[ Validation ]== - -asVersion =! (ver) -> - throw TypeError.new "\"version\" expected number, got type=#{typeof ver}" unless typeof ver is 'number' - throw RangeError.new "\"version\" expected safe integer, got #{ver}" unless Number.isSafeInteger ver - throw RangeError.new "Invalid version=#{ver}. Expected number [1..40]" if ver < 1 or ver > 40 - ver - -asNum =! (n, title) -> - throw TypeError.new "\"#{title}\" expected number, got type=#{typeof n}" unless typeof n is 'number' - throw RangeError.new "\"#{title}\" expected safe integer, got #{n}" unless Number.isSafeInteger n - n - -asString =! (s, title) -> - throw TypeError.new "\"#{title}\" expected string, got type=#{typeof s}" unless typeof s is 'string' - s - -# Exact WHATWG TextEncoder byte count without encoding; lone surrogates cost -# the three-byte replacement character. -utf8Length =! (str) -> - length = 0 - i = 0 - while i < str.length - c = str.charCodeAt i - if c < 0x80 then length++ - else if c < 0x800 then length += 2 - else if c < 0xd800 or c > 0xdfff then length += 3 - else if c <= 0xdbff and i + 1 < str.length - next = str.charCodeAt(i + 1) - if next >= 0xdc00 and next <= 0xdfff - length += 4 - i++ - else length += 3 - else length += 3 - i++ - length - -byteCapacity =! (ver, ecc) -> - lengthBits = LENGTH_BITS.byte[(ver + 7) // 17] - Math.min((1 << lengthBits) - 1, (capacity(ver, ecc).capacity - 4 - lengthBits) // 8) - -# Cross-realm and Buffer views pass; JSON-shaped spoofs do not. -isBytes =! (a) -> - a instanceof Uint8Array or - (ArrayBuffer.isView(a) and a.constructor.name is 'Uint8Array' and a.BYTES_PER_ELEMENT is 1) - -# ==[ Renderers ]== -# A raster is the finished matrix plus output geometry; `map` takes an output -# coordinate to its module index, -1 in the border. - -darkAt =! (r, x, y) -> r.map[x] >= 0 and r.map[y] >= 0 and matGet(r.m, r.map[x], r.map[y]) is 1 - -renderRaw =! (r) -> - W = r.W - res = Array.new(W) - for y in [0...W] - row = Array.new(W) - for x in [0...W] - row[x] = darkAt(r, x, y) - res[y] = row - res - -renderAscii =! (r) -> - W = r.W - out = '' - y = 0 - while y < W - for x in [0...W] - first = darkAt r, x, y - second = if y + 1 >= W then true else darkAt(r, x, y + 1) - out += if not first and not second then '█' else if not first and second then '▀' else if first and not second then '▄' else ' ' - out += '\n' - y += 2 - out - -renderTerm =! (r) -> - W = r.W - black = '\x1b[40m \x1b[0m' - white = '\x1b[1;47m \x1b[0m' - out = '' - for y in [0...W] - for x in [0...W] - out += (if darkAt(r, x, y) then black else white) - out += '\n' - out - -renderSvg =! (r, optimize) -> - W = r.W - out = '' - path = '' - prevX = 0 - prevY = 0 - hasPrev = false - for y in [0...W] - for x in [0...W] - continue unless darkAt(r, x, y) - unless optimize - out += '' - continue - mv = "M#{x} #{y}" - if hasPrev - rel = "m#{x - prevX} #{y - prevY}" - mv = rel if rel.length <= mv.length - back = if x < 10 then "H#{x}" else 'h-1' - path += "#{mv}h1v1#{back}Z" - prevX = x - prevY = y - hasPrev = true - out += '' if optimize - out + '' -renderGif =! (r) -> - W = r.W - {m, map} = r - row = new Uint8Array(W) - prevMy = -2 - writeGif W, W, (y) -> - my = map[y] - if my isnt prevMy - prevMy = my - row.fill 0 - if my >= 0 - for x in [0...W] - row[x] = matGet(m, map[x], my) if map[x] >= 0 - row - -# ==[ Public API ]== - -export encodeQR =! (text, output = 'raw', opts = {}) -> - asString text, 'text' - asString output, 'output' - throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if typeof opts isnt 'object' or opts is null or Array.isArray(opts) - ver = opts.version - ver = asVersion ver if ver isnt undefined - ecc = if opts.ecc is undefined then 'medium' else opts.ecc - fail "invalid ecc=#{ecc}" unless ECC_LEVELS.includes ecc - encoding = if opts.encoding is undefined then detectType(text) else opts.encoding - fail "invalid encoding=#{encoding}" unless LENGTH_BITS[encoding] - if encoding isnt 'byte' - alpha = if encoding is 'numeric' then ALPHANUMERIC.slice(0, 10) else ALPHANUMERIC - for ch in text - fail "Unknown letter: \"#{ch}\". Allowed: #{alpha}" unless alpha.includes ch - if opts.mask isnt undefined and (asNum(opts.mask, 'opts.mask') < 0 or opts.mask > 7) - fail "invalid mask=#{opts.mask}" - textEncoder = opts.textEncoder - # Reject impossible built-in UTF-8 payloads before encoding duplicates a - # huge input; a custom encoder's output is its own source of truth. - if encoding is 'byte' and textEncoder is undefined - maxBytes = byteCapacity (if ver is undefined then 40 else ver), ecc - fail 'Capacity overflow' if text.length > maxBytes or utf8Length(text) > maxBytes - utf8 = if encoding is 'byte' - if textEncoder isnt undefined then textEncoder(text) else TextEncoder.new().encode(text) - else - undefined - if utf8 isnt undefined and not isBytes(utf8) - throw TypeError.new "\"opts.textEncoder\" expected Uint8Array, got type=#{typeof utf8}" - dataLen = if encoding is 'byte' then utf8.length else text.length - encodedBits = switch encoding - when 'numeric' then (dataLen // 3) * 10 + NUMERIC_BITS[dataLen % 3] - when 'alphanumeric' then (dataLen // 2) * 11 + (dataLen % 2) * 6 - else dataLen * 8 - if ver is undefined - ver = 1 - while ver <= 40 - lengthBits = LENGTH_BITS[encoding][(ver + 7) // 17] - break if dataLen < 1 << lengthBits and 4 + lengthBits + encodedBits <= capacity(ver, ecc).capacity - ver++ - fail 'Capacity overflow' if ver > 40 - else - lengthBits = LENGTH_BITS[encoding][(ver + 7) // 17] - fail 'Capacity overflow' if dataLen >= 1 << lengthBits or 4 + lengthBits + encodedBits > capacity(ver, ecc).capacity - data = encodeData ver, ecc, text, encoding, utf8 - m = drawSymbol ver, ecc, data, opts.mask - # A quiet zone is required (§5.3.8); custom renderers wanting the borderless - # matrix request border 1 and slice the ring off. - border = if opts.border is undefined then 2 else asNum(opts.border, 'opts.border') - throw RangeError.new "invalid border=#{border}" if border <= 0 - scale = if opts.scale is undefined then 1 else asNum(opts.scale, 'opts.scale') - throw RangeError.new "invalid scale factor: #{scale}" if scale <= 0 or scale > 1024 - W = (m.size + 2 * border) * scale - maxOutputSize = if output is 'ascii' or output is 'gif' or output is 'data-url' then MAX_COMPACT_OUTPUT_SIZE else MAX_OUTPUT_SIZE - throw RangeError.new "invalid opts: output is #{W}x#{W} (max #{maxOutputSize}), reduce border/scale" if W > maxOutputSize - map = new Int32Array(W) - for i in [0...W] - f = i // scale - border - map[i] = if f >= 0 and f < m.size then f else -1 - r = { m, W, map } - switch output - when 'raw' then renderRaw r - when 'ascii' then renderAscii r - when 'term' then renderTerm r - when 'svg' then renderSvg r, (if opts.optimize is undefined then true else opts.optimize) - when 'gif' then renderGif r - when 'data-url' then gifDataUrl renderGif(r) - else fail "Unknown output: #{output}" +export { encodeQR, decodeQR, decodeQRBatch, QRScanner } from './qr.rip' +export { encodeCode128, decodeCode128, readCode128 } from './code128.rip' export default encodeQR - -export { encodeCode128 } from './code128.rip' - -# Internals for the test suite. -export _tests =! { mat, matGet, penalty, drawSymbol, encodeData, rsEcc, detectType } diff --git a/packages/barcodes/code128.rip b/packages/barcodes/code128.rip index 446c17b7..917f0ac6 100644 --- a/packages/barcodes/code128.rip +++ b/packages/barcodes/code128.rip @@ -156,6 +156,14 @@ modules =! (codes) -> # ==[ Renderers ]== +asString =! (s, title) -> + throw TypeError.new "\"#{title}\" expected string, got type=#{typeof s}" unless typeof s is 'string' + s + +asObject =! (o, title) -> + throw TypeError.new "\"#{title}\" expected object, got type=#{typeof o}" if o is null or typeof o isnt 'object' + o + asInt =! (n, title, min) -> throw TypeError.new "\"#{title}\" expected number, got type=#{typeof n}" unless typeof n is 'number' throw RangeError.new "\"#{title}\" expected integer >= #{min}, got #{n}" unless Number.isSafeInteger(n) and n >= min @@ -226,10 +234,8 @@ renderGif =! (r) -> # ==[ Encoder ]== export encodeCode128 =! (text, output = 'raw', opts = {}) -> - throw TypeError.new "\"text\" expected string, got type=#{typeof text}" unless typeof text is 'string' - throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if opts is null or typeof opts isnt 'object' - for name in ['scale', 'border', 'height'] - throw TypeError.new "invalid opts.#{name}=#{opts[name]} (#{typeof opts[name]})" if opts[name] isnt undefined and typeof opts[name] isnt 'number' + asString text, 'text' + asObject opts, 'opts' scale = if opts.scale is undefined then 1 else asInt(opts.scale, 'scale', 1) border = if opts.border is undefined then QUIET_ZONE else asInt(opts.border, 'border', 0) height = if opts.height is undefined then DEFAULT_HEIGHT else asInt(opts.height, 'height', 1) @@ -428,7 +434,7 @@ scanLines =! (luma, width, height, vertical, runs, rev, codes) -> null export readCode128 =! (img, opts = {}) -> - throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if opts is null or typeof opts isnt 'object' + asObject opts, 'opts' validateImage img, opts.format {width, height} = img luma = new Uint8Array(width * height) diff --git a/packages/barcodes/dom.rip b/packages/barcodes/dom.rip index 9759054e..2cfbfa49 100644 --- a/packages/barcodes/dom.rip +++ b/packages/barcodes/dom.rip @@ -8,7 +8,7 @@ # BarcodeDetector is a ponyfill over decodeQR for the Shape Detection API. # ============================================================================== -import { QRScanner, decodeQR } from './decode.rip' +import { QRScanner, decodeQR } from './qr.rip' Y8 =! [0, 0, 1] UV8 =! [1, 1, 1] diff --git a/packages/barcodes/package.json b/packages/barcodes/package.json index b9969250..0f0b1943 100644 --- a/packages/barcodes/package.json +++ b/packages/barcodes/package.json @@ -6,7 +6,7 @@ "description": "QR and Code 128 generator and reader \u2014 packed-bitmap QR encoder, camera-budgeted decoder, scan-line Code 128, zero dependencies.", "exports": { ".": "./barcodes.rip", - "./decode": "./decode.rip", + "./qr": "./qr.rip", "./code128": "./code128.rip", "./dom": "./dom.rip" }, @@ -21,7 +21,7 @@ "spec.rip", "gif.rip", "image.rip", - "decode.rip", + "qr.rip", "code128.rip", "dom.rip", "README.md" diff --git a/packages/barcodes/decode.rip b/packages/barcodes/qr.rip similarity index 76% rename from packages/barcodes/decode.rip rename to packages/barcodes/qr.rip index 30aaa6b9..f18e58fb 100644 --- a/packages/barcodes/decode.rip +++ b/packages/barcodes/qr.rip @@ -1,21 +1,663 @@ # ============================================================================== -# rip/barcodes — QR decoder, budgeted for live camera frames +# rip/barcodes — QR (ISO/IEC 18004) # -# A scanner owns a luma arena sized to its maximum frame and a four-level -# 2x2 box-filter pyramid. Each layer is binarized lazily against 8x8 block -# thresholds into a packed one-bit bitmap, finder patterns are found by -# 1:1:3:1:1 run windows on every second row, and the best three are projected -# through a homography onto a module grid, corrected with Reed-Solomon, and -# parsed. Nothing allocates on the frame path: every buffer is created once -# per scanner and reused. Coarse layers run first; native resolution last. +# Encoder: a symbol is a packed bit matrix, one Uint32Array with 32 modules +# per word, LSB-first, bits at x >= size held zero (the penalty scanners +# depend on it). Everything the version alone determines is built once and +# cached in a single slot: the function-pattern template, the zigzag +# placement order, and the eight mask XOR planes with their transposes. Mask +# selection XORs whole words and scores the test form word-parallel, so the +# eight-mask race costs one transpose per encode. +# +# Decoder, budgeted for live camera frames: a scanner owns a luma arena +# sized to its maximum frame and a four-level 2x2 box-filter pyramid. Each +# layer is binarized lazily against 8x8 block thresholds into a packed +# one-bit bitmap, finder patterns are found by 1:1:3:1:1 run windows on +# every second row, and the best three are projected through a homography +# onto a module grid, corrected with Reed-Solomon, and parsed. Nothing +# allocates on the frame path: every buffer is created once per scanner and +# reused. Coarse layers run first; native resolution last. # ============================================================================== import { ALPHANUMERIC, BYTES, ECC_BLOCKS, ECC_LEVELS, GF256, WORDS_PER_BLOCK, - formatBits, maskBits, popcnt, versionBits, + alignmentPatterns, formatBits, maskBits, popcnt, versionBits, } from './spec.rip' +import { gifDataUrl, writeGif } from './gif.rip' import { FORMATS, copyLuma, darkToImage, validateImage, validateSize } from './image.rip' +# ==[ Encoder ]== + +MAX_OUTPUT_SIZE =! 1024 +MAX_COMPACT_OUTPUT_SIZE =! 4096 + +MODE_BITS =! { numeric: 1, alphanumeric: 2, byte: 4 } +LENGTH_BITS =! { numeric: [10, 12, 14], alphanumeric: [9, 11, 13], byte: [8, 16, 16] } +NUMERIC_BITS =! [0, 4, 7, 10] + +fail =! (msg) -> throw Error.new msg + +# charCode -> Table 5 value; -1 outside the alphabet. +ALNUM_VAL =! do -> + t = new Int8Array(128).fill(-1) + for i in [0...ALPHANUMERIC.length] + t[ALPHANUMERIC.charCodeAt(i)] = i + t + +# ==[ Reed-Solomon ]== + +# Generator polynomial (leading 1 dropped) and every coefficient*feedback +# product, cached per parity length. +RS_CACHE =! [] + +rsGenerator =! (n) -> + {exp, log} = GF256 + gen = new Uint8Array(n) + gen[n - 1] = 1 + root = 1 + for i in [0...n] + for j in [0...n] + c = gen[j] + gen[j] = (if c then exp[log[c] + log[root]] else 0) ^ (if j + 1 < n then gen[j + 1] else 0) + root = exp[log[root] + 1] + gen + +rsCached =! (n) -> + return RS_CACHE[n] if RS_CACHE[n] + {exp, log} = GF256 + gen = rsGenerator n + products = new Uint8Array(256 * n) + for f in [1...256] + lf = log[f] + base = f * n + for j in [0...n] + c = gen[j] + products[base + j] = exp[log[c] + lf] if c + RS_CACHE[n] = { gen, products } + +# Parity via LFSR remainder. +rsEcc =! (data, rs) -> + {gen, products} = rs + n = gen.length + last = n - 1 + res = new Uint8Array(n) + for i in [0...data.length] + base = (data[i] ^ res[0]) * n + for j in [0...last] + res[j] = res[j + 1] ^ products[base + j] + res[last] = products[base + last] + res + +capacity =! (ver, ecc) -> + bytes = BYTES[ver - 1] + words = WORDS_PER_BLOCK[ecc][ver - 1] + numBlocks = ECC_BLOCKS[ecc][ver - 1] + blockLen = (bytes // numBlocks) - words + shortBlocks = numBlocks - bytes % numBlocks + { words, numBlocks, shortBlocks, blockLen, capacity: (bytes - words * numBlocks) * 8 } + +# ==[ Data codewords ]== + +detectType =! (str) -> + type = 'numeric' + for i in [0...str.length] + v = ALNUM_VAL[str.charCodeAt(i)] + return 'byte' unless v >= 0 + type = 'alphanumeric' if v > 9 + type + +# Segment bits, terminator, padding, then RS blocks interleaved. +encodeData =! (ver, ecc, text, type, utf8) -> + cap = capacity ver, ecc + lengthBits = LENGTH_BITS[type][(ver + 7) // 17] + dataLen = if type is 'byte' then utf8.length else text.length + fail 'Capacity overflow' if dataLen >= 1 << lengthBits + bytes = new Uint8Array(cap.capacity >>> 3) + # MSB-first accumulator flushed a byte at a time; pushes are <= 16 bits and + # a flush keeps it below 8, so it never nears 32. + acc = 0 + accBits = 0 + bytePos = 0 + push = (value, len) -> + acc = (acc << len) | value + accBits += len + while accBits >= 8 + accBits -= 8 + bytes[bytePos++] = (acc >>> accBits) & 0xff + return + push MODE_BITS[type], 4 + push dataLen, lengthBits + if type is 'numeric' + i = 0 + while i < dataLen + n = Math.min(3, dataLen - i) + push Number(text.slice(i, i + n)), NUMERIC_BITS[n] + i += 3 + else if type is 'alphanumeric' + i = 0 + while i + 1 < dataLen + push ALNUM_VAL[text.charCodeAt(i)] * 45 + ALNUM_VAL[text.charCodeAt(i + 1)], 11 + i += 2 + push ALNUM_VAL[text.charCodeAt(dataLen - 1)], 6 if dataLen & 1 + else + for i in [0...utf8.length] + push utf8[i], 8 + bitPos = bytePos * 8 + accBits + fail 'Capacity overflow' if bitPos > cap.capacity + bytes[bytePos] = (acc << (8 - accBits)) & 0xff if accBits + bitPos += Math.min(4, cap.capacity - bitPos) + bitPos += 8 - (bitPos & 7) if bitPos & 7 + pad = 0 + start = bitPos >>> 3 + for i in [start...bytes.length] + bytes[i] = if pad then 0x11 else 0xec + pad ^= 1 + {words, numBlocks, shortBlocks, blockLen} = cap + rs = rsCached words + blocks = [] + eccs = [] + pos = 0 + for i in [0...numBlocks] + len = blockLen + (if i < shortBlocks then 0 else 1) + block = bytes.subarray pos, pos + len + blocks.push block + eccs.push rsEcc(block, rs) + pos += len + res = new Uint8Array(bytes.length + words * numBlocks) + out = 0 + for i in [0..blockLen] + for b in blocks + res[out++] = b[i] if i < b.length + for i in [0...words] + for e in eccs + res[out++] = e[i] + res + +# ==[ Packed bit matrix ]== + +mat =! (size) -> + words = (size + 31) >>> 5 + { size, words, v: new Uint32Array(words * size) } + +matGet =! (m, x, y) -> (m.v[y * m.words + (x >>> 5)] >>> (x & 31)) & 1 + +matSet =! (m, x, y, bit) -> + i = y * m.words + (x >>> 5) + b = 1 << (x & 31) + m.v[i] = if bit then m.v[i] | b else m.v[i] & ~b + return + +TRANSPOSE_MASKS =! [0x55555555, 0x33333333, 0x0f0f0f0f, 0x00ff00ff, 0x0000ffff] +TRANSPOSE_TMP =! new Uint32Array(32) + +# 32x32 in-place bit-matrix transpose (butterfly network). +transpose32 =! (a) -> + for stage in [0...5] + m = TRANSPOSE_MASKS[stage] >>> 0 + s = 1 << stage + i = 0 + while i < 32 + for k in [0...s] + x = a[i + k] >>> 0 + y = a[i + k + s] >>> 0 + t = ((x >>> s) ^ y) & m + a[i + k] = (x ^ (t << s)) >>> 0 + a[i + k + s] = (y ^ t) >>> 0 + i += s << 1 + return + +transposeMat =! (src, dst) -> + {size, words, v} = src + tmp = TRANSPOSE_TMP + y0 = 0 + while y0 < size + for bx in [0...words] + rows = Math.min(32, size - y0) + for r in [0...rows] + tmp[r] = v[(y0 + r) * words + bx] + tmp.fill 0, rows + transpose32 tmp + dstY = bx * 32 + i = 0 + while i < 32 and dstY < size + dst.v[dstY * dst.words + (y0 >>> 5)] = tmp[i] + i++ + dstY++ + y0 += 32 + return + +# ==[ Mask penalty ]== + +# N1, all columns of a 32-wide stripe at once: D = row ^ next row flags +# changes, a monochrome 5-window is four clear D bits, and a run of length L +# contributes L-4 windows plus one run-start window counted twice. +runsPenaltyVertical =! (m) -> + {size, words, v} = m + tail = if size & 31 then ((1 << (size & 31)) - 1) >>> 0 else 0xffffffff + score = 0 + for wi in [0...words] + valid = if wi is words - 1 then tail else 0xffffffff + r3 = v[3 * words + wi] + dPrev = 0xffffffff + d0 = v[wi] ^ v[words + wi] + d1 = v[words + wi] ^ v[2 * words + wi] + d2 = v[2 * words + wi] ^ r3 + idx = 4 * words + wi + for y in [0..size - 5] + r4 = v[idx] + d3 = r3 ^ r4 + w = ~(d0 | d1 | d2 | d3) & valid + score += popcnt(w >>> 0) + 2 * popcnt((w & dPrev) >>> 0) if w + dPrev = d0 + d0 = d1 + d1 = d2 + d2 = d3 + r3 = r4 + idx += words + score + +# N3: 1011101 with four light modules before or after, both orientations of +# the pattern across a 32-wide stripe at once. +finderPenaltyVertical =! (m) -> + {size, words, v} = m + tail = if size & 31 then ((1 << (size & 31)) - 1) >>> 0 else 0xffffffff + count = 0 + for wi in [0...words] + valid = if wi is words - 1 then tail else 0xffffffff + for y in [0..size - 11] + i = y * words + wi + r0 = v[i] + r1 = v[i + words] + r2 = v[i + 2 * words] + r3 = v[i + 3 * words] + r4 = v[i + 4 * words] + r5 = v[i + 5 * words] + r6 = v[i + 6 * words] + r7 = v[i + 7 * words] + r8 = v[i + 8 * words] + r9 = v[i + 9 * words] + r10 = v[i + 10 * words] + m0 = valid & r0 & ~r1 & r2 & r3 & r4 & ~r5 & r6 & ~(r7 | r8 | r9 | r10) + m1 = valid & ~(r0 | r1 | r2 | r3) & r4 & ~r5 & r6 & r7 & r8 & ~r9 & r10 + count += popcnt(m0 >>> 0) + popcnt(m1 >>> 0) + count + +# Score a symbol given both orientations. `limit` is the best score so far +# in the mask race: every term is non-negative, so a partial sum that reaches +# it can no longer win and the expensive N3 search is skipped. +penaltyScore =! (m, t, limit = Infinity) -> + {size, words, v} = m + adjacent = runsPenaltyVertical(m) + runsPenaltyVertical(t) + return adjacent if adjacent >= limit + # N2: three points per overlapping 2x2 same-color box. Valid left edges in + # the last word: one fewer than the bits it holds. + tail2 = ((1 << (size - 32 * (words - 1) - 1)) - 1) >>> 0 + boxes = 0 + dark = 0 + for y in [0...size] + for wi in [0...words] + a0 = v[y * words + wi] + dark += popcnt(a0 >>> 0) + continue if y is size - 1 + a1 = v[(y + 1) * words + wi] + n0 = if wi + 1 < words then v[y * words + wi + 1] else 0 + n1 = if wi + 1 < words then v[(y + 1) * words + wi + 1] else 0 + eqV = ~(a0 ^ a1) + eqH0 = ~(a0 ^ ((a0 >>> 1) | (n0 << 31))) + eqH1 = ~(a1 ^ ((a1 >>> 1) | (n1 << 31))) + w = eqV & eqH0 & eqH1 + w &= tail2 if wi is words - 1 + boxes += popcnt(w >>> 0) + total = size * size + darkSteps = Math.ceil(Math.max(0, Math.abs(dark * 100 - total * 50) - total * 5) / (total * 5)) + partial = adjacent + 3 * boxes + 10 * darkSteps + return partial if partial >= limit + partial + 40 * (finderPenaltyVertical(m) + finderPenaltyVertical(t)) + +penalty =! (m, t) -> + transposeMat m, t + penaltyScore m, t + +# ==[ Symbol layout ]== + +drawInfo =! (m, ver, ecc, mask) -> + size = m.size + bits = formatBits ecc, mask + for i in [0...15] + value = (bits >> i) & 1 + if i < 6 then matSet m, 8, i, value + else if i < 8 then matSet m, 8, i + 1, value + else if i is 8 then matSet m, 7, 8, value + else matSet m, 14 - i, 8, value + if i < 8 then matSet m, size - 1 - i, 8, value + else matSet m, 8, size - 15 + i, value + matSet m, 8, size - 8, 1 + if ver >= 7 + vbits = versionBits ver + for i in [0...18] + value = (vbits >> i) & 1 + x = size - 11 + i % 3 + y = i // 3 + matSet m, x, y, value + matSet m, y, x, value + return + +# Everything the layout alone determines, built once per version: the +# function-pattern template with the data region zero, the zigzag placement +# order as packed (wordIndex << 5 | bitOffset) positions, the eight mask XOR +# planes and their transposes, and four scratch matrices. Single slot: +# workloads overwhelmingly encode one version repeatedly. +symCache = null + +buildSymCache =! (ver) -> + size = 21 + 4 * (ver - 1) + m = mat size + fun = new Uint8Array(size * size) + setF = (x, y, bit) -> + matSet m, x, y, bit + fun[y * size + x] = 1 + return + for [fx, fy] in [[0, 0], [size - 7, 0], [0, size - 7]] + for dy in [-1...8] + for dx in [-1...8] + x = fx + dx + y = fy + dy + continue if x < 0 or y < 0 or x >= size or y >= size + dark = dx >= 0 and dx < 7 and dy >= 0 and dy < 7 and + (dx is 0 or dx is 6 or dy is 0 or dy is 6 or (dx > 1 and dx < 5 and dy > 1 and dy < 5)) + setF x, y, (if dark then 1 else 0) + align = alignmentPatterns ver + for ay in align + for ax in align + continue if fun[ay * size + ax] + for dy in [-2..2] + for dx in [-2..2] + dark = Math.max(Math.abs(dx), Math.abs(dy)) isnt 1 + setF ax + dx, ay + dy, (if dark then 1 else 0) + for i in [0...size] + setF i, 6, (if i % 2 is 0 then 1 else 0) unless fun[6 * size + i] + setF 6, i, (if i % 2 is 0 then 1 else 0) unless fun[i * size + 6] + # Format, version and dark-module cells are reserved at zero: the "test + # form" the mask penalties are scored on. + for i in [0...9] + if i isnt 6 + setF 8, i, 0 + setF i, 8, 0 + if i < 8 + setF size - 1 - i, 8, 0 + setF 8, size - 1 - i, 0 + if ver >= 7 + for i in [0...18] + x = size - 11 + i % 3 + y = i // 3 + setF x, y, 0 + setF y, x, 0 + planes = (mat(size) for i in [0...8]) + posBuf = new Uint16Array(size * size) + n = 0 + xOffset = size - 1 + dir = -1 + y = size - 1 + while xOffset > 0 + xOffset = 5 if xOffset is 6 + loop + for j in [0...2] + x = xOffset - j + continue if fun[y * size + x] + wi = y * m.words + (x >>> 5) + posBuf[n++] = (wi << 5) | (x & 31) + mb = maskBits x, y + pl = 0 + while mb + planes[pl].v[wi] |= 1 << (x & 31) if mb & 1 + pl++ + mb >>= 1 + break if y + dir < 0 or y + dir >= size + y += dir + xOffset -= 2 + dir = -dir + planesT = planes.map (pl) -> + t = mat size + transposeMat pl, t + t.v + { + ver + tpl: m.v + pos: posBuf.slice(0, n) + planes: planes.map((pl) -> pl.v) + planesT + work: [mat(size), mat(size), mat(size), mat(size)] + } + +# Template copy, data-bit scatter along the cached zigzag order, then mask +# selection over XOR candidates; the first lowest score wins. +drawSymbol =! (ver, ecc, data, maskIdx, test = false) -> + symCache = buildSymCache ver unless symCache?.ver is ver + {tpl, pos, planes, planesT, work} = symCache + [m, t, cand, candT] = work + m.v.set tpl + need = Math.min(8 * data.length, pos.length) + for i in [0...need] + if data[i >>> 3] & (0x80 >>> (i & 7)) + q = pos[i] + m.v[q >>> 5] |= 1 << (q & 31) + mask = maskIdx + unless mask? + transposeMat m, t + bestScore = Infinity + for pl in [0...8] + pv = planes[pl] + ptv = planesT[pl] + for i in [0...cand.v.length] + cand.v[i] = m.v[i] ^ pv[i] + candT.v[i] = t.v[i] ^ ptv[i] + score = penaltyScore cand, candT, bestScore + if score < bestScore + bestScore = score + mask = pl + chosen = planes[mask] + for i in [0...m.v.length] + m.v[i] ^= chosen[i] + drawInfo m, ver, ecc, mask unless test + m + +# ==[ Validation ]== + +asVersion =! (ver) -> + throw TypeError.new "\"version\" expected number, got type=#{typeof ver}" unless typeof ver is 'number' + throw RangeError.new "\"version\" expected safe integer, got #{ver}" unless Number.isSafeInteger ver + throw RangeError.new "Invalid version=#{ver}. Expected number [1..40]" if ver < 1 or ver > 40 + ver + +asNum =! (n, title) -> + throw TypeError.new "\"#{title}\" expected number, got type=#{typeof n}" unless typeof n is 'number' + throw RangeError.new "\"#{title}\" expected safe integer, got #{n}" unless Number.isSafeInteger n + n + +asString =! (s, title) -> + throw TypeError.new "\"#{title}\" expected string, got type=#{typeof s}" unless typeof s is 'string' + s + +# Exact WHATWG TextEncoder byte count without encoding; lone surrogates cost +# the three-byte replacement character. +utf8Length =! (str) -> + length = 0 + i = 0 + while i < str.length + c = str.charCodeAt i + if c < 0x80 then length++ + else if c < 0x800 then length += 2 + else if c < 0xd800 or c > 0xdfff then length += 3 + else if c <= 0xdbff and i + 1 < str.length + next = str.charCodeAt(i + 1) + if next >= 0xdc00 and next <= 0xdfff + length += 4 + i++ + else length += 3 + else length += 3 + i++ + length + +byteCapacity =! (ver, ecc) -> + lengthBits = LENGTH_BITS.byte[(ver + 7) // 17] + Math.min((1 << lengthBits) - 1, (capacity(ver, ecc).capacity - 4 - lengthBits) // 8) + +# Cross-realm and Buffer views pass; JSON-shaped spoofs do not. +isBytes =! (a) -> + a instanceof Uint8Array or + (ArrayBuffer.isView(a) and a.constructor.name is 'Uint8Array' and a.BYTES_PER_ELEMENT is 1) + +# ==[ Renderers ]== +# A raster is the finished matrix plus output geometry; `map` takes an output +# coordinate to its module index, -1 in the border. + +darkAt =! (r, x, y) -> r.map[x] >= 0 and r.map[y] >= 0 and matGet(r.m, r.map[x], r.map[y]) is 1 + +renderRaw =! (r) -> + W = r.W + res = Array.new(W) + for y in [0...W] + row = Array.new(W) + for x in [0...W] + row[x] = darkAt(r, x, y) + res[y] = row + res + +renderAscii =! (r) -> + W = r.W + out = '' + y = 0 + while y < W + for x in [0...W] + first = darkAt r, x, y + second = if y + 1 >= W then true else darkAt(r, x, y + 1) + out += if not first and not second then '█' else if not first and second then '▀' else if first and not second then '▄' else ' ' + out += '\n' + y += 2 + out + +renderTerm =! (r) -> + W = r.W + black = '\x1b[40m \x1b[0m' + white = '\x1b[1;47m \x1b[0m' + out = '' + for y in [0...W] + for x in [0...W] + out += (if darkAt(r, x, y) then black else white) + out += '\n' + out + +renderSvg =! (r, optimize) -> + W = r.W + out = '' + path = '' + prevX = 0 + prevY = 0 + hasPrev = false + for y in [0...W] + for x in [0...W] + continue unless darkAt(r, x, y) + unless optimize + out += '' + continue + mv = "M#{x} #{y}" + if hasPrev + rel = "m#{x - prevX} #{y - prevY}" + mv = rel if rel.length <= mv.length + back = if x < 10 then "H#{x}" else 'h-1' + path += "#{mv}h1v1#{back}Z" + prevX = x + prevY = y + hasPrev = true + out += '' if optimize + out + '' +renderGif =! (r) -> + W = r.W + {m, map} = r + row = new Uint8Array(W) + prevMy = -2 + writeGif W, W, (y) -> + my = map[y] + if my isnt prevMy + prevMy = my + row.fill 0 + if my >= 0 + for x in [0...W] + row[x] = matGet(m, map[x], my) if map[x] >= 0 + row + +# ==[ Public API ]== + +export encodeQR =! (text, output = 'raw', opts = {}) -> + asString text, 'text' + asString output, 'output' + throw TypeError.new "\"opts\" expected object, got type=#{typeof opts}" if typeof opts isnt 'object' or opts is null or Array.isArray(opts) + ver = opts.version + ver = asVersion ver if ver isnt undefined + ecc = if opts.ecc is undefined then 'medium' else opts.ecc + fail "invalid ecc=#{ecc}" unless ECC_LEVELS.includes ecc + encoding = if opts.encoding is undefined then detectType(text) else opts.encoding + fail "invalid encoding=#{encoding}" unless LENGTH_BITS[encoding] + if encoding isnt 'byte' + alpha = if encoding is 'numeric' then ALPHANUMERIC.slice(0, 10) else ALPHANUMERIC + for ch in text + fail "Unknown letter: \"#{ch}\". Allowed: #{alpha}" unless alpha.includes ch + if opts.mask isnt undefined and (asNum(opts.mask, 'opts.mask') < 0 or opts.mask > 7) + fail "invalid mask=#{opts.mask}" + textEncoder = opts.textEncoder + # Reject impossible built-in UTF-8 payloads before encoding duplicates a + # huge input; a custom encoder's output is its own source of truth. + if encoding is 'byte' and textEncoder is undefined + maxBytes = byteCapacity (if ver is undefined then 40 else ver), ecc + fail 'Capacity overflow' if text.length > maxBytes or utf8Length(text) > maxBytes + utf8 = if encoding is 'byte' + if textEncoder isnt undefined then textEncoder(text) else TextEncoder.new().encode(text) + else + undefined + if utf8 isnt undefined and not isBytes(utf8) + throw TypeError.new "\"opts.textEncoder\" expected Uint8Array, got type=#{typeof utf8}" + dataLen = if encoding is 'byte' then utf8.length else text.length + encodedBits = switch encoding + when 'numeric' then (dataLen // 3) * 10 + NUMERIC_BITS[dataLen % 3] + when 'alphanumeric' then (dataLen // 2) * 11 + (dataLen % 2) * 6 + else dataLen * 8 + if ver is undefined + ver = 1 + while ver <= 40 + lengthBits = LENGTH_BITS[encoding][(ver + 7) // 17] + break if dataLen < 1 << lengthBits and 4 + lengthBits + encodedBits <= capacity(ver, ecc).capacity + ver++ + fail 'Capacity overflow' if ver > 40 + else + lengthBits = LENGTH_BITS[encoding][(ver + 7) // 17] + fail 'Capacity overflow' if dataLen >= 1 << lengthBits or 4 + lengthBits + encodedBits > capacity(ver, ecc).capacity + data = encodeData ver, ecc, text, encoding, utf8 + m = drawSymbol ver, ecc, data, opts.mask + # A quiet zone is required (§5.3.8); custom renderers wanting the borderless + # matrix request border 1 and slice the ring off. + border = if opts.border is undefined then 2 else asNum(opts.border, 'opts.border') + throw RangeError.new "invalid border=#{border}" if border <= 0 + scale = if opts.scale is undefined then 1 else asNum(opts.scale, 'opts.scale') + throw RangeError.new "invalid scale factor: #{scale}" if scale <= 0 or scale > 1024 + W = (m.size + 2 * border) * scale + maxOutputSize = if output is 'ascii' or output is 'gif' or output is 'data-url' then MAX_COMPACT_OUTPUT_SIZE else MAX_OUTPUT_SIZE + throw RangeError.new "invalid opts: output is #{W}x#{W} (max #{maxOutputSize}), reduce border/scale" if W > maxOutputSize + map = new Int32Array(W) + for i in [0...W] + f = i // scale - border + map[i] = if f >= 0 and f < m.size then f else -1 + r = { m, W, map } + switch output + when 'raw' then renderRaw r + when 'ascii' then renderAscii r + when 'term' then renderTerm r + when 'svg' then renderSvg r, (if opts.optimize is undefined then true else opts.optimize) + when 'gif' then renderGif r + when 'data-url' then gifDataUrl renderGif(r) + else fail "Unknown output: #{output}" + +# ==[ Decoder ]== + MAX_ARENA_BYTES =! 64 * 1024 * 1024 # Failure values shared by every attempt; a decode returns a string or one @@ -31,10 +673,10 @@ FAIL =! Object.freeze version: Object.freeze(Error.new 'version') {exp: EXP, log: LOG} = GF256 -mul = (a, b) -> if a and b then EXP[LOG[a] + LOG[b]] else 0 -inv = (a) -> EXP[255 - LOG[a]] +mul =! (a, b) -> if a and b then EXP[LOG[a] + LOG[b]] else 0 +inv =! (a) -> EXP[255 - LOG[a]] -clamp = (value, lo, hi) -> Math.max(lo, Math.min(hi, value)) +clamp =! (value, lo, hi) -> Math.max(lo, Math.min(hi, value)) # ==[ Payload ]== @@ -160,8 +802,8 @@ finishPayload =! (decoded, textDecoder) -> # Finder records are stride-4 Float64Array slots: x, y, module size, row-hit # confidence. `a` and `b` are element offsets. -dist2 = (pts, a, b) -> (pts[a] - pts[b]) ** 2 + (pts[a + 1] - pts[b + 1]) ** 2 -distance = (first, second) -> Math.hypot(second.x - first.x, second.y - first.y) +dist2 =! (pts, a, b) -> (pts[a] - pts[b]) ** 2 + (pts[a + 1] - pts[b + 1]) ** 2 +distance =! (first, second) -> Math.hypot(second.x - first.x, second.y - first.y) # Version 7+ carries two BCH version words; one must match the sampled # dimension within radius three. @@ -589,7 +1231,7 @@ findRows =! (layer, from, to) -> # Legal sides are 17 + 4 * version; a non-finite estimate coerces to zero and # fails the dimension bounds downstream. -snapSize = (estimate) -> (Math.round((estimate - 17) / 4) * 4 + 17) | 0 +snapSize =! (estimate) -> (Math.round((estimate - 17) / 4) * 4 + 17) | 0 runDecode =! (walk) -> step = walk.next() @@ -623,7 +1265,7 @@ runDecodeAsync =! (walk, timeLimit) -> # ==[ Scanner ]== # A fractional initial value keeps the fields unboxed doubles. -makePattern = -> { x: 0.1, y: 0.1, ms: 0.1 } +makePattern =! -> { x: 0.1, y: 0.1, ms: 0.1 } # Reusable decode state. Operations are exclusive: while decodeAsync is # pending, staging, decoding and cleaning throw until it settles. @@ -1976,9 +2618,6 @@ export decodeQR =! (img, opts = {}) -> throw Error.new result.message if result instanceof Error result -export default decodeQR - -export { decodeCode128, readCode128 } from './code128.rip' # Every QR in each image through one cooperatively scheduled scanner. export decodeQRBatch =! (images, opts = {}) -> @@ -1996,3 +2635,6 @@ export decodeQRBatch =! (images, opts = {}) -> finally scanner.clean() results + +# Internals for the test suite. +export _tests =! { mat, matGet, penalty, drawSymbol, encodeData, rsEcc, detectType } diff --git a/packages/barcodes/test.rip b/packages/barcodes/test.rip index d13c9c35..333bcc17 100644 --- a/packages/barcodes/test.rip +++ b/packages/barcodes/test.rip @@ -4,10 +4,10 @@ # ============================================================================== import { test, eq, ok, throws } from 'rip/testing' -import encodeQR, { _tests } from 'rip/barcodes' +import encodeQR from 'rip/barcodes' import * as enc from 'rip/barcodes' -import decodeQR, { QRScanner, decodeQRBatch } from 'rip/barcodes/decode' -import * as dec from 'rip/barcodes/decode' +import { decodeQR, QRScanner, decodeQRBatch, _tests } from 'rip/barcodes/qr' +import * as qr from 'rip/barcodes/qr' import * as dom from 'rip/barcodes/dom' import { encodeCode128, readCode128, decodeCode128, _tests as c128 } from 'rip/barcodes/code128' import * as code128 from 'rip/barcodes/code128' @@ -21,12 +21,12 @@ rows = (raw) -> raw.map((row) -> row.map((b) -> if b then '1' else '0').join('') console.log "\nPackage" test "exports", -> - eq Object.keys(enc).sort(), ['_tests', 'default', 'encodeCode128', 'encodeQR'] + eq Object.keys(enc).sort(), ['QRScanner', 'decodeCode128', 'decodeQR', 'decodeQRBatch', 'default', 'encodeCode128', 'encodeQR', 'readCode128'] eq enc.default, encodeQR + eq enc.decodeQR, decodeQR eq enc.encodeCode128, encodeCode128 - eq Object.keys(dec).sort(), ['QRScanner', 'decodeCode128', 'decodeQR', 'decodeQRBatch', 'default', 'readCode128'] - eq dec.default, decodeQR - eq dec.decodeCode128, decodeCode128 + eq enc.decodeCode128, decodeCode128 + eq Object.keys(qr).sort(), ['QRScanner', '_tests', 'decodeQR', 'decodeQRBatch', 'encodeQR'] eq Object.keys(code128).sort(), ['_tests', 'decodeCode128', 'encodeCode128', 'readCode128'] eq Object.keys(dom).sort(), ['BarcodeDetector', 'QRCamera', 'QRCanvas', 'frameLoop', 'getSize', 'gifToPng', 'rearCamera', 'selfieCamera', 'svgToPng'] @@ -34,7 +34,7 @@ test "no runtime deps, declares browser safety and earns it", -> pkg = JSON.parse readFileSync("#{import.meta.dir}/package.json", 'utf8') eq pkg.dependencies, undefined eq pkg.rip, { browser: true } - for file in ['spec.rip', 'gif.rip', 'image.rip', 'barcodes.rip', 'decode.rip', 'code128.rip', 'dom.rip'] + for file in ['spec.rip', 'gif.rip', 'image.rip', 'qr.rip', 'code128.rip', 'barcodes.rip', 'dom.rip'] source = readFileSync "#{import.meta.dir}/#{file}", 'utf8' eq /\bBun\.|node:|process\.|globalThis/.test(source), false, file From 4253de980eda1b17eb19c61b4bdc2b8b3244a505 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Wed, 16 Sep 2026 00:00:28 -0600 Subject: [PATCH 4/5] barcodes: word-wise luma and resize, lazy payload views Read packed four-byte pixels as whole words in the luma conversion, one load per pixel unrolled four wide; the (r + 2g + b) / 4 weights are symmetric in r and b, so every RGBA/BGRA/X layout takes the path. Sum the 2x2 pyramid box filter in two 16-bit lanes per word. Both gate on little endian and alignment and keep the byte loops as fallback. Create payload byte views on first use instead of one per possible length at construction, and test the finder ratio in the scan loop before calling recordFinder. Decoder results are unchanged: parity oracles report zero mismatches. Against the reference implementation under Bun the decoder now runs 33% faster at 1080p, 35% at 720p and 66% on a small raster. --- packages/barcodes/image.rip | 28 +++++++++++++++++++++++ packages/barcodes/qr.rip | 45 ++++++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/barcodes/image.rip b/packages/barcodes/image.rip index 1bdcfee5..6988665c 100644 --- a/packages/barcodes/image.rip +++ b/packages/barcodes/image.rip @@ -53,6 +53,32 @@ export validateImage =! (img, named, layout, capacity) -> return RGBA if bytes.length is 4 * px throw RangeError.new "\"img.data\" expected #{3 * px} or #{4 * px} bytes without opts.format, got #{bytes.length}" +export LITTLE_ENDIAN =! new Uint8Array(new Uint32Array([1]).buffer)[0] is 1 + +# Packed four-byte pixels read as whole words: one load per pixel instead of +# three, unrolled four wide, the same (r + 2g + b) / 4 luma. Alpha and the X +# byte fall out of the mask, and the weights are symmetric in r and b, so +# every RGBA/BGRA/X layout takes this path. +copyWords =! (out, data, byteStart, n) -> + words = new Uint32Array(data.buffer, byteStart, n) + i = 0 + end = n - 3 + while i < end + p = words[i] + q = words[i + 1] + r = words[i + 2] + s = words[i + 3] + out[i] = ((p & 255) + ((p >>> 7) & 510) + ((p >>> 16) & 255)) >> 2 + out[i + 1] = ((q & 255) + ((q >>> 7) & 510) + ((q >>> 16) & 255)) >> 2 + out[i + 2] = ((r & 255) + ((r >>> 7) & 510) + ((r >>> 16) & 255)) >> 2 + out[i + 3] = ((s & 255) + ((s >>> 7) & 510) + ((s >>> 16) & 255)) >> 2 + i += 4 + while i < n + p = words[i] + out[i] = ((p & 255) + ((p >>> 7) & 510) + ((p >>> 16) & 255)) >> 2 + i++ + return + # Convert any input into the tight native luma plane. export copyLuma =! (out, maxSize, img, named, layout) -> {step, bits} = validateImage img, named, layout, maxSize @@ -60,6 +86,8 @@ export copyLuma =! (out, maxSize, img, named, layout) -> stride = layout?.stride or width * step offset = layout?.offset or 0 return if data is out and not offset and stride is width and step is 1 + if step is 4 and LITTLE_ENDIAN and stride is width * 4 and ((data.byteOffset + offset) & 3) is 0 + return copyWords out, data, data.byteOffset + offset, width * height for y in [0...height] src = offset + y * stride dst = y * width diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index f18e58fb..2d1f795c 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -24,7 +24,7 @@ import { alignmentPatterns, formatBits, maskBits, popcnt, versionBits, } from './spec.rip' import { gifDataUrl, writeGif } from './gif.rip' -import { FORMATS, copyLuma, darkToImage, validateImage, validateSize } from './image.rip' +import { FORMATS, LITTLE_ENDIAN, copyLuma, darkToImage, validateImage, validateSize } from './image.rip' # ==[ Encoder ]== @@ -392,7 +392,9 @@ buildSymCache =! (ver) -> y = i // 3 setF x, y, 0 setF y, x, 0 - planes = (mat(size) for i in [0...8]) + planes = [] + for i in [0...8] + planes.push mat(size) posBuf = new Uint16Array(size * size) n = 0 xOffset = size - 1 @@ -706,7 +708,12 @@ class Payload @data = new Uint8Array(0) @dataLen = 0 @bytes = new Uint8Array(capacity) - @views = (new Uint8Array(@bytes.buffer, 0, i) for i in [0..capacity]) + @views = Array.new(capacity + 1) + + # A length-n window over the byte arena, created once per length so the + # frame path never allocates after its first use. + view: (n) -> + @views[n] or (@views[n] = new Uint8Array(@bytes.buffer, 0, n)) read: (bits) -> start = @position @@ -783,7 +790,7 @@ class Payload decoder = ECI_DECODERS[eci] or TextDecoder.new(encoding) for i in [0...length] @bytes[i] = @read(8) - res += decoder.decode @views[length] + res += decoder.decode @view(length) else return FAIL.data return res unless parts @@ -1096,6 +1103,9 @@ evalLow =! (poly, length, x) -> # 2x2 box filter into the next pyramid layer. resizeRows =! (src, dst, width, dstWidth, from, to) -> + if LITTLE_ENDIAN and (width & 3) is 0 and (dstWidth & 1) is 0 and (src.byteOffset & 3) is 0 + resizeWords src, dst, width, dstWidth, from, to + return for y in [from...to] srcPos = (y << 1) * width dstPos = y * dstWidth @@ -1104,6 +1114,25 @@ resizeRows =! (src, dst, width, dstWidth, from, to) -> srcPos += 2 return +# The same box filter over whole words: one word from each source row holds +# four pixels, summed in two 16-bit lanes to produce two output pixels. +resizeWords =! (src, dst, width, dstWidth, from, to) -> + words = new Uint32Array(src.buffer, src.byteOffset, (width * (to << 1)) >> 2) + wordsPerRow = width >> 2 + pairs = dstWidth >> 1 + for y in [from...to] + w0 = (y << 1) * wordsPerRow + w1 = w0 + wordsPerRow + dstPos = y * dstWidth + for k in [0...pairs] + a = words[w0 + k] + b = words[w1 + k] + sum = (a & 0x00ff00ff) + (b & 0x00ff00ff) + ((a >>> 8) & 0x00ff00ff) + ((b >>> 8) & 0x00ff00ff) + dst[dstPos] = ((sum & 0xffff) + 2) >> 2 + dst[dstPos + 1] = ((sum >>> 16) + 2) >> 2 + dstPos += 2 + return + # One threshold per 8x8 block. Low-contrast blocks take the darkest sample so # faint modules survive, propagated from the neighbors above and left. blockRows =! (layer, from, to) -> @@ -1177,9 +1206,7 @@ bitmapRows =! (layer, from, to) -> # A five-run window that starts, centers and ends on a dark run: cross-check # it and merge into a finder record within two modules of an existing center. -recordFinder =! (layer, y, x, r0, r1, r2, r3, r4, inverted) -> - ms = ratio r0, r1, r2, r3, r4 - return unless ms +recordFinder =! (layer, y, x, r0, r1, r2, r3, r4, ms, inverted) -> start = x - r0 - r1 - r2 - r3 - r4 cx = Math.round(start + r0 + r1 + r2 / 2) limit = ms * 3 @@ -1226,7 +1253,9 @@ findRows =! (layer, from, to) -> runs++ dark = previous previous = not previous - recordFinder layer, y, x, r0, r1, r2, r3, r4, (not dark) if runs >= 5 + if runs >= 5 + ms = ratio r0, r1, r2, r3, r4 + recordFinder layer, y, x, r0, r1, r2, r3, r4, ms, (not dark) if ms return # Legal sides are 17 + 4 * version; a non-finite estimate coerces to zero and From a860975ffab3893595aa7dc7335f203346ab7c63 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Wed, 16 Sep 2026 00:03:27 -0600 Subject: [PATCH 5/5] barcodes: register the workspace member in the lockfile --- bun.lock | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index ee5791e7..6301921a 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,10 @@ "name": "@rip/app", "version": "0.0.0", }, + "packages/barcodes": { + "name": "@rip/barcodes", + "version": "0.0.0", + }, "packages/csv": { "name": "@rip/csv", "version": "4.0.0", @@ -66,7 +70,7 @@ }, "packages/print": { "name": "@rip/print", - "version": "4.0.0", + "version": "4.0.1", "bin": { "rip-print": "./print.rip", }, @@ -179,6 +183,8 @@ "@rip/app": ["@rip/app@workspace:packages/app"], + "@rip/barcodes": ["@rip/barcodes@workspace:packages/barcodes"], + "@rip/csv": ["@rip/csv@workspace:packages/csv"], "@rip/db": ["@rip/db@workspace:packages/db"],