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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module ZXingFFI
|
|
6
|
+
# A normalized pixel buffer that owns its memory.
|
|
7
|
+
#
|
|
8
|
+
# The bytes are copied into an +FFI::MemoryPointer+ (malloc'd, never moved by the GC), which is what the
|
|
9
|
+
# decoder reads while the GVL is released. Call {#release!} to free large buffers early.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# image = ZXingFFI::Image.new(gray_bytes, width: 640, height: 480)
|
|
13
|
+
# ZXingFFI.read(image)
|
|
14
|
+
class Image
|
|
15
|
+
# Bytes per pixel of each supported pixel format.
|
|
16
|
+
BYTES_PER_PIXEL = {lum: 1, lum_a: 2, rgb: 3, bgr: 3, rgba: 4, argb: 4, bgra: 4, abgr: 4}.freeze
|
|
17
|
+
|
|
18
|
+
# Largest buffer the C API can describe (its sizes are C ints).
|
|
19
|
+
MAX_BYTES = 2**31 - 1
|
|
20
|
+
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
attr_reader :width, :height, :row_stride, :bytesize
|
|
23
|
+
# @return [Symbol] one of {BYTES_PER_PIXEL}'s keys
|
|
24
|
+
attr_reader :format
|
|
25
|
+
|
|
26
|
+
# Decodes PGM/PNM data (a String or an IO) into an 8-bit luminance image.
|
|
27
|
+
# @return [Image]
|
|
28
|
+
def self.from_pgm(io_or_string, max_pixels: nil)
|
|
29
|
+
decoded = Pnm.decode(io_or_string, max_pixels: max_pixels)
|
|
30
|
+
new(decoded.pixels, width: decoded.width, height: decoded.height)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param bytes [String] pixel data, at least +row_stride * height+ bytes
|
|
34
|
+
# @param width [Integer]
|
|
35
|
+
# @param height [Integer]
|
|
36
|
+
# @param format [Symbol] +:lum+ (default), +:lum_a+, +:rgb+, +:bgr+, +:rgba+, +:argb+, +:bgra+, +:abgr+
|
|
37
|
+
# @param row_stride [Integer, nil] bytes per row (default: width × bytes per pixel)
|
|
38
|
+
def initialize(bytes, width:, height:, format: :lum, row_stride: nil)
|
|
39
|
+
raise ArgumentError, "unsupported pixel format #{format.inspect}" unless BYTES_PER_PIXEL.key?(format)
|
|
40
|
+
raise ArgumentError, "width must be a positive Integer" unless width.is_a?(Integer) && width.positive?
|
|
41
|
+
raise ArgumentError, "height must be a positive Integer" unless height.is_a?(Integer) && height.positive?
|
|
42
|
+
|
|
43
|
+
min_stride = width * BYTES_PER_PIXEL.fetch(format)
|
|
44
|
+
row_stride ||= min_stride
|
|
45
|
+
raise ArgumentError, "row_stride must be an Integer >= #{min_stride}" unless row_stride.is_a?(Integer) && row_stride >= min_stride
|
|
46
|
+
|
|
47
|
+
bytes = String.try_convert(bytes) or raise(TypeError, "bytes must be a String, got #{bytes.class}")
|
|
48
|
+
size = row_stride * height
|
|
49
|
+
raise ArgumentError, "image too large: #{size} bytes (max #{MAX_BYTES})" if size > MAX_BYTES
|
|
50
|
+
raise ArgumentError, "expected at least #{size} bytes (row_stride #{row_stride} × height #{height}), got #{bytes.bytesize}" if bytes.bytesize < size
|
|
51
|
+
|
|
52
|
+
@width = width
|
|
53
|
+
@height = height
|
|
54
|
+
@format = format
|
|
55
|
+
@row_stride = row_stride
|
|
56
|
+
@bytesize = size
|
|
57
|
+
@pointer = FFI::MemoryPointer.new(:uint8, size, false)
|
|
58
|
+
@pointer.put_bytes(0, bytes, 0, size)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The pixel buffer. Raises once the image has been released.
|
|
62
|
+
# @return [FFI::MemoryPointer]
|
|
63
|
+
def pointer
|
|
64
|
+
@pointer or raise Error, "#{inspect} has been released"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Frees the pixel buffer now instead of waiting for GC. Later use of the image raises.
|
|
68
|
+
# Never release an image while another thread is decoding it.
|
|
69
|
+
# @return [self]
|
|
70
|
+
def release!
|
|
71
|
+
pointer = @pointer
|
|
72
|
+
@pointer = nil
|
|
73
|
+
pointer&.free
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def released?
|
|
79
|
+
@pointer.nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Integer] bytes per pixel
|
|
83
|
+
def bytes_per_pixel
|
|
84
|
+
BYTES_PER_PIXEL.fetch(format)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A BINARY copy of the pixel buffer.
|
|
88
|
+
# @return [String]
|
|
89
|
+
def to_bytes
|
|
90
|
+
pointer.get_bytes(0, bytesize)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A photographic negative of a +:lum+ image (every byte v becomes 255 - v), made at C speed with String#tr.
|
|
94
|
+
# Used by the inverted pass: zxing-cpp's try_invert only applies to 2D readers, so white-on-black linear codes
|
|
95
|
+
# need inverted pixels.
|
|
96
|
+
# @return [Image]
|
|
97
|
+
def inverted
|
|
98
|
+
raise ArgumentError, "inverted needs a :lum image, this one is #{format.inspect}" unless format == :lum
|
|
99
|
+
|
|
100
|
+
Image.new(to_bytes.tr(INVERT_FROM, INVERT_TO), width: width, height: height, row_stride: row_stride)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
escape = ->(byte) { ["-", "^", "\\"].include?(byte) ? "\\#{byte}".b : byte.b }
|
|
104
|
+
# String#tr maps for byte inversion; tr's special characters (-, ^, \) are escaped.
|
|
105
|
+
INVERT_FROM = (0..255).map { |v| escape.call(v.chr) }.join.b.freeze
|
|
106
|
+
INVERT_TO = (0..255).map { |v| escape.call((255 - v).chr) }.join.b.freeze
|
|
107
|
+
private_constant :INVERT_FROM, :INVERT_TO
|
|
108
|
+
|
|
109
|
+
# PGM (P5) encoding of a +:lum+ image, e.g. for piping into an external transformer.
|
|
110
|
+
# @return [String]
|
|
111
|
+
def to_pgm
|
|
112
|
+
raise ArgumentError, "to_pgm needs a :lum image, this one is #{format.inspect}" unless format == :lum
|
|
113
|
+
|
|
114
|
+
rows = (row_stride == width) ? to_bytes : Array.new(height) { |y| pointer.get_bytes(y * row_stride, width) }.join
|
|
115
|
+
Pnm.encode_pgm(rows, width, height)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [String] e.g. "#<ZXingFFI::Image 640x480 lum>"
|
|
119
|
+
def inspect
|
|
120
|
+
"#<#{self.class.name} #{width}x#{height} #{format}#{" (released)" if released?}>"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module ZXingFFI
|
|
6
|
+
# Locating and invoking ImageMagick safely, shared by the loader and the transformer.
|
|
7
|
+
#
|
|
8
|
+
# ImageMagick 7 is used through +magick+; ImageMagick 6 through +convert+ and +identify+. Inputs are always read
|
|
9
|
+
# with an explicit coder prefix matching our sniffed kind (e.g. +png:/abs/path+) so ImageMagick cannot re-detect a
|
|
10
|
+
# dangerous format (PDF, PS, EPS, SVG, MVG, MSL, text) from the content. Paths containing characters ImageMagick
|
|
11
|
+
# interprets (+[ ] * ? { } @ %+ …) are symlinked to a safe temporary name first.
|
|
12
|
+
module ImageMagick
|
|
13
|
+
# Sniffed kind => ImageMagick coder. PDF and vector/text formats are deliberately absent.
|
|
14
|
+
CODERS = {
|
|
15
|
+
png: "png", jpeg: "jpeg", tiff: "tiff", gif: "gif", bmp: "bmp", webp: "webp",
|
|
16
|
+
heif: "heic", avif: "avif", pnm: "pnm"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Paths made only of these characters are passed to ImageMagick as they are.
|
|
20
|
+
SAFE_PATH = %r{\A[A-Za-z0-9_./\- ]+\z}
|
|
21
|
+
|
|
22
|
+
# A resolved ImageMagick installation.
|
|
23
|
+
# @!attribute flavor [Symbol] +:im7+ or +:im6+
|
|
24
|
+
# @!attribute convert [Array<String>] argv prefix that converts images
|
|
25
|
+
# @!attribute identify [Array<String>] argv prefix that identifies images
|
|
26
|
+
# @!attribute version [String, nil]
|
|
27
|
+
Tool = Data.define(:flavor, :convert, :identify, :version)
|
|
28
|
+
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# The ImageMagick installation to use, or nil (memoized per configured path).
|
|
33
|
+
# @return [Tool, nil]
|
|
34
|
+
def tool(config = ZXingFFI.config)
|
|
35
|
+
configured = config.tool_path(:magick)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@tools ||= {}
|
|
38
|
+
return @tools[configured] if @tools.key?(configured)
|
|
39
|
+
|
|
40
|
+
@tools[configured] = detect(configured)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Forgets detected installations (tests, or after changing config.tool_paths[:magick]).
|
|
45
|
+
def reset!
|
|
46
|
+
@mutex.synchronize { @tools = nil }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String] why no ImageMagick is usable
|
|
50
|
+
def unavailable_reason(config = ZXingFFI.config)
|
|
51
|
+
configured = config.tool_path(:magick)
|
|
52
|
+
configured ? "#{configured} is not a working ImageMagick 7 `magick`" : "neither `magick` (IM7) nor `convert`/`identify` (IM6) found"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Yields a path safe to hand to ImageMagick (the original, or a symlink with a plain name).
|
|
56
|
+
def with_safe_path(path)
|
|
57
|
+
return yield(path) if path.match?(SAFE_PATH)
|
|
58
|
+
|
|
59
|
+
Dir.mktmpdir("zxing_ffi_im") do |dir|
|
|
60
|
+
link = File.join(dir, "input")
|
|
61
|
+
File.symlink(path, link)
|
|
62
|
+
yield link
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def detect(configured)
|
|
69
|
+
if configured
|
|
70
|
+
version = version_of([configured, "-version"])
|
|
71
|
+
return version && Tool.new(flavor: :im7, convert: [configured], identify: [configured, "identify"], version: version)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if (magick = Subprocess.which("magick")) && (version = version_of([magick, "-version"]))
|
|
75
|
+
return Tool.new(flavor: :im7, convert: [magick], identify: [magick, "identify"], version: version)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
convert = Subprocess.which("convert")
|
|
79
|
+
identify = Subprocess.which("identify")
|
|
80
|
+
if convert && identify && (version = version_of([convert, "-version"]))
|
|
81
|
+
return Tool.new(flavor: :im6, convert: [convert], identify: [identify], version: version)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def version_of(argv)
|
|
88
|
+
status, out, = Subprocess.run(argv, timeout: 10, max_stdout: 64 * 1024)
|
|
89
|
+
status.success? && out[/ImageMagick\s+(\d+\.\d+\.\d+(?:-\d+)?)/, 1]
|
|
90
|
+
rescue Error
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# The loaded library's ReaderOptions defaults, read through the get* functions when first referenced.
|
|
5
|
+
# Never assume them: e.g. zxing-cpp 3.1.1 enables try_invert and uses the HRI text mode by default.
|
|
6
|
+
LIBRARY_DEFAULTS = Reader.library_defaults.freeze
|
|
7
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module ZXingFFI
|
|
6
|
+
# Finds a compatible libZXing and checks its version and required symbols.
|
|
7
|
+
#
|
|
8
|
+
# Candidates, in order (the first one that loads and passes the checks wins):
|
|
9
|
+
# 1. +ZXingFFI.config.library_path+ / +ENV["ZXING_LIB"]+ — authoritative: when set, nothing else is tried.
|
|
10
|
+
# 2. The library bundled in a platform gem (+vendor/lib/+; the plain ruby-platform gem has none).
|
|
11
|
+
# 3. System library names resolved by the dynamic loader.
|
|
12
|
+
# 4. Common prefixes (+/opt/homebrew/lib+, +/usr/local/lib+).
|
|
13
|
+
# {Found#source} records which of these the library came from.
|
|
14
|
+
module LibraryLoader
|
|
15
|
+
# zxing-cpp versions whose C API this gem binds.
|
|
16
|
+
SUPPORTED_VERSIONS = Gem::Requirement.new(">= 3.1.0", "< 4.0")
|
|
17
|
+
|
|
18
|
+
# Symbols that must resolve for the library to be usable.
|
|
19
|
+
REQUIRED_SYMBOLS = %w[ZXing_Version ZXing_ReadBarcodes ZXing_BarcodeFormatsFromString].freeze
|
|
20
|
+
|
|
21
|
+
# Names handed to the dynamic loader. zxing-cpp 3.1's SONAME is 4.
|
|
22
|
+
SYSTEM_NAMES = [
|
|
23
|
+
FFI.map_library_name("ZXing"),
|
|
24
|
+
"libZXing.so.4", "libZXing.so.3", "libZXing.so",
|
|
25
|
+
"libZXing.4.dylib", "libZXing.dylib"
|
|
26
|
+
].uniq.freeze
|
|
27
|
+
|
|
28
|
+
# Directories searched after the loader's own search path.
|
|
29
|
+
PREFIXES = %w[/opt/homebrew/lib /usr/local/lib].freeze
|
|
30
|
+
|
|
31
|
+
# Directory holding a library bundled with a platform gem.
|
|
32
|
+
BUNDLED_DIR = File.expand_path("../../vendor/lib", __dir__)
|
|
33
|
+
|
|
34
|
+
# A library that passed every check.
|
|
35
|
+
# @!attribute name [String] what was handed to dlopen
|
|
36
|
+
# @!attribute path [String] resolved file path (best effort, falls back to +name+)
|
|
37
|
+
# @!attribute version [String] value of ZXing_Version()
|
|
38
|
+
# @!attribute source [Symbol, nil] which candidate group it came from: +:explicit+ (library_path / ZXING_LIB),
|
|
39
|
+
# +:bundled+ (a platform gem's vendor/lib), +:system+ (the dynamic loader's search path) or +:prefix+
|
|
40
|
+
# ({PREFIXES}); nil when {LibraryLoader.probe} was called directly
|
|
41
|
+
Found = Data.define(:name, :path, :version, :source) do
|
|
42
|
+
def initialize(name:, path:, version:, source: nil) = super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Outcome of probing one candidate.
|
|
46
|
+
Attempt = Data.define(:name, :status, :detail)
|
|
47
|
+
|
|
48
|
+
# Flags for probing candidates: resolve lazily, keep symbols local.
|
|
49
|
+
DLOPEN_FLAGS = FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_LOCAL
|
|
50
|
+
|
|
51
|
+
# Resolves the absolute path of a loaded library via dladdr(3).
|
|
52
|
+
module Dl
|
|
53
|
+
extend FFI::Library
|
|
54
|
+
|
|
55
|
+
ffi_lib FFI::Library::LIBC
|
|
56
|
+
|
|
57
|
+
# Dl_info from <dlfcn.h>.
|
|
58
|
+
class Info < FFI::Struct
|
|
59
|
+
layout :dli_fname, :pointer, :dli_fbase, :pointer, :dli_sname, :pointer, :dli_saddr, :pointer
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
attach_function :dladdr, [:pointer, Info.by_ref], :int
|
|
64
|
+
rescue FFI::NotFoundError
|
|
65
|
+
# not available on this platform; paths are reported as given
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Finds the first compatible library.
|
|
71
|
+
#
|
|
72
|
+
# @param explicit [String, nil] explicit path (defaults to the configured library_path)
|
|
73
|
+
# @param bundled_dir [String] directory searched for a bundled library (defaults to {BUNDLED_DIR})
|
|
74
|
+
# @return [Found]
|
|
75
|
+
# @raise [LibraryNotFound] when no candidate loads
|
|
76
|
+
# @raise [IncompatibleLibrary] when libraries load but none is compatible
|
|
77
|
+
def find(explicit: ZXingFFI.config.library_path, bundled_dir: BUNDLED_DIR)
|
|
78
|
+
attempts = []
|
|
79
|
+
sourced_candidates(explicit, bundled_dir).each do |name, source|
|
|
80
|
+
result = probe(name, source: source)
|
|
81
|
+
return result if result.is_a?(Found)
|
|
82
|
+
|
|
83
|
+
attempts << result
|
|
84
|
+
end
|
|
85
|
+
raise failure(attempts, explicit)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Candidate names/paths in search order.
|
|
89
|
+
# @param (see .find)
|
|
90
|
+
# @return [Array<String>]
|
|
91
|
+
def candidates(explicit: ZXingFFI.config.library_path, bundled_dir: BUNDLED_DIR)
|
|
92
|
+
sourced_candidates(explicit, bundled_dir).map(&:first)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Loads one candidate and checks its version and required symbols.
|
|
96
|
+
# @param source [Symbol, nil] recorded in the {Found} result
|
|
97
|
+
# @return [Found, Attempt]
|
|
98
|
+
def probe(name, source: nil)
|
|
99
|
+
library = FFI::DynamicLibrary.open(name.to_s, DLOPEN_FLAGS)
|
|
100
|
+
version_symbol = library.find_function("ZXing_Version")
|
|
101
|
+
return Attempt.new(name, :incompatible, "ZXing_Version not exported (zxing-cpp < 2.2 or built without the C API)") unless version_symbol
|
|
102
|
+
|
|
103
|
+
version = FFI::Function.new(:string, [], version_symbol).call.to_s
|
|
104
|
+
return Attempt.new(name, :incompatible, "version #{version} is not #{SUPPORTED_VERSIONS}") unless supported_version?(version)
|
|
105
|
+
|
|
106
|
+
missing = REQUIRED_SYMBOLS.reject { |sym| library.find_function(sym) }
|
|
107
|
+
return Attempt.new(name, :incompatible, "version #{version} lacks #{missing.join(", ")}") if missing.any?
|
|
108
|
+
|
|
109
|
+
Found.new(name: name.to_s, path: resolve_path(version_symbol) || name.to_s, version: version, source: source)
|
|
110
|
+
rescue LoadError => e
|
|
111
|
+
Attempt.new(name, :not_found, e.message.lines.first.to_s.strip)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param version [String] e.g. "3.1.1"
|
|
115
|
+
def supported_version?(version)
|
|
116
|
+
numeric = version.to_s[/\A\d+(?:\.\d+)*/]
|
|
117
|
+
return false unless numeric
|
|
118
|
+
|
|
119
|
+
SUPPORTED_VERSIONS.satisfied_by?(Gem::Version.new(numeric))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# [name, source] pairs in search order (see {Found#source}).
|
|
125
|
+
def sourced_candidates(explicit, bundled_dir)
|
|
126
|
+
return [[explicit, :explicit]] if explicit && !explicit.to_s.empty?
|
|
127
|
+
|
|
128
|
+
bundled = Dir[File.join(bundled_dir, "libZXing*.{dylib,so}*")].sort.map { |path| [path, :bundled] }
|
|
129
|
+
system = SYSTEM_NAMES.map { |name| [name, :system] }
|
|
130
|
+
prefixed = PREFIXES.flat_map { |dir| SYSTEM_NAMES.map { |name| File.join(dir, name) } }
|
|
131
|
+
.select { |path| File.exist?(path) }.map { |path| [path, :prefix] }
|
|
132
|
+
(bundled + system + prefixed).uniq(&:first)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def resolve_path(symbol_pointer)
|
|
136
|
+
return nil unless Dl.respond_to?(:dladdr)
|
|
137
|
+
|
|
138
|
+
info = Dl::Info.new
|
|
139
|
+
return nil if Dl.dladdr(symbol_pointer, info).zero? || info[:dli_fname].null?
|
|
140
|
+
|
|
141
|
+
path = info[:dli_fname].read_string
|
|
142
|
+
File.exist?(path) ? File.realpath(path) : path
|
|
143
|
+
rescue
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def failure(attempts, explicit)
|
|
148
|
+
incompatible = attempts.select { |a| a.status == :incompatible }
|
|
149
|
+
tried = attempts.map { |a| " - #{a.name}: #{a.detail}" }.join("\n")
|
|
150
|
+
if incompatible.any?
|
|
151
|
+
version = incompatible.first.detail[/version (\S+)/, 1]
|
|
152
|
+
IncompatibleLibrary.new(<<~MSG.chomp, version: version)
|
|
153
|
+
Found libZXing, but no compatible version (need zxing-cpp #{SUPPORTED_VERSIONS} built with the C API).
|
|
154
|
+
Tried:
|
|
155
|
+
#{tried}
|
|
156
|
+
#{HELP}
|
|
157
|
+
MSG
|
|
158
|
+
else
|
|
159
|
+
LibraryNotFound.new(<<~MSG.chomp)
|
|
160
|
+
Could not load libZXing#{" from #{explicit.inspect}" if explicit && !explicit.to_s.empty?}.
|
|
161
|
+
Tried:
|
|
162
|
+
#{tried}
|
|
163
|
+
#{HELP}
|
|
164
|
+
MSG
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Advice appended to LibraryNotFound / IncompatibleLibrary messages.
|
|
170
|
+
HELP = <<~HELP.chomp
|
|
171
|
+
To fix: install zxing-cpp 3.x with its C API (e.g. `brew install zxing-cpp`), or build it from source
|
|
172
|
+
(`rake zxing:build` in this gem's repository, or cmake with -DZXING_C_API=ON), then point ZXING_LIB
|
|
173
|
+
(or ZXingFFI.config.library_path) at the resulting libZXing shared library.
|
|
174
|
+
HELP
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
module Loaders
|
|
5
|
+
# Size and orientation of one page or frame, known before rendering.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute number [Integer] 1-based
|
|
8
|
+
# @!attribute width [Numeric] as displayed (after /Rotate or EXIF orientation), in +unit+
|
|
9
|
+
# @!attribute height [Numeric] as displayed, in +unit+
|
|
10
|
+
# @!attribute unit [Symbol] +:pt+ for PDF pages, +:px+ for raster frames
|
|
11
|
+
# @!attribute rotation [Integer] PDF /Rotate or applied EXIF rotation in degrees (0 when unknown)
|
|
12
|
+
# @!attribute native_ppi [Float, nil] resolution of the scan image covering the page (PDF), or the
|
|
13
|
+
# raster's resolution when recorded; nil when unknown or born-digital
|
|
14
|
+
PageInfo = Data.define(:number, :width, :height, :unit, :rotation, :native_ppi)
|
|
15
|
+
|
|
16
|
+
# A rendered page.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute number [Integer] 1-based page/frame number
|
|
19
|
+
# @!attribute image [Image] normalized 8-bit luminance image
|
|
20
|
+
# @!attribute dpi [Integer, nil] render DPI (PDF) or nil for rasters
|
|
21
|
+
# @!attribute scale_to_base [Float] factor mapping this image's pixels to the page's base image (1.0 for the base)
|
|
22
|
+
# @!attribute metadata [Hash] what was applied, e.g. +orientation_applied:+, +aspect_corrected:+, +dpi_capped:+
|
|
23
|
+
Page = Data.define(:number, :image, :dpi, :scale_to_base, :metadata)
|
|
24
|
+
|
|
25
|
+
# Base class for loaders. Subclasses implement the class-level capability checks and {#open}.
|
|
26
|
+
class Base
|
|
27
|
+
class << self
|
|
28
|
+
# @return [Symbol] registry name, e.g. +:poppler+
|
|
29
|
+
def loader_name
|
|
30
|
+
raise NotImplementedError, "#{name}.loader_name"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Input kinds this loader can handle in principle (see {Sniffer::KINDS}).
|
|
34
|
+
# @return [Array<Symbol>]
|
|
35
|
+
def kinds
|
|
36
|
+
raise NotImplementedError, "#{name}.kinds"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Whether the tool/gem is present at all (memoized; see {.reset!}).
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def available?
|
|
42
|
+
return @available if defined?(@available)
|
|
43
|
+
|
|
44
|
+
@available = probe
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Why {.available?} is false (or nil when available).
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
def unavailable_reason
|
|
50
|
+
available? ? nil : (@unavailable_reason || "not available")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether this loader can handle +kind+ on this machine (e.g. vips without HEIF support → false for :heif).
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def supports?(kind)
|
|
56
|
+
kinds.include?(kind.to_sym) && available?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Why +kind+ is not supported although it is in {.kinds} and the loader is available (e.g. an operation
|
|
60
|
+
# refused by configuration), or nil.
|
|
61
|
+
# @return [String, nil]
|
|
62
|
+
def unsupported_reason(kind)
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# What to install to get this loader.
|
|
67
|
+
# @return [String]
|
|
68
|
+
def install_hint
|
|
69
|
+
""
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Tool versions and capabilities, for {ZXingFFI.diagnostics}.
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def diagnostics
|
|
75
|
+
{available: available?, reason: unavailable_reason, kinds: available? ? kinds.select { |k| supports?(k) } : []}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Forgets memoized availability (tests, or after installing tools at runtime).
|
|
79
|
+
def reset!
|
|
80
|
+
remove_instance_variable(:@available) if defined?(@available)
|
|
81
|
+
@unavailable_reason = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Subclasses return true/false and may set @unavailable_reason.
|
|
87
|
+
def probe
|
|
88
|
+
raise NotImplementedError, "#{name}.probe"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Config] effective configuration (global config merged with per-call overrides)
|
|
93
|
+
attr_reader :config
|
|
94
|
+
|
|
95
|
+
# @param config [Config]
|
|
96
|
+
def initialize(config = ZXingFFI.config)
|
|
97
|
+
@config = config
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Opens a document. Encrypted PDFs without a (correct) password raise before any rendering.
|
|
101
|
+
#
|
|
102
|
+
# @param source [Source] absolute path + sniffed kind
|
|
103
|
+
# @param password [String, nil]
|
|
104
|
+
# @return [Document]
|
|
105
|
+
# @raise [PasswordRequired, IncorrectPassword, RenderError, LimitExceeded, UnsupportedInput]
|
|
106
|
+
def open(source, password: nil)
|
|
107
|
+
raise NotImplementedError, "#{self.class}#open"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# An opened input: a sequence of pages (PDF) or frames (raster). Always {#close} it.
|
|
112
|
+
class Document
|
|
113
|
+
# @return [Source]
|
|
114
|
+
attr_reader :source
|
|
115
|
+
|
|
116
|
+
def initialize(source)
|
|
117
|
+
@source = source
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Integer]
|
|
121
|
+
def page_count
|
|
122
|
+
raise NotImplementedError, "#{self.class}#page_count"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param number [Integer] 1-based
|
|
126
|
+
# @return [PageInfo]
|
|
127
|
+
def page_info(number)
|
|
128
|
+
raise NotImplementedError, "#{self.class}#page_info"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Renders or decodes one page. +dpi+ is honored for PDFs and ignored for rasters.
|
|
132
|
+
#
|
|
133
|
+
# @param number [Integer] 1-based
|
|
134
|
+
# @param dpi [Integer, Float, nil]
|
|
135
|
+
# @param timeout [Numeric, nil] seconds for this render, capped by config.render_timeout (subprocess loaders;
|
|
136
|
+
# in-process loaders cannot be interrupted and ignore it)
|
|
137
|
+
# @return [Page]
|
|
138
|
+
def render(number, dpi: nil, timeout: nil)
|
|
139
|
+
raise NotImplementedError, "#{self.class}#render"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Releases resources (temp files, handles). Idempotent.
|
|
143
|
+
def close
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def check_page!(number)
|
|
149
|
+
return if number.is_a?(Integer) && number.between?(1, page_count)
|
|
150
|
+
|
|
151
|
+
raise ArgumentError, "page #{number.inspect} out of range 1..#{page_count}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|