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,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ # Single-pass decoding of an {Image}: options → native ReaderOptions, native object lifetimes,
5
+ # and extraction of results into Ruby objects before the native collection is freed.
6
+ module Reader
7
+ # Option → [ReaderOptions setter, kind]. Kinds: :bool, :byte (1..255), or an Array of allowed symbols.
8
+ SETTERS = {
9
+ try_harder: [:ZXing_ReaderOptions_setTryHarder, :bool],
10
+ try_rotate: [:ZXing_ReaderOptions_setTryRotate, :bool],
11
+ try_invert: [:ZXing_ReaderOptions_setTryInvert, :bool],
12
+ try_downscale: [:ZXing_ReaderOptions_setTryDownscale, :bool],
13
+ try_denoise: [:ZXing_ReaderOptions_setTryDenoise, :bool],
14
+ pure: [:ZXing_ReaderOptions_setIsPure, :bool],
15
+ return_errors: [:ZXing_ReaderOptions_setReturnErrors, :bool],
16
+ validate_optional_checksum: [:ZXing_ReaderOptions_setValidateOptionalChecksum, :bool],
17
+ binarizer: [:ZXing_ReaderOptions_setBinarizer, %i[local_average global_histogram fixed_threshold bool_cast]],
18
+ text_mode: [:ZXing_ReaderOptions_setTextMode, %i[plain eci hri escaped hex hex_eci]],
19
+ ean_add_on: [:ZXing_ReaderOptions_setEanAddOnSymbol, %i[ignore read require]],
20
+ max_symbols: [:ZXing_ReaderOptions_setMaxNumberOfSymbols, :byte],
21
+ min_line_count: [:ZXing_ReaderOptions_setMinLineCount, :byte]
22
+ }.freeze
23
+
24
+ # Every option {ZXingFFI.read} accepts.
25
+ OPTION_KEYS = ([:formats] + SETTERS.keys).freeze
26
+
27
+ # Gem defaults applied unless overridden. Everything else keeps the
28
+ # library's default (see {ZXingFFI::LIBRARY_DEFAULTS}).
29
+ GEM_DEFAULTS = {
30
+ formats: :all,
31
+ validate_optional_checksum: true,
32
+ text_mode: :plain,
33
+ return_errors: false
34
+ }.freeze
35
+
36
+ # Validated reader options, reusable across many decodes (the scanner builds one per pass).
37
+ class Options
38
+ # @return [Hash{Symbol => Object}] effective settings (gem defaults merged with the given options)
39
+ attr_reader :settings
40
+ # @return [Array<Integer>, nil] resolved ZXing_BarcodeFormat values (nil = every format)
41
+ attr_reader :format_values
42
+
43
+ # @param options [Hash] see {ZXingFFI.read}; +nil+ values mean "library default"
44
+ # @param defaults [Hash] merged underneath +options+
45
+ # @raise [ArgumentError] for unknown keys or invalid values
46
+ # @raise [NotSupported] for +try_denoise: true+ when the library lacks it
47
+ def initialize(options = {}, defaults = GEM_DEFAULTS)
48
+ options = options.to_h
49
+ unknown = options.keys - OPTION_KEYS
50
+ raise ArgumentError, "unknown option(s) #{unknown.map(&:inspect).join(", ")}; valid: #{OPTION_KEYS.join(", ")}" if unknown.any?
51
+
52
+ @settings = defaults.merge(options).reject { |_, value| value.nil? }
53
+ @settings.each { |key, value| validate(key, value) unless key == :formats }
54
+ @format_values = Formats.resolve(@settings[:formats]).freeze
55
+ if @settings[:try_denoise] && !Native.supports?(:try_denoise)
56
+ raise NotSupported, "try_denoise needs libZXing built with ZXING_EXPERIMENTAL_API (#{Native.library.path})"
57
+ end
58
+ @settings.freeze
59
+ freeze
60
+ end
61
+
62
+ # @return [Options] a copy with +overrides+ applied on top
63
+ def merge(overrides)
64
+ return self if overrides.empty?
65
+
66
+ Options.new(settings.merge(overrides), {})
67
+ end
68
+
69
+ # @return [Object] the effective value of +key+ (nil = library default)
70
+ def [](key)
71
+ settings[key]
72
+ end
73
+
74
+ # @return [Hash]
75
+ def to_h
76
+ settings.dup
77
+ end
78
+
79
+ # Applies the settings to a native ZXing_ReaderOptions*.
80
+ # @api private
81
+ def apply(native_options)
82
+ settings.each do |key, value|
83
+ next if key == :formats
84
+ next if key == :try_denoise && !value # absent symbol + false is a no-op
85
+
86
+ Native.public_send(SETTERS.fetch(key).first, native_options, value)
87
+ end
88
+ return unless format_values
89
+
90
+ buffer = FFI::MemoryPointer.new(:int, format_values.size)
91
+ buffer.write_array_of_int(format_values)
92
+ Native.ZXing_ReaderOptions_setFormats(native_options, buffer, format_values.size)
93
+ end
94
+
95
+ private
96
+
97
+ def validate(key, value)
98
+ kind = SETTERS.fetch(key).last
99
+ valid =
100
+ case kind
101
+ when :bool then value == true || value == false
102
+ when :byte then value.is_a?(Integer) && value.between?(1, 255)
103
+ else kind.include?(value)
104
+ end
105
+ return if valid
106
+
107
+ expected =
108
+ case kind
109
+ when :bool then "true or false"
110
+ when :byte then "an Integer in 1..255"
111
+ else kind.map(&:inspect).join(", ")
112
+ end
113
+ raise ArgumentError, "invalid #{key}: #{value.inspect} (expected #{expected})"
114
+ end
115
+ end
116
+
117
+ class << self
118
+ # Decodes +image+ once with +options+ and returns every barcode the library reports.
119
+ #
120
+ # @param image [Image]
121
+ # @param options [Hash, Options]
122
+ # @param crop [Array(Integer, Integer, Integer, Integer), nil] +[left, top, width, height]+ view into the
123
+ # image (no copy, via ZXing_ImageView_crop); positions are then relative to the crop.
124
+ # @return [Array<Barcode>]
125
+ # @raise [DecodeError] when ZXing_ReadBarcodes returns NULL
126
+ def read(image, options = {}, crop: nil)
127
+ raise TypeError, "expected a ZXingFFI::Image, got #{image.class}" unless image.is_a?(Image)
128
+
129
+ options = Options.new(options) unless options.is_a?(Options)
130
+ Native.load!
131
+ pixels = image.pointer # raises if released; keeps the buffer referenced for the whole call
132
+ validate_crop!(image, crop) if crop
133
+
134
+ text_mode = options[:text_mode] || LIBRARY_DEFAULTS[:text_mode]
135
+ view = options_ptr = barcodes = nil
136
+ # Interrupts (Thread#raise, Timeout, signals) are deferred until the native objects are freed: otherwise one
137
+ # arriving just after a native call returns, before its result is assigned, would leak that result. The
138
+ # decode itself cannot be interrupted anyway while it runs without the GVL.
139
+ Thread.handle_interrupt(Object => :never) do
140
+ view = new_view(image, pixels)
141
+ Native.ZXing_ImageView_crop(view, *crop) if crop
142
+ options_ptr = Native.ZXing_ReaderOptions_new
143
+ raise DecodeError, "ZXing_ReaderOptions_new failed: #{Native.last_error_message}" if options_ptr.null?
144
+
145
+ options.apply(options_ptr)
146
+ barcodes = Native.ZXing_ReadBarcodes(view, options_ptr)
147
+ if barcodes.null?
148
+ raise DecodeError, Native.last_error_message || "ZXing_ReadBarcodes failed (no error message available)"
149
+ end
150
+
151
+ Array.new(Native.ZXing_Barcodes_size(barcodes)) { |i| build(Native.ZXing_Barcodes_at(barcodes, i), text_mode) }
152
+ ensure
153
+ Native.ZXing_Barcodes_delete(barcodes) if barcodes && !barcodes.null?
154
+ Native.ZXing_ReaderOptions_delete(options_ptr) if options_ptr && !options_ptr.null?
155
+ Native.ZXing_ImageView_delete(view) if view && !view.null?
156
+ end
157
+ end
158
+
159
+ # The library's ReaderOptions defaults, read through the get* functions.
160
+ # @return [Hash{Symbol => Object}]
161
+ def library_defaults
162
+ Native.load!
163
+ ptr = Native.ZXing_ReaderOptions_new
164
+ raise DecodeError, "ZXing_ReaderOptions_new failed" if ptr.null?
165
+
166
+ begin
167
+ defaults = {
168
+ formats: Native.take_formats { |count| Native.ZXing_ReaderOptions_getFormats(ptr, count) }.to_a.map { |v| Formats.symbol_for(v) },
169
+ try_harder: Native.ZXing_ReaderOptions_getTryHarder(ptr),
170
+ try_rotate: Native.ZXing_ReaderOptions_getTryRotate(ptr),
171
+ try_invert: Native.ZXing_ReaderOptions_getTryInvert(ptr),
172
+ try_downscale: Native.ZXing_ReaderOptions_getTryDownscale(ptr)
173
+ }
174
+ defaults[:try_denoise] = Native.ZXing_ReaderOptions_getTryDenoise(ptr) if Native.supports?(:try_denoise)
175
+ defaults.merge(
176
+ pure: Native.ZXing_ReaderOptions_getIsPure(ptr),
177
+ return_errors: Native.ZXing_ReaderOptions_getReturnErrors(ptr),
178
+ validate_optional_checksum: Native.ZXing_ReaderOptions_getValidateOptionalChecksum(ptr),
179
+ binarizer: Native.ZXing_ReaderOptions_getBinarizer(ptr),
180
+ text_mode: Native.ZXing_ReaderOptions_getTextMode(ptr),
181
+ ean_add_on: Native.ZXing_ReaderOptions_getEanAddOnSymbol(ptr),
182
+ max_symbols: Native.ZXing_ReaderOptions_getMaxNumberOfSymbols(ptr),
183
+ min_line_count: Native.ZXing_ReaderOptions_getMinLineCount(ptr)
184
+ )
185
+ ensure
186
+ Native.ZXing_ReaderOptions_delete(ptr)
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def new_view(image, pixels)
193
+ view =
194
+ if Native.supports?(:image_view_new_checked)
195
+ Native.ZXing_ImageView_new_checked(pixels, image.bytesize, image.width, image.height, image.format, image.row_stride, 0)
196
+ else
197
+ Native.ZXing_ImageView_new(pixels, image.width, image.height, image.format, image.row_stride, 0)
198
+ end
199
+ raise DecodeError, "could not create an ImageView for #{image.inspect}: #{Native.last_error_message}" if view.null?
200
+
201
+ view
202
+ end
203
+
204
+ def validate_crop!(image, crop)
205
+ left, top, width, height = crop
206
+ valid = crop.is_a?(Array) && crop.size == 4 && crop.all?(Integer) &&
207
+ left >= 0 && top >= 0 && width.positive? && height.positive? &&
208
+ left + width <= image.width && top + height <= image.height
209
+ raise ArgumentError, "crop #{crop.inspect} is outside #{image.width}x#{image.height}" unless valid
210
+ end
211
+
212
+ # Copies every field of a (parent-owned) ZXing_Barcode* into a Ruby Barcode.
213
+ def build(ptr, text_mode)
214
+ format = Native.ZXing_Barcode_format(ptr)
215
+ error_type = Native.ZXing_Barcode_errorType(ptr)
216
+ bytes = Native.take_bytes { |len| Native.ZXing_Barcode_bytes(ptr, len) }
217
+ Barcode.new(
218
+ text: text(Native.take_string(Native.ZXing_Barcode_text(ptr)) || "", bytes, text_mode),
219
+ bytes: bytes,
220
+ format: Formats.symbol_for(format),
221
+ symbology: Formats.symbol_for(Native.ZXing_Barcode_symbology(ptr)),
222
+ format_name: Formats.name_for(format),
223
+ content_type: Native.ZXing_Barcode_contentType(ptr),
224
+ symbology_identifier: Native.take_string(Native.ZXing_Barcode_symbologyIdentifier(ptr)) || "",
225
+ position: quad(Native.ZXing_Barcode_position(ptr)),
226
+ page_position: nil,
227
+ rotation: Native.barcode_rotation(ptr) % 360,
228
+ mirrored: Native.ZXing_Barcode_isMirrored(ptr),
229
+ inverted: Native.ZXing_Barcode_isInverted(ptr),
230
+ eci: Native.ZXing_Barcode_hasECI(ptr),
231
+ valid: Native.ZXing_Barcode_isValid(ptr),
232
+ error: (error_type == :none) ? nil : {type: error_type, message: Native.take_string(Native.ZXing_Barcode_errorMsg(ptr))},
233
+ line_count: Native.ZXing_Barcode_lineCount(ptr),
234
+ sequence: sequence(ptr),
235
+ extra_json: Native.take_string(Native.ZXing_Barcode_extra(ptr, nil)),
236
+ page: nil,
237
+ pass: nil,
238
+ dpi: nil
239
+ )
240
+ end
241
+
242
+ # ZXing_Barcode_text returns a NUL-terminated C string, so plain text of a payload containing NUL bytes is
243
+ # cut at the first NUL. Rebuild it from the bytes the way zxing-cpp renders undeclared binary data (UTF-8 if
244
+ # valid, else ISO-8859-1), provided the library's prefix agrees.
245
+ def text(text, bytes, text_mode)
246
+ return text unless text_mode == :plain && bytes.include?("\x00")
247
+
248
+ utf8 = bytes.dup.force_encoding(Encoding::UTF_8)
249
+ rebuilt = utf8.valid_encoding? ? utf8 : bytes.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
250
+ rebuilt.start_with?(text) ? rebuilt : text
251
+ end
252
+
253
+ def quad(position)
254
+ corners = %i[top_left top_right bottom_right bottom_left].map do |corner|
255
+ point = position[corner]
256
+ Geometry::Point.new(point[:x], point[:y])
257
+ end
258
+ Geometry::Quad.new(*corners)
259
+ end
260
+
261
+ # Structured append: the library reports -1/-1 outside a sequence; size 0 means "unknown count".
262
+ def sequence(ptr)
263
+ index = Native.ZXing_Barcode_sequenceIndex(ptr)
264
+ size = Native.ZXing_Barcode_sequenceSize(ptr)
265
+ return nil if index.negative? || size.negative? || size == 1
266
+
267
+ {index: index, size: size.zero? ? nil : size, id: Native.take_string(Native.ZXing_Barcode_sequenceId(ptr))}
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ # Results for one page or frame.
5
+ #
6
+ # @!attribute page [Integer] 1-based
7
+ # @!attribute barcodes [Array<Barcode>] ordered top-to-bottom, left-to-right
8
+ # @!attribute dpi [Integer, nil] base render DPI (PDF) or nil
9
+ # @!attribute width [Integer, nil] base image width in pixels
10
+ # @!attribute height [Integer, nil] base image height in pixels
11
+ # @!attribute passes_run [Array<Symbol>]
12
+ # @!attribute skipped_passes [Hash{Symbol => String}] passes that could not run, with the reason
13
+ # @!attribute duration [Float] seconds spent on the page
14
+ # @!attribute error [Exception, nil] set only with +on_page_error: :skip+
15
+ # @!attribute metadata [Hash] what the loader applied (orientation, aspect correction, DPI capping, …)
16
+ PageResult = Data.define(:page, :barcodes, :dpi, :width, :height, :passes_run, :skipped_passes, :duration, :error, :metadata)
17
+
18
+ # Orchestrates a scan: input → loader → pages → pass ladder → ordered results, optionally on several
19
+ # threads.
20
+ class Scanner
21
+ # Options that only the scanner understands.
22
+ SCAN_OPTIONS = %i[
23
+ effort passes stop dpi max_dpi max_pixels max_pages pages password threads on_page_error loader
24
+ min_length timeout instrument
25
+ ].freeze
26
+
27
+ # Every option +scan+/+scan_pages+ accept: the scanner's plus every {ZXingFFI.read} option.
28
+ OPTIONS = (SCAN_OPTIONS + Reader::OPTION_KEYS).freeze
29
+
30
+ # Accepted values of +on_page_error:+.
31
+ ON_PAGE_ERROR = %i[raise skip].freeze
32
+
33
+ # @param options [Hash] see {ZXingFFI.scan}
34
+ # @raise [ArgumentError] for unknown or invalid options (before any input is opened)
35
+ def initialize(**options)
36
+ unknown = options.keys - OPTIONS
37
+ raise ArgumentError, "unknown option(s) #{unknown.map(&:inspect).join(", ")}; valid: #{OPTIONS.join(", ")}" if unknown.any?
38
+
39
+ @passes = Strategy.passes_for(effort: options.fetch(:effort, :normal), passes: options[:passes])
40
+ @stop = Strategy.validate_stop(options.fetch(:stop, :found))
41
+ @dpi = validate_dpi(options.fetch(:dpi, :auto))
42
+ @pages = validate_pages(options[:pages])
43
+ @password = options[:password]&.to_s
44
+ @threads = validate_positive_integer(:threads, options.fetch(:threads, 1))
45
+ @on_page_error = options.fetch(:on_page_error, :raise)
46
+ raise ArgumentError, "on_page_error must be :raise or :skip, got #{@on_page_error.inspect}" unless ON_PAGE_ERROR.include?(@on_page_error)
47
+
48
+ @loader = options[:loader]
49
+ @timeout = options[:timeout] && validate_positive_number(:timeout, options[:timeout])
50
+ @instrument = options[:instrument]
51
+ raise ArgumentError, "instrument must respond to #call" if @instrument && !@instrument.respond_to?(:call)
52
+
53
+ @config = effective_config(options)
54
+ @reader_options = Reader::Options.new(options.slice(*Reader::OPTION_KEYS))
55
+ @min_length = validate_min_length(options.fetch(:min_length, {}))
56
+ @notify_mutex = Mutex.new
57
+ end
58
+
59
+ # @return [Array<Barcode>] every page's barcodes, ordered by page then position
60
+ def scan(input)
61
+ results = []
62
+ scan_pages(input) { |page_result| results.concat(page_result.barcodes) }
63
+ results
64
+ end
65
+
66
+ # Yields a {PageResult} per selected page, in page order.
67
+ # @yieldparam page_result [PageResult]
68
+ # @return [nil]
69
+ def scan_pages(input, &block)
70
+ raise ArgumentError, "a block is required" unless block
71
+
72
+ if input.is_a?(Image)
73
+ yield process(nil, 1, pdf: false, image: input)
74
+ else
75
+ Source.open(input) { |source| scan_source(source, &block) }
76
+ end
77
+ nil
78
+ end
79
+
80
+ # Orders barcodes top-to-bottom, then left-to-right (by quad center). Centers within half the smaller
81
+ # code's height (at least 8 px) count as the same row.
82
+ # @return [Array<Barcode>]
83
+ def self.order(barcodes)
84
+ rows = []
85
+ barcodes.sort_by { |b| [b.center.y, b.center.x] }.each do |barcode|
86
+ row = rows.last
87
+ if row && (barcode.center.y - row.first.center.y).abs <= row_tolerance(row.first, barcode)
88
+ row << barcode
89
+ else
90
+ rows << [barcode]
91
+ end
92
+ end
93
+ rows.flat_map { |row| row.sort_by { |b| b.center.x } }
94
+ end
95
+
96
+ def self.row_tolerance(a, b)
97
+ heights = [a, b].map { |barcode| barcode.position.bounding_box.then { |_, y0, _, y1| y1 - y0 } }
98
+ [8, heights.min / 2.0].max
99
+ end
100
+ private_class_method :row_tolerance
101
+
102
+ private
103
+
104
+ def scan_source(source, &block)
105
+ HeaderProbe.check!(source.path, source.kind, @config.max_pixels) unless source.kind == :pdf
106
+ loader_class = Loaders::Registry.loader_for(source.kind, config: @config, loader: @loader)
107
+ document = loader_class.new(@config).open(source, password: @password)
108
+ begin
109
+ numbers = select_pages(document.page_count)
110
+ if @config.max_pages && numbers.size > @config.max_pages
111
+ raise LimitExceeded.new("#{numbers.size} pages selected, max_pages is #{@config.max_pages}",
112
+ limit: :max_pages, value: numbers.size)
113
+ end
114
+
115
+ pdf = source.kind == :pdf
116
+ each_result(numbers, ->(number) { process(document, number, pdf: pdf) }, &block)
117
+ ensure
118
+ document.close
119
+ end
120
+ end
121
+
122
+ # Runs +work+ for each page number, on @threads threads, yielding results in page order.
123
+ def each_result(numbers, work)
124
+ if @threads <= 1 || numbers.size <= 1
125
+ numbers.each { |number| yield work.call(number) }
126
+ return
127
+ end
128
+
129
+ queue = Queue.new
130
+ numbers.each { |number| queue << number }
131
+ queue.close
132
+ results = Queue.new
133
+ workers = Array.new([@threads, numbers.size].min) do
134
+ Thread.new do
135
+ while (number = queue.pop)
136
+ outcome =
137
+ begin
138
+ work.call(number)
139
+ rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised on the caller's thread
140
+ e
141
+ end
142
+ results << [number, outcome]
143
+ end
144
+ end
145
+ end
146
+
147
+ buffered = {}
148
+ numbers.each do |number|
149
+ buffered.store(*results.pop) until buffered.key?(number)
150
+ outcome = buffered.delete(number)
151
+ raise outcome if outcome.is_a?(Exception)
152
+
153
+ yield outcome
154
+ end
155
+ completed = true
156
+ ensure
157
+ if workers
158
+ queue.clear # stop handing out pages
159
+ # Leaving early (error, break, Ctrl-C, Timeout): don't wait for pages in progress. Killing is safe: native
160
+ # decodes defer interrupts until their objects are freed, and Subprocess kills its child process group.
161
+ workers.each(&:kill) unless completed
162
+ workers.each(&:join)
163
+ end
164
+ end
165
+
166
+ def process(document, number, pdf:, image: nil)
167
+ started = monotonic
168
+ deadline = @timeout && started + @timeout
169
+ page = nil
170
+ info = document&.page_info(number)
171
+ choice = pdf ? choose_dpi(info) : nil
172
+ page = image ? Loaders::Page.new(number: 1, image: image, dpi: nil, scale_to_base: 1.0, metadata: {loader: :image}) : document.render(number, dpi: choice&.dpi)
173
+ notify(:page_loaded, page: number, width: page.image.width, height: page.image.height, dpi: page.dpi, duration: monotonic - started)
174
+
175
+ # re-renders (high_res pass) get only what is left of the page budget
176
+ rerender = pdf ? ->(dpi) { document.render(number, dpi: dpi, timeout: deadline && [deadline - monotonic, 0.05].max) } : nil
177
+ result = strategy.run(page, rerender: rerender, page_info: info, deadline: deadline,
178
+ notify: ->(event, payload) { notify(event, page: number, **payload) })
179
+ barcodes = Scanner.order(result.barcodes.map { |barcode| finalize(barcode, number, page.dpi) })
180
+ metadata = page.metadata.merge(choice ? {dpi_source: choice.source, dpi_capped: choice.capped} : {})
181
+ page_result = PageResult.new(
182
+ page: number, barcodes: barcodes, dpi: page.dpi, width: page.image.width, height: page.image.height,
183
+ passes_run: result.passes_run, skipped_passes: result.skipped_passes, duration: monotonic - started,
184
+ error: nil, metadata: metadata
185
+ )
186
+ notify(:page_completed, page: number, barcodes: barcodes.size, passes_run: result.passes_run, duration: page_result.duration)
187
+ page_result
188
+ rescue => e
189
+ raise if @on_page_error == :raise
190
+
191
+ PageResult.new(
192
+ page: number, barcodes: [], dpi: page&.dpi, width: nil, height: nil, passes_run: [], skipped_passes: {},
193
+ duration: monotonic - started, error: e, metadata: {}
194
+ )
195
+ ensure
196
+ page.image.release! if page && !image
197
+ end
198
+
199
+ def strategy
200
+ Strategy.new(passes: @passes, stop: @stop, options: @reader_options, config: @config, min_length: @min_length,
201
+ transformer: -> { Transformers.first_available(@config.transformers, config: @config) })
202
+ end
203
+
204
+ def choose_dpi(info)
205
+ Dpi.choose(requested: @dpi, width_pt: info.width, height_pt: info.height, native_ppi: info.native_ppi,
206
+ default_dpi: @config.default_dpi, max_dpi: @config.max_dpi, max_pixels: @config.max_pixels)
207
+ end
208
+
209
+ # Adds page metadata and PDF page coordinates (points from the top-left of the displayed page).
210
+ def finalize(barcode, number, dpi)
211
+ page_position = dpi && barcode.position.map { |p| Geometry::Point.new((p.x * 72.0 / dpi).round(2), (p.y * 72.0 / dpi).round(2)) }
212
+ barcode.with(page: number, dpi: dpi, page_position: page_position)
213
+ end
214
+
215
+ def select_pages(count)
216
+ numbers =
217
+ case @pages
218
+ when nil then (1..count).to_a
219
+ when Integer then [@pages]
220
+ when Range
221
+ last = if @pages.end.nil?
222
+ count
223
+ else
224
+ (@pages.exclude_end? ? @pages.end - 1 : @pages.end)
225
+ end
226
+ ((@pages.begin || 1)..[last, count].min).to_a
227
+ else @pages.uniq.sort
228
+ end
229
+ numbers.select! { |n| n.between?(1, count) }
230
+ raise ArgumentError, "no pages selected: #{@pages.inspect} is outside 1..#{count}" if numbers.empty?
231
+
232
+ numbers
233
+ end
234
+
235
+ def notify(event, **payload)
236
+ return unless @instrument
237
+
238
+ @notify_mutex.synchronize { @instrument.call(event, payload) }
239
+ end
240
+
241
+ def effective_config(options)
242
+ overrides = options.slice(:max_dpi, :max_pixels, :max_pages)
243
+ if @timeout
244
+ overrides[:render_timeout] = [ZXingFFI.config.render_timeout, @timeout].compact.min
245
+ end
246
+ ZXingFFI.config.with(**overrides)
247
+ end
248
+
249
+ def validate_dpi(dpi)
250
+ return dpi if dpi == :auto || (dpi.is_a?(Integer) && dpi.positive?)
251
+
252
+ raise ArgumentError, "dpi must be a positive Integer or :auto, got #{dpi.inspect}"
253
+ end
254
+
255
+ def validate_pages(pages)
256
+ valid =
257
+ case pages
258
+ when nil then true
259
+ when Integer then pages.positive?
260
+ when Range then [pages.begin, pages.end].all? { |n| n.nil? || (n.is_a?(Integer) && n.positive?) }
261
+ when Array then pages.any? && pages.all? { |n| n.is_a?(Integer) && n.positive? }
262
+ else false
263
+ end
264
+ return pages if valid
265
+
266
+ raise ArgumentError, "pages must be a positive Integer, a Range or an Array of 1-based page numbers, got #{pages.inspect}"
267
+ end
268
+
269
+ def validate_min_length(min_length)
270
+ raise ArgumentError, "min_length must be a Hash of format => Integer" unless min_length.is_a?(Hash)
271
+
272
+ min_length.each do |format, length|
273
+ raise ArgumentError, "min_length: unknown format #{format.inspect}" unless Formats.all.include?(format)
274
+ raise ArgumentError, "min_length for #{format.inspect} must be a non-negative Integer" unless length.is_a?(Integer) && length >= 0
275
+ end
276
+ min_length.dup.freeze
277
+ end
278
+
279
+ def validate_positive_integer(name, value)
280
+ return value if value.is_a?(Integer) && value.positive?
281
+
282
+ raise ArgumentError, "#{name} must be a positive Integer, got #{value.inspect}"
283
+ end
284
+
285
+ def validate_positive_number(name, value)
286
+ return value if value.is_a?(Numeric) && value.positive?
287
+
288
+ raise ArgumentError, "#{name} must be a positive number of seconds, got #{value.inspect}"
289
+ end
290
+
291
+ def monotonic
292
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
293
+ end
294
+ end
295
+ end