From 8c99ea2452530a1d20c8f56c94d794694f845182 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:50:04 -0600 Subject: [PATCH 01/32] barcodes: stage probes for the decoder, the pixel pipeline and every encoder output Three scripts under test/ print one hash line per case so two builds can be shown identical beyond the corpus counts: probe-stages hashes the decoder's grid, codewords and result for every version, ecc level, mask, text kind, scale and damage level (6495 symbols); probe-pixels hashes every pyramid layer's luma, cuts, blocks, packed bitmap and finder count for sizes from 8x8 to 1922x1082 across every input format, padding, offset and the in-place arena path (924 inputs); probe-encode hashes QR, Code 128 and PDF417 output in every format, option and alphabet (12040 encodes). Each header carries its diff recipe. --- packages/barcodes/test/probe-encode.rip | 123 +++++++++++++++++++ packages/barcodes/test/probe-pixels.rip | 156 ++++++++++++++++++++++++ packages/barcodes/test/probe-stages.rip | 107 ++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 packages/barcodes/test/probe-encode.rip create mode 100644 packages/barcodes/test/probe-pixels.rip create mode 100644 packages/barcodes/test/probe-stages.rip diff --git a/packages/barcodes/test/probe-encode.rip b/packages/barcodes/test/probe-encode.rip new file mode 100644 index 00000000..11622111 --- /dev/null +++ b/packages/barcodes/test/probe-encode.rip @@ -0,0 +1,123 @@ +# ============================================================================== +# rip/barcodes — encoder output probe +# +# One line per encode: a SHA-256 of the output, so two builds can be shown +# byte-identical on every format. QR: payloads from empty to three thousand +# characters in alphanumeric, numeric and byte alphabets, every ecc level, +# six border/scale pairs, the automatic mask and an explicit one, SVG with +# and without path optimization, raw, ascii, term, gif, data-url and svg. +# Code 128: ASCII, control characters, digit runs and GS1 payloads through +# every output, scale, border and height. PDF417: every ecc level, forced +# columns and rows, row heights, aspects, encodings and the compact form. +# An encode that throws hashes its message. +# +# rip test/probe-encode.rip > probe-encode.txt +# diff probe-encode-main.txt probe-encode.txt +# ============================================================================== + +import { createHash } from 'crypto' +import { encodeQR } from '../qr.rip' +import { encodeCode128 } from '../code128.rip' +import { encodePDF417 } from '../pdf417.rip' + +seed = 12345 +rnd =! -> (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff + +# Raw output hashes as '0'/'1' rows (Code 128's raw is a single row) so a +# boolean and a bit never collide with a rendered string. +bits =! (row) -> (row.map (b) -> if b then '1' else '0').join('') +digest =! (x) -> + h = createHash 'sha256' + if x instanceof Uint8Array then h.update x + else if typeof x is 'string' then h.update x + else if typeof x[0] is 'boolean' then h.update bits(x) + else h.update (x.map bits).join('\n') + h.digest('hex').slice 0, 16 + +count = 0 +line =! (name, fn) -> + out = '' + try + out = digest fn() + catch e + out = "ERR #{e.message}" + p "#{name} #{out}" + count++ + return + +OUTPUTS =! ['raw', 'ascii', 'term', 'gif', 'data-url', 'svg'] +t0 = performance.now() + +# ==[ QR ]== + +ALNUM =! '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' +texts = ['', 'x', 'HELLO WORLD', '0123456789', 'https://github.com/paulmillr/qr', 'ünïcödé 😀'] +len = 1 +while len <= 3000 + a = '' + n = '' + b = '' + for i in [0...len] + a += ALNUM[(rnd() * 45) | 0] + n += String((rnd() * 10) | 0) + b += String.fromCharCode 32 + ((rnd() * 200) | 0) + texts.push a, n, b + len = Math.ceil len * 1.35 + +for text, ti in texts + for ecc, ei in ['low', 'medium', 'quartile', 'high'] + for [border, scale], bi in [[2, 1], [1, 1], [4, 1], [2, 2], [3, 3], [5, 2]] + for output, oi in OUTPUTS + k = ti + ei + bi + oi + opts = { ecc, border, scale } + opts.mask = k % 8 if k % 3 is 0 + opts.optimize = false if output is 'svg' and k % 2 is 1 + line "qr t#{ti}:#{text.length} #{ecc} b#{border} s#{scale} m#{opts.mask} o#{opts.optimize} #{output}", -> encodeQR text, output, opts + +# Explicit versions with every mask, so each mask's drawing is exercised +# on every symbol size the raw output can show. +for v in [1..40] by 3 + for mask in [0...8] + line "qr v#{v} m#{mask} raw", -> encodeQR "MASK #{mask} VERSION #{v}", 'raw', { version: v, mask, border: 1 } + +# ==[ Code 128 ]== + +C128 =! [ + '', 'ok', 'Wikipedia', 'Hello, World! 2026', '0123456789', '01234567891', '1234', + 'ABC123456789DEF', 'a1b2c3d4', '\x01\x02\x03TAB\tEND', 'mixed 00 11 22 CASE', 'x'.repeat(80) + '(01)09501101530003(17)200131', '01095011015300031720013110ABC\x1d21123' +] +for text, ti in C128 + for output, oi in OUTPUTS + for [scale, border, height], gi in [[1, 10, 30], [2, 0, 1], [3, 1, 12], [1, 4, 100]] + k = ti + oi + gi + opts = { scale, border, height } + opts.gs1 = true if ti >= 12 + opts.optimize = false if output is 'svg' and k % 2 is 1 + line "c128 t#{ti} s#{scale} b#{border} h#{height} g#{opts.gs1} o#{opts.optimize} #{output}", -> encodeCode128 text, output, opts + +# ==[ PDF417 ]== + +P417 =! [ + '', 'x', 'PDF417', 'Hello, World! 2026', '0123456789012345678901234567890123456789', + 'ünïcödé bytes \x00\xff', 'Mixed Case text with punctuation, numbers 12345 and MORE.', 'x'.repeat(400) + (String.fromCharCode(32 + ((rnd() * 200) | 0)) for i in [0...200]).join('') + (String((rnd() * 10) | 0) for i in [0...600]).join('') +] +for text, ti in P417 + for output, oi in OUTPUTS + line "p417 t#{ti} #{output}", -> encodePDF417 text, output + for ecc in [0..8] + line "p417 t#{ti} ecc#{ecc} raw", -> encodePDF417 text, 'raw', { ecc } + for encoding in ['auto', 'text', 'byte', 'numeric'] + line "p417 t#{ti} #{encoding} raw", -> encodePDF417 text, 'raw', { encoding } + for [columns, rows] in [[1, undefined], [3, undefined], [10, undefined], [30, undefined], [undefined, 3], [undefined, 20], [undefined, 90], [5, 12], [2, 60]] + line "p417 t#{ti} c#{columns} r#{rows} raw", -> encodePDF417 text, 'raw', { columns, rows } + for [rowHeight, aspect, scale, border] in [[1, 1, 1, 0], [2, 2, 2, 1], [3, 3, 1, 2], [5, 4, 3, 4]] + line "p417 t#{ti} rh#{rowHeight} a#{aspect} s#{scale} b#{border} raw", -> encodePDF417 text, 'raw', { rowHeight, aspect, scale, border } + line "p417 t#{ti} compact raw", -> encodePDF417 text, 'raw', { compact: true } + line "p417 t#{ti} compact svg", -> encodePDF417 text, 'svg', { compact: true } + line "p417 t#{ti} svg unoptimized", -> encodePDF417 text, 'svg', { optimize: false } + line "p417 t#{ti} gif b0", -> encodePDF417 text, 'gif', { border: 0 } + +console.error "#{count} encodes in #{((performance.now() - t0) / 1000).toFixed 1}s" diff --git a/packages/barcodes/test/probe-pixels.rip b/packages/barcodes/test/probe-pixels.rip new file mode 100644 index 00000000..c7cad6db --- /dev/null +++ b/packages/barcodes/test/probe-pixels.rip @@ -0,0 +1,156 @@ +# ============================================================================== +# rip/barcodes — pixel stage probe +# +# One line per input: a hash of every pyramid layer's luma, threshold cuts, +# block averages, packed bitmap and finder-pattern count, plus the decode +# result. Covers sizes from 8x8 to 1922x1082, four pixel textures, every +# input format (unnamed RGB/RGBA, RGBA, BGRA, RGBX, RGB, I420, NV12, +# I420P10), row padding and byte offsets through the layout path, and the +# in-place arena path where pixels are written straight into the scanner's +# luma plane before processImage. Two builds must print the same text. +# +# rip test/probe-pixels.rip > probe-pixels.txt +# diff probe-pixels-main.txt probe-pixels.txt +# ============================================================================== + +import { encodeQR, QRScanner } from '../qr.rip' + +seed = 99 +rnd =! -> ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) >>> 8) & 255 + +fnv =! (h, v) -> Math.imul(h ^ (v & 0xff), 16777619) | 0 +hashArr =! (h, a, n) -> + for i in [0...n] + v = a[i] | 0 + h = fnv h, v + h = fnv h, v >> 8 + h = fnv h, v >> 16 + h = fnv h, v >> 24 + h + +STEP =! { auto: 4, RGBA: 4, BGRA: 4, RGBX: 4, RGB: 3, I420: 1, NV12: 1, I420P10: 2 } + +# A gray level per pixel: noise, a blurred checkerboard, low contrast, a ramp. +texture =! (kind, x, y, w) -> + switch kind + when 0 then rnd() + when 1 then (if ((x >> 2) + (y >> 2)) & 1 then 40 + (rnd() & 15) else 210 - (rnd() & 15)) + when 2 then 128 + (rnd() & 7) + else (x * 255 / w) | 0 + +# Write one frame of `format` into `data` at `offset` with row `stride`. +fill! =! (data, format, w, h, kind, offset, stride) -> + step = STEP[format] + for y in [0...h] + at = offset + y * stride + for x in [0...w] + v = texture kind, x, y, w + switch step + when 1 then data[at] = v + when 2 + wide = (v << 2) | (rnd() & 3) # ten bits, the low two are noise + data[at] = wide & 255 + data[at + 1] = wide >> 8 + when 3 + data[at] = v ^ (rnd() & 3) + data[at + 1] = v + data[at + 2] = v ^ (rnd() & 3) + else + data[at] = v ^ (rnd() & 3) + data[at + 1] = v + data[at + 2] = v ^ (rnd() & 3) + data[at + 3] = rnd() # alpha or X, never read + at += step + # Chroma planes follow the luma plane for the planar formats. + if step is 1 + data[i] = rnd() for i in [offset + stride * h...data.length] + return + +drain! =! (g) -> + loop + break if g.next().done + return + +# The decode result, then every used layer binarized whether or not the +# scan reached it. +settle =! (scanner) -> + r = null + try + r = scanner.decode()[0] + catch e + r = e + for layer in scanner.layers when layer.used and not layer.found + drain scanner.binarize(layer, false) + if r instanceof Error then "E:#{r.message}" else "ok:#{r}" + +hashLayers =! (scanner) -> + h = 2166136261 | 0 + for layer in scanner.layers when layer.used + h = hashArr h, layer.luma, layer.width * layer.height + h = hashArr h, layer.cuts, layer.blockWidth * layer.blockHeight + h = hashArr h, layer.blocks, layer.blockWidth * layer.blockHeight + h = hashArr h, layer.bitmap, layer.words * layer.height + h = fnv h, layer.patternCount + h + +line =! (name, scanner) -> + result = settle scanner + p "#{name} #{(hashLayers(scanner) >>> 0).toString 16} #{result}" + return + +t0 = performance.now() +count = 0 + +# External buffers through addImage: every format, four textures. +sizes = [[8, 8], [9, 7], [13, 5], [17, 9], [31, 31], [64, 48], [65, 33], [100, 101], [129, 130], [257, 255], [640, 480], [1922, 1082]] +for [w, h] in sizes + for format in ['auto', 'RGBA', 'BGRA', 'RGBX', 'RGB', 'I420', 'NV12', 'I420P10'] + for kind in [0...4] + step = STEP[format] + total = w * h * step + (if step is 1 then w * h else 0) + data = Uint8Array.new total + fill data, format, w, h, kind, 0, w * step + scanner = QRScanner.new { maxSize: { width: w, height: h } } + scanner.addImage { width: w, height: h, data }, (if format is 'auto' then undefined else format) + line "add #{format} #{w}x#{h}/#{kind}", scanner + count++ + +# The arena path: pixels written into the scanner's own luma plane, then +# processImage with a layout, at row paddings and byte offsets that steer +# the conversion between the word-wise and per-pixel loops. +for [w, h] in [[8, 8], [9, 7], [13, 5], [64, 48], [65, 33], [100, 101], [640, 480], [1922, 1082]] + for format in ['RGBA', 'BGRA', 'RGBX', 'RGB', 'I420', 'NV12', 'I420P10'] + for pad in [0, 3, 8] + for skip in [0, 1, 4] + step = STEP[format] + stride = w * step + pad + total = skip + stride * h + (if step is 1 then stride * h else 0) + scanner = QRScanner.new { maxSize: { width: w, height: h }, stride: Math.ceil(total / (w * h)) + 1 } + fill scanner.luma, format, w, h, (w + skip + pad) & 3, skip, stride + scanner.processImage { width: w, height: h }, format, { offset: skip, stride } + line "arena #{format} #{w}x#{h} pad#{pad} off#{skip}", scanner + count++ + +# Symbols at several scales and offsets, so the pattern counts and the +# coarse layers carry real finders. +for scale in [1, 2, 3, 5, 8, 13] + raw = encodeQR "EQUIV #{scale}", 'raw', { border: 3 } + n = raw.length * scale + for [w, h, ox, oy] in [[n, n, 0, 0], [n + 37, n + 11, 19, 5], [Math.max(n, 300), Math.max(n, 200), 7, 3]] + for format in ['RGBA', 'I420'] + step = STEP[format] + data = Uint8Array.new(w * h * step * 2).fill 180 + for y in [0...n] + for x in [0...n] + v = if raw[y // scale][x // scale] then 10 else 245 + at = ((oy + y) * w + ox + x) * step + data[at] = v + if step is 4 + data[at + 1] = v + data[at + 2] = v + scanner = QRScanner.new { maxSize: { width: w, height: h } } + scanner.addImage { width: w, height: h, data: data.subarray(0, w * h * step * (if step is 1 then 2 else 1)) }, format + line "qr s#{scale} #{format} #{w}x#{h}+#{ox},#{oy}", scanner + count++ + +console.error "#{count} probes in #{((performance.now() - t0) / 1000).toFixed 1}s" diff --git a/packages/barcodes/test/probe-stages.rip b/packages/barcodes/test/probe-stages.rip new file mode 100644 index 00000000..2bd46ab2 --- /dev/null +++ b/packages/barcodes/test/probe-stages.rip @@ -0,0 +1,107 @@ +# ============================================================================== +# rip/barcodes — decoder stage probe +# +# One line per symbol: a hash of the decoder's grid (the symbol's size*size +# modules), its codewords (the version's count, so arena sizing never enters +# the hash) and the result string. A failed decode hashes its message only: +# the scratch it leaves belongs to whichever attempt ran last, unspecified +# beyond that attempt's writes. Every version, ecc level, the automatic +# mask and one explicit mask, three text kinds (alphanumeric, a UTF-8 URL, +# numeric), scales 1 to 3, clean and with 1/3/8/20/50/120 modules flipped. +# Two builds must print the same text; diff the outputs. +# +# rip test/probe-stages.rip > probe-stages.txt +# diff probe-stages-main.txt probe-stages.txt +# +# A single symbol prints with a name prefix: rip test/probe-stages.rip 'clean v7' +# ============================================================================== + +import { encodeQR, QRScanner, _tests } from '../qr.rip' + +{BYTES} = _tests + +only =! process.argv[2] + +seed = 12345 +rnd =! -> ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) >>> 8) + +fnv =! (h, v) -> Math.imul(h ^ (v & 0xff), 16777619) | 0 +hashArr =! (h, a, n) -> + h = fnv h, a[i] | 0 for i in [0...n] + h +hashStr =! (h, s) -> + for i in [0...s.length] + c = s.charCodeAt i + h = fnv h, c + h = fnv h, c >> 8 + h + +# RGBA raster of a module matrix, `scale` pixels per module. +toImage =! (raw, scale) -> + n = raw.length + W = n * scale + data = Uint8Array.new(W * W * 4) + for y in [0...W] + row = raw[y // scale] + for x in [0...W] + v = if row[x // scale] then 0 else 255 + q = 4 * (y * W + x) + data[q] = data[q + 1] = data[q + 2] = v + data[q + 3] = 255 + { width: W, height: W, data } + +probe =! (name, image, opts = {}) -> + return if only and not name.startsWith only + scanner = QRScanner.new { ...opts, maxSize: { width: image.width, height: image.height } } + scanner.addImage image + result = '' + try + r = scanner.decode()[0] + result = if r instanceof Error then "E:#{r.message}" else "ok:#{r}" + catch e + result = "X:#{e.message}" + size = if result.startsWith 'ok:' then scanner.decodedSize else 0 + h = 2166136261 | 0 + h = hashArr h, scanner.grid, size * size + h = hashArr h, scanner.codewords, (if size then BYTES[(size - 17) / 4 - 1] else 0) + h = hashStr h, result + shown = if result.length > 40 then result.slice(0, 40) + '…' else result + p "#{name} #{(h >>> 0).toString 16} #{shown}" + return + +ALPHABET =! 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 $%*+-./:' +texts =! (v) -> + n = Math.max 4, Math.floor(v * v * 1.2) + s = '' + s += ALPHABET[rnd() % ALPHABET.length] for i in [0...n] + [s, "https://例え.jp/#{s.slice 0, Math.max(1, n >> 2)}ü€", String(rnd()).repeat(Math.max 1, v)] + +t0 = performance.now() +count = 0 +for v in [1..40] + for ecc in ['low', 'medium', 'quartile', 'high'] + for mask in [undefined, (v + ecc.length) % 8] + for text, ti in texts(v) + raw = null + try + raw = encodeQR text, 'raw', { ecc, border: 3, version: v, mask } + catch + continue + scale = 1 + (v + ti) % 3 + tag = "v#{v} #{ecc} m#{mask} t#{ti} s#{scale}" + probe "clean #{tag}", toImage(raw, scale) + count++ + n = raw.length + for flips in [1, 3, 8, 20, 50, 120] + break if flips > n * n / 8 + m = raw.map (r) -> r.slice() + for f in [0...flips] + y = 3 + rnd() % (n - 6) + x = 3 + rnd() % (n - 6) + m[y][x] = not m[y][x] + probe "flip#{flips} #{tag}", toImage(m, scale) + count++ + +# Byte segments through a custom textDecoder. +probe 'deferText', toImage(encodeQR('héllo wörld ✓ 日本語', 'raw', { border: 3 }), 3), textDecoder: (b) -> "[#{b.length}]" +console.error "#{count + 1} probes in #{((performance.now() - t0) / 1000).toFixed 1}s" From 13b920324d8548427c2eb5beaa2b3999444871ac Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:51:19 -0600 Subject: [PATCH 02/32] barcodes: walk vertical finder runs down the column without bit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vertical run keeps its column's word offset and bit mask and steps the word position by the row stride, so each row costs one load and one mask instead of a bit() call that recomputes both. bit() stays for its two remaining callers. Alternating A/B, min of 3, bun: 1080p QR noise miss 10.81 → 10.35 ms, phone noise miss 28.27 → 26.17 ms. Results identical: 46 tests, corpus per-set counts, finder-stage probe (pattern records, polarity, loose counts, retry sets over synthetic scenes, rasters and rotations), stage probes. --- packages/barcodes/qr.rip | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 1cc9cf80..61ec8adb 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -1089,9 +1089,18 @@ ratio =! (a, b, c, d, e, loose = false) -> run =! (layer, x, y, dx, dy, color, cap) -> n = 0 if dy - while bit(layer, x, y) is color and n <= cap + return 0 if x < 0 or x >= layer.width + # The column's word offset and mask are fixed; only the row bound moves. + bitmap = layer.bitmap + words = layer.words + height = layer.height + mask = 1 << (x & 31) + want = if color then mask else 0 + pos = y * words + (x >>> 5) + while y >= 0 and y < height and (bitmap[pos] & mask) is want and n <= cap n++ - y += dy + y += dy + pos += dy * words return n return 0 if y < 0 or y >= layer.height row = y * layer.words From 0d512409387cb197e89d8a72b85ef06dad841989 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:51:44 -0600 Subject: [PATCH 03/32] barcodes: leave cross() as soon as a run cannot pass the ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The center run is measured first; a center under two bits, or any side run that reaches the center, fails before the remaining runs are walked. The loose fit admits sides up to 4/3 of the center (sides under 1.6 modules, center over 1.2), so the loose bound is 4·r2/3 and the strict one r2. Alternating A/B, min of 3, bun: 1080p QR noise miss 10.36 → 9.42 ms, phone noise miss 25.94 → 23.45 ms; other rows within noise. Results identical: tests, corpus, finder-stage probe, stage probes. --- packages/barcodes/qr.rip | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 61ec8adb..cc29f6b6 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -1121,19 +1121,27 @@ run =! (layer, x, y, dx, dy, color, cap) -> # coordinate, the measured pitch when asked, or -1 on ratio failure; a # loose check accepts the looser fit too. cross =! (layer, cx, cy, dx, dy, cap, inverted, measure = false, loose = 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 + center = if inverted then 0 else 1 + side = if inverted then 1 else 0 + back = run layer, cx, cy, -dx, -dy, center, Infinity + forward = run layer, cx + dx, cy + dy, dx, dy, center, Infinity + r2 = back + forward + # ratio() needs a center run over 1.5 modules with every other run under + # that (over 1.2 with the others under 1.6 for a loose fit), so a center + # shorter than two bits fails before the remaining runs, and any run + # reaching the center (four thirds of it when loose) fails as measured. + return -1 if r2 < 2 + most = if loose then 4 * r2 / 3 else r2 r1 = run layer, cx - dx * back, cy - dy * back, -dx, -dy, side, cap + return -1 if r1 >= most back += r1 r0 = run layer, cx - dx * back, cy - dy * back, -dx, -dy, center, cap + return -1 if r0 >= most 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, cap + return -1 if r3 >= most ahead += r3 r4 = run layer, cx + dx * ahead, cy + dy * ahead, dx, dy, center, cap return -1 unless ratio(r0, r1, r2, r3, r4, loose) From d7ed3a73b2a79276f7d260ed2c0c63de40d8f648 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:52:08 -0600 Subject: [PATCH 04/32] barcodes: keep the current word resident across finder runs in findRows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packed row's word and the shift inside it are carried from one run to the next instead of being reloaded per run, so a row walk touches each word once. Alternating A/B, min of 5, bun: 1080p QR noise miss 9.85 → 9.44 ms, weave 3.68 → 3.47 ms; phone weave 10.35 → 9.89 ms. Results identical: tests, corpus, finder-stage probe, stage probes. --- packages/barcodes/qr.rip | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index cc29f6b6..921eeb55 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -1479,22 +1479,24 @@ recordFinder! =! (layer, y, x, r0, r1, r2, r3, r4, ms, inverted, loose) -> # Rolling run-length window over every second row; the loose pass admits # loose windows with loose cross-checks. Each run is measured straight off # the packed row: the word's opposite-color bits are isolated with clz32 -# and whole words are consumed until a stop or the row's end. +# and whole words are consumed until a stop or the row's end. The current +# word and its consumed-bit shift carry from run to run; the next word +# loads only when a run crosses a word boundary. findRows! =! (layer, from, to, loose) -> width =! layer.width words =! layer.words bitmap =! layer.bitmap for y in [from...to] by 2 r0 = r1 = r2 = r3 = r4 = 0 - runs = 0 - row = y * words - previous = (bitmap[row] & 1) is 1 + runs = 0 + at = y * words + word = bitmap[at] + shift = 0 + previous = (word & 1) is 1 x = 0 while x < width length = 0 loop - shift = x & 31 - word = bitmap[row + (x >>> 5)] stops = if previous then ~word else word w = stops >> shift span = Math.min 32 - shift, width - x @@ -1502,7 +1504,10 @@ findRows! =! (layer, from, to, loose) -> len = Math.min first, span length += len x += len + shift += len break if first < span or x >= width + word = bitmap[++at] + shift = 0 r0 = r1 r1 = r2 r2 = r3 From efc0e45ab2c7409f2f422502e873ca4101d21a98 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:52:32 -0600 Subject: [PATCH 05/32] barcodes: sample the module grid without per-module calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit projectQuad inlines mapPoint and read(): the plane fields and the nine homography entries are hoisted, and the per-row products are summed in read()'s order so the bilinear luma and threshold samples land on the same doubles. mapPoint allocated a point per module and read() was a call per sample, which is where the grid walk spent its time. Alternating A/B, min of 5, bun: projectQuad v10 36.1 → 16.9 µs, v18 97.9 → 41.0 µs, v40 418.5 → 174.6 µs; bench QR v1 132x132 38 → 32 µs. Results identical: tests, corpus, stage probe over 7247 symbols (grid, codewords, block bytes, result at every version, ecc, mask, text and damage level), stage probes. --- packages/barcodes/qr.rip | 58 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 921eeb55..2da0b14c 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -2546,12 +2546,64 @@ export class QRScanner formatValue = if formatValue is first then second else -1 decoded - # One symbol or one alignment tile into the scanner grid; tiles never overlap. + # One symbol or one alignment tile into the scanner grid; tiles never + # overlap. Sampling is read() unrolled: each row's homography terms are + # products of one module coordinate, computed once per row and summed in + # read()'s order, so every sample matches read() bit for bit. projectQuad!: (s, map, size, left = 0, right = size, top = 0, bottom = size) -> - grid = @grid + grid = @grid + inverted = @invertedProjection + {W, H, d, cut, bw, bh, sh} = s + xMax = W - 1 + yMax = H - 1 + uMax = bw - 1 + vMax = bh - 1 + half = (1 << sh) / 2 + twice = 2 * half + m0 = map[0] + m1 = map[1] + m2 = map[2] + m3 = map[3] + m4 = map[4] + m5 = map[5] + m6 = map[6] + m7 = map[7] + m8 = map[8] for y in [top...bottom] + my = y + 0.5 + rx = m1 * my + ry = m4 * my + rd = m7 * my + row = y * size for x in [left...right] - grid[y * size + x] = @read s, map, x + 0.5, y + 0.5 + mx = x + 0.5 + den = m6 * mx + rd + m8 + px = (m0 * mx + rx + m2) / den + py = (m3 * mx + ry + m5) / den + sx = px - 0.5 + sy = py - 0.5 + if sx < 0 or sy < 0 or sx >= xMax or sy >= yMax + grid[row + x] = 0 + continue + x0 = sx | 0 + y0 = sy | 0 + fx = sx - x0 + fy = sy - y0 + i = y0 * W + x0 + value = (d[i] * (1 - fx) + d[i + 1] * fx) * (1 - fy) + (d[i + W] * (1 - fx) + d[i + W + 1] * fx) * fy + u = Math.min Math.max((px - half) / twice, 0), uMax + v = Math.min Math.max((py - half) / twice, 0), vMax + u0 = u | 0 + v0 = v | 0 + u1 = Math.min u0 + 1, uMax + v1 = Math.min v0 + 1, vMax + gu = u - u0 + gv = v - v0 + r0 = v0 * bw + r1 = v1 * bw + threshold = (cut[r0 + u0] * (1 - gu) + cut[r0 + u1] * gu) * (1 - gv) + (cut[r1 + u0] * (1 - gu) + cut[r1 + u1] * gu) * gv + grid[row + x] = if (value <= threshold) isnt inverted then 1 else 0 + return projectMap: (s, map, size) -> return FAIL.timing unless @timing(s, map, size) From 1cae133ebf606aa42dc2e71ea82be67ce85270f4 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:52:57 -0600 Subject: [PATCH 06/32] barcodes: walk codewords with packed column masks and whole-byte stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One mask period per column is packed into a word, y mod 12 is tracked incrementally and each codeword byte is stored whole once its eight bits are in, so the clearing fill of the codeword arena goes. Alternating A/B, min of 5, bun: decodeGrid v10 9.1 → 8.5 µs, v18 21.6 → 19.9 µs, v40 95.6 → 80.5 µs. Results identical: tests, corpus, stage probe over 7247 symbols, stage probes. --- packages/barcodes/qr.rip | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 2da0b14c..b43c2d33 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -2432,25 +2432,42 @@ export class QRScanner y = i // 3 fun[y * size + x] = 1 fun[x * size + y] = 1 - bytes = @codewords - total = BYTES[ver - 1] - bytes.fill 0, 0, total + bytes = @codewords + total = BYTES[ver - 1] + limit = 8 * total grid = @grid bitIndex = 0 + acc = 0 dir = -1 y = size - 1 xOffset = size - 1 while xOffset > 0 xOffset = 5 if xOffset is 6 + # Mask predicates repeat every twelve rows: one period per column + # packs into a word, indexed by the row modulo twelve. + mask0 = 0 + mask1 = 0 + for i in [0...12] + mask0 |= ((maskBits(xOffset, i) >> mask) & 1) << i + mask1 |= ((maskBits(xOffset - 1, i) >> mask) & 1) << i + ym = y % 12 loop + row = y * size 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) + continue if fun[row + x] + # Codewords fill in walk order, so each byte lands whole after + # its eighth bit. + if bitIndex < limit + acc = (acc << 1) | (grid[row + x] ^ (((if j then mask1 else mask0) >> ym) & 1)) + if (bitIndex & 7) is 7 + bytes[bitIndex >> 3] = acc + acc = 0 bitIndex++ break if y + dir < 0 or y + dir >= size - y += dir + y += dir + ym += dir + if ym < 0 then ym = 11 else if ym is 12 then ym = 0 dir = -dir xOffset -= 2 ecc = ECC_LEVELS[eccIndex] From 484d652468a2af0f40747359fe4520c4daf72ee3 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:53:22 -0600 Subject: [PATCH 07/32] barcodes: read payload fields through a three-byte window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widest field any segment reads is 16 bits (every read call site and the ECI lengths are 8 or 16), so a three-byte window over the data bytes serves every read without the per-bit loop. Alternating A/B, min of 5, bun: decodePayload v10 0.4 → 0.2 µs, v18 1.0 → 0.5 µs, v40 4.9 → 2.4 µs. Results identical: tests, corpus, stage probe over 7247 symbols, stage probes. --- packages/barcodes/qr.rip | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index b43c2d33..aea9135d 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -830,16 +830,17 @@ class Payload view: (n) -> @views[n] or (@views[n] = Uint8Array.new(@bytes.buffer, 0, n)) + # No field exceeds 16 bits, so three bytes cover one at any bit offset; + # bytes past the end read as zero and are masked away with the rest of + # the window. 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 + data = @data + byte = start >> 3 + window = (data[byte] << 16) | (data[byte + 1] << 8) | data[byte + 2] + @position = start + bits + (window >> (24 - (start & 7) - bits)) & ((1 << bits) - 1) # Returns a string, or with deferText an array of strings and [bytes, eci] # parts for a caller-supplied text decoder, or FAIL.data. From 29013512bddc2c081097a238829a813e6b8651ae Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:53:46 -0600 Subject: [PATCH 08/32] barcodes: fold the Reed-Solomon remainder four coefficients a word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The products table packs four coefficients to an Int32 (coefficient j in byte j & 3 of word j >> 2) and is shared by the encoder's rsEcc and the decoder's clean-block check: each data byte shifts the LFSR register down one byte across the words and XORs in the packed products row of the feedback byte. The register lives in a module Int32Array for the encoder and a scanner-owned one for the decoder. The byte products table had no remaining consumer and is gone. Alternating A/B, min of 5, bun: decodeGrid clean v10 8.0 → 6.5 µs, v18 18.7 → 14.9 µs, v40 75.5 → 57.0 µs; encodeQR raw v18 36.8 → 34.2 µs; bench QR raw v10 15 → 14 µs, v22 47 → 44 µs. Results identical: tests, corpus, stage probe over 7247 symbols, encoder hashes identical on raw, ascii, term, svg and gif for 40 versions × 4 ecc × 2 masks × 3 texts, stage probes (12040 encodes). --- packages/barcodes/qr.rip | 61 +++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index aea9135d..cac4196d 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -175,7 +175,8 @@ ALNUM_VAL =! do -> # ==[ Reed-Solomon ]== # Generator polynomial (leading 1 dropped) and every coefficient*feedback -# product, cached per parity length. +# product packed four to a word (coefficient j in byte j & 3 of word +# j >> 2), cached per parity length. RS_CACHE =! [] rsGenerator =! (n) -> @@ -191,26 +192,38 @@ rsGenerator =! (n) -> rsCached =! (n) -> return RS_CACHE[n] if RS_CACHE[n] - gen = rsGenerator n - products = Uint8Array.new(256 * n) + gen = rsGenerator n + stride = (n + 3) >> 2 + packed = Int32Array.new(256 * stride) for f in [1...256] lf = LOG[f] - base = f * n + base = f * stride for j in [0...n] c = gen[j] - products[base + j] = EXP[LOG[c] + lf] if c - RS_CACHE[n] = { gen, products } + packed[base + (j >> 2)] |= EXP[LOG[c] + lf] << (8 * (j & 3)) if c + RS_CACHE[n] = { gen, packed } -# Parity via LFSR remainder. +RS_TMP =! Int32Array.new(8) + +# Parity via LFSR remainder, four coefficients a word: each data byte shifts +# the remainder down one byte across the words and XORs in the packed +# products row of the feedback byte. rsEcc =! (data, rs) -> - {gen, products} = rs - n = gen.length - last = n - 1 - res = Uint8Array.new(n) + {gen, packed} = rs + n = gen.length + stride = packed.length >>> 8 + last = stride - 1 + w = RS_TMP.fill 0, 0, stride for i in [0...data.length] - base = (data[i] ^ res[0]) * n - res[j] = res[j + 1] ^ products[base + j] for j in [0...last] - res[last] = products[base + last] + word = w[0] + base = (data[i] ^ (word & 0xff)) * stride + for k in [0...last] + up = w[k + 1] + w[k] = ((word >>> 8) | (up << 24)) ^ packed[base + k] + word = up + w[last] = (word >>> 8) ^ packed[base + last] + res = Uint8Array.new(n) + res[j] = w[j >> 2] >>> (8 * (j & 3)) for j in [0...n] res capacity =! (ver, ecc) -> @@ -1598,6 +1611,7 @@ export class QRScanner @sigma = Uint8Array.new(31) @previous = Uint8Array.new(31) @next = Uint8Array.new(31) + @remainder = Int32Array.new(8) @candidates = Uint32Array.new(MAX_CANDIDATES) @ranks = Float64Array.new(MAX_CANDIDATES) @found = Uint32Array.new(4 * MAX_CANDIDATES * 3) @@ -2322,16 +2336,19 @@ export class QRScanner syndromes = @syndromes # The generator divides an intact block: a zero LFSR remainder, computed # exactly as the encoder does, settles the common case without syndromes. - {products} = rsCached eccWords - rem = @next - last = eccWords - 1 - rem.fill 0, 0, eccWords + # Coefficient j lives in byte j & 3 of word j >> 2; the top word's spare + # bytes shift in zeros and fold zero products, so they stay zero. + {packed} = rsCached eccWords + rem = @remainder + stride = (eccWords + 3) >> 2 + last = stride - 1 + rem.fill 0, 0, stride for i in [0...length] - base = (blockBytes[offset + i] ^ rem[0]) * eccWords - rem[j] = rem[j + 1] ^ products[base + j] for j in [0...last] - rem[last] = products[base + last] + base = (blockBytes[offset + i] ^ (rem[0] & 0xff)) * stride + rem[j] = ((rem[j] >>> 8) | (rem[j + 1] << 24)) ^ packed[base + j] for j in [0...last] + rem[last] = (rem[last] >>> 8) ^ packed[base + last] dirty = 0 - dirty |= rem[j] for j in [0...eccWords] + dirty |= rem[j] for j in [0...stride] return true unless dirty for i in [0...eccWords] value = 0 From 1e1cbbc9ec9a55d961deeb44145d4692c66433fa Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:54:09 -0600 Subject: [PATCH 09/32] barcodes: derive a damaged block's syndromes from the remainder register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remainder register holds x^eccWords · C(x) mod g(x), which agrees with the block at every generator root up to the factor alpha^(i·eccWords), so a damaged block's syndromes come from its eccWords register coefficients instead of a Horner pass over the whole block. Alternating A/B, min of 5, bun: decodeGrid damaged v10 with 6 flips 16.3 → 9.8 µs, v18/12 39.8 → 24.1 µs, v40/30 147.6 → 88.7 µs, v40/80 234.8 → 120.6 µs; clean blocks unchanged. Results identical: tests, corpus, stage probe over 7247 symbols (damage levels 1..400 flips), stage probes. --- packages/barcodes/qr.rip | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index cac4196d..128b91ea 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -2350,13 +2350,18 @@ export class QRScanner dirty = 0 dirty |= rem[j] for j in [0...stride] return true unless dirty + # The register holds x^eccWords * C(x) mod g(x), which agrees with the + # block at every generator root up to the factor alpha^(i * eccWords), + # so the syndromes come from its eccWords coefficients instead of the + # whole block. + next = @next + next[k] = (rem[k >> 2] >> ((k & 3) * 8)) & 0xff for k in [0...eccWords] for i in [0...eccWords] value = 0 - value = mul(value, EXP[i]) ^ blockBytes[offset + j] for j in [0...length] - syndromes[i] = value + value = mul(value, EXP[i]) ^ next[k] for k in [0...eccWords] + syndromes[i] = mul value, EXP[255 - ((i * eccWords) % 255)] sigma = @sigma previous = @previous - next = @next sigma.fill 0, 0, eccWords + 1 previous.fill 0, 0, eccWords + 1 sigma[0] = 1 From 77e2586c10c87465c18cbb1f28404117ad771dd2 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:55:00 -0600 Subject: [PATCH 10/32] barcodes: slide the 5x5 threshold smoother along each block row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five column sums per block row are kept and rolled one column at a time for the interior blocks, so each cut costs two column updates instead of a 25-cell sum. The small-grid branch, which folds the window onto edge blocks, is kept as it is. The sums are integers, so every cut is bit-identical. Paired in-process A/B, best of 3, bun: 1080p QR text miss 3.24 → 2.84 ms, weave −3%, phone desk and weave −6%, QR v1 132x132 −5%; other rows within noise. Results identical: tests, corpus, pixel probe (924 inputs: every layer's luma, cuts, blocks, bitmap and finder count), stage probes. --- packages/barcodes/qr.rip | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 128b91ea..deb84750 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -1388,23 +1388,44 @@ bitmapRows! =! (layer, from, to) -> blocks = layer.blocks # Four pixels per word when rows keep word alignment. lumaWords = if LITTLE_ENDIAN and (width & 3) is 0 and (luma.byteOffset & 3) is 0 then layer.lumaWords else null + # A grid under five blocks folds the window onto its edge blocks. + small = bWidth < 5 or bHeight < 5 for y in [from...to] yPos = clamp y * BLOCK, 0, maxY top = clamp y, 2, bHeight - 3 + sum = 0 + unless small + # The window is clamped two blocks inside either edge, so it slides one + # column at a time between them: five column sums built once per block + # row, and each step drops the column leaving and adds the one entering. + r0 = bWidth * (top - 2) + r1 = r0 + bWidth + r2 = r1 + bWidth + r3 = r2 + bWidth + r4 = r3 + bWidth + c0 = blocks[r0] + blocks[r1] + blocks[r2] + blocks[r3] + blocks[r4] + c1 = blocks[r0 + 1] + blocks[r1 + 1] + blocks[r2 + 1] + blocks[r3 + 1] + blocks[r4 + 1] + c2 = blocks[r0 + 2] + blocks[r1 + 2] + blocks[r2 + 2] + blocks[r3 + 2] + blocks[r4 + 2] + c3 = blocks[r0 + 3] + blocks[r1 + 3] + blocks[r2 + 3] + blocks[r3 + 3] + blocks[r4 + 3] + c4 = blocks[r0 + 4] + blocks[r1 + 4] + blocks[r2 + 4] + blocks[r3 + 4] + blocks[r4 + 4] + sum = c0 + c1 + c2 + c3 + c4 for x in [0...bWidth] xPos = clamp x * BLOCK, 0, maxX - left = clamp x, 2, bWidth - 3 - sum = 0 - if bWidth < 5 or bHeight < 5 - # A grid under five blocks folds the window onto its edge blocks. + if small + left = clamp x, 2, bWidth - 3 + sum = 0 for yy in [-2..2] row = bWidth * clamp(top + yy, 0, bHeight - 1) for xx in [-2..2] sum += blocks[row + clamp(left + xx, 0, bWidth - 1)] - else - for yy in [-2..2] - row = bWidth * (top + yy) + left - sum += blocks[row - 2] + blocks[row - 1] + blocks[row] + blocks[row + 1] + blocks[row + 2] + else if x > 2 and x <= bWidth - 3 + col = x + 2 + c0 = c1 + c1 = c2 + c2 = c3 + c3 = c4 + c4 = blocks[r0 + col] + blocks[r1 + col] + blocks[r2 + col] + blocks[r3 + col] + blocks[r4 + col] + sum = c0 + c1 + c2 + c3 + c4 cut = (sum / 25) | 0 cuts[y * bWidth + x] = cut pos = yPos * width + xPos From 4cce4725fd37f27a2a179f4069c9b1f37dc55bde Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:55:23 -0600 Subject: [PATCH 11/32] barcodes: wipe each luma arena once in clean() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clean() sweeps every typed-array field, and layer zero's luma is the scanner's own arena while every layer's lumaWords is a view over the same bytes, so those were zero-filled two and three times. Views and aliases of an arena already wiped are skipped by identity; the reflective sweep stays. Paired in-process A/B, best of 3, bun: clean() at 1080p 93.8 → 46.9 µs. Results identical: tests (scanner reuse), corpus, stage probes. --- packages/barcodes/qr.rip | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index deb84750..1663ca20 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -1805,7 +1805,9 @@ export class QRScanner @retryStart = 0 @retries = 0 - # Lifecycle wipe: every typed array on the scanner and its layers is zeroed. + # Lifecycle wipe: every typed array on the scanner and its layers is zeroed + # once. Layer zero's luma is the scanner's own arena and each lumaWords + # views its layer's luma, so those aliases are skipped by identity. clean!: -> @beginOperation() try @@ -1815,7 +1817,7 @@ export class QRScanner v.fill 0 if ArrayBuffer.isView v for layer in @layers for own k, v of layer - v.fill 0 if ArrayBuffer.isView v + v.fill 0 if ArrayBuffer.isView(v) and v isnt layer.lumaWords and v isnt @luma layer.blockHeight = 0 layer.blockWidth = 0 layer.height = 0 From 98cbdf9ea555efc80d416b9f7d38a12b19353f91 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:55:46 -0600 Subject: [PATCH 12/32] barcodes: grow finder records on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit patterns and inverted start at 64 records and double inside recordFinder, the only writer, up to the same one-per-7x7-cell ceiling with the same "finder storage exhausted" error. No caller holds a pattern alias across a findRows pass, so a grown array is never read through a stale reference. Paired in-process A/B, best of 3, bun: constructor at 1080p 88 → 66 µs, clean() at 1080p 45 → 23 µs; decode rows within noise. Results identical: tests, corpus, pixel probe (86 frames hashing patterns, inverted and the count per layer), stage probes. --- packages/barcodes/qr.rip | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 1663ca20..0e072fd4 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -799,7 +799,7 @@ STRICT =! 8 # some row fit the finder ratios within half # Failure values shared by every attempt; a decode returns a string or one # of these. The frame path throws only when a layer's finder storage -# overflows, which its sizing rules out: `patterns` holds one record per +# overflows, which its ceiling rules out: `patterns` grows to one record per # 7x7-pixel cell, a finder spans at least seven pixels each way, and a new # center within two modules of an existing record merges into it. FAIL =! Object.freeze @@ -1464,6 +1464,20 @@ bitmapRows! =! (layer, from, to) -> pos += width word += words +# Double a layer's finder records, up to one per 7x7 cell of the staged frame. +growFinders =! (layer) -> + centers = Math.ceil(layer.width / 7) * Math.ceil(layer.height / 7) + count = layer.inverted.length + fail "finder storage exhausted at #{layer.width}x#{layer.height}" if count >= centers + records = Math.min centers, count * 2 + patterns = Float64Array.new(records * 4) + inverted = Uint8Array.new(records) + patterns.set layer.patterns + inverted.set layer.inverted + layer.patterns = patterns + layer.inverted = inverted + patterns + # 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, loose) -> @@ -1504,7 +1518,7 @@ recordFinder! =! (layer, y, x, r0, r1, r2, r3, r4, ms, inverted, loose) -> layer.looseCount++ unless strict index = layer.patternCount++ pos = index * 4 - fail "finder storage exhausted at #{layer.width}x#{layer.height}" if pos + 3 >= patterns.length + patterns = growFinders layer if pos + 3 >= patterns.length patterns[pos] = refinedX patterns[pos + 1] = cy patterns[pos + 2] = ms @@ -1675,7 +1689,8 @@ export class QRScanner break if i and Math.min(width, height) < 64 blockWidth = Math.ceil(width / BLOCK) blockHeight = Math.ceil(height / BLOCK) - centers = Math.ceil(width / 7) * Math.ceil(height / 7) + # Finder records start small and grow on demand, up to one per 7x7 cell. + centers = Math.min 64, Math.ceil(width / 7) * Math.ceil(height / 7) luma = if i then Uint8Array.new(width * height) else @luma cuts = Int16Array.new(blockWidth * blockHeight) layers.push From 347e857917dfa3331f67636cf124e84af4adb5fc Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:57:31 -0600 Subject: [PATCH 13/32] barcodes: size version scratch to the symbol actually attempted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grid, fun, codewords and blockBytes start empty and reserve(size) grows them where decodedSize is committed in projectTriple, the one point every consumer (projectQuad, confirm, decodeFormat, markDecoded) is downstream of. Payload starts with an empty byte arena and reserves per segment: byte segments reserve dataLen, which bounds any segment; Kanji and Hanzi reserve min(2·length, KANJI_CHUNK) where KANJI_CHUNK = MAX_CODEWORDS & ~1 keeps the chunk boundary exactly where it is, so decoded text is unchanged for any input. The length guard against the view count goes: length ≤ dataLen ≤ 2956 already. Paired in-process A/B, best of 3, bun: decodeQR v1 58px 22.8 → 18.0 µs, v1 116px 36.9 → 33.0 µs, QR v1 132x132 −9.5%, constructor at 1080p −8%. Results identical: tests, corpus, stage probe (7254 lines hashing the grid at every decodeFormat entry and the corrected data at every decodePayload entry, up to 24 attempts per case, plus a reused scanner across versions 1, 40, 7, 20, 1, 33, 2), payload probe (320 byte, Kanji, Hanzi and mixed segment cases, 27 longer than one chunk), stage probes. --- packages/barcodes/qr.rip | 48 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 0e072fd4..3384bad1 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -828,15 +828,25 @@ evalLow =! (poly, length, x) -> # ==[ Payload ]== -# Bit reader over corrected data codewords plus a scratch byte buffer with -# one prefix view per length, so byte segments decode without allocating. +# Bit reader over corrected data codewords plus a scratch byte arena sized +# by the first segment that needs it, with one prefix view per length, so +# byte segments decode without allocating once a scanner has met their +# symbol size. Kanji runs of more than a chunk decode chunk by chunk. +KANJI_CHUNK =! MAX_CODEWORDS & ~1 + class Payload - constructor: (capacity) -> + constructor: -> @position = 0 @data = Uint8Array.new(0) @dataLen = 0 - @bytes = Uint8Array.new(capacity) - @views = Array.new(capacity + 1) + @bytes = Uint8Array.new(0) + @views = [] + + # Grow the byte arena to n; the prefix views follow the new buffer. + reserve!: (n) -> + return if @bytes.length >= n + @bytes = Uint8Array.new(n) + @views.length = 0 # A length-n window over the byte arena, created once per length so the # frame path never allocates after its first use. @@ -915,7 +925,7 @@ class Payload parts.push [segment, eci] res = '' else - return FAIL.data if length >= @views.length + @reserve dataLen @bytes[i] = @read(8) for i in [0...length] # A segment under no ECI is read the way readers in the wild do. decoder = if named then ECI_DECODERS[eci] else guessDecoder(@view length) @@ -930,7 +940,8 @@ class Payload return FAIL.data if length < 0 or @position + 13 * length > dataLen * 8 decoder = ECI_DECODERS[if hanzi then 29 else 20] return FAIL.data unless decoder - chunk = (@views.length - 1) & ~1 + chunk = KANJI_CHUNK + @reserve Math.min(2 * length, chunk) n = 0 for i in [0...length] value = @read 13 @@ -1637,11 +1648,12 @@ export class QRScanner @luma = Uint8Array.new(bytes) @image = { data: @luma, height: 0, width: 0 } @input = { data: @luma, height: 0, width: 0 } - @grid = Uint8Array.new(MAX_SIZE * MAX_SIZE) - @fun = Uint8Array.new(MAX_SIZE * MAX_SIZE) + # Version-sized scratch, grown by reserve() to the largest symbol attempted. + @grid = Uint8Array.new(0) + @fun = Uint8Array.new(0) + @codewords = Uint8Array.new(0) + @blockBytes = Uint8Array.new(0) @alignPos = Uint8Array.new(7) - @codewords = Uint8Array.new(MAX_CODEWORDS) - @blockBytes = Uint8Array.new(MAX_CODEWORDS) @syndromes = Uint8Array.new(31) @sigma = Uint8Array.new(31) @previous = Uint8Array.new(31) @@ -1653,7 +1665,7 @@ export class QRScanner @pick = Float64Array.new(8) @nodes = Float64Array.new(7 * 7 * 2) @located = Float64Array.new((7 * 7 - 3) * 4) - @payload = Payload.new MAX_CODEWORDS + @payload = Payload.new() @inFlight = false @staged = false @resized = false @@ -1757,6 +1769,17 @@ export class QRScanner packQuad @to, tl.x, tl.y, tr.x, tr.y, brX, brY, bl.x, bl.y @mapQuad out + # Grow the module grid, function map and codeword scratch to one symbol + # size: a scanner that only ever meets small symbols never pays for + # Version 40. + reserve!: (size) -> + return if @grid.length >= size * size + words = BYTES[versionOf(size) - 1] + @grid = Uint8Array.new(size * size) + @fun = Uint8Array.new(size * size) + @codewords = Uint8Array.new(words) + @blockBytes = Uint8Array.new(words) + setAlignments: (ver) -> return 0 if ver is 1 last = sizeOf(ver) - 7 @@ -2829,6 +2852,7 @@ export class QRScanner continue if (i > 0 and size is snapped) or (i > 1 and size is mean) continue if size < MIN_SIZE or size > MAX_SIZE or Math.abs(size - estimate) > 6 @decodedSize = size + @reserve size # A located bottom-right alignment pattern upgrades the affine BR # estimate to perspective. f = 1 - 3 / (size - FINDER_INSET) From e87c5732bad7b8db3d26cdcab829115d6ee2a2d0 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:57:54 -0600 Subject: [PATCH 14/32] barcodes: fold three-byte RGB input a word at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three words carry four RGB pixels, so a tight little-endian RGB frame whose rows are 4-byte aligned is converted from word triples instead of the per-channel byte loop; copyTriples sits beside copyWords with the same gate and a byte-wise tail. Paired in-process A/B, best of 3, bun: addImage 1080p RGB 1.22 ms → 678 µs, decodeQR 1080p RGB 1.72 → 1.19 ms; everything else within noise. Results identical: luma probe on 336 cases (8 sizes × 10 formats × 5 layouts, aligned and unaligned views), 23.8 million random RGB pixels at five alignments and padded strides with 0 mismatches, tests, corpus, stage probes. --- packages/barcodes/image.rip | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/barcodes/image.rip b/packages/barcodes/image.rip index 052066b4..767f5fcf 100644 --- a/packages/barcodes/image.rip +++ b/packages/barcodes/image.rip @@ -95,6 +95,28 @@ copyWords! =! (out, data, byteStart, n) -> p = words[i] out[i] = ((p & 255) + ((p >>> 7) & 510) + ((p >>> 16) & 255)) >> 2 +# Luma of three-byte pixels from their words: three words carry four +# pixels, so a pixel's channels come from the word or word pair that holds +# them; the tail stays byte-wise. +copyTriples! =! (out, data, byteStart, n) -> + words = Int32Array.new(data.buffer, byteStart, (3 * n) >> 2) + i = 0 + w = 0 + while i + 3 < n + a = words[w] + b = words[w + 1] + c = words[w + 2] + out[i ] = ((a & 255) + ((a >>> 7) & 510) + ((a >>> 16) & 255)) >> 2 + out[i + 1] = ((a >>> 24) + ((b & 255) << 1) + ((b >>> 8) & 255)) >> 2 + out[i + 2] = (((b >>> 16) & 255) + ((b >>> 23) & 510) + (c & 255)) >> 2 + out[i + 3] = (((c >>> 8) & 255) + ((c >>> 15) & 510) + (c >>> 24)) >> 2 + i += 4 + w += 3 + src = byteStart - data.byteOffset + i * 3 + while i < n + out[i++] = (data[src] + 2 * data[src + 1] + data[src + 2]) >> 2 + src += 3 + # Convert any input into the tight native luma plane. export copyLuma! =! (out, maxSize, img, named, layout) -> {step, bits} = validateImage img, named, layout, maxSize @@ -105,6 +127,9 @@ export copyLuma! =! (out, maxSize, img, named, layout) -> if step is 4 and LITTLE_ENDIAN and stride is width * 4 and ((data.byteOffset + offset) & 3) is 0 copyWords out, data, data.byteOffset + offset, width * height return + if step is 3 and LITTLE_ENDIAN and stride is width * 3 and ((data.byteOffset + offset) & 3) is 0 + copyTriples out, data, data.byteOffset + offset, width * height + return if step is 1 and stride is width out.set data.subarray(offset, offset + width * height) return From da879805c274847ad92cd958c8c3d2fe9dc20f4d Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:58:50 -0600 Subject: [PATCH 15/32] barcodes: cache SVG path commands per output width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finished path commands for the two common relative moves (same row, next row) with the h-1 return are kept in a table keyed by (dy, dx) for the current output width, filled on first use, so a dark module costs one lookup and one concatenation; absolute moves and the H form keep the inline path, and bits are read from the packed row directly. Alternating processes, min of 5, bun: svg v8 22.17 → 21.51 µs, v18 70.84 → 65.31 µs; v1 and v3 within noise. Output byte-identical: encoder oracle (4551 hashes over every version, ecc, mask, output, border and scale, widths interleaved so the caches refill), encode probe (12040), tests, corpus. --- packages/barcodes/qr.rip | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 3384bad1..acf069fb 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -664,30 +664,46 @@ renderTerm =! (r) -> digits =! (n) -> if n < 10 then 1 else if n < 100 then 2 else if n < 1000 then 3 else 4 chars =! (d) -> if d < 0 then 1 + digits(-d) else digits(d) +# Finished path commands for the two common relative moves (same row, next +# row) with the `h-1` return, keyed by (dy, dx) for one output width: the +# coordinates never change between symbols of the same size, so the number +# formatting is paid once per distinct move. +svgCache = null + renderSvg =! (r, optimize) -> - W = r.W + {m, W, map} = r + {words, v} = m + svgCache = { W, cmds: Array.new(4 * W) } unless svgCache?.W is W + cmds = svgCache.cmds out = '' path = '' prevX = 0 prevY = 0 hasPrev = false for y in [0...W] + my = map[y] + continue if my < 0 + base = my * words for x in [0...W] - continue unless darkAt(r, x, y) + mx = map[x] + continue unless mx >= 0 and (v[base + (mx >>> 5)] >>> (mx & 31)) & 1 unless optimize out += '' continue # The shorter move wins, relative on ties; lengths are counted so only - # the chosen command is built. - if hasPrev - dx = x - prevX - dy = y - prevY - mv = if chars(dx) + chars(dy) <= digits(x) + digits(y) then "m#{dx} #{dy}" else "M#{x} #{y}" + # the chosen command is built. Return to the cell's left edge by the + # shorter command: `H` is two characters for single-digit x, `h-1` + # three. + dx = x - prevX + dy = y - prevY + if hasPrev and x >= 10 and dy <= 1 and chars(dx) + 1 <= digits(x) + digits(y) + k = (2 * dy + 1) * W + dx + cmd = cmds[k] + cmd = cmds[k] = "m#{dx} #{dy}h1v1h-1Z" if cmd is undefined else - mv = "M#{x} #{y}" - # Return to the cell's left edge by the shorter command: `H` is two - # characters for single-digit x, `h-1` three. - path += if x < 10 then "#{mv}h1v1H#{x}Z" else "#{mv}h1v1h-1Z" + mv = if hasPrev and chars(dx) + chars(dy) <= digits(x) + digits(y) then "m#{dx} #{dy}" else "M#{x} #{y}" + cmd = if x < 10 then "#{mv}h1v1H#{x}Z" else "#{mv}h1v1h-1Z" + path += cmd prevX = x prevY = y hasPrev = true From 9548d8ef9718f1cba5a6aa10cf5ae42ac5535e90 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:59:13 -0600 Subject: [PATCH 16/32] barcodes: append a run of adjacent dark modules as one SVG string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From x = 10 on, a dark cell immediately after another always wins the one-step relative move (m1 0h1v1h-1Z is two characters, no longer than the digits of x and y), so a run of k such cells is appended as one cached string repeated k times; scale multiplies run lengths. The guard is exact, so the tie rule never changes a byte. Alternating processes, min of 5, bun: svg v1 4.53 → 4.18 µs, v3 7.30 → 6.70 µs, v8 21.18 → 17.93 µs, v18 64.32 → 54.82 µs. Output byte-identical: encoder oracle, encode probe, tests, corpus. --- packages/barcodes/qr.rip | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index acf069fb..574dcf52 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -667,8 +667,9 @@ chars =! (d) -> if d < 0 then 1 + digits(-d) else digits(d) # Finished path commands for the two common relative moves (same row, next # row) with the `h-1` return, keyed by (dy, dx) for one output width: the # coordinates never change between symbols of the same size, so the number -# formatting is paid once per distinct move. +# formatting is paid once per distinct move. `runs[k]` is k one-step moves. svgCache = null +runs = [] renderSvg =! (r, optimize) -> {m, W, map} = r @@ -684,11 +685,15 @@ renderSvg =! (r, optimize) -> my = map[y] continue if my < 0 base = my * words - for x in [0...W] + x = 0 + while x < W mx = map[x] - continue unless mx >= 0 and (v[base + (mx >>> 5)] >>> (mx & 31)) & 1 + unless mx >= 0 and (v[base + (mx >>> 5)] >>> (mx & 31)) & 1 + x++ + continue unless optimize out += '' + x++ continue # The shorter move wins, relative on ties; lengths are counted so only # the chosen command is built. Return to the cell's left edge by the @@ -704,9 +709,23 @@ renderSvg =! (r, optimize) -> mv = if hasPrev and chars(dx) + chars(dy) <= digits(x) + digits(y) then "m#{dx} #{dy}" else "M#{x} #{y}" cmd = if x < 10 then "#{mv}h1v1H#{x}Z" else "#{mv}h1v1h-1Z" path += cmd - prevX = x - prevY = y hasPrev = true + prevY = y + # From x = 10 on, a dark cell right after another always takes the + # one-step relative move, so a run of them is one cached string. + x++ + k = 0 + if x >= 10 + while x < W + mx = map[x] + break unless mx >= 0 and (v[base + (mx >>> 5)] >>> (mx & 31)) & 1 + k++ + x++ + if k + rep = runs[k] + rep = runs[k] = 'm1 0h1v1h-1Z'.repeat(k) if rep is undefined + path += rep + prevX = x - 1 out += '' if optimize out + '' From 55ce9cd5abff1c6a1d1cc67531d6c2b41e38f59e Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 22:59:36 -0600 Subject: [PATCH 17/32] barcodes: append ASCII glyphs four cells at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cell codes are packed two bits each and a 256-entry table of glyph quads is appended once per four cells; the two module rows of a text line are hoisted and read from the packed words. Alternating processes, min of 5, bun: ascii v1 2.73 → 2.56 µs, v3 −6%, v8 12.83 → 12.05 µs, v18 35.1 → 33.8 µs. Output byte-identical: encoder oracle, encode probe, tests, corpus. --- packages/barcodes/qr.rip | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 574dcf52..42431cdc 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -635,17 +635,37 @@ renderRaw =! (r) -> res # Half-block glyphs with dark modules printed blank and light ones as blocks, -# which reads correctly on a dark terminal with light text. +# which reads correctly on a dark terminal with light text. GLYPH is indexed +# by (upper, lower) darkness and QUAD by four such cells packed two bits +# each, so a line grows four glyphs per concatenation. +GLYPH =! ['█', '▀', '▄', ' '] +QUAD =! (GLYPH[i >> 6] + GLYPH[(i >> 4) & 3] + GLYPH[(i >> 2) & 3] + GLYPH[i & 3] for i in [0...256]) + renderAscii =! (r) -> - W = r.W + {m, W, map} = r + {words, v} = m out = '' for y in [0...W] by 2 + my0 = map[y] + # Past the bottom edge the missing row reads as dark: the half-block + # glyph then shows only the last real row. + my1 = if y + 1 < W then map[y + 1] else -2 + b0 = my0 * words + b1 = my1 * words + acc = 0 + n = 0 for x in [0...W] - first = darkAt r, x, y - # Past the bottom edge the missing row reads as dark: the half-block - # glyph then shows only the last real row. - second = if y + 1 >= W then true else darkAt(r, x, y + 1) - out += if first then (if second then ' ' else '▄') else (if second then '▀' else '█') + mx = map[x] + g = if my1 is -2 then 1 else 0 + if mx >= 0 + g |= 2 if my0 >= 0 and (v[b0 + (mx >>> 5)] >>> (mx & 31)) & 1 + g |= 1 if my1 >= 0 and (v[b1 + (mx >>> 5)] >>> (mx & 31)) & 1 + acc = (acc << 2) | g + if ++n is 4 + out += QUAD[acc] + acc = 0 + n = 0 + out += GLYPH[(acc >> (2 * (n - 1 - i))) & 3] for i in [0...n] out += '\n' out From de347e6109d945a014d273451322d8f2fd4c27fd Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:00:00 -0600 Subject: [PATCH 18/32] barcodes: copy GIF rows whole, then spread them into LZW chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel rows are copied back to back into the unused tail of the output buffer, then spread forward chunk by chunk behind their two-byte headers with copyWithin; every chunk lands at or before its source, so the moves never clobber pixels still to be copied, and the per-pixel byte loop goes. The QR row builder reads bits from the packed words with the row's word base hoisted. The 1-D encoders return the same row for every y, so the byte loop was their whole cost. Alternating processes, min of 5, bun: QR gif v3 4.06 → 3.84 µs, v8 11.71 → 10.87 µs, v18 31.41 → 28.49 µs, data-url v1..v18 −8 to −9%; Code 128 gif scale 1 6.14 → 3.11 µs, scale 3 105.7 → 27.7 µs, data-url scale 3 113.6 → 39.0 µs; PDF417 gif scale 1 7.37 → 6.18 µs, scale 3 67.8 → 34.3 µs. Output byte-identical for all three encoders: encoder oracle, encode probe, tests, corpus. --- packages/barcodes/gif.rip | 33 +++++++++++++++++---------------- packages/barcodes/qr.rip | 11 +++++++---- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/barcodes/gif.rip b/packages/barcodes/gif.rip index 6438e6b9..bf2f246a 100644 --- a/packages/barcodes/gif.rip +++ b/packages/barcodes/gif.rip @@ -4,8 +4,7 @@ # 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 copied in spans bounded -# by the chunk boundaries. +# may return the same buffer). # ============================================================================== SIGNATURE =! Uint8Array.new [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] # GIF87a @@ -37,21 +36,23 @@ export writeGif =! (W, H, rowFor) -> u16 H out[pos++] = 0x00 out[pos++] = 0x07 # LZW minimum code size 7: clear is 0x80, end is 0x81 - i = 0 + # Pixel rows are copied back to back into the unused tail of the output, + # then spread forward chunk by chunk behind their two-byte headers: every + # chunk lands at or before its source, so the moves never clobber pixels + # still to be copied. + src = out.length - 4 - pixels + q = src for y in [0...H] - row = rowFor y - x = 0 - while x < W - if i % CHUNK is 0 - rem = pixels - i - out[pos++] = (if rem < CHUNK then rem else CHUNK) + 1 - out[pos++] = 0x80 - n = Math.min(CHUNK - i % CHUNK, W - x) - # A span is at most 126 bytes; a byte loop beats a view per span. - out[pos + k] = row[x + k] for k in [0...n] - pos += n - x += n - i += n + out.set rowFor(y), q + q += W + s = src + for i in [0...pixels] by CHUNK + n = if pixels - i < CHUNK then pixels - i else CHUNK + out[pos++] = n + 1 + out[pos++] = 0x80 + out.copyWithin pos, s, s + n + pos += n + s += n if tail is 0 out[pos++] = 1 out[pos++] = 0x80 diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 42431cdc..d980fcb9 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -750,9 +750,9 @@ renderSvg =! (r, optimize) -> out + '' renderGif =! (r) -> - W = r.W - {m, map} = r - row = Uint8Array.new(W) + {m, W, map} = r + {words, v} = m + row = Uint8Array.new(W) prevMy = -2 writeGif W, W, (y) -> my = map[y] @@ -760,7 +760,10 @@ renderGif =! (r) -> prevMy = my row.fill 0 if my >= 0 - row[x] = matGet(m, map[x], my) for x in [0...W] when map[x] >= 0 + base = my * words + for x in [0...W] + mx = map[x] + row[x] = (v[base + (mx >>> 5)] >>> (mx & 31)) & 1 if mx >= 0 row # ==[ Encoder API ]== From 7f3c5fade84c1d04e8a0c41185708cc194f97bcf Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:00:24 -0600 Subject: [PATCH 19/32] barcodes: validate text against the value table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alphabet check scanned a 45-character string per input character; the value table is tested against the mode's alphabet size instead, and the error message is built only on failure from the code point at the offending index, the same character the string iterator yielded. Alternating processes, min of 5, bun: numeric v8 16.91 → 15.26 µs, v18 54.5 → 45.9 µs; alphanumeric v8 −12%, v18 −16%. Output byte-identical, including all 105 error cases (lone and paired surrogates, astral code points, chars ≥ 128): encoder oracle, encode probe, tests, corpus. --- packages/barcodes/qr.rip | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index d980fcb9..c8d74af0 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -778,9 +778,12 @@ export encodeQR =! (text, output = 'raw', opts = {}) -> encoding = if opts.encoding is undefined then detectType(text) else opts.encoding fail "invalid encoding=#{encoding}" unless Object.hasOwn 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 + limit = if encoding is 'numeric' then 10 else ALPHANUMERIC.length + for i in [0...text.length] + code = ALNUM_VAL[text.charCodeAt(i)] # undefined past 127 + unless code >= 0 and code < limit + ch = String.fromCodePoint text.codePointAt(i) + fail "Unknown letter: \"#{ch}\". Allowed: #{ALPHANUMERIC.slice(0, limit)}" mask = if opts.mask is undefined then undefined else asInt(opts.mask, 'opts.mask', 0, 7) textEncoder = opts.textEncoder # Reject impossible built-in UTF-8 payloads before encoding duplicates a From 100cee34e2f489bf09a850ee63137a61eccfb176 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:00:47 -0600 Subject: [PATCH 20/32] barcodes: build raw rows from the packed words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Border rows are stored without map or bit lookups and module rows read the column map once per cell with the row's word base hoisted. Alternating processes, min of 5, bun: raw v1 −4%, v3 3.85 → 3.65 µs, v8 −2%, v18 29.74 → 28.27 µs. Output byte-identical: encoder oracle, encode probe, tests, corpus. --- packages/barcodes/qr.rip | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index c8d74af0..2ba61cc9 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -624,13 +624,24 @@ isBytes =! (a) -> # Dark module at output pixel (x, y); false 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 +# Border rows are stored without map or bit lookups; module rows read the +# column map once per cell with the row's word base hoisted. The boolean +# rows themselves are the floor: filled, sliced and push-built rows all +# measure slower than plain indexed stores. renderRaw =! (r) -> - W = r.W + {m, W, map} = r + {words, v} = m res = Array.new(W) for y in [0...W] + my = map[y] row = Array.new(W) - for x in [0...W] - row[x] = darkAt(r, x, y) + if my < 0 + row[x] = false for x in [0...W] + else + base = my * words + for x in [0...W] + mx = map[x] + row[x] = mx >= 0 and ((v[base + (mx >>> 5)] >>> (mx & 31)) & 1) is 1 res[y] = row res From 67d61c23310a56c6ab001b34d8839f34052daf63 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:01:12 -0600 Subject: [PATCH 21/32] barcodes: place data bits two at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zigzag fills a two-module column, so consecutive placement positions mostly sit side by side in one word; a per-pair table (word index, shift and a pair flag) lets one OR place a 2-bit value, falling back to the single-bit positions where a pair straddles a word or a function pattern. Alternating processes, min of 5, bun: raw v1 2.43 → 2.29 µs, v3 −5%, v8 10.70 → 9.96 µs, v18 28.53 → 26.96 µs; alphanumeric v8/v18 −6/−7%. Output byte-identical: encoder oracle, encode probe, tests, corpus. --- packages/barcodes/qr.rip | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 2ba61cc9..65e1a934 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -542,6 +542,15 @@ buildSymCache =! (ver) -> y += dir xOffset -= 2 dir = -dir + # The zigzag fills a two-module column, so consecutive positions mostly + # sit side by side in one word: such a pair is placed as one 2-bit OR. + # Entries are (wordIndex << 6 | shift << 1 | 1), or 0 where the pair + # straddles a word or a function pattern. + pair = Int32Array.new(n >>> 1) + for i in [0...n - 1] by 2 + a = posBuf[i] + b = posBuf[i + 1] + pair[i >>> 1] = ((a >>> 5) << 6) | ((b & 31) << 1) | 1 if (a >>> 5) is (b >>> 5) and (a & 31) is (b & 31) + 1 planesT = planes.map (pl) -> t = mat size transposeMat pl, t @@ -550,23 +559,34 @@ buildSymCache =! (ver) -> ver tpl: m.v pos: posBuf.slice(0, n) + pair 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. +# Template copy, data bits placed two at a time along the cached zigzag +# order, then mask selection over XOR candidates; the first lowest score +# wins. drawSymbol =! (ver, ecc, data, maskIdx) -> symCache = buildSymCache ver unless symCache?.ver is ver - {tpl, pos, planes, planesT, work} = symCache + {tpl, pos, pair, 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) + for i in [0...need] by 2 + two = (data[i >>> 3] >>> (6 - (i & 7))) & 3 + continue unless two + pr = pair[i >>> 1] + if pr & 1 + m.v[pr >>> 6] |= two << ((pr >>> 1) & 31) + else + if two & 2 + q = pos[i] + m.v[q >>> 5] |= 1 << (q & 31) + if two & 1 + q = pos[i + 1] + m.v[q >>> 5] |= 1 << (q & 31) mask = maskIdx if mask is undefined transposeMat m, t From c5b4bc11fdc70dc7dbd4cbbeb50c842ec323bf8d Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:01:36 -0600 Subject: [PATCH 22/32] barcodes: take numeric groups straight from the code points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three digits a group from their code points, the tail group carrying one or two; validation already guarantees digits, so the slice-and-Number per group goes. Alternating processes, min of 5, bun: numeric v8 15.01 → 12.99 µs, v18 43.4 → 40.2 µs; alphanumeric untouched. Output byte-identical: encoder oracle, encode probe, tests, corpus. --- packages/barcodes/qr.rip | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 65e1a934..aadaab57 100644 --- a/packages/barcodes/qr.rip +++ b/packages/barcodes/qr.rip @@ -265,9 +265,15 @@ encodeData =! (ver, ecc, text, type, utf8) -> push MODE_BITS[type], 4 push dataLen, lengthBits if type is 'numeric' - for i in [0...dataLen] by 3 - n = Math.min(3, dataLen - i) - push Number(text.slice(i, i + n)), NUMERIC_BITS[n] + # Three digits a group straight from their code points; the tail group + # carries one or two. + i = 0 + while i + 3 <= dataLen + push (text.charCodeAt(i) - 48) * 100 + (text.charCodeAt(i + 1) - 48) * 10 + text.charCodeAt(i + 2) - 48, 10 + i += 3 + rest = dataLen - i + if rest is 2 then push (text.charCodeAt(i) - 48) * 10 + text.charCodeAt(i + 1) - 48, 7 + else if rest is 1 then push text.charCodeAt(i) - 48, 4 else if type is 'alphanumeric' for i in [0...dataLen - 1] by 2 push ALNUM_VAL[text.charCodeAt(i)] * 45 + ALNUM_VAL[text.charCodeAt(i + 1)], 11 From ac416d6cc73ec7a02b8cd240b42acdb5a8c7d938 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:02:37 -0600 Subject: [PATCH 23/32] barcodes: keep Code 128 line buffers across calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit luma, runs and rev (three diagonal-length arrays, about 37 KB at 1080p) live in a module record sized for the longest diagonal seen, as PDF417's state already does; every consumer takes explicit run counts, so stale content is never read. Paired in-process A/B, best of 3, bun: Code 128 1080p read −1.8%, within noise; a camera loop feeding frames of one size allocates once. Results identical: reader probe (876 lines: sampleLine over 6 formats × 4 widths × 4 alignments × 6 start and advance combinations, full readCode128 and readPDF417 results on 3 texts × scales × 3 formats × 4 rotations × 2 polarities, 6 miss frames), tests, corpus, stage probes. --- packages/barcodes/code128.rip | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/code128.rip b/packages/barcodes/code128.rip index 0eca08f0..7388a4f1 100644 --- a/packages/barcodes/code128.rip +++ b/packages/barcodes/code128.rip @@ -816,6 +816,10 @@ trackEdges =! (img, format, hit, codes, luma, runs, rev) -> [oa, ob] = edges [point(oa, a0 + b0 * oa), point(oa, a1 + b1 * oa), point(ob, a1 + b1 * ob), point(ob, a0 + b0 * ob)] +# Line buffers sized for the longest diagonal seen so far, so a camera loop +# feeding frames of one size allocates once. +lines =! { side: 0, luma: Uint8Array.new(0), runs: Float64Array.new(0), rev: Float64Array.new(0) } + # First Code 128 on any scan line, where it was read and its corners; null # on a miss. Lines run along both axes; after each axis pass, lines along # the tilt estimated from its best unread crossings, unless `opts.tilt` is @@ -827,9 +831,12 @@ export readCode128 =! (img, opts = {}) -> fail "invalid opts.tilt=#{tilt} (#{typeof tilt})", TypeError unless typeof tilt is 'boolean' # A line along a diagonal is as long as the frame's diagonal. side = Math.ceil(Math.hypot(img.width, img.height)) + 2 - luma = Uint8Array.new side - runs = Float64Array.new side - rev = Float64Array.new side + if side > lines.side + lines.side = side + lines.luma = Uint8Array.new side + lines.runs = Float64Array.new side + lines.rev = Float64Array.new side + {luma, runs, rev} = lines codes = [] more = [] hit = null From cc31eb667ab1b002c404b04f01093dbc882663b6 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:03:02 -0600 Subject: [PATCH 24/32] barcodes: sample axis scan lines through whole-span copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sampleLine with consecutive pixels copies a planar row with set and folds word-aligned RGBA and RGB rows with copyWords and copyTriples. sampleAlong routes the two exact axis directions through sampleLine, which also gives vertical lines a plain strided loop; the samples are exactly those of one scan line, since (x + k·sx) >> 16 equals (x >> 16) + k·(sx >> 16) for whole-pixel steps. Paired in-process A/B, best of 3, bun: Code 128 1080p read 176 → 141 µs, PDF417 desk 1080p miss 475 → 353 µs, Code 128 text phone miss −9%. Results identical: reader probe (876 lines), tests, corpus, stage probes. --- packages/barcodes/code128.rip | 6 +++++- packages/barcodes/image.rip | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/code128.rip b/packages/barcodes/code128.rip index 7388a4f1..21089137 100644 --- a/packages/barcodes/code128.rip +++ b/packages/barcodes/code128.rip @@ -17,7 +17,7 @@ # ============================================================================== import { gifDataUrl, writeGif } from './gif.rip' -import { MAX_IMAGE_SIDE, asInt, asObject, asString, contrast, edgeRuns, fail, reverseRuns, validateImage } from './image.rip' +import { MAX_IMAGE_SIDE, asInt, asObject, asString, contrast, edgeRuns, fail, reverseRuns, sampleLine, validateImage } from './image.rip' # Bar/space widths for values 0..106; 106 is Stop with its termination bar. PATTERNS =! %w[ @@ -706,6 +706,10 @@ sampleAlong =! (luma, data, width, height, format, ox, oy, dx, dy) -> y = Math.round((oy + t0 * dy) * 65536) + 32768 sx = Math.round dx * 65536 sy = Math.round dy * 65536 + # An axis line steps one whole pixel per sample: a row segment or a column. + if (sx is 65536 and sy is 0) or (sx is 0 and sy is 65536) + sampleLine luma, data, (y >> 16) * row + (x >> 16) * step, (if sy then row else step), count, format + return count switch step when 1 for k in [0...count] diff --git a/packages/barcodes/image.rip b/packages/barcodes/image.rip index 767f5fcf..0c5e3145 100644 --- a/packages/barcodes/image.rip +++ b/packages/barcodes/image.rip @@ -166,9 +166,23 @@ export darkToImage =! (width, height, isDark) -> # One scan line as 8-bit luma straight from the source pixels: `at` is the # first pixel's byte and `advance` the bytes to the next pixel. Packed RGB # takes (r + 2g + b) / 4, symmetric in r and b so every byte order reads -# alike; wide luma planes take their top eight bits. +# alike; wide luma planes take their top eight bits. Consecutive pixels +# copy whole, planar bytes straight across and word-aligned packed pixels +# a word at a time. export sampleLine! =! (luma, data, at, advance, count, format) -> - switch format.step + step = format.step + if advance is step + aligned = LITTLE_ENDIAN and ((data.byteOffset + at) & 3) is 0 + if step is 1 + luma.set data.subarray(at, at + count) + return + if step is 4 and aligned + copyWords luma, data, data.byteOffset + at, count + return + if step is 3 and aligned + copyTriples luma, data, data.byteOffset + at, count + return + switch step when 1 for k in [0...count] luma[k] = data[at] From f4f7851a422b042a76bed5f7014115d3b1707e65 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:03:26 -0600 Subject: [PATCH 25/32] barcodes: route PDF417 axis rays through sampleLine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sampleRay for the four exact axis directions computes the first inner sample's index and a fixed advance and hands the luma span to sampleLine, so horizontal rays take the whole-span copy. Profile at 1080p, 2000 decodes: sampleRay self time 158 → 11 ms. Paired in-process A/B, best of 3, bun: PDF417 1080p read 150 → 134 µs; misses unchanged. Results identical: reader probe (876 lines), tests, corpus, stage probes. --- packages/barcodes/pdf417.rip | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/barcodes/pdf417.rip b/packages/barcodes/pdf417.rip index 9c7e42b7..5face5d3 100644 --- a/packages/barcodes/pdf417.rip +++ b/packages/barcodes/pdf417.rip @@ -906,6 +906,12 @@ sampleRay! =! (luma, img, format, x, y, dx, dy, count) -> step = format.step row = width * step data = img.data + # An axis ray steps one whole pixel per sample, so the inner samples run + # from the first by a fixed advance: a row segment or a column. + if hi > lo and ((dy is 0 and (dx is 1 or dx is -1)) or (dx is 0 and (dy is 1 or dy is -1))) + at = (y + lo * dy | 0) * row + (x + lo * dx | 0) * step + sampleLine luma.subarray(lo, hi), data, at, dx * step + dy * row, hi - lo, format + return if step is 1 for n in [lo...hi] luma[n] = data[(y + n * dy | 0) * row + (x + n * dx | 0)] From 419bbc748605cbee1fbe23e019e4ff836e2584bf Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:03:50 -0600 Subject: [PATCH 26/32] barcodes: correlate four gradient products per step in barShift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit barShift is 37% of a Code 128 miss (16 calls × 35 shifts × 1024 Int16 products); the inner sum is unrolled four wide, which is exact because the products are integers. Paired in-process A/B, best of 3, bun: Code 128 1080p read 135.5 → 114.7 µs, desk 1080p miss 1.00 ms → 844 µs, phone desk/text/noise misses −11/−17/−6%. Results identical: reader probe (876 lines), tests, corpus, stage probes. --- packages/barcodes/code128.rip | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/barcodes/code128.rip b/packages/barcodes/code128.rip index 21089137..a5e672ed 100644 --- a/packages/barcodes/code128.rip +++ b/packages/barcodes/code128.rip @@ -562,9 +562,16 @@ barShift =! (luma, luma2, lo, hi) -> gradB[k] = luma2[x + 1] - luma2[x] best = -Infinity bestAt = 0 + # Integer products, four per step; the sums are exact in any order. for s in [0..2 * TILT_SHIFT] sum = 0 - sum += gradA[k + s] * gradB[k] for k in [0...span] + k = 0 + while k + 3 < span + sum += gradA[k + s] * gradB[k] + gradA[k + s + 1] * gradB[k + 1] + gradA[k + s + 2] * gradB[k + 2] + gradA[k + s + 3] * gradB[k + 3] + k += 4 + while k < span + sum += gradA[k + s] * gradB[k] + k++ corr[s] = sum if sum > best best = sum From b2104e07191f518fcdd302c4abc5159c80839823 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:04:14 -0600 Subject: [PATCH 27/32] barcodes: find edges without a closure per scan line edgeRuns built an edge closure over eight locals on every scan line and called it per edge; in noise nearly every sample is an edge. The rising and falling branches are merged with the direction as a conditional (the comparisons are unchanged, so every crossing is bit-identical) and the edge body is written in place, once in the loop and once for the trailing edge. Alternating runs, min of 5, bun: PDF417 desk miss 0.81x, noise 0.85x, read 0.84x; Code 128 read 0.94x, noise 0.89x. Results identical: reader oracle (2204 reads: ZXing code128, pdf417 and falsepositives sets at 4 rotations, synthetic rasters at scales, rotations, inversions, skews, blur and noise in RGBA and I420, symbol-free frames, hashing corners, angle, codewords and flags), tests, corpus, stage probes. --- packages/barcodes/image.rip | 57 ++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/barcodes/image.rip b/packages/barcodes/image.rip index 0c5e3145..6f61e7f7 100644 --- a/packages/barcodes/image.rip +++ b/packages/barcodes/image.rip @@ -250,47 +250,44 @@ export edgeRuns =! (luma, count, runs, fraction) -> best = minV bestAt = minAt last = 0 - # The edge between the extremum at `at` and the candidate at `bestAt`. - edge = -> + k++ + while k < count + v = luma[k] + if (if rising then v > best else v < best) + best = v + bestAt = k + else if (if rising then v <= best - swing else v >= best + swing) + # The edge between the extremum at `at` and the candidate at + # `bestAt`: cross between samples i - 1 and i. + level = (value + best) / 2 + i = at + if rising + i++ while luma[i] < level + else + i++ while luma[i] > level + a = luma[i - 1] + b = luma[i] + pos = i - 1 + (level - a) / (b - a) + runs[n++] = pos - last + last = pos + at = bestAt + value = best + best = v + bestAt = k + rising = not rising + k++ + if Math.abs(best - value) >= swing level = (value + best) / 2 i = at if rising i++ while luma[i] < level else i++ while luma[i] > level - # Cross between samples i - 1 and i. a = luma[i - 1] b = luma[i] pos = i - 1 + (level - a) / (b - a) runs[n++] = pos - last last = pos - k++ - while k < count - v = luma[k] - if rising - if v > best - best = v - bestAt = k - else if v <= best - swing - edge() - at = bestAt - value = best - best = v - bestAt = k - rising = false - else - if v < best - best = v - bestAt = k - else if v >= best + swing - edge() - at = bestAt - value = best - best = v - bestAt = k - rising = true - k++ - edge() if Math.abs(best - value) >= swing runs[n++] = count - last n From b877dd9ff4d3870eafe562294720ca5a4fc13d6b Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:04:38 -0600 Subject: [PATCH 28/32] barcodes: test the widest pair of runs before a PDF417 anchor match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every position that passed the sliding width gate went to matchRuns, which re-sums the window and only then rejects on its first pair; on textured frames that is most positions. A pattern's widest pair of runs must span at least (p_j + p_{j+1} − 0.8) / modules of the window when the pattern matches, which is the per-pair tolerance matchRuns applies, so checking that pair against the sliding total with a further 0.01 margin skips only windows the match would refuse. widest() derives the pair and the fraction from the pattern, so findRuns stays generic. Alternating runs, min of 5, bun: PDF417 noise miss 0.66x, desk 0.87x, desk 1080p 0.86x, read 0.98x. Results identical: reader oracle (2204 reads), tests, corpus, stage probes. --- packages/barcodes/pdf417.rip | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/barcodes/pdf417.rip b/packages/barcodes/pdf417.rip index 5face5d3..9ff46bec 100644 --- a/packages/barcodes/pdf417.rip +++ b/packages/barcodes/pdf417.rip @@ -834,6 +834,18 @@ lookup =! (runs, at, total, mirror) -> pattern = (pattern << 1) | (if mirror then k & 1 else 1 - (k & 1)) bits[pattern] - 1 +# The widest pair of a pattern's runs and the least fraction of the window +# it spans when the pattern matches: what the match allows, less a margin, +# so a check before the match only skips windows the match would refuse. +widest =! (pattern, modules) -> + at = 0 + wide = 0 + for k in [0...pattern.length - 1] + if pattern[k] + pattern[k + 1] > wide + wide = pattern[k] + pattern[k + 1] + at = k + { at, fraction: (wide - 0.8) / modules - 0.01 } + # Width in pixels of the runs at `at` when they match a fixed pattern, or 0. # Each distance from a run's leading edge to the next run of its kind is # compared, since print gain moves every edge by the same amount and leaves @@ -855,17 +867,18 @@ matchRuns =! (runs, at, pattern, modules) -> if err < total * 0.25 then total else 0 # First run index at or after `from`, same parity, where the pattern matches. -# A sliding sum of the window rejects one narrower than a module a run -# before the full comparison. +# A sliding sum of the window rejects one narrower than a module a run, +# then the pattern's widest pair of runs, before the full comparison. findRuns =! (runs, n, from, pattern, modules) -> len = pattern.length last = n - len return -1 if from > last + {at: pair, fraction} = widest pattern, modules total = 0 total += runs[from + k] for k in [0...len] at = from loop - return at if total >= modules and matchRuns(runs, at, pattern, modules) + return at if total >= modules and runs[at + pair] + runs[at + pair + 1] >= fraction * total and matchRuns(runs, at, pattern, modules) at += 2 return -1 if at > last total += runs[at + len - 2] + runs[at + len - 1] - runs[at - 2] - runs[at - 1] From 23f9b22cc1cd6eab2ca428937415f367424adf67 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:05:02 -0600 Subject: [PATCH 29/32] barcodes: one PDF417 anchor sweep per runs array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanLine ran eight findRuns sweeps over each line (two modes × forward and reversed × two parities). sweep walks each of the two arrays once, sliding the four windows (start pattern and mirrored stop, both parities) together, and collects the matching positions per mode and parity; decodes are then tried in the original order. Each window is summed as its own sweep would sum it (fresh at its parity's start and after every match, the same association for the slide), so the width gate reads the same totals and the candidate sequence is identical. Alternating runs, min of 5, bun: PDF417 noise miss 0.91x on top of the widest-pair gate (811 → 736 µs), other rows within ±2%. Results identical: reader oracle (2204 reads), tests, corpus, stage probes. --- packages/barcodes/pdf417.rip | 53 +++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/barcodes/pdf417.rip b/packages/barcodes/pdf417.rip index 9ff46bec..414b2b78 100644 --- a/packages/barcodes/pdf417.rip +++ b/packages/barcodes/pdf417.rip @@ -866,6 +866,46 @@ matchRuns =! (runs, at, pattern, modules) -> err += d if err < total * 0.25 then total else 0 +# Every run index where a mode's anchor pattern matches, for both modes +# and both parities of one runs array, in one pass: `hits[base + 2 * mode +# + parity]` receives the ascending positions. Four windows slide +# together, each summed as its own sweep would sum it, restarted after a +# match, so the width gate reads the same totals as a sweep per window. +START_WIDEST =! widest START_RUNS, FORWARD.modules +STOP_WIDEST =! widest STOP_BACK, MIRROR.modules +tot =! Float64Array.new 4 +fresh =! Uint8Array.new 4 +sweep! =! (runs, n, hits, base) -> + hits[base + w].length = 0 for w in [0...4] + fresh.fill 1 + last8 = n - START_RUNS.length + last9 = n - STOP_BACK.length + for at in [0..last8] + w = at & 1 + if fresh[w] + t = 0 + t += runs[at + k] for k in [0...START_RUNS.length] + fresh[w] = 0 + else + t = tot[w] + (runs[at + 6] + runs[at + 7] - runs[at - 2] - runs[at - 1]) + tot[w] = t + if t >= FORWARD.modules and runs[at + START_WIDEST.at] + runs[at + START_WIDEST.at + 1] >= START_WIDEST.fraction * t and matchRuns(runs, at, START_RUNS, FORWARD.modules) + hits[base + w].push at + fresh[w] = 1 + continue if at > last9 + w += 2 + if fresh[w] + t = 0 + t += runs[at + k] for k in [0...STOP_BACK.length] + fresh[w] = 0 + else + t = tot[w] + (runs[at + 7] + runs[at + 8] - runs[at - 2] - runs[at - 1]) + tot[w] = t + if t >= MIRROR.modules and runs[at + STOP_WIDEST.at] + runs[at + STOP_WIDEST.at + 1] >= STOP_WIDEST.fraction * t and matchRuns(runs, at, STOP_BACK, MIRROR.modules) + hits[base + w].push at + fresh[w] = 1 + return + # First run index at or after `from`, same parity, where the pattern matches. # A sliding sum of the window rejects one narrower than a module a run, # then the pattern's widest pair of runs, before the full comparison. @@ -1709,20 +1749,19 @@ tryDecode =! (s, x, y, dx, dy, unit, first, endAt, extent, mode) -> # then the same for stop patterns read backward; each is tried through to a # decode. scanLine =! (s, vertical, line, count) -> - {runs, rev} = s + {runs, rev, hits} = s n = edgeRuns s.luma, count, runs, 5 # A start pattern and an indicator need sixteen runs. return null if n < 16 m = reverseRuns runs, n, rev - for mode in [FORWARD, MIRROR] then for reversed in [false, true] + sweep runs, n, hits, 0 + sweep rev, m, hits, 4 + for mode, mi in [FORWARD, MIRROR] then for reversed in [false, true] r = if reversed then rev else runs rn = if reversed then m else n for inverted in [false, true] first = if inverted then 0 else 1 - at = first - loop - at = findRuns r, rn, at, mode.anchor, mode.modules - break if at < 0 + for at in hits[(if reversed then 4 else 0) + 2 * mi + first] pos = 0 pos += r[k] for k in [0...at] pixel = if reversed then count - 1 - pos else pos @@ -1745,7 +1784,6 @@ scanLine =! (s, vertical, line, count) -> hit.reversed = reversed hit.inverted = inverted return hit - at += 2 null # Scan lines middle-out at a stride that covers the axis in about 32 lines. @@ -1780,6 +1818,7 @@ buffers =! (side) -> state.luma = Uint8Array.new(side) state.runs = Float64Array.new(side) state.rev = Float64Array.new(side) + state.hits = ([] for w in [0...8]) state.rluma = Uint8Array.new(2 * side) state.rruns = Float64Array.new(2 * side + 2) state.cells = Int16Array.new(maxRays * MAX_CELLS) From adc4c51edda0fa3d25abc26dcb56a5988fa80fc4 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Thu, 17 Sep 2026 23:10:43 -0600 Subject: [PATCH 30/32] barcodes: performance tables remeasured Every bench row and both race tables on an idle machine, min of three alternating rounds against the previous figures; the Test section names the three stage probes. --- packages/barcodes/README.md | 67 +++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/packages/barcodes/README.md b/packages/barcodes/README.md index e149d115..040114f2 100644 --- a/packages/barcodes/README.md +++ b/packages/barcodes/README.md @@ -330,33 +330,33 @@ with the rows it touches. | Encode | | |---------------------------|------:| | QR raw, version 1 | 2 µs | -| QR raw, version 10 | 15 µs | -| QR raw, version 22 | 47 µs | -| QR svg, version 10 | 32 µs | -| QR gif, version 10 | 15 µs | +| QR raw, version 10 | 13 µs | +| QR raw, version 22 | 41 µs | +| QR svg, version 10 | 20 µs | +| QR gif, version 10 | 13 µs | | Code 128 raw, 18 chars | 2 µs | -| PDF417 raw, 18 chars | 12 µs | +| PDF417 raw, 18 chars | 11 µs | | Read a clean raster | | |---------------------------|--------:| -| QR version 1, 132x132 | 36 µs | -| QR 1280x720 | 653 µs | -| QR version 10, 1920x1080 | 1.56 ms | -| Code 128, 1920x1080 | 171 µs | -| PDF417, 1920x1080 | 149 µs | +| QR version 1, 132x132 | 26 µs | +| QR 1280x720 | 591 µs | +| QR version 10, 1920x1080 | 1.41 ms | +| Code 128, 1920x1080 | 107 µs | +| PDF417, 1920x1080 | 114 µs | | Miss, no symbol | 1920x1080 | 2160x2592 | |-------------------|----------:|----------:| -| QR, desk | 5.01 ms | 13.59 ms | -| QR, printed page | 2.83 ms | 8.21 ms | -| QR, noise | 10.78 ms | 27.54 ms | -| QR, fine weave | 3.60 ms | 10.44 ms | -| QR, desk, `nativeLimit: 1500` | | 4.70 ms | -| Code 128, desk | 1.04 ms | 1.43 ms | -| Code 128, printed page | | 927 µs | -| Code 128, noise | | 2.20 ms | -| PDF417, desk | 288 µs | 464 µs | -| PDF417, noise | | 1.31 ms | +| QR, desk | 4.64 ms | 12.55 ms | +| QR, printed page | 2.55 ms | 7.12 ms | +| QR, noise | 8.95 ms | 21.94 ms | +| QR, fine weave | 3.13 ms | 8.73 ms | +| QR, desk, `nativeLimit: 1500` | | 4.05 ms | +| Code 128, desk | 764 µs | 1.16 ms | +| Code 128, printed page | | 742 µs | +| Code 128, noise | | 1.73 ms | +| PDF417, desk | 223 µs | 401 µs | +| PDF417, noise | | 785 µs | A camera runs the miss rows thirty times a second, so they are what sets the frame budget; the phone is about twice as slow as this machine. @@ -382,18 +382,18 @@ best of three 400 ms means, this package timed first: | Encode (µs) | rip | paulmillr/qr | speedup | |-----------------------------|-------:|-------------:|--------:| -| raw, version 1 | 2.3 | 3.0 | 1.33x | -| raw, version 10 | 14.1 | 16.8 | 1.19x | -| raw, version 22 | 45.5 | 51.2 | 1.13x | -| svg, version 10 | 29.7 | 46.6 | 1.57x | -| gif, version 10 | 14.4 | 17.8 | 1.23x | +| raw, version 1 | 2.2 | 3.1 | 1.39x | +| raw, version 10 | 13.1 | 19.5 | 1.49x | +| raw, version 22 | 41.5 | 54.1 | 1.30x | +| svg, version 10 | 19.6 | 48.7 | 2.49x | +| gif, version 10 | 12.9 | 18.4 | 1.43x | | Decode (µs) | rip | paulmillr/qr | speedup | |-----------------------------|-------:|-------------:|--------:| -| 132x132 raster, version 1 | 33.0 | 112 | 3.38x | -| 1280x720 frame, one symbol | 620 | 996 | 1.61x | -| 1920x1080 frame, one symbol | 1489 | 2190 | 1.47x | -| 1920x1080 weave, no symbol | 5403 | 23660 | 4.38x | +| 132x132 raster, version 1 | 23.9 | 117 | 4.89x | +| 1280x720 frame, one symbol | 577 | 1026 | 1.78x | +| 1920x1080 frame, one symbol | 1382 | 2246 | 1.63x | +| 1920x1080 weave, no symbol | 4482 | 24413 | 5.45x | Encode inputs are `Hello world`, 192 bytes and 768 bytes of text. Decode inputs are synthetic RGBA frames with one symbol centered on a flat @@ -437,6 +437,15 @@ including Macro PDF417, then round-trips rasters across scales, row heights, rotations, inversion, compact and extreme shapes, skew, cropped and torn start columns and holes. +Three probes under `test/` print one hash line per case, so two builds can +be shown identical beyond the corpus counts: `probe-stages.rip` hashes the +decoder's grid, codewords and result for every version, level, mask, text +kind, scale and damage level; `probe-pixels.rip` hashes every pyramid +layer's luma, cuts, blocks, packed bitmap and finder count across sizes, +input formats, padding and offsets; `probe-encode.rip` hashes every +encoder output in every format and option. Each header carries its diff +recipe. + ## Design The QR encoder keeps a symbol as one `Int32Array` with 32 modules per word, From 5b5178335c47de28c98e1f1521e9a2b9da978123 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Fri, 18 Sep 2026 00:23:31 -0600 Subject: [PATCH 31/32] barcodes: the race includes ZXing, qr with pull request 39 and the package before its review round test/compare.rip runs six columns in one process: @zxing/library and zxing-wasm (dev dependencies), paulmillr/qr 0.7.0 as released and with pull request 39 applied (misc/qr, misc/qr-perf), and this package as it stands on main (misc/rip-prior) and as the working tree; every cell shows its slowdown against the working tree, and every reader is checked against the expected text before it is timed. Code 128 and PDF417 join the race on the bench's 1080p hit frames. The README carries the tables and the recipe. --- bun.lock | 18 +++ packages/barcodes/README.md | 61 ++++++---- packages/barcodes/package.json | 6 +- packages/barcodes/test/compare.rip | 189 +++++++++++++++++++++++------ 4 files changed, 211 insertions(+), 63 deletions(-) diff --git a/bun.lock b/bun.lock index c95ca2c1..a833bd87 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,10 @@ "packages/barcodes": { "name": "@rip/barcodes", "version": "0.0.0", + "devDependencies": { + "@zxing/library": "^0.23.0", + "zxing-wasm": "^3.1.4", + }, }, "packages/csv": { "name": "@rip/csv", @@ -243,6 +247,8 @@ "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="], + "@types/node": ["@types/node@26.6.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -285,6 +291,10 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@zxing/library": ["@zxing/library@0.23.0", "", { "dependencies": { "ts-custom-error": "^3.3.1" }, "optionalDependencies": { "@zxing/text-encoding": "~0.9.0" } }, "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA=="], + + "@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], @@ -307,6 +317,12 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "ts-custom-error": ["ts-custom-error@3.3.1", "", {}, "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A=="], + + "type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], @@ -325,6 +341,8 @@ "vscode-rip": ["vscode-rip@workspace:packages/vscode"], + "zxing-wasm": ["zxing-wasm@3.1.4", "", { "dependencies": { "@types/emscripten": "^1.41.6", "type-fest": "^5.9.0" } }, "sha512-1+v8p7sauddySVNG+ye7GD/0TS95BG2ycdkrC+EnBrFBFkHeIghzUt2yVjtx3NucJeNrQnhT8yGAIz60Vx9o0w=="], + "vscode-languageclient/vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.13", "", {}, "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw=="], } } diff --git a/packages/barcodes/README.md b/packages/barcodes/README.md index 040114f2..494a668d 100644 --- a/packages/barcodes/README.md +++ b/packages/barcodes/README.md @@ -374,31 +374,48 @@ output, committed: | code128-1 to 3, where tested | 49 | 96 | 90 | | falsepositives-1 and 2 | 47 | 0 false | up to 6 allowed | -`rip test/compare.rip` races the QR half against -[paulmillr/qr](https://github.com/paulmillr/qr) 0.7.0, the TypeScript -implementation it was ported from, with Paul's package checked out at -`misc/qr`. Both run in one process on the same inputs; each row is the -best of three 400 ms means, this package timed first: - -| Encode (µs) | rip | paulmillr/qr | speedup | -|-----------------------------|-------:|-------------:|--------:| -| raw, version 1 | 2.2 | 3.1 | 1.39x | -| raw, version 10 | 13.1 | 19.5 | 1.49x | -| raw, version 22 | 41.5 | 54.1 | 1.30x | -| svg, version 10 | 19.6 | 48.7 | 2.49x | -| gif, version 10 | 12.9 | 18.4 | 1.43x | - -| Decode (µs) | rip | paulmillr/qr | speedup | -|-----------------------------|-------:|-------------:|--------:| -| 132x132 raster, version 1 | 23.9 | 117 | 4.89x | -| 1280x720 frame, one symbol | 577 | 1026 | 1.78x | -| 1920x1080 frame, one symbol | 1382 | 2246 | 1.63x | -| 1920x1080 weave, no symbol | 4482 | 24413 | 5.45x | +`rip test/compare.rip` races everyone in one process on the same inputs: +ZXing as its JavaScript port ([@zxing/library](https://github.com/zxing-js/library)) +and its C++ build ([zxing-wasm](https://github.com/Sec-ant/zxing-wasm), +`tryHarder` on as its own benchmark runs it), both dev dependencies of +this package; [paulmillr/qr](https://github.com/paulmillr/qr), the +implementation the QR half was ported from, as 0.7.0 was released and with +[pull request 39](https://github.com/paulmillr/qr/pull/39) applied, checked +out at `misc/qr` and `misc/qr-perf`; and this package as it stood before +its own review round, checked out at `misc/rip-prior`, and as it is. A row +is the best of three 400 ms means, the columns timed left to right, each +shown with its slowdown against this package: + +| Encode (µs) | qr 0.7.0 | qr + PR 39 | rip prior | rip | +|-----------------------------|-------------:|-------------:|-------------:|-------:| +| raw, version 1 | 3.1 (1.4x) | 2.2 (1.0x) | 2.3 (1.1x) | 2.2 | +| raw, version 10 | 18.5 (1.4x) | 12.9 (1.0x) | 14.6 (1.1x) | 12.9 | +| raw, version 22 | 53.0 (1.3x) | 41.2 (1.0x) | 46.5 (1.2x) | 40.1 | +| svg, version 10 | 45.5 (2.4x) | 21.2 (1.1x) | 29.4 (1.5x) | 19.3 | +| gif, version 10 | 18.4 (1.4x) | 12.7 (1.0x) | 14.8 (1.2x) | 12.9 | + +| Decode (µs) | ZXing | zxing-wasm | qr 0.7.0 | qr + PR 39 | rip prior | rip | +|-----------------------------|---------------:|---------------:|---------------:|-------------:|-------------:|-------:| +| 132x132 raster, version 1 | 49.2 (2.0x) | 79.8 (3.3x) | 117 (4.8x) | 21.9 (0.9x) | 34.5 (1.4x) | 24.4 | +| 1280x720 frame, one symbol | 2040 (3.6x) | 2102 (3.7x) | 1004 (1.7x) | 554 (1.0x) | 621 (1.1x) | 574 | +| 1920x1080 frame, one symbol | 4509 (3.3x) | 4774 (3.5x) | 2276 (1.7x) | 1232 (0.9x) | 1498 (1.1x) | 1378 | +| 1920x1080 weave, no symbol | 4516 (1.0x) | 8122 (1.8x) | 24623 (5.5x) | 4437 (1.0x) | 5536 (1.2x) | 4452 | + +| Read (µs) | ZXing | zxing-wasm | rip prior | rip | +|-----------------------------|---------------:|---------------:|-------------:|-------:| +| Code 128, 1920x1080 | 2028 (18.8x) | 1648 (15.3x) | 170 (1.6x) | 108 | +| PDF417, 1920x1080 | 4448 (39.4x) | 4276 (37.9x) | 146 (1.3x) | 113 | Encode inputs are `Hello world`, 192 bytes and 768 bytes of text. Decode inputs are synthetic RGBA frames with one symbol centered on a flat background, plus a full-frame weave for the miss case, which is dominated -by the finder search; this package walks it on packed words. +by the finder search; this package walks it on packed words. The Code 128 +and PDF417 rasters are the bench's 1080p hit frames; ZXing reads them +through its own luma conversion, as its benchmark does. Pull request 39 +carries this package's QR decoder work back to its origin, so those two +columns trade blows; the one algorithmic difference left between them is +the grid sampler, which reads the nearest pixel there and interpolates +here. Encode timings are sensitive to which symbol size a process sees first. A version 1 symbol fits one 32-bit word per row and never fills a word, so @@ -420,7 +437,7 @@ in the world, and a great deal of measuring. bun run test # rip test.rip, the contract bun run corpus # rip test/corpus.rip, the ZXing blackbox scorecard; --record matches test/corpus.txt bun run bench # rip test/bench.rip, the timing contract -bun run compare # rip test/compare.rip, the race against paulmillr/qr (needs misc/qr) +bun run compare # rip test/compare.rip, the race against ZXing and paulmillr/qr (checkouts named in its header) ``` The suite pins spec tables, encoded codewords, every output format, every diff --git a/packages/barcodes/package.json b/packages/barcodes/package.json index b062f6d8..8d49dcc8 100644 --- a/packages/barcodes/package.json +++ b/packages/barcodes/package.json @@ -30,5 +30,9 @@ "pdf417.rip", "dom.rip", "README.md" - ] + ], + "devDependencies": { + "@zxing/library": "^0.23.0", + "zxing-wasm": "^3.1.4" + } } diff --git a/packages/barcodes/test/compare.rip b/packages/barcodes/test/compare.rip index f1e135a0..0722c9b5 100644 --- a/packages/barcodes/test/compare.rip +++ b/packages/barcodes/test/compare.rip @@ -1,13 +1,57 @@ -# The QR race against paulmillr/qr, the implementation this package was -# ported from: `rip test/compare.rip`, with Paul's package checked out at -# misc/qr in the repository root (`git clone https://github.com/paulmillr/qr -# misc/qr`). Both run in one process on the same inputs, this package first -# on each row; a row is the best of three means over 400 ms windows. The -# rows are the README's Performance table. +# The race, everyone in one process on the same inputs: `rip test/compare.rip`. +# ZXing runs as its JavaScript port, @zxing/library, and as its C++ build, +# zxing-wasm (`tryHarder` on, as its own benchmark runs it), both dev +# dependencies of this package. paulmillr/qr, the implementation the QR half was ported from, runs +# twice: 0.7.0 as released, checked out at misc/qr in the repository root +# (`git clone https://github.com/paulmillr/qr misc/qr`), and with pull +# request 39 applied, checked out beside it (`git -C misc/qr fetch +# https://github.com/shreeve/qr perf && git -C misc/qr worktree add ../qr-perf +# FETCH_HEAD`). This package runs twice as well: as it stands on main, +# checked out at misc/rip-prior (`git worktree add misc/rip-prior main`), +# and as the working tree. A row is the best of three means over 400 ms +# windows, the columns timed left to right, each shown with its slowdown +# against the working tree. The rows are the README's Performance tables. +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import * as zxing from '@zxing/library' +import { prepareZXingModule, readBarcodes } from 'zxing-wasm/reader' import { encodeQR, decodeQR } from '../qr.rip' -import { encodeQR as encodeRef } from '../../../misc/qr/src/index.ts' -import { decodeQR as decodeRef } from '../../../misc/qr/src/decode.ts' +import { encodeCode128, readCode128 } from '../code128.rip' +import { encodePDF417, readPDF417 } from '../pdf417.rip' +import { encodeQR as encode070 } from '../../../misc/qr/src/index.ts' +import { decodeQR as decode070 } from '../../../misc/qr/src/decode.ts' +import { encodeQR as encode39 } from '../../../misc/qr-perf/src/index.ts' +import { decodeQR as decode39 } from '../../../misc/qr-perf/src/decode.ts' +import { encodeQR as encodePrior, decodeQR as decodePrior } from '../../../misc/rip-prior/packages/barcodes/qr.rip' +import { readCode128 as readCode128Prior } from '../../../misc/rip-prior/packages/barcodes/code128.rip' +import { readPDF417 as readPDF417Prior } from '../../../misc/rip-prior/packages/barcodes/pdf417.rip' +# zxing-wasm takes its binary by hand outside a browser. +wasmBinary =! readFileSync fileURLToPath(import.meta.resolve 'zxing-wasm/reader/zxing_reader.wasm') +prepareZXingModule! { overrides: { wasmBinary }, fireImmediately: true } + +# @zxing/library warns with a stack trace on every miss. +warn =! console.warn +console.warn = (...args) -> warn ...args unless typeof args[0] is 'string' and args[0].startsWith 'MultiFormatReader:' + +# @zxing/library reads one-byte luma, converted here as its own benchmark does. +zxingRead =! (img, format) -> + {data, width, height} = img + luma = Uint8ClampedArray.new(width * height) + j = 0 + for i in [0...data.length] by 4 + luma[j++] = (data[i] + 2 * data[i + 1] + data[i + 2]) / 4 + source = zxing.RGBLuminanceSource.new luma, width, height + bitmap = zxing.BinaryBitmap.new zxing.HybridBinarizer.new(source) + hints = Map.new() + hints.set zxing.DecodeHintType.POSSIBLE_FORMATS, [format] + zxing.MultiFormatReader.new().decode(bitmap, hints).getText() + +wasmRead =! (img, format) -> + found = readBarcodes! img, { formats: [format], maxNumberOfSymbols: 1, tryHarder: true } + if found.length then found[0].text else null + +# Best of three 400 ms windows after warm-up; an `async` cell awaits each call. time =! (fn) -> fn() for i in [0...5] best = Infinity @@ -20,43 +64,108 @@ time =! (fn) -> best = Math.min best, (performance.now() - t0) * 1000 / n best -row =! (name, ours, theirs) -> - a = time ours - b = time theirs - us = (v) -> if v >= 100 then v.toFixed(0) else v.toFixed(1) - p "| #{name.padEnd 27} | #{us(a).padStart 6} | #{us(b).padStart 12} | #{(b / a).toFixed(2).padStart 6}x |" - -raster =! (text, scale, W, H) -> - raw = encodeQR text, 'raw', { scale, border: 4 } - data = Uint8Array.new(W * H * 4).fill 200 - ox = (W - raw.length) >> 1 - oy = (H - raw.length) >> 1 - for y in [0...raw.length] - for x in [0...raw.length] - q = 4 * ((oy + y) * W + ox + x) - data[q] = data[q + 1] = data[q + 2] = if raw[y][x] then 0 else 255 - data[q + 3] = 255 +timeAsync =! (fn) -> + for i in [0...5] then fn! + best = Infinity + for rep in [0...3] + t0 = performance.now() + n = 0 + while performance.now() - t0 < 400 + fn! + n++ + best = Math.min best, (performance.now() - t0) * 1000 / n + best + +later =! (fn) -> + fn.async = true + fn + +us =! (v) -> if v >= 100 then v.toFixed(0) else v.toFixed(1) + +# The last cell is this package; every other cell shows its slowdown against it. +row! =! (name, widths, cells) -> + values = [] + for cell in cells + values.push(if not cell then null else if cell.async then timeAsync! cell else time cell) + base = values[values.length - 1] + out = "| #{name.padEnd 27} |" + for v, i in values + text = if v is null then '' else if i is values.length - 1 then us v else "#{us v} (#{(v / base).toFixed 1}x)" + out += " #{text.padStart widths[i]} |" + p out + return + +head =! (title, names, widths) -> + p "| #{title.padEnd 27} |" + (" #{names[i].padStart widths[i]} |" for i in [0...names.length]).join('') + p "|-----------------------------|" + ("#{'-'.repeat widths[i] + 1}:|" for i in [0...names.length]).join('') + +rgba =! (W, H, fill = 200) -> + data = Uint8Array.new(W * H * 4).fill fill + data[i] = 255 for i in [3...data.length] by 4 { width: W, height: H, data } +place =! (img, rows, ox, oy) -> + for y in [0...rows.length] + for x in [0...rows[y].length] + v = if rows[y][x] then 0 else 255 + q = 4 * ((oy + y) * img.width + ox + x) + img.data[q] = img.data[q + 1] = img.data[q + 2] = v + img + +qrRaster =! (text, scale, W, H) -> + raw = encodeQR text, 'raw', { scale, border: 4 } + place rgba(W, H), raw, (W - raw.length) >> 1, (H - raw.length) >> 1 + +barsRaster =! (text, scale, h, W, H) -> + raw = encodeCode128 text, 'raw', { scale, height: 1 } + place rgba(W, H), (raw for y in [0...h]), (W - raw.length) >> 1, (H - h) >> 1 + +stackRaster =! (text, scale, W, H) -> + raw = encodePDF417 text, 'raw', { scale } + place rgba(W, H), raw, (W - raw[0].length) >> 1, (H - raw.length) >> 1 + miss =! (fn, img) -> -> try fn(img) catch then null +# Every reader reads the hit rows before it is timed. +expect =! (name, got, want) -> fail "#{name}: read #{JSON.stringify got}, not #{JSON.stringify want}" unless got is want + t10 = 'x'.repeat 192 t22 = 'x'.repeat 768 -p '| Encode (µs) | rip | paulmillr/qr | speedup |' -p '|-----------------------------|-------:|-------------:|--------:|' -row 'raw, version 1', (-> encodeQR 'Hello world', 'raw'), (-> encodeRef 'Hello world', 'raw') -row 'raw, version 10', (-> encodeQR t10, 'raw'), (-> encodeRef t10, 'raw') -row 'raw, version 22', (-> encodeQR t22, 'raw'), (-> encodeRef t22, 'raw') -row 'svg, version 10', (-> encodeQR t10, 'svg'), (-> encodeRef t10, 'svg') -row 'gif, version 10', (-> encodeQR t10, 'gif'), (-> encodeRef t10, 'gif') -small = raster 'Hello world', 4, 132, 132 -hd = raster 'https://example.com/some/path?with=query', 8, 1280, 720 -fhd = raster t10, 6, 1920, 1080 +QR_JS =! zxing.BarcodeFormat.QR_CODE +C128_JS =! zxing.BarcodeFormat.CODE_128 +PDF_JS =! zxing.BarcodeFormat.PDF_417 + +encodeWidths =! [12, 12, 12, 6] +head 'Encode (µs)', ['qr 0.7.0', 'qr + PR 39', 'rip prior', 'rip'], encodeWidths +for [name, text, format] in [['raw, version 1', 'Hello world', 'raw'], ['raw, version 10', t10, 'raw'], ['raw, version 22', t22, 'raw'], ['svg, version 10', t10, 'svg'], ['gif, version 10', t10, 'gif']] + row! name, encodeWidths, [(-> encode070 text, format), (-> encode39 text, format), (-> encodePrior text, format), (-> encodeQR text, format)] + +small = qrRaster 'Hello world', 4, 132, 132 +hd = qrRaster 'https://example.com/some/path?with=query', 8, 1280, 720 +fhd = qrRaster t10, 6, 1920, 1080 noise = { width: 1920, height: 1080, data: Uint8Array.new(1920 * 1080 * 4).map((_, i) -> if (i & 3) is 3 then 255 else (i * 7919) & 0xff) } +decodeWidths =! [14, 14, 14, 12, 12, 6] +p '' +head 'Decode (µs)', ['ZXing', 'zxing-wasm', 'qr 0.7.0', 'qr + PR 39', 'rip prior', 'rip'], decodeWidths +for [name, img, text] in [['132x132 raster, version 1', small, 'Hello world'], ['1280x720 frame, one symbol', hd, 'https://example.com/some/path?with=query'], ['1920x1080 frame, one symbol', fhd, t10]] + expect name, zxingRead(img, QR_JS), text + expect name, (wasmRead! img, 'QRCode'), text + expect name, decode070(img), text + expect name, decode39(img), text + expect name, decodePrior(img), text + expect name, decodeQR(img), text + row! name, decodeWidths, [(-> zxingRead img, QR_JS), later(-> wasmRead img, 'QRCode'), (-> decode070 img), (-> decode39 img), (-> decodePrior img), (-> decodeQR img)] +row! '1920x1080 weave, no symbol', decodeWidths, [miss(((i) -> zxingRead i, QR_JS), noise), later(-> wasmRead noise, 'QRCode'), miss(decode070, noise), miss(decode39, noise), miss(decodePrior, noise), miss(decodeQR, noise)] + +bars = barsRaster 'Hello, World! 2026', 3, 120, 1920, 1080 +stack = stackRaster 'Hello, World! 2026', 3, 1920, 1080 +readWidths =! [14, 14, 12, 6] +for [name, img, format, wasm, prior, read] in [['Code 128', bars, C128_JS, 'Code128', readCode128Prior, readCode128], ['PDF417', stack, PDF_JS, 'PDF417', readPDF417Prior, readPDF417]] + expect name, zxingRead(img, format), 'Hello, World! 2026' + expect name, (wasmRead! img, wasm), 'Hello, World! 2026' + expect name, prior(img).text, 'Hello, World! 2026' + expect name, read(img).text, 'Hello, World! 2026' p '' -p '| Decode (µs) | rip | paulmillr/qr | speedup |' -p '|-----------------------------|-------:|-------------:|--------:|' -row '132x132 raster, version 1', (-> decodeQR small), (-> decodeRef small) -row '1280x720 frame, one symbol', (-> decodeQR hd), (-> decodeRef hd) -row '1920x1080 frame, one symbol',(-> decodeQR fhd), (-> decodeRef fhd) -row '1920x1080 weave, no symbol', miss(decodeQR, noise), miss(decodeRef, noise) +head 'Read (µs)', ['ZXing', 'zxing-wasm', 'rip prior', 'rip'], readWidths +row! 'Code 128, 1920x1080', readWidths, [(-> zxingRead bars, C128_JS), later(-> wasmRead bars, 'Code128'), (-> readCode128Prior bars), (-> readCode128 bars)] +row! 'PDF417, 1920x1080', readWidths, [(-> zxingRead stack, PDF_JS), later(-> wasmRead stack, 'PDF417'), (-> readPDF417Prior stack), (-> readPDF417 stack)] From e624bb6729f299ce500fcc7f28e70f939cee6e3e Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Fri, 18 Sep 2026 02:27:05 -0600 Subject: [PATCH 32/32] barcodes: the race states each column as a speedup of this package --- packages/barcodes/README.md | 2 +- packages/barcodes/test/compare.rip | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/barcodes/README.md b/packages/barcodes/README.md index 494a668d..2041fd8d 100644 --- a/packages/barcodes/README.md +++ b/packages/barcodes/README.md @@ -384,7 +384,7 @@ implementation the QR half was ported from, as 0.7.0 was released and with out at `misc/qr` and `misc/qr-perf`; and this package as it stood before its own review round, checked out at `misc/rip-prior`, and as it is. A row is the best of three 400 ms means, the columns timed left to right, each -shown with its slowdown against this package: +with how many times faster this package is: | Encode (µs) | qr 0.7.0 | qr + PR 39 | rip prior | rip | |-----------------------------|-------------:|-------------:|-------------:|-------:| diff --git a/packages/barcodes/test/compare.rip b/packages/barcodes/test/compare.rip index 0722c9b5..d3dfa960 100644 --- a/packages/barcodes/test/compare.rip +++ b/packages/barcodes/test/compare.rip @@ -9,8 +9,8 @@ # FETCH_HEAD`). This package runs twice as well: as it stands on main, # checked out at misc/rip-prior (`git worktree add misc/rip-prior main`), # and as the working tree. A row is the best of three means over 400 ms -# windows, the columns timed left to right, each shown with its slowdown -# against the working tree. The rows are the README's Performance tables. +# windows, the columns timed left to right, each shown with how many times +# faster the working tree is. The rows are the README's Performance tables. import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import * as zxing from '@zxing/library' @@ -82,7 +82,7 @@ later =! (fn) -> us =! (v) -> if v >= 100 then v.toFixed(0) else v.toFixed(1) -# The last cell is this package; every other cell shows its slowdown against it. +# The last cell is this package; every other cell shows how many times faster it is. row! =! (name, widths, cells) -> values = [] for cell in cells