Skip to content

barcodes: PDF417 encoder and reader - #320

Merged
shreeve merged 9 commits into
mainfrom
pdf-417
Sep 16, 2026
Merged

shreeve merged 9 commits into
mainfrom
pdf-417

Conversation

@shreeve

@shreeve shreeve commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

Adds PDF417 (ISO/IEC 15438) to rip/barcodes as packages/barcodes/pdf417.rip, a cleanroom encoder and reader alongside QR and Code 128, with the ECI table moved to eci.rip and the scan-line samplers to image.rip for sharing.

Encoder: text (four submodes with cost-based latches and shifts), byte and numeric compaction switching where the specification's rules say a switch pays, UTF-8 behind ECI 26 for text beyond Latin-1, GF(929) parity at nine levels with the recommended automatic level, automatic layout by aspect ratio or explicit columns and rows, compact symbols, and the six outputs (raw, ascii, term, svg, gif, data-url).

Reader: middle-out scan lines find a start pattern forward or reversed in both polarities, or a stop pattern read backward when the start is cropped. Both the anchor edge and the edge at the row's other end are tracked up and down the stack with least-squares fits, rays are sampled across every row along the direction between them (right for rotations and for side-on perspective alike) with a drift-corrected second pass, run lengths come from midpoint crossings between local extrema, codewords are looked up by an edge-to-similar-edge key and a 2^17 width table, each cell is voted into the row and column its indicators and cluster name, and errors and erasures are corrected together with one parity codeword held back so damage misses rather than misdecoding. Macro PDF417 segment metadata is parsed. BarcodeDetector reports pdf417, and the scan demo reads PDF417 after Code 128.

Verification

  • Package suite 42 tests; bun run test:all 26 lanes, 9,904 tests green.
  • Encoder: zxing-cpp 3.1.1 decodes every case of a 30-case harness byte for byte (compaction modes, ECI, levels 0 and 8, 1 and 30 columns, 90 rows, compact, scales, row heights, punctuation runs, a 928-codeword symbol).
  • Reader: ZXing's PDF417 blackbox photo sets 1 to 3 decode 58 of 58 at 0, 90, 180 and 270 degrees. Set 4 decodes one Macro segment per image byte-exact with its metadata; multi-symbol images are not assembled.
  • Three read-only review passes (reader, encoder against the specification, docs and conventions); every finding is fixed in its own commit.

Performance

Bun 1.4.0, Apple M5, best of three, synthetic RGBA frames:

PDF417 encode (µs) rip
raw, Hello PDF417 10.4
raw, 192 bytes of text 39.5
raw, 768 bytes of text 135.7
svg, 192 bytes 26.0
gif, 192 bytes 25.3
PDF417 decode (µs) rip
300x100 raster, 9 rows x 2 cols 83
1280x720 frame, one symbol 232
1920x1080 frame, 192 bytes 343
1920x1080 noise, no symbol 302

Corpus photographs decode in 0.4 to 8 ms warm.

Encoder: spec-recommended text/byte/numeric switching, four text
submodes, ECI for non-Latin-1 text, GF(929) Reed-Solomon at all nine
levels from a generated polynomial, automatic level and dimensions,
compact symbols, and the raw/ascii/term/svg/gif/data-url renderers.
Verified byte-for-byte against zxing-cpp on 22 encodings.

Reader: start or stop pattern located on middle-out scan lines, the
edge tracked and fitted through every line, rays sampled along the true
row direction with sub-pixel edges, codewords by edge-to-similar-edge
lookup with a width fallback, cells voted into rows by indicator and
cluster, errors-and-erasures Reed-Solomon, and text/byte/numeric/ECI/
macro decompaction. Reads 54 of the 58 ZXing blackbox images in sets
1-3 at 0, 90, 180 and 270 degrees, in single-digit milliseconds.

The scan-line samplers move from code128.rip to image.rip and the ECI
decoders from qr.rip to eci.rip so both readers share them.
The reader measures its own row-direction error from the cluster drift
along each ray and resamples with the corrected direction, which reads
wide, short symbols and lifts the ZXing blackbox sets 1-3 to 57 of 58.
A missed cell advances by the nominal pitch so repeated codewords cannot
be read two runs out of phase. Rays run one row past each end.

