zxing_ffi 0.1.0-aarch64-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.
Files changed (45) 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. data/vendor/lib/LICENSE-libzueci.txt +41 -0
  42. data/vendor/lib/LICENSE-zxing-cpp.txt +202 -0
  43. data/vendor/lib/NOTICE.txt +18 -0
  44. data/vendor/lib/libZXing.so +0 -0
  45. 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