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 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 = ''
+
+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 = ''
+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 }