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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ # Identifies inputs by their magic bytes, never by file name or extension.
5
+ #
6
+ # @example
7
+ # ZXingFFI::Sniffer.sniff("upload.bin") # => :tiff
8
+ # ZXingFFI::Sniffer.detect("%PDF-1.7\n...") # => :pdf
9
+ module Sniffer
10
+ # Every kind {detect} and {sniff} can return.
11
+ KINDS = %i[pdf png jpeg tiff gif bmp webp heif avif pnm].freeze
12
+ # Number of leading bytes examined. {sniff} reads at most this many.
13
+ HEAD_SIZE = 1024
14
+
15
+ PNG_MAGIC = "\x89PNG\r\n\x1A\n".b.freeze
16
+ JPEG_MAGIC = "\xFF\xD8\xFF".b.freeze
17
+ # Classic TIFF and BigTIFF, little- and big-endian.
18
+ TIFF_MAGICS = ["II*\0", "MM\0*", "II+\0", "MM\0+"].freeze
19
+ GIF_MAGICS = %w[GIF87a GIF89a].freeze
20
+ PDF_MAGIC = "%PDF-"
21
+ # Sizes of the known BMP DIB headers (BITMAPCOREHEADER, OS/2 v2, BITMAPINFOHEADER .. BITMAPV5HEADER).
22
+ BMP_HEADER_SIZES = [12, 16, 40, 52, 56, 64, 108, 124].freeze
23
+ AVIF_BRANDS = %w[avif avis].freeze
24
+ HEIF_BRANDS = %w[heic heix heim heis hevc hevx].freeze
25
+ # Generic HEIF image / image-sequence brands: AVIF when an AVIF brand is also listed, HEIF otherwise.
26
+ MIAF_BRANDS = %w[mif1 msf1].freeze
27
+ # P1-P6 followed by whitespace or a comment. P7 (PAM) is not supported.
28
+ PNM_MAGIC = /\AP[1-6][ \t\n\v\f\r#]/n
29
+ NON_PRINTABLE = /[^\x20-\x7E]/n
30
+ private_constant :PNG_MAGIC, :JPEG_MAGIC, :TIFF_MAGICS, :GIF_MAGICS, :PDF_MAGIC, :BMP_HEADER_SIZES,
31
+ :AVIF_BRANDS, :HEIF_BRANDS, :MIAF_BRANDS, :PNM_MAGIC, :NON_PRINTABLE
32
+
33
+ class << self
34
+ # Detects the kind of data from its first bytes.
35
+ #
36
+ # Signatures at offset 0 are checked first. A PDF is recognized by +%PDF-+ anywhere in the first
37
+ # {HEAD_SIZE} bytes, because PDF readers accept leading junk.
38
+ #
39
+ # @param head [String, nil] the first bytes of the input, in any encoding (always treated as bytes);
40
+ # only the first {HEAD_SIZE} bytes are examined
41
+ # @return [Symbol, nil] one of {KINDS}, or nil if the format is not recognized
42
+ def detect(head)
43
+ return nil if head.nil?
44
+ raise TypeError, "expected a String, got #{head.class}" unless head.is_a?(String)
45
+
46
+ bytes = head.b.byteslice(0, HEAD_SIZE)
47
+ signature_kind(bytes) || (bytes.include?(PDF_MAGIC) ? :pdf : nil)
48
+ end
49
+
50
+ # Detects the kind of a file or IO from its content.
51
+ #
52
+ # @param input [String, Pathname, IO] a path, or an IO read from its current position. At most
53
+ # {HEAD_SIZE} bytes are read, and a seekable IO is moved back to where it was (bytes read from a pipe
54
+ # stay consumed).
55
+ # @return [Symbol] one of {KINDS}
56
+ # @raise [UnsupportedInput] if the input is empty or its format is not recognized
57
+ # @raise [SystemCallError] if the file cannot be read (e.g. +Errno::ENOENT+)
58
+ # @raise [TypeError] if +input+ is neither a path nor an IO
59
+ def sniff(input)
60
+ head, label = read_head(input)
61
+ raise UnsupportedInput, "empty input: #{label} contains no data" if head.empty?
62
+
63
+ detect(head) || raise(UnsupportedInput, unrecognized_message(head, label))
64
+ end
65
+
66
+ private
67
+
68
+ def signature_kind(bytes)
69
+ if bytes.start_with?(PNG_MAGIC) then :png
70
+ elsif bytes.start_with?(JPEG_MAGIC) then :jpeg
71
+ elsif bytes.start_with?(*TIFF_MAGICS) then :tiff
72
+ elsif bytes.start_with?(*GIF_MAGICS) then :gif
73
+ elsif bmp?(bytes) then :bmp
74
+ elsif bytes.start_with?("RIFF") && bytes.byteslice(8, 4) == "WEBP" then :webp
75
+ elsif bytes.byteslice(4, 4) == "ftyp" then iso_bmff_kind(bytes)
76
+ elsif bytes.match?(PNM_MAGIC) then :pnm
77
+ end
78
+ end
79
+
80
+ # "BM" alone is too weak (plain text can start with it): also require a known DIB header size.
81
+ def bmp?(bytes)
82
+ bytes.start_with?("BM") && bytes.bytesize >= 18 &&
83
+ BMP_HEADER_SIZES.include?(bytes.unpack1("V", offset: 14))
84
+ end
85
+
86
+ # HEIF and AVIF files start with an ISO-BMFF ftyp box: size (uint32 BE, 0 = to the end of the file),
87
+ # "ftyp", major brand, minor version, then compatible brands up to the end of the box.
88
+ def iso_bmff_kind(bytes)
89
+ return nil if bytes.bytesize < 12
90
+
91
+ size = bytes.unpack1("N")
92
+ return nil if size.between?(1, 15) # 1 = 64-bit size (never used for ftyp); 2..15 can't hold the brands
93
+
94
+ box_end = size.zero? ? bytes.bytesize : [size, bytes.bytesize].min
95
+ compatible = (16..box_end - 4).step(4).map { |offset| bytes.byteslice(offset, 4) }
96
+ brand_kind(bytes.byteslice(8, 4), compatible)
97
+ end
98
+
99
+ def brand_kind(major, compatible)
100
+ return :avif if AVIF_BRANDS.include?(major)
101
+ return :heif if HEIF_BRANDS.include?(major)
102
+ return nil unless MIAF_BRANDS.include?(major)
103
+
104
+ compatible.intersect?(AVIF_BRANDS) ? :avif : :heif
105
+ end
106
+
107
+ # Returns the first bytes (BINARY) and a label for messages.
108
+ def read_head(input)
109
+ if input.is_a?(String) || (defined?(::Pathname) && input.is_a?(::Pathname))
110
+ path = input.to_s
111
+ [File.open(path, "rb") { |file| file.read(HEAD_SIZE) }.to_s, path]
112
+ elsif input.respond_to?(:read)
113
+ [read_io_head(input), io_label(input)]
114
+ else
115
+ raise TypeError, "expected a path (String or Pathname) or an IO, got #{input.class}"
116
+ end
117
+ end
118
+
119
+ def read_io_head(io)
120
+ position = io_position(io)
121
+ begin
122
+ io.read(HEAD_SIZE).to_s.b
123
+ ensure
124
+ io.seek(position) if position
125
+ end
126
+ end
127
+
128
+ def io_position(io)
129
+ return nil unless io.respond_to?(:pos) && io.respond_to?(:seek)
130
+
131
+ io.pos
132
+ rescue SystemCallError, IOError # pipes, sockets and other unseekable streams
133
+ nil
134
+ end
135
+
136
+ def io_label(io)
137
+ path = io.path if io.respond_to?(:path)
138
+ path ? path.to_s : io.class.to_s
139
+ rescue IOError
140
+ io.class.to_s
141
+ end
142
+
143
+ def unrecognized_message(head, label)
144
+ "unrecognized input format: #{label} is not a PDF, PNG, JPEG, TIFF, GIF, BMP, WebP, HEIF, AVIF or " \
145
+ "PNM file (first bytes: #{hex_dump(head)}); the format is detected from the content, not the file name"
146
+ end
147
+
148
+ # "25 50 44 46 |%PDF|": hex and printable ASCII of the first 16 bytes.
149
+ def hex_dump(bytes)
150
+ sample = bytes.byteslice(0, 16)
151
+ "#{sample.unpack1("H*").scan(/../).join(" ")} |#{sample.gsub(NON_PRINTABLE, ".")}|"
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module ZXingFFI
6
+ # A scan input normalized to an absolute file path plus its sniffed kind.
7
+ #
8
+ # Paths are made absolute so a leading "-" can never be read as a CLI option. IO inputs are spooled to a
9
+ # 0600 Tempfile that {.open} always removes.
10
+ class Source
11
+ # @return [String] absolute path
12
+ attr_reader :path
13
+ # @return [Symbol] sniffed kind (see {Sniffer::KINDS})
14
+ attr_reader :kind
15
+ # @return [String, nil] a display name (original path, or the IO's path if it has one)
16
+ attr_reader :name
17
+
18
+ # Yields a Source for +input+ and cleans up any temp file afterwards.
19
+ #
20
+ # @param input [String, Pathname, IO, #read] a path or a readable IO (read from its current position)
21
+ # @yieldparam source [Source]
22
+ # @raise [UnsupportedInput] for unknown file types or unsupported input objects
23
+ def self.open(input)
24
+ tempfile = nil
25
+ path, name =
26
+ # Pathname responds to #read too, so check it before the IO branch (it must not be spooled).
27
+ if input.is_a?(String) || (defined?(::Pathname) && input.is_a?(::Pathname)) || (input.respond_to?(:to_path) && !input.respond_to?(:read))
28
+ full = File.expand_path(input.respond_to?(:to_path) ? input.to_path : input)
29
+ raise Errno::ENOENT, full unless File.file?(full)
30
+
31
+ [full, input.to_s]
32
+ elsif input.respond_to?(:read)
33
+ tempfile = spool(input)
34
+ [tempfile.path, (input.respond_to?(:path) ? input.path : nil)]
35
+ else
36
+ raise UnsupportedInput, "expected a path, an IO or a ZXingFFI::Image, got #{input.class}"
37
+ end
38
+ yield new(path, Sniffer.sniff(path), name)
39
+ ensure
40
+ if tempfile
41
+ tempfile.close
42
+ File.unlink(tempfile.path) if File.exist?(tempfile.path)
43
+ end
44
+ end
45
+
46
+ # Copies an IO into a private temp file.
47
+ # @return [File] closed-for-writing temp file (caller removes it)
48
+ def self.spool(io)
49
+ file = Tempfile.create(["zxing_ffi", ".input"], binmode: true) # created 0600
50
+ IO.copy_stream(io, file)
51
+ file.flush
52
+ file
53
+ rescue
54
+ if file
55
+ file.close
56
+ File.unlink(file.path) if File.exist?(file.path)
57
+ end
58
+ raise
59
+ end
60
+
61
+ def initialize(path, kind, name = nil)
62
+ @path = path
63
+ @kind = kind
64
+ @name = name || path
65
+ end
66
+
67
+ # @return [Integer] file size in bytes
68
+ def size
69
+ File.size(path)
70
+ end
71
+
72
+ # @return [String]
73
+ def inspect
74
+ "#<#{self.class.name} #{kind} #{name}>"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ # The escalating pass ladder run on one page.
5
+ #
6
+ # Every pass decodes the page's base image or a derivative of it (a re-render, an upscale, tiles, a rotation)
7
+ # and maps its results back to base-image coordinates with an {Geometry::Affine}. Results are filtered,
8
+ # deduplicated against earlier passes (the earliest pass wins) and the ladder stops according to +stop+.
9
+ class Strategy
10
+ # Pass names in ladder order.
11
+ PASSES = %i[base inverted global_binarizer high_res tiles rotated_45 denoise].freeze
12
+
13
+ # Passes per effort level.
14
+ EFFORTS = {
15
+ fast: %i[base],
16
+ normal: %i[base inverted global_binarizer high_res],
17
+ thorough: PASSES
18
+ }.freeze
19
+
20
+ # Raster images whose longest side is below this get a 2× upscale in the high_res pass.
21
+ UPSCALE_BELOW = 2000
22
+
23
+ # Tile geometry for the tiles pass.
24
+ TILES = {min_tile: 1024, max_tile: 1536, overlap: 0.2}.freeze
25
+
26
+ # Rotation of the rotated pass (with try_rotate it also covers 135°, 225° and 315°).
27
+ ROTATED_DEGREES = 45
28
+
29
+ # @!attribute barcodes [Array<Barcode>] positions in base-image pixels, +pass+ set
30
+ # @!attribute passes_run [Array<Symbol>]
31
+ # @!attribute skipped_passes [Hash{Symbol => String}] passes that could not run, with the reason
32
+ Result = Data.define(:barcodes, :passes_run, :skipped_passes)
33
+
34
+ # An image (or a crop of it) to decode plus the transform from its pixels to the base image.
35
+ # +negative+ marks a pixel-inverted copy: its results are reported as inverted.
36
+ View = Data.define(:image, :to_base, :crop, :negative) do
37
+ def initialize(image:, to_base:, crop: nil, negative: false)
38
+ super
39
+ end
40
+ end
41
+
42
+ class << self
43
+ # @param effort [Symbol] +:fast+, +:normal+, +:thorough+
44
+ # @param passes [Array<Symbol>, nil] explicit ladder, overrides +effort+
45
+ # @return [Array<Symbol>]
46
+ # @raise [ArgumentError]
47
+ def passes_for(effort: :normal, passes: nil)
48
+ if passes
49
+ list = Array(passes).map(&:to_sym)
50
+ unknown = list - PASSES
51
+ raise ArgumentError, "unknown pass(es) #{unknown.map(&:inspect).join(", ")}; valid: #{PASSES.join(", ")}" if unknown.any?
52
+ raise ArgumentError, "passes must not be empty" if list.empty?
53
+
54
+ return list.uniq
55
+ end
56
+ EFFORTS.fetch(effort) { raise ArgumentError, "effort must be one of #{EFFORTS.keys.join(", ")}, got #{effort.inspect}" }
57
+ end
58
+
59
+ # @param stop [Symbol, Integer] +:found+, +:exhaustive+ or a positive Integer
60
+ # @return [Symbol, Integer]
61
+ # @raise [ArgumentError]
62
+ def validate_stop(stop)
63
+ return stop if stop == :found || stop == :exhaustive || (stop.is_a?(Integer) && stop.positive?)
64
+
65
+ raise ArgumentError, "stop must be :found, :exhaustive or a positive Integer, got #{stop.inspect}"
66
+ end
67
+ end
68
+
69
+ # @param passes [Array<Symbol>]
70
+ # @param stop [Symbol, Integer]
71
+ # @param options [Reader::Options] options of the base pass (library defaults + gem defaults + user options)
72
+ # @param config [Config] effective configuration
73
+ # @param min_length [Hash{Symbol => Integer}] minimum text length per format or symbology
74
+ # @param transformer [#call, nil] returns a {Transformers::Base} or nil, called only when a pass needs one
75
+ def initialize(passes:, stop:, options:, config:, min_length: {}, transformer: nil)
76
+ @passes = passes
77
+ @stop = stop
78
+ @options = options
79
+ @config = config
80
+ @min_length = min_length
81
+ @transformer_factory = transformer
82
+ @pass_options = {}
83
+ end
84
+
85
+ # Runs the ladder on one page.
86
+ #
87
+ # @param page [Loaders::Page] the base page (not released here)
88
+ # @param rerender [#call, nil] +->(dpi) { Loaders::Page }+ for PDF pages; nil for rasters
89
+ # @param page_info [Loaders::PageInfo, nil] page size in points, for the PDF high_res pixel cap
90
+ # @param deadline [Float, nil] monotonic clock time after which no further pass starts (the first pass always runs)
91
+ # @param notify [#call, nil] +->(event, payload)+, receives +:pass_completed+
92
+ # @return [Result]
93
+ def run(page, rerender: nil, page_info: nil, deadline: nil, notify: nil)
94
+ base = View.new(page.image, Geometry::Affine.identity, nil)
95
+ found = []
96
+ passes_run = []
97
+ skipped = {}
98
+ best = base # highest-resolution image so far (tiles pass)
99
+ owned = [] # derived images to release
100
+
101
+ @passes.each do |name|
102
+ break if stop?(found, passes_run)
103
+ if deadline && passes_run.any? && monotonic > deadline # the first pass always runs
104
+ skipped[name] = "page timeout"
105
+ next
106
+ end
107
+
108
+ started = monotonic
109
+ begin
110
+ views, options, reason = prepare(name, page, base, best, rerender, page_info, owned)
111
+ if reason
112
+ skipped[name] = reason
113
+ next
114
+ end
115
+ results = views.flat_map { |view| decode(name, view, options) }
116
+ rescue Error => e
117
+ raise if passes_run.empty? # the first pass failing is the page failing
118
+ # an escalation pass failing (re-render timeout, transformer error, …) must not lose earlier results
119
+ skipped[name] = "failed: #{e.class.name.split("::").last}: #{e.message.lines.first&.strip}"
120
+ next
121
+ end
122
+ best = views.first if name == :high_res
123
+ added = merge!(found, filter(results))
124
+ passes_run << name
125
+ release!(owned.pop) if %i[inverted rotated_45].include?(name) # single-use derived images
126
+ notify&.call(:pass_completed, {pass: name, found: results.size, added: added, duration: monotonic - started})
127
+ end
128
+ Result.new(barcodes: found, passes_run: passes_run, skipped_passes: skipped)
129
+ ensure
130
+ owned&.each { |image| release!(image) }
131
+ end
132
+
133
+ private
134
+
135
+ def stop?(found, passes_run)
136
+ return false if passes_run.empty?
137
+
138
+ case @stop
139
+ when :found then found.any?
140
+ when :exhaustive then false
141
+ else found.size >= @stop
142
+ end
143
+ end
144
+
145
+ # @return [Array(Array<View>, Reader::Options, nil), Array(nil, nil, String)] views + options, or a skip reason
146
+ def prepare(name, page, base, best, rerender, page_info, owned)
147
+ case name
148
+ when :base
149
+ [[base], @options]
150
+ when :inverted
151
+ prepare_inverted(base, owned)
152
+ when :global_binarizer
153
+ return [nil, nil, "binarizer is already :global_histogram"] if @options[:binarizer] == :global_histogram
154
+
155
+ [[base], options_for(name) { @options.merge(binarizer: :global_histogram) }]
156
+ when :high_res
157
+ prepare_high_res(page, base, rerender, page_info, owned)
158
+ when :tiles
159
+ grid = Geometry.tile_grid(best.image.width, best.image.height, **TILES)
160
+ views = grid.map do |x, y, w, h|
161
+ View.new(best.image, best.to_base.compose(Geometry::Affine.translation(x, y)), [x, y, w, h])
162
+ end
163
+ [views, options_for(name) { @options.merge(try_downscale: false) }]
164
+ when :rotated_45
165
+ prepare_rotated(base, owned)
166
+ when :denoise
167
+ return [nil, nil, "try_denoise is not supported by this libZXing (needs ZXING_EXPERIMENTAL_API)"] unless Native.supports?(:try_denoise)
168
+
169
+ [[base], options_for(name) { @options.merge(try_denoise: true) }]
170
+ end
171
+ end
172
+
173
+ def prepare_high_res(page, base, rerender, page_info, owned)
174
+ image, reason = (rerender && page.dpi) ? rerender_high_res(page, rerender, page_info) : upscale(base)
175
+ return [nil, nil, reason] unless image
176
+
177
+ owned << image
178
+ scale = Geometry::Affine.scale(base.image.width.to_f / image.width, base.image.height.to_f / image.height)
179
+ [[View.new(image, scale, nil)], @options]
180
+ end
181
+
182
+ # PDF pages: re-render at 2x the base DPI, within max_dpi and the pixel cap.
183
+ def rerender_high_res(page, rerender, page_info)
184
+ return [nil, "no page size to compute the high-res DPI"] unless page_info
185
+
186
+ dpi = Dpi.high_res(page.dpi, width_pt: page_info.width, height_pt: page_info.height,
187
+ max_dpi: @config.max_dpi, max_pixels: @config.max_pixels)
188
+ return [nil, "already at max_dpi or the pixel cap (#{page.dpi} DPI)"] unless dpi
189
+
190
+ [rerender.call(dpi).image, nil]
191
+ end
192
+
193
+ # Rasters: 2x upscale when the longest side is below UPSCALE_BELOW.
194
+ def upscale(base)
195
+ return [nil, "the upscale needs a :lum image"] unless base.image.format == :lum
196
+
197
+ longest = [base.image.width, base.image.height].max
198
+ return [nil, "image is already #{longest} px (>= #{UPSCALE_BELOW})"] if longest >= UPSCALE_BELOW
199
+ return [nil, "upscaled image would exceed max_pixels"] if @config.max_pixels && base.image.width * base.image.height * 4 > @config.max_pixels
200
+
201
+ transformer = self.transformer or return [nil, "no transformer available"]
202
+ [transformer.resize(base.image, 2), nil]
203
+ end
204
+
205
+ # White-on-black codes. zxing-cpp's try_invert only helps its 2D readers (linear readers scan raw rows), so this
206
+ # pass decodes a pixel-inverted copy: restricted to linear formats when try_invert already covers 2D, all
207
+ # selected formats otherwise.
208
+ def prepare_inverted(base, owned)
209
+ return [nil, nil, "the inverted pass needs a :lum image"] unless base.image.format == :lum
210
+
211
+ linear_only = effective(:try_invert)
212
+ formats = linear_only ? Formats.linear_subset(@options[:formats]) : nil
213
+ return [nil, nil, "try_invert already covers the selected formats (no linear format selected)"] if linear_only && formats.empty?
214
+
215
+ image = base.image.inverted
216
+ owned << image
217
+ options = options_for(:inverted) do
218
+ linear_only ? @options.merge(formats: formats, try_invert: false) : @options.merge(try_invert: false)
219
+ end
220
+ [[View.new(image, Geometry::Affine.identity, nil, true)], options]
221
+ end
222
+
223
+ def prepare_rotated(base, owned)
224
+ return [nil, nil, "the rotated pass needs a :lum image"] unless base.image.format == :lum
225
+
226
+ formats = Formats.linear_subset(@options[:formats])
227
+ return [nil, nil, "no linear formats selected"] if formats.empty?
228
+
229
+ to_canvas, width, height = Geometry.rotation_canvas(base.image.width, base.image.height, ROTATED_DEGREES)
230
+ if @config.max_pixels && width * height > @config.max_pixels
231
+ return [nil, nil, "rotated canvas #{width}x#{height} would exceed max_pixels"]
232
+ end
233
+
234
+ transformer = self.transformer or return [nil, nil, "no transformer available"]
235
+ image = transformer.rotate(base.image, ROTATED_DEGREES, background: 255)
236
+ owned << image
237
+ # transformers may round the expanded canvas differently (±1 px): keep the image centered on the actual canvas
238
+ to_canvas = Geometry::Affine.translation((image.width - width) / 2.0, (image.height - height) / 2.0).compose(to_canvas)
239
+ [[View.new(image, to_canvas.invert, nil)], options_for(:rotated_45) { @options.merge(formats: formats) }]
240
+ end
241
+
242
+ def transformer
243
+ return @transformer if defined?(@transformer)
244
+
245
+ @transformer = @transformer_factory&.call
246
+ end
247
+
248
+ def options_for(name)
249
+ @pass_options[name] ||= yield
250
+ end
251
+
252
+ # Effective value of a reader option: the caller's, else the library default.
253
+ def effective(key)
254
+ @options[key].nil? ? LIBRARY_DEFAULTS[key] : @options[key]
255
+ end
256
+
257
+ def decode(pass, view, options)
258
+ rotation = view.to_base.rotation_degrees
259
+ Reader.read(view.image, options, crop: view.crop).map do |barcode|
260
+ barcode.with(
261
+ position: view.to_base.apply_quad(barcode.position).round,
262
+ rotation: (barcode.rotation + rotation).round % 360,
263
+ inverted: barcode.inverted ^ view.negative,
264
+ pass: pass
265
+ )
266
+ end
267
+ end
268
+
269
+ def filter(barcodes)
270
+ barcodes.select do |barcode|
271
+ next false unless barcode.valid? || @options[:return_errors]
272
+
273
+ minimum = @min_length[barcode.format] || @min_length[barcode.symbology]
274
+ minimum.nil? || barcode.text.length >= minimum
275
+ end
276
+ end
277
+
278
+ # Adds results that are not duplicates of earlier ones; returns how many were added.
279
+ def merge!(found, results)
280
+ before = found.size
281
+ results.each { |barcode| found << barcode unless found.any? { |kept| Dedupe.duplicate?(kept, barcode) } }
282
+ found.size - before
283
+ end
284
+
285
+ def release!(image)
286
+ image&.release!
287
+ end
288
+
289
+ def monotonic
290
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
291
+ end
292
+ end
293
+ end