Tests: codeword vectors including the specification's worked examples,
Reed-Solomon at the capacity boundary, layout, pinned symbols verified
with zxing-cpp, renderer agreement, round trips over scales, row
heights, rotations, inversion, shapes, shear, corners, cropped and torn
start patterns, holes, and the decompaction paths.
The reader keeps one set of buffers sized to the largest image side seen,
so a camera loop allocates once. Ray sampling clips the in-image span
analytically and fills the rest as paper, with per-format inner loops
free of bounds checks. Each scan line's end pattern bounds the ray length
to the symbol's width instead of the widest symbol possible. The edge
hysteresis is a third of the ray's range, which keeps the blurred corpus
image 2/06 decoding once rays no longer run past the image edge. The
start-pattern search keeps a sliding window sum and skips windows
narrower than a module per run before comparing.

Bun 1.4.0, Apple M5, best of three, synthetic RGBA frames:

  decode 300x100 raster, 9 rows x 2 cols     192 us ->  69 us
  decode 1280x720 frame, one symbol          850 us -> 178 us
  decode 1920x1080 frame, 192 bytes         1060 us -> 289 us
  decode 1920x1080 noise, no symbol          806 us -> 295 us
  read 2390x2220 corpus image 1/12, warm   11000 us -> 7800 us

ZXing blackbox sets 1-3 stay at 57/58 at 0, 90, 180 and 270 degrees;
the debug state and unused test exports are gone from the reader.
The README gains a PDF417 section (compaction, options, outputs, reader
result, limits, corpus figures), encode and decode timings, and credits
for the zxing-cpp and ZXing corpus oracles. BarcodeDetector reports
pdf417 alongside qr_code, trying QR first and then readPDF417, with the
symbol corners as cornerPoints. The scan demo feeds each frame to
readPDF417 after readCode128.
A row fit whose rows run against the walk along the tracked edge marks a
mirror image; the stack is then walked from its other end instead of the
fit being clamped. Tests cover a reflected symbol, whole rows torn off
the top and bottom, rows one pixel tall, and the aspect option, so every
reader claim in the README has a test behind it. The ascii and term
outputs are described as drawing each row rowHeight modules tall, aspect
as an integer, and the credits and pin comment no longer narrate history.
The pixel position in readFrom and the paint helper is pos and at rather
than a shadow of p, and the assignment runs the review found are aligned.
…ches

An odd text tail padded in the punctuation submode is a latch to alpha,
not a shift, so the encoder continues from alpha after a byte shift as
every decoder does; 'AB1;;\x01ABCDE' decoded as 'AB1;;\x01;BCDE' before.
A symbol holds at most 928 codewords whatever its shape: the automatic
layout skips larger pairs (needs 921..928 chose 20x47) and explicit
columns or rows past the cap raise RangeError. A punctuation run from
alpha or lower latches through mixed when the latch and the way back out
cost fewer symbols than a shift per character, and the mixed-submode
latch uses the same accounting.

Pins changed by the latch rule, each decoded byte-exact by zxing-cpp:
  ';;;;;;;;;'                    86x18 -> 86x15, hash 4889440228876649914
  'line one\nline two\r\n\ttabbed' hash 3017900233706190855, same size
Two new codeword vectors, 'AB1;;\x01;;;;;' and 'AB1;;\x01ABCDE', parse
back to their text and decode byte-exact with zxing-cpp.
In mirror mode the tracked edge is the stop pattern, so the far corners
lie on the anchor's other side; they were projected a symbol width past
it. Metadata claiming more than 928 cells is rejected before the vote,
since the codeword buffers hold 928 and reads past them made every
syndrome NaN and the correction report a clean block. A stream that
corrects but does not parse is a miss, not an exception out of
readPDF417. The run buffer has room for the extra run a ray of
alternating pixels produces. The cropped-start test now checks the stop
edge and the width from the corners.
A photograph taken from the side leans the start and stop edges while
the rows stay level, so rays square to the start edge crossed more than
a row per codeword and the one such image in ZXing's corpus never got a
readable cell. The pattern at the row's other end is tracked as an edge
of its own when the scan line found it, and the row direction runs from
the leading edge to that one, averaged over the two ends; a tail edge
that holds fewer than half the lines, or that points away, leaves the
rows square to the edge as before. ZXing blackbox sets 1-3 go from 57/58
to 58/58 at 0, 90, 180 and 270 degrees.

Bun 1.4.0, Apple M5, best of three, the second track's cost:

  decode 300x100 raster, 9 rows x 2 cols    69 us ->  83 us
  decode 1280x720 frame, one symbol        176 us -> 232 us
  decode 1920x1080 frame, 192 bytes        285 us -> 343 us
  decode 1920x1080 noise, no symbol        290 us -> 302 us
@shreeve
shreeve merged commit d866e36 into main Sep 16, 2026
2 checks passed
@shreeve
shreeve deleted the pdf-417 branch September 16, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant