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 e149d115..2041fd8d 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. @@ -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.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 | - -| 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 | +`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 +with how many times faster this package is: + +| 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 @@ -437,6 +454,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, diff --git a/packages/barcodes/code128.rip b/packages/barcodes/code128.rip index 0eca08f0..a5e672ed 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[ @@ -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 @@ -706,6 +713,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] @@ -816,6 +827,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 +842,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 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/image.rip b/packages/barcodes/image.rip index 052066b4..6f61e7f7 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 @@ -141,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] @@ -211,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 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/pdf417.rip b/packages/barcodes/pdf417.rip index 9c7e42b7..414b2b78 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 @@ -854,18 +866,59 @@ 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 -# 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] @@ -906,6 +959,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)] @@ -1690,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 @@ -1726,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. @@ -1761,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) diff --git a/packages/barcodes/qr.rip b/packages/barcodes/qr.rip index 1cc9cf80..aadaab57 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) -> @@ -252,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 @@ -529,6 +548,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 @@ -537,23 +565,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 @@ -611,28 +650,59 @@ 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 # 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 @@ -651,40 +721,75 @@ 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. `runs[k]` is k one-step moves. +svgCache = null +runs = [] + 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] - for x in [0...W] - continue unless darkAt(r, x, y) + my = map[y] + continue if my < 0 + base = my * words + x = 0 + while x < W + mx = map[x] + 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. - 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" - prevX = x - prevY = y + 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 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 + '' 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] @@ -692,7 +797,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 ]== @@ -707,9 +815,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 @@ -786,7 +897,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 @@ -815,31 +926,42 @@ 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. 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. @@ -901,7 +1023,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) @@ -916,7 +1038,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 @@ -1089,9 +1212,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 @@ -1112,19 +1244,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) @@ -1357,23 +1497,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 @@ -1412,6 +1573,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) -> @@ -1452,7 +1627,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 @@ -1462,22 +1637,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 @@ -1485,7 +1662,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 @@ -1566,22 +1746,24 @@ 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) @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) @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 @@ -1617,7 +1799,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 @@ -1684,6 +1867,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 @@ -1747,7 +1941,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 @@ -1757,7 +1953,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 @@ -2299,24 +2495,32 @@ 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 + # 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 @@ -2410,25 +2614,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] @@ -2524,12 +2745,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) @@ -2677,6 +2950,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) diff --git a/packages/barcodes/test/compare.rip b/packages/barcodes/test/compare.rip index f1e135a0..d3dfa960 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 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' +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 how many times faster it is. +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)] 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"