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.
- 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
- 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
|