zxing_ffi 0.1.0-x86_64-linux-gnu
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +275 -0
- data/exe/zxing-scan +6 -0
- data/lib/zxing_ffi/barcode.rb +77 -0
- data/lib/zxing_ffi/cli.rb +152 -0
- data/lib/zxing_ffi/config.rb +119 -0
- data/lib/zxing_ffi/dedupe.rb +79 -0
- data/lib/zxing_ffi/diagnostics.rb +69 -0
- data/lib/zxing_ffi/dpi.rb +103 -0
- data/lib/zxing_ffi/errors.rb +69 -0
- data/lib/zxing_ffi/formats.rb +207 -0
- data/lib/zxing_ffi/geometry.rb +508 -0
- data/lib/zxing_ffi/header_probe.rb +98 -0
- data/lib/zxing_ffi/image.rb +123 -0
- data/lib/zxing_ffi/image_magick.rb +95 -0
- data/lib/zxing_ffi/library_defaults.rb +7 -0
- data/lib/zxing_ffi/library_loader.rb +176 -0
- data/lib/zxing_ffi/loaders/base.rb +155 -0
- data/lib/zxing_ffi/loaders/image_magick.rb +159 -0
- data/lib/zxing_ffi/loaders/pnm.rb +113 -0
- data/lib/zxing_ffi/loaders/poppler.rb +260 -0
- data/lib/zxing_ffi/loaders/registry.rb +54 -0
- data/lib/zxing_ffi/loaders/vips.rb +332 -0
- data/lib/zxing_ffi/loaders.rb +41 -0
- data/lib/zxing_ffi/native.rb +237 -0
- data/lib/zxing_ffi/pnm.rb +676 -0
- data/lib/zxing_ffi/reader.rb +271 -0
- data/lib/zxing_ffi/scanner.rb +295 -0
- data/lib/zxing_ffi/sniffer.rb +155 -0
- data/lib/zxing_ffi/source.rb +77 -0
- data/lib/zxing_ffi/strategy.rb +293 -0
- data/lib/zxing_ffi/subprocess.rb +416 -0
- data/lib/zxing_ffi/transformers/base.rb +69 -0
- data/lib/zxing_ffi/transformers/image_magick.rb +72 -0
- data/lib/zxing_ffi/transformers/vips.rb +85 -0
- data/lib/zxing_ffi/transformers.rb +36 -0
- data/lib/zxing_ffi/version.rb +5 -0
- data/lib/zxing_ffi.rb +82 -0
- data/vendor/lib/LICENSE-libzueci.txt +41 -0
- data/vendor/lib/LICENSE-zxing-cpp.txt +202 -0
- data/vendor/lib/NOTICE.txt +18 -0
- data/vendor/lib/libZXing.so +0 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fca126ce5bfcd5cacc39797bffa92d5324af1f85434ac357a9a9844de7f7243f
|
|
4
|
+
data.tar.gz: 5ce4ae0174c6fe83c24cc8f7abf9b82a0f1f0566053b7072aa97731546b4a03a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8fd94ac067999ae90ab17346307ed4097cfd48e08d7c90ae0ce04d6c830b024d42be4ab3d6ab69f36b62a011bc562de6cd09b49b3c3fb3ebcbb19c11432ba4f3
|
|
7
|
+
data.tar.gz: 99f04a6996a1474ed98501c43ec040f5981cd217366fba76b69f38faf596689c69bba6cc59e0a96586dd0387ccded21241449110bbfb1c864ab243dc39d5e9e0
|
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,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
|