zxing_ffi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +275 -0
  5. data/exe/zxing-scan +6 -0
  6. data/lib/zxing_ffi/barcode.rb +77 -0
  7. data/lib/zxing_ffi/cli.rb +152 -0
  8. data/lib/zxing_ffi/config.rb +119 -0
  9. data/lib/zxing_ffi/dedupe.rb +79 -0
  10. data/lib/zxing_ffi/diagnostics.rb +69 -0
  11. data/lib/zxing_ffi/dpi.rb +103 -0
  12. data/lib/zxing_ffi/errors.rb +69 -0
  13. data/lib/zxing_ffi/formats.rb +207 -0
  14. data/lib/zxing_ffi/geometry.rb +508 -0
  15. data/lib/zxing_ffi/header_probe.rb +98 -0
  16. data/lib/zxing_ffi/image.rb +123 -0
  17. data/lib/zxing_ffi/image_magick.rb +95 -0
  18. data/lib/zxing_ffi/library_defaults.rb +7 -0
  19. data/lib/zxing_ffi/library_loader.rb +176 -0
  20. data/lib/zxing_ffi/loaders/base.rb +155 -0
  21. data/lib/zxing_ffi/loaders/image_magick.rb +159 -0
  22. data/lib/zxing_ffi/loaders/pnm.rb +113 -0
  23. data/lib/zxing_ffi/loaders/poppler.rb +260 -0
  24. data/lib/zxing_ffi/loaders/registry.rb +54 -0
  25. data/lib/zxing_ffi/loaders/vips.rb +332 -0
  26. data/lib/zxing_ffi/loaders.rb +41 -0
  27. data/lib/zxing_ffi/native.rb +237 -0
  28. data/lib/zxing_ffi/pnm.rb +676 -0
  29. data/lib/zxing_ffi/reader.rb +271 -0
  30. data/lib/zxing_ffi/scanner.rb +295 -0
  31. data/lib/zxing_ffi/sniffer.rb +155 -0
  32. data/lib/zxing_ffi/source.rb +77 -0
  33. data/lib/zxing_ffi/strategy.rb +293 -0
  34. data/lib/zxing_ffi/subprocess.rb +416 -0
  35. data/lib/zxing_ffi/transformers/base.rb +69 -0
  36. data/lib/zxing_ffi/transformers/image_magick.rb +72 -0
  37. data/lib/zxing_ffi/transformers/vips.rb +85 -0
  38. data/lib/zxing_ffi/transformers.rb +36 -0
  39. data/lib/zxing_ffi/version.rb +5 -0
  40. data/lib/zxing_ffi.rb +82 -0
  41. metadata +108 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3d24681af443b3597c80a351b2393e0a407a364f605eb2f453b030c52f3db7e0
