zxing_ffi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +275 -0
  5. data/exe/zxing-scan +6 -0
  6. data/lib/zxing_ffi/barcode.rb +77 -0
  7. data/lib/zxing_ffi/cli.rb +152 -0
  8. data/lib/zxing_ffi/config.rb +119 -0
  9. data/lib/zxing_ffi/dedupe.rb +79 -0
  10. data/lib/zxing_ffi/diagnostics.rb +69 -0
  11. data/lib/zxing_ffi/dpi.rb +103 -0
  12. data/lib/zxing_ffi/errors.rb +69 -0
  13. data/lib/zxing_ffi/formats.rb +207 -0
  14. data/lib/zxing_ffi/geometry.rb +508 -0
  15. data/lib/zxing_ffi/header_probe.rb +98 -0
  16. data/lib/zxing_ffi/image.rb +123 -0
  17. data/lib/zxing_ffi/image_magick.rb +95 -0
  18. data/lib/zxing_ffi/library_defaults.rb +7 -0
  19. data/lib/zxing_ffi/library_loader.rb +176 -0
  20. data/lib/zxing_ffi/loaders/base.rb +155 -0
  21. data/lib/zxing_ffi/loaders/image_magick.rb +159 -0
  22. data/lib/zxing_ffi/loaders/pnm.rb +113 -0
  23. data/lib/zxing_ffi/loaders/poppler.rb +260 -0
  24. data/lib/zxing_ffi/loaders/registry.rb +54 -0
  25. data/lib/zxing_ffi/loaders/vips.rb +332 -0
  26. data/lib/zxing_ffi/loaders.rb +41 -0
  27. data/lib/zxing_ffi/native.rb +237 -0
  28. data/lib/zxing_ffi/pnm.rb +676 -0
  29. data/lib/zxing_ffi/reader.rb +271 -0
  30. data/lib/zxing_ffi/scanner.rb +295 -0
  31. data/lib/zxing_ffi/sniffer.rb +155 -0
  32. data/lib/zxing_ffi/source.rb +77 -0
  33. data/lib/zxing_ffi/strategy.rb +293 -0
  34. data/lib/zxing_ffi/subprocess.rb +416 -0
  35. data/lib/zxing_ffi/transformers/base.rb +69 -0
  36. data/lib/zxing_ffi/transformers/image_magick.rb +72 -0
  37. data/lib/zxing_ffi/transformers/vips.rb +85 -0
  38. data/lib/zxing_ffi/transformers.rb +36 -0
  39. data/lib/zxing_ffi/version.rb +5 -0
  40. data/lib/zxing_ffi.rb +82 -0
  41. metadata +108 -0
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Loaders
5
+ # In-process loader built on libvips via the optional ruby-vips gem: rasters, and PDFs as a fallback.
6
+ #
7
+ # Every input is opened with the libvips loader matching its sniffed kind (e.g. +pngload+), so libvips never
8
+ # re-detects a different format from the content. Operations libvips flags as *untrusted* (in libvips 8.18:
9
+ # +pdfload+, +magickload+, +ppmload+) are refused while +config.vips_block_untrusted+ is true (the default); this
10
+ # gives the protection of +vips_block_untrusted_set+ without changing libvips' process-global state.
11
+ # Consequently vips renders PDFs only after opting in with +vips_block_untrusted = false+ — and a crash in
12
+ # libvips or poppler-glib then takes down the Ruby process.
13
+ #
14
+ # Documents are safe to render concurrently (each render builds its own libvips pipeline).
15
+ class VipsLoader < Base
16
+ # Sniffed kind => libvips load operation.
17
+ OPERATIONS = {
18
+ png: "pngload", jpeg: "jpegload", tiff: "tiffload", gif: "gifload", webp: "webpload",
19
+ heif: "heifload", avif: "heifload", pnm: "ppmload", bmp: "magickload", pdf: "pdfload"
20
+ }.freeze
21
+
22
+ # Loaders that take a +page:+ argument.
23
+ PAGED = %w[tiffload gifload webpload heifload pdfload magickload].freeze
24
+
25
+ # VIPS_OPERATION_UNTRUSTED in VipsOperationFlags.
26
+ UNTRUSTED_FLAG = 16
27
+
28
+ # Resolutions (pixels per inch) differing by more than this ratio are corrected.
29
+ ASPECT_TOLERANCE = 0.05
30
+
31
+ class << self
32
+ # (see Base.loader_name)
33
+ def loader_name = :vips
34
+
35
+ # (see Base.kinds)
36
+ def kinds = OPERATIONS.keys
37
+
38
+ # (see Base.install_hint)
39
+ def install_hint
40
+ "install libvips (brew install vips / apt install libvips-tools) and the ruby-vips gem"
41
+ end
42
+
43
+ # Whether this libvips can load +kind+ right now (operation present and, unless opted in, trusted).
44
+ def supports?(kind)
45
+ operation = OPERATIONS[kind.to_sym]
46
+ return false unless operation && available? && operation?(operation)
47
+
48
+ !(ZXingFFI.config.vips_block_untrusted && untrusted?(operation))
49
+ end
50
+
51
+ # (see Base.unsupported_reason)
52
+ def unsupported_reason(kind)
53
+ operation = OPERATIONS[kind.to_sym]
54
+ return nil unless operation && available?
55
+ return "this libvips has no #{operation}" unless operation?(operation)
56
+ return nil unless ZXingFFI.config.vips_block_untrusted && untrusted?(operation)
57
+
58
+ "libvips flags #{operation} as untrusted; it is refused while config.vips_block_untrusted is true " \
59
+ "(set it to false to allow it)"
60
+ end
61
+
62
+ # (see Base.diagnostics)
63
+ def diagnostics
64
+ return super unless available?
65
+
66
+ super.merge(
67
+ version: ::Vips.version_string,
68
+ block_untrusted: ZXingFFI.config.vips_block_untrusted,
69
+ untrusted_operations: OPERATIONS.values.uniq.select { |op| operation?(op) && untrusted?(op) }
70
+ )
71
+ end
72
+
73
+ # @api private
74
+ def operation?(name)
75
+ @operations ||= {}
76
+ return @operations[name] if @operations.key?(name)
77
+
78
+ @operations[name] = ::Vips.type_find("VipsOperation", name) != 0
79
+ end
80
+
81
+ # @api private
82
+ def untrusted?(name)
83
+ @untrusted ||= {}
84
+ return @untrusted[name] if @untrusted.key?(name)
85
+
86
+ @untrusted[name] = (::Vips.vips_operation_get_flags(::Vips::Operation.new(name)) & UNTRUSTED_FLAG) != 0
87
+ end
88
+
89
+ # (see Base.reset!)
90
+ def reset!
91
+ super
92
+ @operations = nil
93
+ @untrusted = nil
94
+ end
95
+
96
+ private
97
+
98
+ def probe
99
+ require "vips"
100
+ true
101
+ rescue LoadError, StandardError => e
102
+ @unavailable_reason = "ruby-vips/libvips not loadable (#{e.class}: #{e.message.lines.first&.strip})"
103
+ false
104
+ end
105
+ end
106
+
107
+ # (see Base#open)
108
+ def open(source, password: nil)
109
+ unless self.class.supports?(source.kind)
110
+ raise LoaderUnavailable, "libvips cannot load #{source.kind} here (missing or untrusted #{OPERATIONS[source.kind]})"
111
+ end
112
+
113
+ if source.kind == :pdf
114
+ PdfDocument.new(source, config, password)
115
+ else
116
+ RasterDocument.new(source, config, OPERATIONS.fetch(source.kind))
117
+ end
118
+ end
119
+
120
+ # Shared helpers for libvips-backed documents.
121
+ module Pipeline
122
+ # Loads +path+ with +operation+ without libvips' operation cache handing back a stale image: libvips caches
123
+ # loads by file name, so a file overwritten at the same path would otherwise decode as its old contents
124
+ # (e.g. an upload handler reusing a temp path). libvips ≥ 8.15 has +revalidate+; older versions load from a
125
+ # buffer, which is never served from the file-name cache.
126
+ # @api private
127
+ def self.load_fresh(operation, path, **options)
128
+ ::Vips.vips_error_clear # see error_summary
129
+ if ::Vips.at_least_libvips?(8, 15)
130
+ ::Vips::Image.public_send(operation, path, revalidate: true, **options)
131
+ else
132
+ ::Vips::Image.public_send(:"#{operation}_buffer", File.binread(path), **options)
133
+ end
134
+ end
135
+
136
+ # The cause of a libvips failure. A +Vips::Error+'s message is libvips' process-wide error buffer, oldest line
137
+ # first, and successful calls can leave warnings there (e.g. heifload's "bad seek"); so the buffer is cleared
138
+ # before our calls and the last lines are the ones that describe this failure.
139
+ # @api private
140
+ def self.error_summary(error, lines: 3)
141
+ error.message.lines.map(&:strip).reject(&:empty?).last(lines).join(" ")
142
+ end
143
+
144
+ private
145
+
146
+ def vips_call
147
+ ::Vips.vips_error_clear
148
+ yield
149
+ rescue ::Vips::Error => e
150
+ raise RenderError.new("libvips failed on #{source.name}: #{Pipeline.error_summary(e)}", stderr: e.message[0, 4096])
151
+ end
152
+
153
+ # Flattens alpha onto white, converts to 8-bit single-band luminance and copies it into an Image.
154
+ def to_image(vimage)
155
+ check_pixels!(vimage.width, vimage.height)
156
+ if vimage.has_alpha?
157
+ white = %i[ushort short].include?(vimage.format) ? 65_535 : 255
158
+ vimage = vimage.flatten(background: [white])
159
+ end
160
+ vimage = vimage.colourspace(:b_w) unless vimage.interpretation == :"b-w" && vimage.format == :uchar
161
+ vimage = vimage.extract_band(0) if vimage.bands > 1
162
+ vimage = vimage.cast(:uchar) unless vimage.format == :uchar
163
+ Image.new(vimage.write_to_memory, width: vimage.width, height: vimage.height)
164
+ end
165
+
166
+ def check_pixels!(width, height)
167
+ pixels = width * height
168
+ return unless @config.max_pixels && pixels > @config.max_pixels
169
+
170
+ raise LimitExceeded.new("#{width}x#{height} (#{pixels} pixels) exceeds max_pixels #{@config.max_pixels}",
171
+ limit: :max_pixels, value: pixels)
172
+ end
173
+ end
174
+
175
+ # A raster file: one page per frame (multi-page TIFF, animated GIF/WebP, HEIF collections).
176
+ class RasterDocument < Document
177
+ include Pipeline
178
+
179
+ def initialize(source, config, operation)
180
+ super(source)
181
+ @config = config
182
+ @operation = operation
183
+ header = load(0)
184
+ @page_count = (header.get_typeof("n-pages") != 0) ? [header.get("n-pages"), 1].max : 1
185
+ @infos = {}
186
+ @mutex = Mutex.new
187
+ end
188
+
189
+ attr_reader :page_count
190
+
191
+ # (see Document#page_info)
192
+ def page_info(number)
193
+ check_page!(number)
194
+ @mutex.synchronize { @infos[number] ||= build_info(number) }
195
+ end
196
+
197
+ # Applies the normalization contract: aspect correction (on the stored axes, because libvips'
198
+ # autorot does not swap xres/yres), EXIF orientation, alpha → white, 16-bit scaling, 8-bit gray.
199
+ def render(number, dpi: nil, timeout: nil) # timeout: in-process, cannot be enforced
200
+ check_page!(number)
201
+ vips_call do
202
+ vimage = load(number - 1)
203
+ vimage, aspect = correct_aspect(vimage)
204
+ orientation = orientation_of(vimage)
205
+ orientation = nil if orientation == 1 # "normal": nothing to apply
206
+ vimage = vimage.autorot if orientation
207
+ image = to_image(vimage)
208
+ Page.new(number: number, image: image, dpi: nil, scale_to_base: 1.0, metadata: {
209
+ loader: :vips, orientation_applied: orientation, aspect_corrected: aspect
210
+ })
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ def load(index)
217
+ vips_call do
218
+ options = PAGED.include?(@operation) ? {page: index} : {}
219
+ Pipeline.load_fresh(@operation, source.path, **options)
220
+ end
221
+ end
222
+
223
+ def build_info(number)
224
+ vimage = load(number - 1)
225
+ width, height = corrected_size(vimage)
226
+ orientation = orientation_of(vimage)
227
+ width, height = height, width if [5, 6, 7, 8].include?(orientation)
228
+ PageInfo.new(number: number, width: width, height: height, unit: :px,
229
+ rotation: {3 => 180, 6 => 90, 8 => 270}.fetch(orientation, 0), native_ppi: ppi(vimage))
230
+ end
231
+
232
+ def orientation_of(vimage)
233
+ (vimage.get_typeof("orientation") != 0) ? vimage.get("orientation") : nil
234
+ end
235
+
236
+ # libvips resolutions are pixels per millimetre; 1.0 px/mm is its "unknown" default.
237
+ def resolutions(vimage)
238
+ x = vimage.xres * 25.4
239
+ y = vimage.yres * 25.4
240
+ (x > 25.5 || y > 25.5) ? [x, y] : nil
241
+ end
242
+
243
+ def ppi(vimage)
244
+ res = resolutions(vimage)
245
+ res&.max&.round(2)
246
+ end
247
+
248
+ def aspect_scale(vimage)
249
+ x, y = resolutions(vimage)
250
+ return nil unless x && y && x.positive? && y.positive?
251
+ return nil if (x - y).abs / [x, y].max <= ASPECT_TOLERANCE
252
+
253
+ (x < y) ? [y / x, 1.0] : [1.0, x / y]
254
+ end
255
+
256
+ def corrected_size(vimage)
257
+ hscale, vscale = aspect_scale(vimage) || [1.0, 1.0]
258
+ [(vimage.width * hscale).round, (vimage.height * vscale).round]
259
+ end
260
+
261
+ # Resamples the lower-resolution axis up (standard fax: 204×98 DPI is stored squashed 2:1).
262
+ def correct_aspect(vimage)
263
+ hscale, vscale = aspect_scale(vimage)
264
+ return [vimage, nil] unless hscale
265
+
266
+ width, height = corrected_size(vimage)
267
+ check_pixels!(width, height)
268
+ resized = vimage.resize(hscale, vscale: vscale, kernel: :linear)
269
+ [resized, resolutions(vimage).map { |r| r.round(1) }]
270
+ end
271
+ end
272
+
273
+ # PDF pages rendered in-process by libvips' pdfload (opt-in, see the class comment).
274
+ class PdfDocument < Document
275
+ include Pipeline
276
+
277
+ def initialize(source, config, password)
278
+ super(source)
279
+ @config = config
280
+ @password = password
281
+ header = load(0, 72)
282
+ @page_count = (header.get_typeof("n-pages") != 0) ? header.get("n-pages") : 1
283
+ @infos = {}
284
+ @mutex = Mutex.new
285
+ end
286
+
287
+ attr_reader :page_count
288
+
289
+ # Sizes in points of the page as displayed (pdfload applies /Rotate).
290
+ def page_info(number)
291
+ check_page!(number)
292
+ @mutex.synchronize do
293
+ @infos[number] ||= begin
294
+ vimage = load(number - 1, 72)
295
+ PageInfo.new(number: number, width: vimage.width, height: vimage.height, unit: :pt, rotation: 0, native_ppi: nil)
296
+ end
297
+ end
298
+ end
299
+
300
+ # (see Document#render)
301
+ def render(number, dpi: nil, timeout: nil) # timeout: in-process, cannot be enforced
302
+ check_page!(number)
303
+ dpi ||= @config.default_dpi
304
+ vips_call do
305
+ image = to_image(load(number - 1, dpi))
306
+ Page.new(number: number, image: image, dpi: dpi, scale_to_base: 1.0, metadata: {loader: :vips})
307
+ end
308
+ end
309
+
310
+ private
311
+
312
+ def load(index, dpi)
313
+ options = {page: index, dpi: dpi, background: [255]}
314
+ options[:password] = @password if @password
315
+ Pipeline.load_fresh("pdfload", source.path, **options)
316
+ rescue ::Vips::Error => e
317
+ raise password_error(e) if e.message.match?(/encrypted|password/i)
318
+
319
+ raise RenderError.new("libvips pdfload failed on #{source.name}: #{Pipeline.error_summary(e, lines: 1)}", stderr: e.message[0, 4096])
320
+ end
321
+
322
+ def password_error(error)
323
+ if @password
324
+ IncorrectPassword.new("the password for #{source.name} was rejected")
325
+ else
326
+ PasswordRequired.new("#{source.name} is encrypted; pass password:")
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ # Input loaders: each turns a file into pages of normalized 8-bit grayscale {Image}s.
5
+ #
6
+ # Loader classes are looked up by name (+:poppler+, +:vips+, +:image_magick+, +:pnm+). Every loader file can
7
+ # be required without its external tool or gem; availability is checked at runtime.
8
+ module Loaders
9
+ autoload :Base, "zxing_ffi/loaders/base"
10
+ autoload :Document, "zxing_ffi/loaders/base"
11
+ autoload :Page, "zxing_ffi/loaders/base"
12
+ autoload :PageInfo, "zxing_ffi/loaders/base"
13
+ autoload :Registry, "zxing_ffi/loaders/registry"
14
+ autoload :PopplerLoader, "zxing_ffi/loaders/poppler"
15
+ autoload :VipsLoader, "zxing_ffi/loaders/vips"
16
+ autoload :ImageMagickLoader, "zxing_ffi/loaders/image_magick"
17
+ autoload :PnmLoader, "zxing_ffi/loaders/pnm"
18
+
19
+ # Loader name => class name.
20
+ NAMES = {
21
+ poppler: :PopplerLoader,
22
+ vips: :VipsLoader,
23
+ image_magick: :ImageMagickLoader,
24
+ pnm: :PnmLoader
25
+ }.freeze
26
+
27
+ class << self
28
+ # @param name [Symbol] e.g. +:poppler+
29
+ # @return [Class<Base>]
30
+ # @raise [ArgumentError] for unknown names
31
+ def fetch(name)
32
+ const_get(NAMES.fetch(name.to_sym) { raise ArgumentError, "unknown loader #{name.inspect}; known: #{NAMES.keys.join(", ")}" })
33
+ end
34
+
35
+ # @return [Hash{Symbol => Class<Base>}] every loader, available or not
36
+ def registry
37
+ NAMES.keys.to_h { |name| [name, fetch(name)] }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module ZXingFFI
6
+ # FFI declarations for the zxing-cpp C API (ZXingC.h, verified against 3.1.1).
7
+ #
8
+ # This module knows nothing about Ruby-level types: it only declares functions, enums and structs.
9
+ # {Reader} owns every native object's lifetime. The library is loaded lazily by {.load!}.
10
+ #
11
+ # Ownership (from the header): non-const +ZXing_X*+ results are freed with +ZXing_X_delete+; non-const
12
+ # +char*+/+uint8_t*+ results with +ZXing_free+; const results belong to their parent.
13
+ module Native
14
+ extend FFI::Library
15
+
16
+ # ZXing_ImageFormat (explicit values in the header).
17
+ enum :image_format, [
18
+ :none, 0,
19
+ :lum, 0x01000000,
20
+ :lum_a, 0x02000000,
21
+ :rgb, 0x03000102,
22
+ :bgr, 0x03020100,
23
+ :rgba, 0x04000102,
24
+ :argb, 0x04010203,
25
+ :bgra, 0x04020100,
26
+ :abgr, 0x04030201
27
+ ]
28
+ enum :binarizer, [:local_average, 0, :global_histogram, :fixed_threshold, :bool_cast]
29
+ enum :ean_add_on_symbol, [:ignore, 0, :read, :require]
30
+ enum :text_mode, [:plain, 0, :eci, :hri, :escaped, :hex, :hex_eci]
31
+ enum :content_type, [:text, 0, :binary, :mixed, :gs1, :iso15434, :unknown_eci]
32
+ enum :error_type, [:none, 0, :format, :checksum, :unsupported]
33
+
34
+ # ZXing_BarcodeFormat values are generated by macros and changed in 3.0: never hardcode them.
35
+ # They are plain C enum values, i.e. ints; see {Formats} for the runtime map.
36
+ typedef :int, :barcode_format
37
+
38
+ # ZXing_PointI
39
+ class PointI < FFI::Struct
40
+ layout :x, :int, :y, :int
41
+ end
42
+
43
+ # ZXing_Position
44
+ class Position < FFI::Struct
45
+ layout :top_left, PointI, :top_right, PointI, :bottom_right, PointI, :bottom_left, PointI
46
+ end
47
+
48
+ # [name, args, return, options] of functions every supported library exports.
49
+ REQUIRED_FUNCTIONS = [
50
+ [:ZXing_Version, [], :string],
51
+ [:ZXing_free, [:pointer], :void],
52
+ [:ZXing_LastErrorMsg, [], :pointer],
53
+
54
+ [:ZXing_ImageView_new, [:pointer, :int, :int, :image_format, :int, :int], :pointer],
55
+ [:ZXing_ImageView_delete, [:pointer], :void],
56
+ [:ZXing_ImageView_crop, [:pointer, :int, :int, :int, :int], :void],
57
+ [:ZXing_ImageView_rotate, [:pointer, :int], :void],
58
+
59
+ [:ZXing_BarcodeFormatSymbology, [:barcode_format], :barcode_format],
60
+ [:ZXing_BarcodeFormatFromString, [:string], :barcode_format],
61
+ [:ZXing_BarcodeFormatToString, [:barcode_format], :pointer],
62
+ [:ZXing_BarcodeFormatsList, [:barcode_format, :pointer], :pointer],
63
+ [:ZXing_BarcodeFormatsFromString, [:string, :pointer], :pointer],
64
+ [:ZXing_BarcodeFormatsToString, [:pointer, :int], :pointer],
65
+
66
+ [:ZXing_Barcode_isValid, [:pointer], :bool],
67
+ [:ZXing_Barcode_errorType, [:pointer], :error_type],
68
+ [:ZXing_Barcode_errorMsg, [:pointer], :pointer],
69
+ [:ZXing_Barcode_format, [:pointer], :barcode_format],
70
+ [:ZXing_Barcode_symbology, [:pointer], :barcode_format],
71
+ [:ZXing_Barcode_contentType, [:pointer], :content_type],
72
+ [:ZXing_Barcode_bytes, [:pointer, :pointer], :pointer],
73
+ [:ZXing_Barcode_bytesECI, [:pointer, :pointer], :pointer],
74
+ [:ZXing_Barcode_text, [:pointer], :pointer],
75
+ [:ZXing_Barcode_symbologyIdentifier, [:pointer], :pointer],
76
+ [:ZXing_Barcode_position, [:pointer], Position.by_value],
77
+ [:ZXing_Barcode_orientation, [:pointer], :int],
78
+ [:ZXing_Barcode_hasECI, [:pointer], :bool],
79
+ [:ZXing_Barcode_isInverted, [:pointer], :bool],
80
+ [:ZXing_Barcode_isMirrored, [:pointer], :bool],
81
+ [:ZXing_Barcode_lineCount, [:pointer], :int],
82
+ [:ZXing_Barcode_sequenceIndex, [:pointer], :int],
83
+ [:ZXing_Barcode_sequenceSize, [:pointer], :int],
84
+ [:ZXing_Barcode_sequenceId, [:pointer], :pointer],
85
+ [:ZXing_Barcode_extra, [:pointer, :string], :pointer],
86
+
87
+ [:ZXing_Barcodes_delete, [:pointer], :void],
88
+ [:ZXing_Barcodes_size, [:pointer], :int],
89
+ [:ZXing_Barcodes_at, [:pointer, :int], :pointer],
90
+
91
+ [:ZXing_ReaderOptions_new, [], :pointer],
92
+ [:ZXing_ReaderOptions_delete, [:pointer], :void],
93
+ [:ZXing_ReaderOptions_setTryHarder, [:pointer, :bool], :void],
94
+ [:ZXing_ReaderOptions_setTryRotate, [:pointer, :bool], :void],
95
+ [:ZXing_ReaderOptions_setTryInvert, [:pointer, :bool], :void],
96
+ [:ZXing_ReaderOptions_setTryDownscale, [:pointer, :bool], :void],
97
+ [:ZXing_ReaderOptions_setIsPure, [:pointer, :bool], :void],
98
+ [:ZXing_ReaderOptions_setValidateOptionalChecksum, [:pointer, :bool], :void],
99
+ [:ZXing_ReaderOptions_setReturnErrors, [:pointer, :bool], :void],
100
+ [:ZXing_ReaderOptions_setFormats, [:pointer, :pointer, :int], :void],
101
+ [:ZXing_ReaderOptions_setBinarizer, [:pointer, :binarizer], :void],
102
+ [:ZXing_ReaderOptions_setEanAddOnSymbol, [:pointer, :ean_add_on_symbol], :void],
103
+ [:ZXing_ReaderOptions_setTextMode, [:pointer, :text_mode], :void],
104
+ [:ZXing_ReaderOptions_setMinLineCount, [:pointer, :int], :void],
105
+ [:ZXing_ReaderOptions_setMaxNumberOfSymbols, [:pointer, :int], :void],
106
+ [:ZXing_ReaderOptions_getTryHarder, [:pointer], :bool],
107
+ [:ZXing_ReaderOptions_getTryRotate, [:pointer], :bool],
108
+ [:ZXing_ReaderOptions_getTryInvert, [:pointer], :bool],
109
+ [:ZXing_ReaderOptions_getTryDownscale, [:pointer], :bool],
110
+ [:ZXing_ReaderOptions_getIsPure, [:pointer], :bool],
111
+ [:ZXing_ReaderOptions_getValidateOptionalChecksum, [:pointer], :bool],
112
+ [:ZXing_ReaderOptions_getReturnErrors, [:pointer], :bool],
113
+ [:ZXing_ReaderOptions_getFormats, [:pointer, :pointer], :pointer],
114
+ [:ZXing_ReaderOptions_getBinarizer, [:pointer], :binarizer],
115
+ [:ZXing_ReaderOptions_getEanAddOnSymbol, [:pointer], :ean_add_on_symbol],
116
+ [:ZXing_ReaderOptions_getTextMode, [:pointer], :text_mode],
117
+ [:ZXing_ReaderOptions_getMinLineCount, [:pointer], :int],
118
+ [:ZXing_ReaderOptions_getMaxNumberOfSymbols, [:pointer], :int],
119
+
120
+ # Releases the GVL so several threads decode concurrently.
121
+ [:ZXing_ReadBarcodes, [:pointer, :pointer], :pointer, {blocking: true}]
122
+ ].freeze
123
+
124
+ # Optional features: feature name => [[name, args, return], ...]; all functions must resolve.
125
+ OPTIONAL_FUNCTIONS = {
126
+ # only with ZXING_EXPERIMENTAL_API
127
+ try_denoise: [
128
+ [:ZXing_ReaderOptions_setTryDenoise, [:pointer, :bool], :void],
129
+ [:ZXing_ReaderOptions_getTryDenoise, [:pointer], :bool]
130
+ ],
131
+ # newer name of ZXing_Barcode_orientation
132
+ rotation: [[:ZXing_Barcode_rotation, [:pointer], :int]],
133
+ # bounds-checked ImageView constructor
134
+ image_view_new_checked: [
135
+ [:ZXing_ImageView_new_checked, [:pointer, :int, :int, :int, :image_format, :int, :int], :pointer]
136
+ ]
137
+ }.freeze
138
+
139
+ @mutex = Mutex.new
140
+ @library = nil
141
+ @features = {}.freeze
142
+
143
+ class << self
144
+ # Loads and attaches libZXing once (thread safe). Later calls are no-ops.
145
+ #
146
+ # @return [LibraryLoader::Found]
147
+ # @raise [LibraryNotFound, IncompatibleLibrary]
148
+ def load!
149
+ return @library if @library
150
+
151
+ @mutex.synchronize do
152
+ return @library if @library
153
+
154
+ found = LibraryLoader.find
155
+ ffi_lib(found.path)
156
+ REQUIRED_FUNCTIONS.each { |name, args, ret, opts| attach_function(name, args, ret, **(opts || {})) }
157
+ @features = OPTIONAL_FUNCTIONS.to_h { |feature, functions| [feature, attach_optional(functions)] }.freeze
158
+ @library = found
159
+ end
160
+ end
161
+
162
+ # @return [Boolean] whether {.load!} has succeeded
163
+ def loaded?
164
+ !@library.nil?
165
+ end
166
+
167
+ # @return [LibraryLoader::Found, nil]
168
+ attr_reader :library
169
+
170
+ # Whether an optional native feature is present: +:try_denoise+, +:rotation+, +:image_view_new_checked+.
171
+ # @return [Boolean]
172
+ def supports?(feature)
173
+ load!
174
+ @features.fetch(feature.to_sym) { raise ArgumentError, "unknown native feature: #{feature.inspect}" }
175
+ end
176
+
177
+ # @return [Array<Symbol>] optional features present in the loaded library
178
+ def optional_features
179
+ load!
180
+ @features.select { |_, present| present }.keys
181
+ end
182
+
183
+ # Rotation of a barcode in degrees as reported by the library (-180..180).
184
+ def barcode_rotation(barcode)
185
+ @features[:rotation] ? ZXing_Barcode_rotation(barcode) : ZXing_Barcode_orientation(barcode)
186
+ end
187
+
188
+ # Copies a caller-owned C string into a UTF-8 Ruby String and frees it. NULL → nil.
189
+ def take_string(pointer)
190
+ return nil if pointer.nil? || pointer.null?
191
+
192
+ pointer.read_string.force_encoding(Encoding::UTF_8)
193
+ ensure
194
+ ZXing_free(pointer) if pointer && !pointer.null?
195
+ end
196
+
197
+ # Calls the block with an +int*+ out-parameter, copies the returned caller-owned
198
+ # buffer of that many bytes into a BINARY String and frees it.
199
+ def take_bytes
200
+ length = FFI::MemoryPointer.new(:int)
201
+ pointer = yield length
202
+ return "".b if pointer.nil? || pointer.null?
203
+
204
+ pointer.read_bytes(length.read_int)
205
+ ensure
206
+ ZXing_free(pointer) if pointer && !pointer.null?
207
+ end
208
+
209
+ # Like {.take_bytes} for caller-owned +ZXing_BarcodeFormat*+ arrays.
210
+ # @return [Array<Integer>, nil] nil when the library returned NULL
211
+ def take_formats
212
+ count = FFI::MemoryPointer.new(:int)
213
+ pointer = yield count
214
+ return nil if pointer.nil? || pointer.null?
215
+
216
+ pointer.read_array_of_int(count.read_int)
217
+ ensure
218
+ ZXing_free(pointer) if pointer && !pointer.null?
219
+ end
220
+
221
+ # Fetches (and clears) the calling thread's last error message.
222
+ # @return [String, nil]
223
+ def last_error_message
224
+ take_string(ZXing_LastErrorMsg())
225
+ end
226
+
227
+ private
228
+
229
+ def attach_optional(functions)
230
+ functions.each { |name, args, ret| attach_function(name, args, ret) }
231
+ true
232
+ rescue FFI::NotFoundError
233
+ false
234
+ end
235
+ end
236
+ end
237
+ end