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