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,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ # Runs external programs (+pdftoppm+, +pdfinfo+, +magick+, ...) on untrusted input with the isolation
5
+ # and resource limits listed below. Pure standard library: +Process.spawn+, +IO.pipe+ and threads.
6
+ #
7
+ # What {run} guarantees:
8
+ # - The argument vector goes straight to +execve+; it is never interpreted by a shell, not even when it
9
+ # has a single element.
10
+ # - The child leads a new process group. Its stdin is +/dev/null+ (or a pipe fed with +stdin_data+) and it
11
+ # inherits no file descriptor besides stdin, stdout and stderr.
12
+ # - stdout and stderr are drained concurrently, so a chatty child can never deadlock on a full pipe.
13
+ # - A monotonic-clock timeout terminates the whole process group: SIGTERM, then SIGKILL after a grace
14
+ # period. Processes the child leaves behind in its group are killed as soon as it exits.
15
+ # - stdout can be capped, stderr is truncated, and the address space is limited on Linux.
16
+ # - Whatever happens (success, timeout, limit, or an exception such as +Interrupt+ raised in the caller),
17
+ # the child is reaped and every pipe and helper thread is released before {run} returns or raises.
18
+ #
19
+ # @example Render a PDF page as PGM
20
+ # status, pgm, stderr = ZXingFFI::Subprocess.run(
21
+ # ["pdftoppm", "-gray", "-r", "300", "-f", "1", "-l", "1", "/abs/path/doc.pdf"],
22
+ # timeout: 60, max_stdout: expected_bytes + 1024, memory_limit: 2 * 1024**3
23
+ # )
24
+ # raise ZXingFFI::RenderError.new("pdftoppm failed", stderr: stderr, exit_status: status.exitstatus) unless status.success?
25
+ module Subprocess
26
+ # Appended to {Result#stderr} when the child wrote more than +stderr_limit+ bytes.
27
+ TRUNCATION_MARKER = "…[truncated]"
28
+
29
+ # Default number of stderr bytes kept by {run}.
30
+ DEFAULT_STDERR_LIMIT = 64 * 1024
31
+
32
+ # Default seconds between SIGTERM and SIGKILL when {run} times out.
33
+ DEFAULT_KILL_GRACE = 2
34
+
35
+ # Outcome of a finished subprocess. Destructures like a +[status, stdout, stderr]+ triple:
36
+ #
37
+ # status, stdout, stderr = ZXingFFI::Subprocess.run(argv, timeout: 5)
38
+ #
39
+ # @!attribute [r] status
40
+ # @return [Process::Status] how the child ended (exit code, or the signal that killed it)
41
+ # @!attribute [r] stdout
42
+ # @return [String] everything the child wrote to stdout (BINARY encoding)
43
+ # @!attribute [r] stderr
44
+ # @return [String] the first +stderr_limit+ bytes of stderr as UTF-8 (invalid bytes replaced with
45
+ # U+FFFD), followed by {TRUNCATION_MARKER} if the child wrote more
46
+ Result = Data.define(:status, :stdout, :stderr) do
47
+ # Enables multiple assignment: +status, stdout, stderr = Subprocess.run(...)+.
48
+ # @return [Array(Process::Status, String, String)]
49
+ def to_ary
50
+ [status, stdout, stderr]
51
+ end
52
+
53
+ # @return [Boolean] whether the child exited normally with status 0
54
+ def success?
55
+ status.success? == true
56
+ end
57
+ end
58
+
59
+ class << self
60
+ # Runs +argv+ to completion and captures its output.
61
+ #
62
+ # A non-zero exit status, or death by a signal, is not an error here: inspect {Result#status}.
63
+ #
64
+ # @param argv [Array<String>] executable and arguments. The executable is looked up on PATH unless
65
+ # it contains a slash. Nothing is interpreted by a shell.
66
+ # @param timeout [Numeric, nil] seconds (monotonic clock) before the process group receives SIGTERM,
67
+ # followed by SIGKILL +kill_grace+ seconds later. +nil+ waits forever.
68
+ # @param max_stdout [Integer, nil] maximum stdout bytes; the process group is killed as soon as more
69
+ # arrive
70
+ # @param memory_limit [Integer, nil] address-space limit in bytes (+RLIMIT_AS+), clamped to the
71
+ # current hard limit. Applied on Linux only: macOS does not enforce +RLIMIT_AS+, so the option is
72
+ # silently ignored there.
73
+ # @param stdin_data [String, nil] bytes written to the child's stdin, which is then closed. A child
74
+ # that exits without reading all of it is not an error. Without it, stdin is +/dev/null+.
75
+ # @param env [Hash{String => String, nil}, nil] environment overrides for the child (+nil+ unsets a
76
+ # variable); everything else is inherited
77
+ # @param stderr_limit [Integer] stderr bytes kept; the rest is read and discarded
78
+ # @param kill_grace [Numeric] seconds between SIGTERM and SIGKILL after a timeout
79
+ # @return [Result]
80
+ # @raise [ArgumentError] if +argv+ is not a non-empty Array of Strings or an option is invalid
81
+ # @raise [LoaderUnavailable] if the executable does not exist or may not be executed
82
+ # @raise [TimeoutError] if the child, or a process holding its pipes open, outlived +timeout+
83
+ # @raise [LimitExceeded] (+limit: :max_stdout+, +value:+ bytes received) if stdout exceeded
84
+ # +max_stdout+
85
+ # @raise [RenderError] if the process could not be started for another reason (e.g. EAGAIN)
86
+ def run(argv, timeout:, max_stdout: nil, memory_limit: nil, stdin_data: nil, env: nil,
87
+ stderr_limit: DEFAULT_STDERR_LIMIT, kill_grace: DEFAULT_KILL_GRACE)
88
+ Execution.new(
89
+ argv,
90
+ timeout: timeout, max_stdout: max_stdout, memory_limit: memory_limit, stdin_data: stdin_data,
91
+ env: env, stderr_limit: stderr_limit, kill_grace: kill_grace
92
+ ).call
93
+ end
94
+
95
+ # Locates an executable without running it (for loader availability checks).
96
+ #
97
+ # A +command+ containing a path separator is checked as given. A bare name is searched in the
98
+ # directories of +PATH+; empty entries are skipped rather than meaning the current directory.
99
+ #
100
+ # @param command [String, nil] executable name or path
101
+ # @return [String, nil] the absolute path of the first executable regular file named +command+ on
102
+ # PATH, +command+ itself if it contains a path separator and is an executable regular file, or nil
103
+ def which(command)
104
+ command = command.to_s
105
+ return nil if command.empty?
106
+ return (executable_file?(command) ? command : nil) if command.include?(File::SEPARATOR)
107
+
108
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
109
+ next if dir.empty?
110
+
111
+ candidate = File.join(dir, command)
112
+ return File.absolute_path(candidate) if executable_file?(candidate)
113
+ end
114
+ nil
115
+ end
116
+
117
+ # @return [Boolean] whether this Ruby runs on Linux, the only platform where +memory_limit+ is applied
118
+ def linux?
119
+ RUBY_PLATFORM.include?("linux")
120
+ end
121
+
122
+ private
123
+
124
+ def executable_file?(path)
125
+ File.file?(path) && File.executable?(path)
126
+ end
127
+ end
128
+
129
+ # One {Subprocess.run} call: owns the child, its pipes and the helper threads.
130
+ #
131
+ # Helper threads: a waiter (reaps the child), one reader per output pipe and, with +stdin_data+, a
132
+ # writer. They report to the calling thread through a queue, which it pops with the time left before
133
+ # the deadline.
134
+ #
135
+ # @api private
136
+ class Execution
137
+ # Bytes requested per read from the child's pipes.
138
+ CHUNK_SIZE = 64 * 1024
139
+ # Seconds to wait for a SIGKILLed child to be reaped. A child stuck in the kernel is left to the
140
+ # waiter thread, which reaps it as soon as it dies (that thread is never killed, so no zombie remains).
141
+ REAP_TIMEOUT = 5
142
+ # Seconds to wait for a pipe thread to stop once its pipe is closed, before killing it.
143
+ THREAD_STOP_TIMEOUT = 1
144
+
145
+ def initialize(argv, timeout:, max_stdout:, memory_limit:, stdin_data:, env:, stderr_limit:, kill_grace:)
146
+ @argv = check_argv(argv)
147
+ @timeout = check_timeout(timeout)
148
+ @max_stdout = check_integer(:max_stdout, max_stdout, min: 0, allow_nil: true)
149
+ @memory_limit = check_integer(:memory_limit, memory_limit, min: 1, allow_nil: true)
150
+ @stdin_data = check_type(:stdin_data, stdin_data, String)
151
+ @env = check_type(:env, env, Hash)
152
+ @stderr_limit = check_integer(:stderr_limit, stderr_limit, min: 0)
153
+ @kill_grace = check_kill_grace(kill_grace)
154
+
155
+ @events = Thread::Queue.new
156
+ @pipes = []
157
+ @io_threads = []
158
+ @stdout = String.new(encoding: Encoding::BINARY)
159
+ @stderr = String.new(encoding: Encoding::BINARY)
160
+ @stderr_bytes = 0
161
+ end
162
+
163
+ # @return [Result]
164
+ def call
165
+ # Asynchronous interrupts (Thread#raise, Interrupt, Timeout.timeout) are deferred while resources
166
+ # are acquired and released, so one arriving right after spawn cannot leak the child. They are
167
+ # delivered while waiting.
168
+ Thread.handle_interrupt(Object => :never) do
169
+ start
170
+ Thread.handle_interrupt(Object => :immediate) { wait }
171
+ ensure
172
+ cleanup
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def start
179
+ @deadline = @timeout && monotonic_now + @timeout
180
+ stdout_r, stdout_w = open_pipe
181
+ stderr_r, stderr_w = open_pipe
182
+ stdin_r, stdin_w = open_pipe if @stdin_data
183
+ @pid = spawn_child(in: stdin_r || File::NULL, out: stdout_w, err: stderr_w)
184
+ [stdin_r, stdout_w, stderr_w].compact.each(&:close) # the child's ends
185
+ @waiter = helper_thread("wait") { wait_child }
186
+ @stdout_reader = helper_thread("stdout") { read_stdout(stdout_r) }
187
+ @stderr_reader = helper_thread("stderr") { read_stderr(stderr_r) }
188
+ @io_threads << @stdout_reader << @stderr_reader
189
+ @io_threads << helper_thread("stdin") { write_stdin(stdin_w) } if stdin_w
190
+ end
191
+
192
+ def open_pipe
193
+ IO.pipe.each do |io|
194
+ io.binmode
195
+ @pipes << io
196
+ end
197
+ end
198
+
199
+ def spawn_child(**redirects)
200
+ options = {**redirects, pgroup: true, close_others: true}
201
+ options[:rlimit_as] = address_space_limit if @memory_limit && Subprocess.linux?
202
+ # The [command, argv0] form always execs directly, even for a single-element argv.
203
+ Process.spawn(@env || {}, [@argv[0], @argv[0]], *@argv.drop(1), **options)
204
+ rescue Errno::ENOENT, Errno::ENOTDIR
205
+ raise LoaderUnavailable, "executable not found: #{@argv[0]}"
206
+ rescue Errno::EACCES, Errno::EPERM
207
+ raise LoaderUnavailable, "executable not permitted: #{@argv[0]}"
208
+ rescue SystemCallError => e
209
+ raise RenderError, "could not start #{command_name}: #{e.message}"
210
+ end
211
+
212
+ def address_space_limit
213
+ [@memory_limit, Process.getrlimit(:AS).last].min
214
+ end
215
+
216
+ def helper_thread(role, &body)
217
+ Thread.new do
218
+ Thread.current.name = "zxing_ffi subprocess #{role}"
219
+ Thread.current.report_on_exception = false
220
+ # Threads inherit the creator's :never mask; these must stay interruptible (IO#close, Thread#kill).
221
+ Thread.handle_interrupt(Object => :immediate, &body)
222
+ end
223
+ end
224
+
225
+ def wait_child
226
+ Process.wait2(@pid).last
227
+ ensure
228
+ @events << :exit
229
+ end
230
+
231
+ def read_stdout(io)
232
+ chunk = String.new(capacity: CHUNK_SIZE, encoding: Encoding::BINARY)
233
+ loop do
234
+ io.readpartial(CHUNK_SIZE, chunk)
235
+ if @max_stdout && @stdout.bytesize + chunk.bytesize > @max_stdout
236
+ @stdout_overflow = @stdout.bytesize + chunk.bytesize
237
+ @events << :overflow
238
+ break
239
+ end
240
+ @stdout << chunk
241
+ end
242
+ rescue EOFError
243
+ # every writer (the child and anything it spawned) closed stdout
244
+ ensure
245
+ @events << :stdout
246
+ end
247
+
248
+ def read_stderr(io)
249
+ chunk = String.new(capacity: CHUNK_SIZE, encoding: Encoding::BINARY)
250
+ loop do
251
+ io.readpartial(CHUNK_SIZE, chunk)
252
+ room = @stderr_limit - @stderr.bytesize
253
+ @stderr << chunk.byteslice(0, room) if room.positive?
254
+ @stderr_bytes += chunk.bytesize # keep draining past the limit
255
+ end
256
+ rescue EOFError
257
+ # every writer closed stderr
258
+ ensure
259
+ @events << :stderr
260
+ end
261
+
262
+ def write_stdin(io)
263
+ io.write(@stdin_data)
264
+ rescue Errno::EPIPE, IOError
265
+ # EPIPE: the child exited or closed stdin early (its exit status tells the story).
266
+ # IOError: the pipe was closed by #cleanup.
267
+ ensure
268
+ close_pipe(io) # EOF for the child
269
+ end
270
+
271
+ def wait
272
+ pending = %i[exit stdout stderr]
273
+ until pending.empty?
274
+ event = @events.pop(timeout: time_left)
275
+ case event
276
+ when nil then time_out!
277
+ when :overflow then raise stdout_overflow_error
278
+ when :exit then child_exited
279
+ when :stdout then @stdout_reader.value # re-raises an unexpected reader failure
280
+ when :stderr then @stderr_reader.value
281
+ end
282
+ pending.delete(event)
283
+ end
284
+ Result.new(status: @status, stdout: @stdout, stderr: stderr_text)
285
+ end
286
+
287
+ def child_exited
288
+ @status = @waiter.value
289
+ # Anything the child left in its group (e.g. a background job holding our pipes) goes too. After
290
+ # this sweep the group is never signalled again: once empty, its id may be reused.
291
+ signal_group(:KILL)
292
+ @group_released = true
293
+ end
294
+
295
+ def time_out!
296
+ signal_group(:TERM)
297
+ signal_group(:KILL) unless finished?(@waiter, @kill_grace)
298
+ raise TimeoutError, "#{command_name} timed out after #{format_seconds(@timeout)}s"
299
+ end
300
+
301
+ def stdout_overflow_error
302
+ LimitExceeded.new(
303
+ "#{command_name} wrote more than #{@max_stdout} bytes to stdout",
304
+ limit: :max_stdout, value: @stdout_overflow
305
+ )
306
+ end
307
+
308
+ def cleanup
309
+ if @pid
310
+ signal_group(:KILL) # no-op after a normal exit (already swept)
311
+ reap
312
+ end
313
+ @pipes.each { |io| close_pipe(io) } # wakes up any helper still blocked on a pipe
314
+ @io_threads.each { |thread| stop_thread(thread) }
315
+ end
316
+
317
+ def reap
318
+ if @waiter
319
+ finished?(@waiter, REAP_TIMEOUT)
320
+ else
321
+ Process.wait(@pid) # the waiter thread could not be started; the group was just killed
322
+ end
323
+ rescue SystemCallError
324
+ nil
325
+ ensure
326
+ @group_released = true
327
+ end
328
+
329
+ def signal_group(signal)
330
+ return if @group_released
331
+
332
+ Process.kill(signal, -@pid)
333
+ rescue Errno::ESRCH, Errno::EPERM
334
+ # the group is gone (macOS reports EPERM when only zombies are left)
335
+ end
336
+
337
+ # Joins +thread+ for up to +limit+ seconds; true if it has ended (with or without an exception).
338
+ def finished?(thread, limit)
339
+ !thread.join(limit).nil?
340
+ rescue
341
+ true
342
+ end
343
+
344
+ def close_pipe(io)
345
+ io.close
346
+ rescue IOError, SystemCallError
347
+ nil
348
+ end
349
+
350
+ def stop_thread(thread)
351
+ thread.kill unless finished?(thread, THREAD_STOP_TIMEOUT)
352
+ end
353
+
354
+ def stderr_text
355
+ text = @stderr.force_encoding(Encoding::UTF_8).scrub
356
+ text << TRUNCATION_MARKER if @stderr_bytes > @stderr_limit
357
+ text
358
+ end
359
+
360
+ def time_left
361
+ @deadline && [@deadline - monotonic_now, 0].max
362
+ end
363
+
364
+ def monotonic_now
365
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
366
+ end
367
+
368
+ def command_name
369
+ File.basename(@argv[0])
370
+ end
371
+
372
+ def format_seconds(seconds)
373
+ (seconds == seconds.to_i) ? seconds.to_i.to_s : seconds.to_f.round(3).to_s
374
+ end
375
+
376
+ def check_argv(argv)
377
+ raise ArgumentError, "argv must be an Array of Strings, got #{argv.class}" unless argv.is_a?(Array)
378
+ raise ArgumentError, "argv must not be empty" if argv.empty?
379
+
380
+ argv.each_with_index do |arg, index|
381
+ raise ArgumentError, "argv[#{index}] must be a String, got #{arg.class}" unless arg.is_a?(String)
382
+ raise ArgumentError, "argv[#{index}] contains a NUL byte" if arg.include?("\0")
383
+ end
384
+ raise ArgumentError, "argv[0] must not be empty" if argv[0].empty?
385
+
386
+ argv
387
+ end
388
+
389
+ def check_timeout(value)
390
+ return nil if value.nil? || value == Float::INFINITY
391
+ return value if value.is_a?(Numeric) && value.real? && value.positive? && value.finite?
392
+
393
+ raise ArgumentError, "timeout must be a positive number of seconds or nil, got #{value.inspect}"
394
+ end
395
+
396
+ def check_kill_grace(value)
397
+ return value if value.is_a?(Numeric) && value.real? && !value.negative? && value.finite?
398
+
399
+ raise ArgumentError, "kill_grace must be a non-negative number of seconds, got #{value.inspect}"
400
+ end
401
+
402
+ def check_integer(name, value, min:, allow_nil: false)
403
+ return value if (allow_nil && value.nil?) || (value.is_a?(Integer) && value >= min)
404
+
405
+ raise ArgumentError, "#{name} must be an Integer >= #{min}#{" or nil" if allow_nil}, got #{value.inspect}"
406
+ end
407
+
408
+ def check_type(name, value, type)
409
+ return value if value.nil? || value.is_a?(type)
410
+
411
+ raise ArgumentError, "#{name} must be a #{type} or nil, got #{value.class}"
412
+ end
413
+ end
414
+ private_constant :Execution
415
+ end
416
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Transformers
5
+ # Base class for image transformers. Implementations must not loop over pixels in Ruby.
6
+ #
7
+ # Geometry contract (shared with {Geometry.rotation_canvas}): {#rotate} turns the image clockwise as
8
+ # displayed (y axis down) about its center and expands the canvas to the rotated bounding box, filling
9
+ # uncovered areas with +background+. {#resize} scales both axes by +scale+ (output side = round(side × scale)).
10
+ class Base
11
+ class << self
12
+ # @return [Symbol] registry name
13
+ def transformer_name
14
+ raise NotImplementedError, "#{name}.transformer_name"
15
+ end
16
+
17
+ # @return [Boolean] memoized
18
+ def available?
19
+ return @available if defined?(@available)
20
+
21
+ @available = probe
22
+ end
23
+
24
+ # @return [String, nil]
25
+ def unavailable_reason
26
+ available? ? nil : (@unavailable_reason || "not available")
27
+ end
28
+
29
+ # @return [Hash]
30
+ def diagnostics
31
+ {available: available?, reason: unavailable_reason}
32
+ end
33
+
34
+ def reset!
35
+ remove_instance_variable(:@available) if defined?(@available)
36
+ @unavailable_reason = nil
37
+ end
38
+
39
+ private
40
+
41
+ def probe
42
+ raise NotImplementedError, "#{name}.probe"
43
+ end
44
+ end
45
+
46
+ # @return [Config]
47
+ attr_reader :config
48
+
49
+ def initialize(config = ZXingFFI.config)
50
+ @config = config
51
+ end
52
+
53
+ # @param image [Image] +:lum+ image
54
+ # @param scale [Numeric] > 0
55
+ # @return [Image] new +:lum+ image
56
+ def resize(image, scale)
57
+ raise NotImplementedError, "#{self.class}#resize"
58
+ end
59
+
60
+ # @param image [Image] +:lum+ image
61
+ # @param degrees [Numeric] clockwise
62
+ # @param background [Integer] gray level for uncovered pixels
63
+ # @return [Image] new +:lum+ image with an expanded canvas
64
+ def rotate(image, degrees, background: 255)
65
+ raise NotImplementedError, "#{self.class}#rotate"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Transformers
5
+ # Resize and rotate by piping PGM through ImageMagick.
6
+ #
7
+ # +-rotate+ turns clockwise and expands the canvas (ImageMagick's canvas can be 1 px larger than
8
+ # {Geometry.rotation_canvas}; the strategy re-centers on the actual size). Integer upscales use +-sample+
9
+ # (pixel replication); other scales use +-resize+.
10
+ class ImageMagickTransformer < Base
11
+ class << self
12
+ def transformer_name = :image_magick
13
+
14
+ def diagnostics
15
+ tool = ZXingFFI::ImageMagick.tool
16
+ tool ? super.merge(version: tool.version, flavor: tool.flavor) : super
17
+ end
18
+
19
+ def reset!
20
+ super
21
+ ZXingFFI::ImageMagick.reset!
22
+ end
23
+
24
+ private
25
+
26
+ def probe
27
+ return true if ZXingFFI::ImageMagick.tool
28
+
29
+ @unavailable_reason = ZXingFFI::ImageMagick.unavailable_reason
30
+ false
31
+ end
32
+ end
33
+
34
+ def resize(image, scale)
35
+ raise ArgumentError, "scale must be positive, got #{scale.inspect}" unless scale.is_a?(Numeric) && scale.positive?
36
+
37
+ width = [(image.width * scale).round, 1].max
38
+ height = [(image.height * scale).round, 1].max
39
+ operation = (scale == scale.to_i && scale >= 1) ? "-sample" : "-resize"
40
+ pipe(image, [operation, "#{width}x#{height}!"], width, height)
41
+ end
42
+
43
+ def rotate(image, degrees, background: 255)
44
+ raise ArgumentError, "degrees must be a finite number" unless degrees.is_a?(Numeric) && degrees.finite?
45
+
46
+ normalized = degrees % 360
47
+ return Image.from_pgm(image.to_pgm) if normalized.zero?
48
+
49
+ _, width, height = Geometry.rotation_canvas(image.width, image.height, degrees)
50
+ gray = format("#%02X%02X%02X", background, background, background)
51
+ pipe(image, ["-background", gray, "-rotate", degrees.to_s, "+repage"], width + 4, height + 4)
52
+ end
53
+
54
+ private
55
+
56
+ def pipe(image, operations, width, height)
57
+ raise ArgumentError, "expected a :lum image, got #{image.format.inspect}" unless image.format == :lum
58
+
59
+ tool = ZXingFFI::ImageMagick.tool(config) or raise LoaderUnavailable, ZXingFFI::ImageMagick.unavailable_reason(config)
60
+ argv = [*tool.convert, "pgm:-", *operations, "-depth", "8", "pgm:-"]
61
+ status, stdout, stderr = Subprocess.run(argv, timeout: config.render_timeout, stdin_data: image.to_pgm,
62
+ max_stdout: (width + 2) * (height + 2) + 1024, memory_limit: config.subprocess_memory_limit)
63
+ unless status.success?
64
+ raise RenderError.new("ImageMagick transform failed (exit #{status.exitstatus}): #{stderr.lines.first&.strip}",
65
+ stderr: stderr, exit_status: status.exitstatus)
66
+ end
67
+
68
+ Image.from_pgm(stdout)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Transformers
5
+ # Resize and rotate with libvips (optional ruby-vips gem).
6
+ #
7
+ # Quarter turns use +rot+ (exact); other angles use libvips' +rotate+, which turns clockwise about the center
8
+ # and expands the canvas like {Geometry.rotation_canvas} (canvas sizes may differ by 1 px because libvips rounds
9
+ # where the geometry rounds up; the strategy re-centers on the actual output size). Integer upscales replicate
10
+ # pixels (+zoom+), which keeps bar edges crisp; other scales use a linear kernel.
11
+ class VipsTransformer < Base
12
+ QUARTER_TURNS = {90 => :d90, 180 => :d180, 270 => :d270}.freeze
13
+
14
+ class << self
15
+ def transformer_name = :vips
16
+
17
+ def diagnostics
18
+ available? ? super.merge(version: ::Vips.version_string) : super
19
+ end
20
+
21
+ private
22
+
23
+ def probe
24
+ require "vips"
25
+ true
26
+ rescue LoadError, StandardError => e
27
+ @unavailable_reason = "ruby-vips/libvips not loadable (#{e.class}: #{e.message.lines.first&.strip})"
28
+ false
29
+ end
30
+ end
31
+
32
+ # @raise [LoaderUnavailable] when ruby-vips/libvips cannot be loaded
33
+ def initialize(config = ZXingFFI.config)
34
+ super
35
+ raise LoaderUnavailable, self.class.unavailable_reason unless self.class.available? # also requires "vips"
36
+ end
37
+
38
+ def resize(image, scale)
39
+ raise ArgumentError, "scale must be positive, got #{scale.inspect}" unless scale.is_a?(Numeric) && scale.positive?
40
+
41
+ vimage = to_vips(image)
42
+ out =
43
+ if scale == scale.to_i && scale >= 1
44
+ vimage.zoom(scale.to_i, scale.to_i)
45
+ else
46
+ vimage.resize(scale, kernel: :linear)
47
+ end
48
+ to_image(out)
49
+ end
50
+
51
+ def rotate(image, degrees, background: 255)
52
+ raise ArgumentError, "degrees must be a finite number" unless degrees.is_a?(Numeric) && degrees.finite?
53
+
54
+ vimage = to_vips(image)
55
+ normalized = degrees % 360
56
+ out =
57
+ if normalized.zero?
58
+ vimage
59
+ elsif QUARTER_TURNS.key?(normalized)
60
+ vimage.rot(QUARTER_TURNS.fetch(normalized))
61
+ else
62
+ vimage.rotate(degrees, background: [background])
63
+ end
64
+ to_image(out)
65
+ end
66
+
67
+ private
68
+
69
+ def to_vips(image)
70
+ raise ArgumentError, "expected a :lum image, got #{image.format.inspect}" unless image.format == :lum
71
+
72
+ bytes = (image.row_stride == image.width) ? image.to_bytes : image.to_pgm.byteslice(-image.width * image.height..)
73
+ ::Vips::Image.new_from_memory_copy(bytes, image.width, image.height, 1, :uchar)
74
+ end
75
+
76
+ def to_image(vimage)
77
+ vimage = vimage.cast(:uchar) unless vimage.format == :uchar
78
+ ::Vips.vips_error_clear # the pipeline runs here; drop lines left in libvips' global error buffer by earlier calls
79
+ Image.new(vimage.write_to_memory, width: vimage.width, height: vimage.height)
80
+ rescue ::Vips::Error => e
81
+ raise RenderError.new("libvips transform failed: #{Loaders::VipsLoader::Pipeline.error_summary(e)}", stderr: e.message[0, 4096])
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ # Image transformers used by the scale and rotate passes. Pixel loops never run in Ruby.
5
+ module Transformers
6
+ autoload :Base, "zxing_ffi/transformers/base"
7
+ autoload :VipsTransformer, "zxing_ffi/transformers/vips"
8
+ autoload :ImageMagickTransformer, "zxing_ffi/transformers/image_magick"
9
+
10
+ # Transformer name => class name.
11
+ NAMES = {vips: :VipsTransformer, image_magick: :ImageMagickTransformer}.freeze
12
+
13
+ class << self
14
+ # @return [Class<Base>]
15
+ # @raise [ArgumentError] for unknown names
16
+ def fetch(name)
17
+ const_get(NAMES.fetch(name.to_sym) { raise ArgumentError, "unknown transformer #{name.inspect}; known: #{NAMES.keys.join(", ")}" })
18
+ end
19
+
20
+ # @return [Hash{Symbol => Class<Base>}]
21
+ def registry
22
+ NAMES.keys.to_h { |name| [name, fetch(name)] }
23
+ end
24
+
25
+ # First available transformer in +order+ (default: config.transformers), or nil.
26
+ # @return [Base, nil] an instance
27
+ def first_available(order = ZXingFFI.config.transformers, config: ZXingFFI.config)
28
+ order.each do |name|
29
+ klass = fetch(name)
30
+ return klass.new(config) if klass.available?
31
+ end
32
+ nil
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ VERSION = "0.1.0"
5
+ end