Read QR codes and every other barcode zxing-cpp understands from images and PDFs, in any orientation, from Ruby.
require "zxing_ffi"
ZXingFFI.scan("invoice.pdf").each do |barcode|
puts "page #{barcode.page}: #{barcode.format} #{barcode.text}"
end
# page 1: qr_code https://example.com/invoices/2026-0042The gem binds the zxing-cpp C API through the ffi gem (no compiler needed to install) and wraps it in a
pipeline: detect the input type by its magic bytes, turn every page or frame into a normalized 8-bit grayscale bitmap
(EXIF orientation, alpha, 16-bit, CMYK, fax resolutions, PDF /Rotate and CropBox all handled), run an escalating
sequence of decode passes, map results back to page coordinates, then deduplicate and order them. It replaces the
unmaintained zbar gem.
Supported inputs: PDF (born-digital and scanned), PNG, JPEG, TIFF (multi-page, fax), GIF, BMP, WebP, HEIF/AVIF (when
a loader supports them) and PNM. Symbologies: QR, Micro QR, rMQR, Data Matrix, Aztec, PDF417 (incl. Compact/Micro),
MaxiCode, Code 128, Code 39/93/32, Codabar, ITF, EAN/UPC, DataBar (all variants), Telepen, PZN, DX Film Edge — call
ZXingFFI.formats for the exact list your library supports.
- Ruby ≥ 3.3 (CRuby only; JRuby and TruffleRuby are not supported)
- libZXing 3.1+ built with the C API (the only hard native requirement)
- For PDFs: Poppler's command-line tools (
pdfinfo,pdftoppm;pdfimagesoptional) - For images: libvips with the
ruby-vipsgem (preferred) or ImageMagick; PNM needs nothing
On x86_64/aarch64 Linux (glibc or musl) and macOS, gem install zxing_ffi gets a
prebuilt platform gem that bundles libZXing, and this step can be skipped. Elsewhere:
Distribution packages often ship zxing-cpp 2.x, whose C API is incompatible. Check with zxing-scan --diagnose.
macOS (Homebrew):
brew install zxing-cpp poppler vips imagemagick
gem install zxing_ffi ruby-vipsHomebrew's library is found automatically (/opt/homebrew/lib).
Ubuntu / Debian: build the pinned release from source (needs cmake and a C++20 compiler). Ruby ≥ 3.3 is
required; Ubuntu 24.04's ruby package is 3.2, so install a newer Ruby first (e.g. with mise or rbenv).
sudo apt-get update
sudo apt-get install -y git cmake g++ poppler-utils libvips-tools imagemagick
sudo apt-get install -y libheif-plugin-libde265 # HEIC photos: libheif only suggests its HEVC decoder
git clone https://github.com/withniva/zxing_ffi && cd zxing_ffi
rake zxing:build # downloads zxing-cpp 3.1.1, verifies its SHA-256, builds into vendor/zxing (no Bundler needed)
sudo cp -P vendor/zxing/lib/libZXing.so* /usr/local/lib && sudo ldconfig # found automatically from now on
gem install zxing_ffi ruby-vips # ruby-vips: in-process image loading with libvipsInstead of copying the library, export ZXING_LIB=$(rake -s zxing:lib_path) points at it inside the checkout (for the
current shell only).
Or build it yourself with cmake -DBUILD_SHARED_LIBS=ON -DZXING_C_API=ON -DZXING_READERS=ON -DZXING_WRITERS=OFF and
point ZXING_LIB at the resulting libZXing.so.
Library discovery tries, in order: ZXingFFI.config.library_path / ENV["ZXING_LIB"] (if set, nothing else is
tried), a library bundled with the gem, the dynamic loader's search path (libZXing.so.4, libZXing.dylib, …), then
/opt/homebrew/lib and /usr/local/lib. Versions outside >= 3.1.0, < 4.0 raise ZXingFFI::IncompatibleLibrary.
For these platforms RubyGems installs a gem with a prebuilt libZXing in its vendor/lib/ (zxing-cpp 3.1.1 with readers
and the C API, compiled from the checksum-verified release tarball), so no libZXing has to be installed. Poppler and
libvips or ImageMagick are still needed for PDFs and images. rake gem:platform builds them locally.
| Platform gem | Runs on |
|---|---|
x86_64-linux-gnu, aarch64-linux-gnu |
glibc ≥ 2.28: Debian 10+, Ubuntu 20.04+, RHEL/AlmaLinux/Rocky 8+, Amazon Linux 2023 |
x86_64-linux-musl, aarch64-linux-musl |
musl: Alpine 3.18+ |
arm64-darwin |
macOS 11+ (Apple silicon) |
x86_64-darwin |
macOS 10.13+ (Intel) |
The Linux libraries link the C++ runtime statically and need only the C library; the macOS ones need only the
system's libc++. Everywhere else (other CPUs, older glibc) RubyGems picks the plain ruby gem, which uses a
system libZXing installed as described above.
- Bundler records platforms in
Gemfile.lock. A lockfile created by a recent Bundler (2.6 checked) lists every platform the gems ship; for an older one, add those you deploy to, e.g.bundle lock --add-platform x86_64-linux aarch64-linux-musl(x86_64-linuxresolves to the-gnugem). A lockfile with onlyrubygets the plain gem everywhere. - Override:
ZXING_LIB(orconfig.library_path) still wins over the bundled library, e.g. to use a newer zxing-cpp.zxing-scan --diagnoseshows which library is loaded:library.pathandlibrary.source(bundled,explicit,systemorprefix). A bundled library that cannot be loaded is skipped in favour of a system one. - Licenses: the bundled library is zxing-cpp (Apache-2.0) and includes libzueci (BSD-3-Clause). Their notices
are installed next to it:
vendor/lib/NOTICE.txt,LICENSE-zxing-cpp.txtandLICENSE-libzueci.txt.
ZXingFFI.scan("photo.heic", formats: %i[qr_code data_matrix])
ZXingFFI.scan(io, effort: :thorough, stop: :exhaustive, pages: 1..3, password: "secret")
ZXingFFI.scan_pages("batch.pdf", threads: 4) do |page|
page.page # 1-based
page.barcodes # Array<ZXingFFI::Barcode>, ordered top-to-bottom, left-to-right
page.dpi # render DPI (PDF)
page.passes_run # [:base, :global_binarizer]
page.skipped_passes # { rotated_45: "no transformer available" }
page.duration # seconds
endscan returns every page's barcodes ordered by page, then position; scan_pages yields a ZXingFFI::PageResult per
page in page order (or returns an Enumerator). Inputs can be a path (String/Pathname), an IO, or a
ZXingFFI::Image.
| Option | Default | Meaning |
|---|---|---|
formats |
:all |
symbols (:qr_code, :ean_13, …), library names ("QR Code"), meta-formats (:all_linear, :all_matrix) |
effort |
:normal |
:fast, :normal, :thorough — which decode passes may run (see below) |
passes |
— | explicit ladder, e.g. %i[base tiles] (overrides effort) |
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) |
dpi |
:auto |
PDF render DPI: :auto uses a scan's native resolution (150–600) or 300 for born-digital pages |
max_dpi |
600 |
upper bound for automatic and high-resolution renders |
pages |
all | Integer, Range or Array of 1-based pages; pages outside the document are ignored |
password |
— | PDF user password (Poppler reads at most 32 bytes: longer ones raise NotSupported; use loader: :vips with vips_block_untrusted = false) |
threads |
1 |
pages processed in parallel (rendering subprocesses and decoding both run concurrently) |
on_page_error |
:raise |
:skip records the exception in PageResult#error and continues |
loader |
config order | force a loader: :poppler, :vips, :image_magick, :pnm |
min_length |
{} |
minimum text length per format, e.g. {itf: 6} |
timeout |
— | per-page budget in seconds: caps renders (re-renders get what is left) and stops escalation (the first pass always runs) |
instrument |
— | callable receiving (event, payload) for :page_loaded, :pass_completed, :page_completed |
oversize |
:raise |
rasters over max_pixels: :raise raises LimitExceeded; :downscale decodes them smaller to fit (libvips loader only: JPEG and WebP shrink while decoding, other formats stream), up to max_source_pixels. Positions and PageResult#width/height stay in the original's pixels and PageResult#metadata[:downscaled] holds the scale; codes left under ~2 px per module are lost |
max_pixels, max_source_pixels, max_pages |
config | per-call limits |
Every ZXingFFI.read option below is accepted too and applies to every pass.
| Pass | What it does | Effort |
|---|---|---|
base |
library defaults + your options | fast, normal, thorough |
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 |
global_binarizer |
global-histogram binarizer | normal, thorough |
high_res |
PDF re-rendered at 2× DPI; rasters under 2000 px upscaled 2× (bicubic, so codes at 1–2 px per module resolve) | normal, thorough |
tiles |
overlapping 1024–1536 px tiles, no downscaling (tiny codes on big pages) | thorough |
rotated_45 |
image rotated 45° for linear codes at odd angles (also covers 135°/225°/315°) | thorough |
denoise |
try_denoise: true (libZXing built with ZXING_EXPERIMENTAL_API only) |
thorough |
2D codes are found at any rotation by the base pass; linear codes at 0/90/180/270 too.
barcode.text # "https://…" (UTF-8)
barcode.bytes # raw payload (BINARY) — authoritative for binary content
barcode.format # :qr_code, :ean_13, :code_128, …
barcode.symbology # family, e.g. :ean_upc for :ean_13
barcode.content_type # :text, :binary, :mixed, :gs1, :iso15434, :unknown_eci
barcode.position # ZXingFFI::Quad in base-image pixels (top_left, top_right, bottom_right, bottom_left)
barcode.page_position # PDF only: points from the top-left of the displayed page (CropBox, after /Rotate)
barcode.rotation # degrees clockwise, 0..359
barcode.page, barcode.pass, barcode.dpi
barcode.mirrored?, barcode.inverted?, barcode.eci?, barcode.valid?
barcode.sequence # structured append {index:, size:, id:} or nil
barcode.extra # symbology metadata, e.g. {"ECLevel" => "M", "Version" => "3"}
barcode.to_h # JSON-friendly (bytes Base64-encoded when not text)image = ZXingFFI::Image.new(gray_bytes, width: 640, height: 480) # also :rgb, :bgra, … and row_stride:
ZXingFFI.read(image, formats: :qr_code, try_harder: true)
image.release! # free the buffer earlyread runs a single decode. Options map 1:1 to zxing-cpp's ReaderOptions: formats, try_harder, try_rotate,
try_invert, try_downscale, try_denoise, pure, binarizer, max_symbols, min_line_count, return_errors,
validate_optional_checksum, text_mode (:plain, :eci, :hri, :escaped, :hex, :hex_eci) and ean_add_on
(:ignore, :read, :require). Unset options keep the library's defaults (ZXingFFI::LIBRARY_DEFAULTS), except
that the gem defaults to text_mode: :plain and validate_optional_checksum: true — the latter drops Code 39 and ITF
symbols without a valid check digit, which avoids phantom reads on text and tables; pass
validate_optional_checksum: false if your labels carry no check digit.
zxing-scan [options] FILE...
-f, --formats LIST comma-separated (default: all)
-e, --effort LEVEL fast|normal|thorough
--stop MODE found|exhaustive|N
--dpi N|auto
-p, --pages RANGE e.g. 1-3,7
--password PW (also ZXING_PDF_PASSWORD)
-j, --threads N
--text-only print text only, one per line
--diagnose print diagnostics JSON and exit
Output is JSON Lines, one object per barcode: {file, page, format, text, bytes_b64?, content_type, position, page_position, rotation, pass}. Exit codes: 0 found, 1 none found, 2 usage error (checked before any file is
read), 3 processing error (reported on stderr per file; the remaining files are still scanned).
ZXingFFI.configure do |c|
c.library_path = ENV["ZXING_LIB"]
c.pdf_loaders = %i[poppler vips]
c.image_loaders = %i[vips image_magick pnm]
c.transformers = %i[vips image_magick]
c.default_dpi = 300
c.max_dpi = 600
c.max_pixels = 64_000_000
c.oversize = :raise # or :downscale (see the scan options)
c.max_source_pixels = 1_000_000_000 # with oversize: :downscale, larger rasters still raise
c.max_pages = nil
c.render_timeout = 60
c.subprocess_memory_limit = 2 * 1024**3
c.tool_paths = {pdftoppm: "pdftoppm", pdfinfo: "pdfinfo", pdfimages: "pdfimages", magick: nil}
c.vips_block_untrusted = true
endConfiguration is read at call time; don't change it while a scan is running. ZXingFFI.diagnostics (or
zxing-scan --diagnose) reports the library path and version, optional features, defaults, loaders, tools and
their versions.
The gem is meant to process untrusted documents:
- Renderers run in subprocesses (Poppler, ImageMagick) with an argument array (never a shell), absolute paths,
a timeout (
render_timeout, process group killed with TERM then KILL), a cap on their output, and an address-space limit (subprocess_memory_limit, enforced on Linux only — macOS ignoresRLIMIT_AS). Passwords are only ever passed as the argument after-upw. - Pixel cap: every bitmap is checked against
max_pixelsbefore it is rendered or decoded. PDF pages that would exceed it are rendered at a lower (possibly fractional) DPI; rasters raiseZXingFFI::LimitExceeded, unlessoversize: :downscalehas libvips decode them smaller to fit (never holding them at full size), in which case the header check holds them tomax_source_pixelsinstead. - No format confusion: inputs are identified by magic bytes, never by extension. ImageMagick always reads with an
explicit coder (
png:/path), never sees PDF, PostScript, SVG, MVG, MSL or text, and gets special characters in paths neutralized. libvips is called with the loader for the sniffed type, and operations libvips flags as untrusted (in 8.18:pdfload,magickload,ppmload) are refused whilevips_block_untrustedis true — so in-process PDF rendering with libvips is opt-in. A crash inside libvips or libZXing takes down the Ruby process. - IO inputs are copied to a private (0600) temp file that is always removed.
- Decoding releases the GVL, so threads decode concurrently; results are identical to serial runs. Interrupts
(
Thread#raise,Timeout, signals) are delivered once the native decode has returned and its results are freed; leaving a threadedscanearly stops the other page workers and kills their renderer processes. - Passes after the first never lose results: if one fails (a re-render timing out, a transformer error), the page
keeps what earlier passes found and
PageResult#skipped_passesrecords"failed: …". Passes whose image would exceedmax_pixels(high_res,rotated_45) are skipped the same way.
Errors all inherit from ZXingFFI::Error: LibraryNotFound, IncompatibleLibrary, NotSupported,
LoaderUnavailable, UnsupportedInput, PasswordRequired (and IncorrectPassword), RenderError (#stderr,
#exit_status), TimeoutError, LimitExceeded (#limit, #value), DecodeError.
mise exec -- bundle install
mise exec -- bundle exec rake zxing:build # vendor/zxing (the test helper points ZXING_LIB at it)
mise exec -- bundle exec rake # all tests + standardrb
mise exec -- bundle exec rake test:unit # also test:native, test:integration, test:corpus
mise exec -- bundle exec rake bench # corpus benchmark → docs/benchmark.md with OUT=…
mise exec -- bundle exec script/generate_fixtures # regenerate committed fixtures (zint, rqrcode, prawn, vips, magick, poppler)
mise exec -- bundle exec script/real_corpus # real documents listed in real_corpus.local.yml → tmp/real_corpus (local only)zxing_ffi is released under the MIT License. Third-party components: zxing-cpp is Apache-2.0 and the
prebuilt libZXing in the platform gems also contains libzueci (BSD-3-Clause); those gems ship both notices in
vendor/lib/ (see Prebuilt platform gems). Poppler (GPL) and ImageMagick are only run as
separate processes; libvips (LGPL) is loaded through your own ruby-vips installation.