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"], diff --git a/packages/barcodes/README.md b/packages/barcodes/README.md new file mode 100644 index 00000000..3ffed1d2 --- /dev/null +++ b/packages/barcodes/README.md @@ -0,0 +1,194 @@ +Rip + +# Rip Barcodes + +> **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 +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. 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`). 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, { decodeQR } from 'rip/barcodes' + +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 + +import { encodeCode128, decodeCode128 } from 'rip/barcodes' + +encodeCode128 'L2602852147', 'svg', scale: 2 # a Code 128 label +decodeCode128 { 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 +- 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 + +```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. + +## Code 128 + +```coffee +import { encodeCode128, decodeCode128, readCode128 } from 'rip/barcodes' + +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 +import { QRScanner } from 'rip/barcodes' + +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. 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 new file mode 100644 index 00000000..eb14a29f --- /dev/null +++ b/packages/barcodes/barcodes.rip @@ -0,0 +1,6 @@ +import { encodeQR } from './qr.rip' + +export { encodeQR, decodeQR, decodeQRBatch, QRScanner } from './qr.rip' +export { encodeCode128, decodeCode128, readCode128 } from './code128.rip' + +export default encodeQR diff --git a/packages/barcodes/code128.rip b/packages/barcodes/code128.rip new file mode 100644 index 00000000..917f0ac6 --- /dev/null +++ b/packages/barcodes/code128.rip @@ -0,0 +1,461 @@ +# ============================================================================== +# 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 ]== + +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 + 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 = {}) -> + 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) + 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 = {}) -> + asObject opts, 'opts' + 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/dom.rip b/packages/barcodes/dom.rip new file mode 100644 index 00000000..2cfbfa49 --- /dev/null +++ b/packages/barcodes/dom.rip @@ -0,0 +1,939 @@ +# ============================================================================== +# 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 './qr.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 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. +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 } + +clearCanvas =! (cc) -> + cc.context.clearRect 0, 0, cc.canvas.width, cc.canvas.height + +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() + +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 } + +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 + +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 = -> + 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 rearCamera =! (player, opts = {}) -> + openCamera! player, 'environment', opts + +export selfieCamera =! (player, opts = {}) -> + openCamera! player, 'user', opts + +# Run a callback per presented frame; returns a canceller. +export 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 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 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/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..6988665c --- /dev/null +++ b/packages/barcodes/image.rip @@ -0,0 +1,119 @@ +# ============================================================================== +# 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}" + +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 + {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 + 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 + 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 new file mode 100644 index 00000000..0f0b1943 --- /dev/null +++ b/packages/barcodes/package.json @@ -0,0 +1,29 @@ +{ + "name": "@rip/barcodes", + "version": "0.0.0", + "private": true, + "type": "module", + "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", + "./qr": "./qr.rip", + "./code128": "./code128.rip", + "./dom": "./dom.rip" + }, + "scripts": { + "test": "rip test.rip" + }, + "rip": { + "browser": true + }, + "files": [ + "barcodes.rip", + "spec.rip", + "gif.rip", + "image.rip", + "qr.rip", + "code128.rip", + "dom.rip", + "README.md" + ] +} diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip new file mode 100644 index 00000000..2d1f795c --- /dev/null +++ b/packages/barcodes/qr.rip @@ -0,0 +1,2669 @@ +# ============================================================================== +# rip/barcodes — QR (ISO/IEC 18004) +# +# 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, + alignmentPatterns, formatBits, maskBits, popcnt, versionBits, +} from './spec.rip' +import { gifDataUrl, writeGif } from './gif.rip' +import { FORMATS, LITTLE_ENDIAN, 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 = [] + for i in [0...8] + planes.push mat(size) + 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 +# 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 = 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 + 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 @view(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. +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. +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] + return + +# One input row is cached so the product may replace its left operand. +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 + +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 ]== + +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' + return + +# ==[ 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. +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 + +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. +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 + +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. +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 + for x in [0...dstWidth] + dst[dstPos++] = (src[srcPos] + src[srcPos + 1] + src[srcPos + width] + src[srcPos + width + 1] + 2) >> 2 + 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) -> + 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 + return + +# Smooth thresholds over 5x5 blocks and write each block's binary pixels. +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 + 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. +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 + 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. +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 + 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 +# 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 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 + + +# Every QR in each image through one cooperatively scheduled scanner. +export 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 + +# Internals for the test suite. +export _tests =! { mat, matGet, penalty, drawSymbol, encodeData, rsEcc, detectType } diff --git a/packages/barcodes/spec.rip b/packages/barcodes/spec.rip new file mode 100644 index 00000000..37bb5dca --- /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 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 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 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 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 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..333bcc17 --- /dev/null +++ b/packages/barcodes/test.rip @@ -0,0 +1,411 @@ +# ============================================================================== +# 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 from 'rip/barcodes' +import * as enc from 'rip/barcodes' +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' +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(), ['QRScanner', 'decodeCode128', 'decodeQR', 'decodeQRBatch', 'default', 'encodeCode128', 'encodeQR', 'readCode128'] + eq enc.default, encodeQR + eq enc.decodeQR, decodeQR + eq enc.encodeCode128, encodeCode128 + 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'] + +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', 'qr.rip', 'code128.rip', 'barcodes.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' + +# ==[ 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 }