4
+ data.tar.gz: bee3e1c5d88d45149c7ed4320ddf5b738dd6fea69019ac7b9ec1198cfd774ef3
5
+ SHA512:
6
+ metadata.gz: 7b334ea1bed486663b38bfaf7e66e3ab2b134fd47c2796e3fb49c6d46143a3f64d4d50f04aa11c481186b14025d5a4ba0d744b1dfc7d1ea2c92a99f3ff46ce17
7
+ data.tar.gz: 1e59c7e05ac3484c771275274946d619a91832bea1a33be22ecb714fe45500cb1cbf3b65525e5cd5520692a7e53a00c5d4198819aeef6e152ab45f6a5eaa365c
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-01)
4
+
5
+ First release candidate of `zxing_ffi`, a replacement for the unmaintained `zbar` gem.
6
+
7
+ - **Native core**: libZXing 3.1+ discovery (explicit `ZXING_LIB`, bundled, system, Homebrew/`/usr/local`) with a
8
+ version gate; FFI bindings for the zxing-cpp C API (GVL released while decoding); runtime format map derived from
9
+ the library; `ZXingFFI.read`, `ZXingFFI::Image`, `ZXingFFI::Barcode`, `ZXingFFI::LIBRARY_DEFAULTS`,
10
+ `ZXingFFI.diagnostics`.
11
+ - **Inputs**: magic-byte sniffing; loaders for PDF (Poppler subprocesses, CropBox-accurate, password support,
12
+ `dpi: :auto` from scan resolution), libvips (rasters; PDF opt-in), ImageMagick (rasters, IM7 or IM6) and PNM
13
+ (pure Ruby); normalization of EXIF orientation, alpha, 16-bit, CMYK, palette, 1-bit and fax aspect ratios.
14
+ - **Pipeline**: effort levels and custom pass ladders (`base`, `inverted`, `global_binarizer`, `high_res`, `tiles`,
15
+ `rotated_45`, `denoise`), stop modes, coordinate mapping back to page pixels and PDF points, deduplication,
16
+ filtering, ordering, `scan` / `scan_pages` with threads, per-page errors and instrumentation.
17
+ - **Safety**: subprocess isolation with timeouts, output caps and memory limits; pixel caps (fractional DPI for
18
+ absurd page sizes); no format confusion (explicit coders/loaders); untrusted libvips operations refused by default.
19
+ - **Robustness**: a failing escalation pass keeps earlier results (recorded in `skipped_passes`); malformed PDFs
20
+ (zero or infinite page sizes) raise `RenderError`; interrupts never leak native results; threaded scans stop
21
+ their workers on early exit; the pure-Ruby PNM decoder works in bounded memory.
22
+ - **CLI**: `zxing-scan` (JSON Lines, `--text-only`, `--diagnose`); one failing file does not stop the batch.
23
+ - **Tooling**: `rake zxing:build` (pinned, checksum-verified zxing-cpp), fixture generators, golden corpus test,
24
+ `rake bench`.
25
+ - **Platform gems**: prebuilt gems bundling libZXing for x86_64/aarch64 Linux (glibc ≥ 2.28, musl) and macOS (arm64
26
+ ≥ 11, x86_64 ≥ 10.13); diagnostics report `library.source` (`rake gem:platform`, `rake gem:verify`).
27
+ - **License**: MIT (`LICENSE.txt`, packed in every gem).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # zxing_ffi
2
+
3
+ Read QR codes and every other barcode [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp) understands from images and
4
+ PDFs, in any orientation, from Ruby.
5
+
6
+ ```ruby
7
+ require "zxing_ffi"
8
+
9
+ ZXingFFI.scan("invoice.pdf").each do |barcode|
10
+ puts "page #{barcode.page}: #{barcode.format} #{barcode.text}"
11
+ end
12
+ # page 1: qr_code https://example.com/invoices/2026-0042
13
+ ```
14
+
15
+ The gem binds the zxing-cpp **C API through the `ffi` gem** (no compiler needed to install) and wraps it in a
16
+ pipeline: detect the input type by its magic bytes, turn every page or frame into a normalized 8-bit grayscale bitmap
17
+ (EXIF orientation, alpha, 16-bit, CMYK, fax resolutions, PDF `/Rotate` and CropBox all handled), run an escalating
18
+ sequence of decode passes, map results back to page coordinates, then deduplicate and order them. It replaces the
19
+ unmaintained `zbar` gem.
20
+
21
+ Supported inputs: PDF (born-digital and scanned), PNG, JPEG, TIFF (multi-page, fax), GIF, BMP, WebP, HEIF/AVIF (when
22
+ a loader supports them) and PNM. Symbologies: QR, Micro QR, rMQR, Data Matrix, Aztec, PDF417 (incl. Compact/Micro),
23
+ MaxiCode, Code 128, Code 39/93/32, Codabar, ITF, EAN/UPC, DataBar (all variants), Telepen, PZN, DX Film Edge — call
24
+ `ZXingFFI.formats` for the exact list your library supports.
25
+
26
+ ## Requirements
27
+
28
+ - Ruby ≥ 3.3 (CRuby only; JRuby and TruffleRuby are not supported)
29
+ - **libZXing 3.1+ built with the C API** (the only hard native requirement)
30
+ - For PDFs: Poppler's command-line tools (`pdfinfo`, `pdftoppm`; `pdfimages` optional)
31
+ - For images: libvips with the `ruby-vips` gem (preferred) or ImageMagick; PNM needs nothing
32
+
33
+ ### Installing libZXing 3.x
34
+
35
+ On x86_64/aarch64 Linux (glibc or musl) and macOS, `gem install zxing_ffi` gets a
36
+ [prebuilt platform gem](#prebuilt-platform-gems) that bundles libZXing, and this step can be skipped. Elsewhere:
37
+
38
+ Distribution packages often ship zxing-cpp 2.x, whose C API is incompatible. Check with `zxing-scan --diagnose`.
39
+
40
+ **macOS (Homebrew):**
41
+
42
+ ```sh
43
+ brew install zxing-cpp poppler vips imagemagick
44
+ gem install zxing_ffi ruby-vips
45
+ ```
46
+
47
+ Homebrew's library is found automatically (`/opt/homebrew/lib`).
48
+
49
+ **Ubuntu / Debian:** build the pinned release from source (needs `cmake` and a C++20 compiler). Ruby ≥ 3.3 is
50
+ required; Ubuntu 24.04's `ruby` package is 3.2, so install a newer Ruby first (e.g. with mise or rbenv).
51
+
52
+ ```sh
53
+ sudo apt-get update
54
+ sudo apt-get install -y git cmake g++ poppler-utils libvips-tools imagemagick
55
+ sudo apt-get install -y libheif-plugin-libde265 # HEIC photos: libheif only suggests its HEVC decoder
56
+ git clone https://github.com/withniva/zxing_ffi && cd zxing_ffi
57
+ rake zxing:build # downloads zxing-cpp 3.1.1, verifies its SHA-256, builds into vendor/zxing (no Bundler needed)
58
+ sudo cp -P vendor/zxing/lib/libZXing.so* /usr/local/lib && sudo ldconfig # found automatically from now on
59
+ gem install zxing_ffi ruby-vips # ruby-vips: in-process image loading with libvips
60
+ ```
61
+
62
+ Instead of copying the library, `export ZXING_LIB=$(rake -s zxing:lib_path)` points at it inside the checkout (for the
63
+ current shell only).
64
+
65
+ Or build it yourself with `cmake -DBUILD_SHARED_LIBS=ON -DZXING_C_API=ON -DZXING_READERS=ON -DZXING_WRITERS=OFF` and
66
+ point `ZXING_LIB` at the resulting `libZXing.so`.
67
+
68
+ **Library discovery** tries, in order: `ZXingFFI.config.library_path` / `ENV["ZXING_LIB"]` (if set, nothing else is
69
+ tried), a library bundled with the gem, the dynamic loader's search path (`libZXing.so.4`, `libZXing.dylib`, …), then
70
+ `/opt/homebrew/lib` and `/usr/local/lib`. Versions outside `>= 3.1.0, < 4.0` raise `ZXingFFI::IncompatibleLibrary`.
71
+
72
+ ### Prebuilt platform gems
73
+
74
+ For these platforms RubyGems installs a gem with a prebuilt libZXing in its `vendor/lib/` (zxing-cpp 3.1.1 with readers
75
+ and the C API, compiled from the checksum-verified release tarball), so no libZXing has to be installed. Poppler and
76
+ libvips or ImageMagick are still needed for PDFs and images. `rake gem:platform` builds them locally.
77
+
78
+ | Platform gem | Runs on |
79
+ |---|---|
80
+ | `x86_64-linux-gnu`, `aarch64-linux-gnu` | glibc ≥ 2.28: Debian 10+, Ubuntu 20.04+, RHEL/AlmaLinux/Rocky 8+, Amazon Linux 2023 |
81
+ | `x86_64-linux-musl`, `aarch64-linux-musl` | musl: Alpine 3.18+ |
82
+ | `arm64-darwin` | macOS 11+ (Apple silicon) |
83
+ | `x86_64-darwin` | macOS 10.13+ (Intel) |
84
+
85
+ The Linux libraries link the C++ runtime statically and need only the C library; the macOS ones need only the
86
+ system's libc++. Everywhere else (other CPUs, older glibc) RubyGems picks the plain `ruby` gem, which uses a
87
+ system libZXing installed as described above.
88
+
89
+ - **Bundler** records platforms in `Gemfile.lock`. A lockfile created by a recent Bundler (2.6 checked) lists every
90
+ platform the gems ship; for an older one, add those you deploy to, e.g.
91
+ `bundle lock --add-platform x86_64-linux aarch64-linux-musl` (`x86_64-linux` resolves to the `-gnu` gem). A
92
+ lockfile with only `ruby` gets the plain gem everywhere.
93
+ - **Override:** `ZXING_LIB` (or `config.library_path`) still wins over the bundled library, e.g. to use a newer
94
+ zxing-cpp. `zxing-scan --diagnose` shows which library is loaded: `library.path` and `library.source` (`bundled`,
95
+ `explicit`, `system` or `prefix`). A bundled library that cannot be loaded is skipped in favour of a system one.
96
+ - **Licenses:** the bundled library is zxing-cpp (Apache-2.0) and includes libzueci (BSD-3-Clause). Their notices
97
+ are installed next to it: `vendor/lib/NOTICE.txt`, `LICENSE-zxing-cpp.txt` and `LICENSE-libzueci.txt`.
98
+
99
+ ## Usage
100
+
101
+ ### Scanning files
102
+
103
+ ```ruby
104
+ ZXingFFI.scan("photo.heic", formats: %i[qr_code data_matrix])
105
+ ZXingFFI.scan(io, effort: :thorough, stop: :exhaustive, pages: 1..3, password: "secret")
106
+
107
+ ZXingFFI.scan_pages("batch.pdf", threads: 4) do |page|
108
+ page.page # 1-based
109
+ page.barcodes # Array<ZXingFFI::Barcode>, ordered top-to-bottom, left-to-right
110
+ page.dpi # render DPI (PDF)
111
+ page.passes_run # [:base, :global_binarizer]
112
+ page.skipped_passes # { rotated_45: "no transformer available" }
113
+ page.duration # seconds
114
+ end
115
+ ```
116
+
117
+ `scan` returns every page's barcodes ordered by page, then position; `scan_pages` yields a `ZXingFFI::PageResult` per
118
+ page in page order (or returns an Enumerator). Inputs can be a path (`String`/`Pathname`), an IO, or a
119
+ `ZXingFFI::Image`.
120
+
121
+ | Option | Default | Meaning |
122
+ |---|---|---|
123
+ | `formats` | `:all` | symbols (`:qr_code`, `:ean_13`, …), library names (`"QR Code"`), meta-formats (`:all_linear`, `:all_matrix`) |
124
+ | `effort` | `:normal` | `:fast`, `:normal`, `:thorough` — which decode passes may run (see below) |
125
+ | `passes` | — | explicit ladder, e.g. `%i[base tiles]` (overrides `effort`) |
126
+ | `stop` | `:found` | stop escalating a page after the first pass that finds something; `:exhaustive`; or an Integer: stop once that many distinct codes are found **on the page** (a page with fewer runs every pass) |
127
+ | `dpi` | `:auto` | PDF render DPI: `:auto` uses a scan's native resolution (150–600) or 300 for born-digital pages |
128
+ | `max_dpi` | `600` | upper bound for automatic and high-resolution renders |
129
+ | `pages` | all | `Integer`, `Range` or `Array` of 1-based pages; pages outside the document are ignored |
130
+ | `password` | — | PDF user password (Poppler reads at most 32 bytes: longer ones raise `NotSupported`; use `loader: :vips` with `vips_block_untrusted = false`) |
131
+ | `threads` | `1` | pages processed in parallel (rendering subprocesses and decoding both run concurrently) |
132
+ | `on_page_error` | `:raise` | `:skip` records the exception in `PageResult#error` and continues |
133
+ | `loader` | config order | force a loader: `:poppler`, `:vips`, `:image_magick`, `:pnm` |
134
+ | `min_length` | `{}` | minimum text length per format, e.g. `{itf: 6}` |
135
+ | `timeout` | — | per-page budget in seconds: caps renders (re-renders get what is left) and stops escalation (the first pass always runs) |
136
+ | `instrument` | — | callable receiving `(event, payload)` for `:page_loaded`, `:pass_completed`, `:page_completed` |
137
+ | `max_pixels`, `max_pages` | config | per-call limits |
138
+
139
+ Every `ZXingFFI.read` option below is accepted too and applies to every pass.
140
+
141
+ ### Effort levels and passes
142
+
143
+ | Pass | What it does | Effort |
144
+ |---|---|---|
145
+ | `base` | library defaults + your options | fast, normal, thorough |
146
+ | `inverted` | decodes a pixel-inverted copy for white-on-black codes (zxing-cpp's `try_invert` only covers 2D codes, so with it on this pass handles the linear formats) | normal, thorough |
147
+ | `global_binarizer` | global-histogram binarizer | normal, thorough |
148
+ | `high_res` | PDF re-rendered at 2× DPI; small rasters upscaled 2× | normal, thorough |
149
+ | `tiles` | overlapping 1024–1536 px tiles, no downscaling (tiny codes on big pages) | thorough |
150
+ | `rotated_45` | image rotated 45° for linear codes at odd angles (also covers 135°/225°/315°) | thorough |
151
+ | `denoise` | `try_denoise: true` (libZXing built with `ZXING_EXPERIMENTAL_API` only) | thorough |
152
+
153
+ 2D codes are found at any rotation by the base pass; linear codes at 0/90/180/270 too.
154
+
155
+ ### Barcode
156
+
157
+ ```ruby
158
+ barcode.text # "https://…" (UTF-8)
159
+ barcode.bytes # raw payload (BINARY) — authoritative for binary content
160
+ barcode.format # :qr_code, :ean_13, :code_128, …
161
+ barcode.symbology # family, e.g. :ean_upc for :ean_13
162
+ barcode.content_type # :text, :binary, :mixed, :gs1, :iso15434, :unknown_eci
163
+ barcode.position # ZXingFFI::Quad in base-image pixels (top_left, top_right, bottom_right, bottom_left)
164
+ barcode.page_position # PDF only: points from the top-left of the displayed page (CropBox, after /Rotate)
165
+ barcode.rotation # degrees clockwise, 0..359
166
+ barcode.page, barcode.pass, barcode.dpi
167
+ barcode.mirrored?, barcode.inverted?, barcode.eci?, barcode.valid?
168
+ barcode.sequence # structured append {index:, size:, id:} or nil
169
+ barcode.extra # symbology metadata, e.g. {"ECLevel" => "M", "Version" => "3"}
170
+ barcode.to_h # JSON-friendly (bytes Base64-encoded when not text)
171
+ ```
172
+
173
+ ### Decoding raw pixels
174
+
175
+ ```ruby
176
+ image = ZXingFFI::Image.new(gray_bytes, width: 640, height: 480) # also :rgb, :bgra, … and row_stride:
177
+ ZXingFFI.read(image, formats: :qr_code, try_harder: true)
178
+ image.release! # free the buffer early
179
+ ```
180
+
181
+ `read` runs a single decode. Options map 1:1 to zxing-cpp's `ReaderOptions`: `formats`, `try_harder`, `try_rotate`,
182
+ `try_invert`, `try_downscale`, `try_denoise`, `pure`, `binarizer`, `max_symbols`, `min_line_count`, `return_errors`,
183
+ `validate_optional_checksum`, `text_mode` (`:plain`, `:eci`, `:hri`, `:escaped`, `:hex`, `:hex_eci`) and `ean_add_on`
184
+ (`:ignore`, `:read`, `:require`). Unset options keep the library's defaults (`ZXingFFI::LIBRARY_DEFAULTS`), except
185
+ that the gem defaults to `text_mode: :plain` and `validate_optional_checksum: true` — the latter drops Code 39 and ITF
186
+ symbols without a valid check digit, which avoids phantom reads on text and tables; pass
187
+ `validate_optional_checksum: false` if your labels carry no check digit.
188
+
189
+ ### Command line
190
+
191
+ ```
192
+ zxing-scan [options] FILE...
193
+ -f, --formats LIST comma-separated (default: all)
194
+ -e, --effort LEVEL fast|normal|thorough
195
+ --stop MODE found|exhaustive|N
196
+ --dpi N|auto
197
+ -p, --pages RANGE e.g. 1-3,7
198
+ --password PW (also ZXING_PDF_PASSWORD)
199
+ -j, --threads N
200
+ --text-only print text only, one per line
201
+ --diagnose print diagnostics JSON and exit
202
+ ```
203
+
204
+ Output is JSON Lines, one object per barcode: `{file, page, format, text, bytes_b64?, content_type, position,
205
+ page_position, rotation, pass}`. Exit codes: 0 found, 1 none found, 2 usage error (checked before any file is
206
+ read), 3 processing error (reported on stderr per file; the remaining files are still scanned).
207
+
208
+ ### Configuration
209
+
210
+ ```ruby
211
+ ZXingFFI.configure do |c|
212
+ c.library_path = ENV["ZXING_LIB"]
213
+ c.pdf_loaders = %i[poppler vips]
214
+ c.image_loaders = %i[vips image_magick pnm]
215
+ c.transformers = %i[vips image_magick]
216
+ c.default_dpi = 300
217
+ c.max_dpi = 600
218
+ c.max_pixels = 64_000_000
219
+ c.max_pages = nil
220
+ c.render_timeout = 60
221
+ c.subprocess_memory_limit = 2 * 1024**3
222
+ c.tool_paths = {pdftoppm: "pdftoppm", pdfinfo: "pdfinfo", pdfimages: "pdfimages", magick: nil}
223
+ c.vips_block_untrusted = true
224
+ end
225
+ ```
226
+
227
+ Configuration is read at call time; don't change it while a scan is running. `ZXingFFI.diagnostics` (or
228
+ `zxing-scan --diagnose`) reports the library path and version, optional features, defaults, loaders, tools and
229
+ their versions.
230
+
231
+ ## Limits and security
232
+
233
+ The gem is meant to process untrusted documents:
234
+
235
+ - **Renderers run in subprocesses** (Poppler, ImageMagick) with an argument array (never a shell), absolute paths,
236
+ a timeout (`render_timeout`, process group killed with TERM then KILL), a cap on their output, and an address-space
237
+ limit (`subprocess_memory_limit`, enforced on Linux only — macOS ignores `RLIMIT_AS`). Passwords are only ever passed
238
+ as the argument after `-upw`.
239
+ - **Pixel cap**: every bitmap is checked against `max_pixels` before it is rendered or decoded. PDF pages that would
240
+ exceed it are rendered at a lower (possibly fractional) DPI; rasters raise `ZXingFFI::LimitExceeded`.
241
+ - **No format confusion**: inputs are identified by magic bytes, never by extension. ImageMagick always reads with an
242
+ explicit coder (`png:/path`), never sees PDF, PostScript, SVG, MVG, MSL or text, and gets special characters in
243
+ paths neutralized. libvips is called with the loader for the sniffed type, and operations libvips flags as
244
+ *untrusted* (in 8.18: `pdfload`, `magickload`, `ppmload`) are refused while `vips_block_untrusted` is true — so
245
+ in-process PDF rendering with libvips is opt-in. A crash inside libvips or libZXing takes down the Ruby process.
246
+ - IO inputs are copied to a private (0600) temp file that is always removed.
247
+ - Decoding releases the GVL, so threads decode concurrently; results are identical to serial runs. Interrupts
248
+ (`Thread#raise`, `Timeout`, signals) are delivered once the native decode has returned and its results are freed;
249
+ leaving a threaded `scan` early stops the other page workers and kills their renderer processes.
250
+ - Passes after the first never lose results: if one fails (a re-render timing out, a transformer error), the page
251
+ keeps what earlier passes found and `PageResult#skipped_passes` records `"failed: …"`. Passes whose image would
252
+ exceed `max_pixels` (`high_res`, `rotated_45`) are skipped the same way.
253
+
254
+ Errors all inherit from `ZXingFFI::Error`: `LibraryNotFound`, `IncompatibleLibrary`, `NotSupported`,
255
+ `LoaderUnavailable`, `UnsupportedInput`, `PasswordRequired` (and `IncorrectPassword`), `RenderError` (`#stderr`,
256
+ `#exit_status`), `TimeoutError`, `LimitExceeded` (`#limit`, `#value`), `DecodeError`.
257
+
258
+ ## Development
259
+
260
+ ```sh
261
+ mise exec -- bundle install
262
+ mise exec -- bundle exec rake zxing:build # vendor/zxing (the test helper points ZXING_LIB at it)
263
+ mise exec -- bundle exec rake # all tests + standardrb
264
+ mise exec -- bundle exec rake test:unit # also test:native, test:integration, test:corpus
265
+ mise exec -- bundle exec rake bench # corpus benchmark → docs/benchmark.md with OUT=…
266
+ mise exec -- bundle exec script/generate_fixtures # regenerate committed fixtures (zint, rqrcode, prawn, vips, magick, poppler)
267
+ mise exec -- bundle exec script/real_corpus # real documents listed in real_corpus.local.yml → tmp/real_corpus (local only)
268
+ ```
269
+
270
+ ## Licensing
271
+
272
+ zxing_ffi is released under the [MIT License](LICENSE.txt). Third-party components: zxing-cpp is Apache-2.0 and the
273
+ prebuilt libZXing in the platform gems also contains libzueci (BSD-3-Clause); those gems ship both notices in
274
+ `vendor/lib/` (see [Prebuilt platform gems](#prebuilt-platform-gems)). Poppler (GPL) and ImageMagick are only run as
275
+ separate processes; libvips (LGPL) is loaded through your own `ruby-vips` installation.
data/exe/zxing-scan ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "zxing_ffi"
5
+
6
+ exit ZXingFFI::CLI.new(ARGV).run
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ZXingFFI
6
+ # One decoded barcode. Immutable.
7
+ #
8
+ # Fields filled by {ZXingFFI.read}: everything except +page+, +pass+, +dpi+ and +page_position+,
9
+ # which the scanner adds. Predicates +mirrored?+, +inverted?+, +eci?+ and +valid?+ alias the booleans.
10
+ #
11
+ # @!attribute text [String] decoded text (UTF-8), per +text_mode+
12
+ # @!attribute bytes [String] raw payload bytes (BINARY)
13
+ # @!attribute format [Symbol] specific format, e.g. +:ean_13+, +:qr_code+
14
+ # @!attribute symbology [Symbol] symbology family, e.g. +:ean_upc+ for +:ean_13+
15
+ # @!attribute format_name [String] library name, e.g. "EAN-13"
16
+ # @!attribute content_type [Symbol] +:text+, +:binary+, +:mixed+, +:gs1+, +:iso15434+, +:unknown_eci+
17
+ # @!attribute symbology_identifier [String] e.g. "]Q1"
18
+ # @!attribute position [Quad] corners in base-image pixels
19
+ # @!attribute page_position [Quad, nil] PDF points from the top-left of the displayed page (PDF input only)
20
+ # @!attribute rotation [Integer] degrees clockwise, 0..359
21
+ # @!attribute error [Hash, nil] +{type:, message:}+ (only with +return_errors: true+)
22
+ # @!attribute line_count [Integer]
23
+ # @!attribute sequence [Hash, nil] structured append +{index:, size:, id:}+
24
+ # @!attribute extra_json [String, nil] raw JSON from ZXing_Barcode_extra (see {#extra})
25
+ # @!attribute page [Integer, nil] 1-based page, nil for {ZXingFFI.read}
26
+ # @!attribute pass [Symbol, nil] ladder pass that produced it
27
+ # @!attribute dpi [Integer, nil] base render DPI (PDF)
28
+ Barcode = Data.define(
29
+ :text, :bytes, :format, :symbology, :format_name, :content_type, :symbology_identifier,
30
+ :position, :page_position, :rotation, :mirrored, :inverted, :eci, :valid, :error,
31
+ :line_count, :sequence, :extra_json, :page, :pass, :dpi
32
+ ) do
33
+ def initialize(extra_json: nil, **fields)
34
+ @extra_cache = {} # mutable holder: the instance itself is frozen by Data
35
+ super
36
+ end
37
+
38
+ def mirrored? = mirrored
39
+
40
+ def inverted? = inverted
41
+
42
+ def eci? = eci
43
+
44
+ def valid? = valid
45
+
46
+ # Symbology-specific metadata (e.g. "ECLevel", "Version" for QR), parsed from the library's JSON
47
+ # on first access. +{}+ when there is none.
48
+ # @return [Hash{String => Object}]
49
+ def extra
50
+ @extra_cache.fetch(:value) do
51
+ json = extra_json
52
+ @extra_cache[:value] = (json.nil? || json.empty?) ? {}.freeze : JSON.parse(json).freeze
53
+ end
54
+ end
55
+
56
+ # Center of {#position}.
57
+ # @return [Geometry::Point]
58
+ def center
59
+ position.center
60
+ end
61
+
62
+ # JSON-friendly Hash. +bytes+ is Base64-encoded (and +bytes_encoding: "base64"+ added) when the content
63
+ # is not text or the bytes are not valid UTF-8; quads become nested Hashes.
64
+ # @return [Hash]
65
+ def to_h
66
+ h = super.except(:extra_json).merge(extra: extra, position: position&.to_h, page_position: page_position&.to_h)
67
+ utf8 = bytes.dup.force_encoding(Encoding::UTF_8)
68
+ if content_type == :text && utf8.valid_encoding?
69
+ h[:bytes] = utf8
70
+ else
71
+ h[:bytes] = [bytes].pack("m0")
72
+ h[:bytes_encoding] = "base64"
73
+ end
74
+ h
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module ZXingFFI
7
+ # The +zxing-scan+ command: scans files and prints one JSON object per barcode (JSON Lines).
8
+ #
9
+ # Exit codes: 0 at least one barcode found, 1 none found, 2 usage error, 3 processing error (any file failed).
10
+ class CLI
11
+ # Exit status: at least one barcode was found.
12
+ EXIT_FOUND = 0
13
+ # Exit status: no barcode was found.
14
+ EXIT_NONE = 1
15
+ # Exit status: invalid command line.
16
+ EXIT_USAGE = 2
17
+ # Exit status: at least one file could not be processed.
18
+ EXIT_ERROR = 3
19
+
20
+ # @param argv [Array<String>]
21
+ # @param stdout [IO]
22
+ # @param stderr [IO]
23
+ # @param env [Hash] environment (for ZXING_PDF_PASSWORD)
24
+ def initialize(argv, stdout: $stdout, stderr: $stderr, env: ENV)
25
+ @argv = argv.dup
26
+ @stdout = stdout
27
+ @stderr = stderr
28
+ @env = env
29
+ @options = {}
30
+ @text_only = false
31
+ @diagnose = false
32
+ end
33
+
34
+ # @return [Integer] exit code
35
+ def run
36
+ files = parse!
37
+ return diagnose if @diagnose
38
+
39
+ if files.empty?
40
+ @stderr.puts parser.banner
41
+ return EXIT_USAGE
42
+ end
43
+
44
+ begin
45
+ scanner = ZXingFFI::Scanner.new(**@options) # invalid options are a usage error, before any file is read
46
+ rescue ArgumentError => e
47
+ @stderr.puts "zxing-scan: #{e.message}"
48
+ return EXIT_USAGE
49
+ end
50
+
51
+ found = false
52
+ failed = false
53
+ files.each do |file|
54
+ scanner.scan(file).each do |barcode|
55
+ found = true
56
+ @stdout.puts(@text_only ? barcode.text : JSON.generate(record(file, barcode)))
57
+ end
58
+ rescue => e # one file failing (unreadable, no such page, …) must not stop the others
59
+ failed = true
60
+ @stderr.puts "zxing-scan: #{display(file)}: #{e.class.name.split("::").last}: #{e.message.lines.first&.strip}"
61
+ end
62
+ return EXIT_ERROR if failed
63
+
64
+ found ? EXIT_FOUND : EXIT_NONE
65
+ rescue OptionParser::ParseError => e
66
+ @stderr.puts "zxing-scan: #{e.message}"
67
+ @stderr.puts parser.banner
68
+ EXIT_USAGE
69
+ end
70
+
71
+ # The JSON object for one barcode: +{file, page, format, text, bytes_b64?, content_type, position,
72
+ # page_position, rotation, pass}+. +bytes_b64+ is present only for non-text content.
73
+ # @return [Hash]
74
+ def record(file, barcode)
75
+ record = {file: display(file), page: barcode.page, format: barcode.format, text: barcode.text}
76
+ record[:bytes_b64] = [barcode.bytes].pack("m0") unless barcode.content_type == :text
77
+ record.merge(
78
+ content_type: barcode.content_type,
79
+ position: corners(barcode.position),
80
+ page_position: barcode.page_position && corners(barcode.page_position),
81
+ rotation: barcode.rotation,
82
+ pass: barcode.pass
83
+ )
84
+ end
85
+
86
+ private
87
+
88
+ # File names are bytes on Linux: make them valid UTF-8 for JSON and messages.
89
+ def display(file)
90
+ file.to_s.dup.force_encoding(Encoding::UTF_8).scrub("�")
91
+ end
92
+
93
+ def corners(quad)
94
+ quad.to_a.map { |point| [point.x, point.y] }
95
+ end
96
+
97
+ def diagnose
98
+ @stdout.puts JSON.pretty_generate(ZXingFFI.diagnostics)
99
+ EXIT_FOUND
100
+ end
101
+
102
+ def parse!
103
+ files = parser.parse(@argv)
104
+ password = @options[:password] || @env["ZXING_PDF_PASSWORD"]
105
+ @options[:password] = password if password && !password.empty?
106
+ files
107
+ end
108
+
109
+ def parser
110
+ @parser ||= OptionParser.new do |o|
111
+ o.banner = "Usage: zxing-scan [options] FILE..."
112
+ o.version = ZXingFFI::VERSION
113
+ o.on("-f", "--formats LIST", "comma-separated formats (default: all)") { |v| @options[:formats] = v.split(",").map(&:strip) }
114
+ o.on("-e", "--effort LEVEL", %w[fast normal thorough], "fast|normal|thorough") { |v| @options[:effort] = v.to_sym }
115
+ o.on("--stop MODE", "found|exhaustive|N") { |v| @options[:stop] = stop(v) }
116
+ o.on("--dpi N", "render DPI for PDFs, or auto") { |v| @options[:dpi] = dpi(v) }
117
+ o.on("-p", "--pages RANGE", "e.g. 1-3,7 (1-based)") { |v| @options[:pages] = pages(v) }
118
+ o.on("--password PW", "PDF password (also ZXING_PDF_PASSWORD)") { |v| @options[:password] = v }
119
+ o.on("-j", "--threads N", Integer, "pages scanned in parallel") { |v| @options[:threads] = v }
120
+ o.on("--text-only", "print text only, one per line") { @text_only = true }
121
+ o.on("--diagnose", "print diagnostics JSON and exit") { @diagnose = true }
122
+ end
123
+ end
124
+
125
+ def stop(value)
126
+ return value.to_sym if %w[found exhaustive].include?(value)
127
+ return Integer(value, 10) if value.match?(/\A\d+\z/)
128
+
129
+ raise OptionParser::InvalidArgument, "--stop #{value} (expected found, exhaustive or a number)"
130
+ end
131
+
132
+ def dpi(value)
133
+ return :auto if value == "auto"
134
+ return Integer(value, 10) if value.match?(/\A\d+\z/)
135
+
136
+ raise OptionParser::InvalidArgument, "--dpi #{value} (expected a number or auto)"
137
+ end
138
+
139
+ # "1-3,7" → [1, 2, 3, 7]; "5-" → 5.. (every page from 5)
140
+ def pages(value)
141
+ return Range.new(Integer(Regexp.last_match(1), 10), nil) if value =~ /\A(\d+)-\z/
142
+
143
+ value.split(",").flat_map do |token|
144
+ case token.strip
145
+ when /\A(\d+)\z/ then [Integer(Regexp.last_match(1), 10)]
146
+ when /\A(\d+)-(\d+)\z/ then (Integer(Regexp.last_match(1), 10)..Integer(Regexp.last_match(2), 10)).to_a
147
+ else raise OptionParser::InvalidArgument, "--pages #{value} (expected e.g. 1-3,7)"
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end