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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Loaders
5
+ # Raster loader running ImageMagick in a subprocess. Never used for PDF or vector/text formats.
6
+ #
7
+ # Load pipeline: aspect correction on the stored axes (best effort from +identify+ resolutions), then
8
+ # +-auto-orient -background white -alpha remove -alpha off -colorspace Gray -depth 8+ to PGM on stdout.
9
+ # The frame's size is checked against +max_pixels+ from +identify+ before anything is decoded.
10
+ class ImageMagickLoader < Base
11
+ # Resolutions differing by more than this ratio are corrected.
12
+ ASPECT_TOLERANCE = 0.05
13
+
14
+ class << self
15
+ # (see Base.loader_name)
16
+ def loader_name = :image_magick
17
+
18
+ # (see Base.kinds)
19
+ def kinds = ZXingFFI::ImageMagick::CODERS.keys
20
+
21
+ # (see Base.install_hint)
22
+ def install_hint
23
+ "install ImageMagick (brew install imagemagick / apt install imagemagick)"
24
+ end
25
+
26
+ # (see Base.diagnostics)
27
+ def diagnostics
28
+ tool = ZXingFFI::ImageMagick.tool
29
+ tool ? super.merge(version: tool.version, flavor: tool.flavor, command: tool.convert.first) : super
30
+ end
31
+
32
+ # (see Base.reset!)
33
+ def reset!
34
+ super
35
+ ZXingFFI::ImageMagick.reset!
36
+ end
37
+
38
+ private
39
+
40
+ def probe
41
+ return true if ZXingFFI::ImageMagick.tool
42
+
43
+ @unavailable_reason = ZXingFFI::ImageMagick.unavailable_reason
44
+ false
45
+ end
46
+ end
47
+
48
+ def open(source, password: nil)
49
+ tool = ZXingFFI::ImageMagick.tool(config) or raise LoaderUnavailable, ZXingFFI::ImageMagick.unavailable_reason(config)
50
+ raise UnsupportedInput, "ImageMagick is never used for #{source.kind} input" unless self.class.kinds.include?(source.kind)
51
+
52
+ MagickDocument.new(source, config, tool)
53
+ end
54
+
55
+ # A raster file; one page per frame.
56
+ class MagickDocument < Document
57
+ # Size and resolution (ppi, nil when unknown) of one frame, from +identify+.
58
+ Frame = Data.define(:width, :height, :x_res, :y_res)
59
+
60
+ def initialize(source, config, tool)
61
+ super(source)
62
+ @config = config
63
+ @tool = tool
64
+ @coder = ZXingFFI::ImageMagick::CODERS.fetch(source.kind)
65
+ @frames = identify
66
+ raise RenderError, "ImageMagick found no frames in #{source.name}" if @frames.empty?
67
+ end
68
+
69
+ # (see Document#page_count)
70
+ def page_count = @frames.size
71
+
72
+ # (see Document#page_info)
73
+ def page_info(number)
74
+ check_page!(number)
75
+ frame = @frames[number - 1]
76
+ width, height = corrected_size(frame)
77
+ PageInfo.new(number: number, width: width, height: height, unit: :px, rotation: 0,
78
+ native_ppi: [frame.x_res, frame.y_res].compact.max)
79
+ end
80
+
81
+ # (see Document#render)
82
+ def render(number, dpi: nil, timeout: nil)
83
+ check_page!(number)
84
+ frame = @frames[number - 1]
85
+ width, height = corrected_size(frame)
86
+ check_pixels!(width, height)
87
+ aspect = aspect_scale(frame)
88
+ resample = aspect ? ["-sample", "#{(aspect[0] * 100).round(4)}%x#{(aspect[1] * 100).round(4)}%!"] : []
89
+ stdout = ZXingFFI::ImageMagick.with_safe_path(source.path) do |path|
90
+ run([*@tool.convert, "#{@coder}:#{path}[#{number - 1}]", *resample, "-auto-orient", "-background", "white",
91
+ "-alpha", "remove", "-alpha", "off", "-colorspace", "Gray", "-depth", "8", "pgm:-"],
92
+ max_stdout: (width + 2) * (height + 2) * 2 + 1024, timeout: timeout)
93
+ end
94
+ decoded =
95
+ begin
96
+ ZXingFFI::Pnm.decode(stdout, max_pixels: @config.max_pixels)
97
+ rescue UnsupportedInput => e
98
+ raise RenderError, "ImageMagick produced invalid PGM for #{source.name}: #{e.message}"
99
+ end
100
+ image = Image.new(decoded.pixels, width: decoded.width, height: decoded.height)
101
+ Page.new(number: number, image: image, dpi: nil, scale_to_base: 1.0, metadata: {
102
+ loader: :image_magick, aspect_corrected: aspect && [frame.x_res, frame.y_res].map { |r| r.round(1) }
103
+ })
104
+ end
105
+
106
+ private
107
+
108
+ # One line per frame: width, height, x/y resolution and units. +-ping+ reads headers only, without decoding.
109
+ def identify
110
+ out = ZXingFFI::ImageMagick.with_safe_path(source.path) do |path|
111
+ run([*@tool.identify, "-ping", "-format", "%w %h %x %y %U\n", "#{@coder}:#{path}"], max_stdout: 1024 * 1024)
112
+ end
113
+ out.each_line.filter_map do |line|
114
+ width, height, x_res, y_res, units = line.split
115
+ next unless width&.match?(/\A\d+\z/) && height&.match?(/\A\d+\z/)
116
+
117
+ scale = (units == "PixelsPerCentimeter") ? 2.54 : 1.0
118
+ Frame.new(width: Integer(width), height: Integer(height), x_res: resolution(x_res, scale), y_res: resolution(y_res, scale))
119
+ end
120
+ end
121
+
122
+ def resolution(value, scale)
123
+ number = Float(value, exception: false)
124
+ number&.positive? ? number * scale : nil
125
+ end
126
+
127
+ def aspect_scale(frame)
128
+ x, y = frame.x_res, frame.y_res
129
+ return nil unless x && y
130
+ return nil if (x - y).abs / [x, y].max <= ASPECT_TOLERANCE
131
+
132
+ (x < y) ? [y / x, 1.0] : [1.0, x / y]
133
+ end
134
+
135
+ def corrected_size(frame)
136
+ hscale, vscale = aspect_scale(frame) || [1.0, 1.0]
137
+ [(frame.width * hscale).round, (frame.height * vscale).round]
138
+ end
139
+
140
+ def check_pixels!(width, height)
141
+ pixels = width * height
142
+ return unless @config.max_pixels && pixels > @config.max_pixels
143
+
144
+ raise LimitExceeded.new("#{width}x#{height} (#{pixels} pixels) exceeds max_pixels #{@config.max_pixels}",
145
+ limit: :max_pixels, value: pixels)
146
+ end
147
+
148
+ def run(argv, max_stdout:, timeout: nil)
149
+ status, stdout, stderr = Subprocess.run(argv, timeout: [timeout, @config.render_timeout].compact.min, max_stdout: max_stdout,
150
+ memory_limit: @config.subprocess_memory_limit)
151
+ return stdout if status.success?
152
+
153
+ raise RenderError.new("ImageMagick failed on #{source.name} (exit #{status.exitstatus}): #{stderr.lines.first&.strip}",
154
+ stderr: stderr, exit_status: status.exitstatus)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Loaders
5
+ # Pure-Ruby loader for PNM files (P1–P6), always available.
6
+ #
7
+ # The file is streamed, never read whole: its header is read when the document is opened (at most
8
+ # {HEADER_LIMIT} bytes), and {PnmDocument#render} checks +max_pixels+ against it before {Pnm.decode} reads
9
+ # the raster in bounded chunks and nothing after it. So memory stays close to the decoded image's size
10
+ # whatever the file holds, a huge tail included.
11
+ class PnmLoader < Base
12
+ # Longest header accepted, comments included (it is read in chunks of 4 KiB and more).
13
+ HEADER_LIMIT = 1024 * 1024
14
+
15
+ class << self
16
+ # (see Base.loader_name)
17
+ def loader_name = :pnm
18
+
19
+ # (see Base.kinds)
20
+ def kinds = [:pnm]
21
+
22
+ # (see Base.install_hint)
23
+ def install_hint = "PNM input needs no external tools"
24
+
25
+ private
26
+
27
+ def probe = true
28
+ end
29
+
30
+ # @return [PnmDocument]
31
+ # @raise [UnsupportedInput] if the file does not start with a valid PNM header of at most {HEADER_LIMIT} bytes
32
+ def open(source, password: nil)
33
+ PnmDocument.new(source, max_pixels: config.max_pixels)
34
+ end
35
+
36
+ # A single-image PNM file.
37
+ class PnmDocument < Document
38
+ def initialize(source, max_pixels:)
39
+ super(source)
40
+ @max_pixels = max_pixels
41
+ @header = read_header
42
+ end
43
+
44
+ # (see Document#page_count)
45
+ def page_count = 1
46
+
47
+ # (see Document#page_info)
48
+ def page_info(number)
49
+ check_page!(number)
50
+ PageInfo.new(number: 1, width: @header.width, height: @header.height, unit: :px, rotation: 0, native_ppi: nil)
51
+ end
52
+
53
+ # (see Document#render)
54
+ # @raise [LimitExceeded] before reading the raster if the header declares more than +max_pixels+
55
+ # @raise [UnsupportedInput] if the raster is truncated or malformed
56
+ def render(number, dpi: nil, timeout: nil)
57
+ check_page!(number)
58
+ check_pixels!
59
+ decoded = File.open(source.path, "rb") { |file| ZXingFFI::Pnm.decode(file, max_pixels: @max_pixels) }
60
+ image = Image.new(decoded.pixels, width: decoded.width, height: decoded.height)
61
+ decoded.pixels.clear # copied into the image: free it now rather than at the next GC
62
+ Page.new(number: 1, image: image, dpi: nil, scale_to_base: 1.0, metadata: {loader: :pnm, pnm_kind: @header.kind})
63
+ end
64
+
65
+ private
66
+
67
+ def read_header
68
+ File.open(source.path, "rb") do |file|
69
+ reader = LimitedReader.new(file, HEADER_LIMIT)
70
+ ZXingFFI::Pnm.read_header(reader)
71
+ rescue UnsupportedInput
72
+ # Reaching the limit means the header was still incomplete there (a malformed one fails earlier).
73
+ raise unless reader.cut_short?
74
+
75
+ raise UnsupportedInput, "PNM header longer than #{HEADER_LIMIT} bytes (long comments?) is not supported"
76
+ end
77
+ end
78
+
79
+ # Checked from the header before reading the raster.
80
+ def check_pixels!
81
+ pixels = @header.width * @header.height
82
+ return unless @max_pixels && pixels > @max_pixels
83
+
84
+ raise LimitExceeded.new("#{@header.width}x#{@header.height} (#{pixels} pixels) exceeds max_pixels #{@max_pixels}",
85
+ limit: :max_pixels, value: pixels)
86
+ end
87
+ end
88
+
89
+ # Reads at most +limit+ bytes of an IO and then behaves as if at its end.
90
+ class LimitedReader
91
+ def initialize(io, limit)
92
+ @io = io
93
+ @remaining = limit
94
+ end
95
+
96
+ # @return [String, nil] like IO#read(length)
97
+ def read(length)
98
+ return nil if @remaining.zero?
99
+
100
+ data = @io.read([length, @remaining].min)
101
+ @remaining -= data.bytesize if data
102
+ data
103
+ end
104
+
105
+ # Whether the limit hid data that the IO still holds.
106
+ def cut_short?
107
+ @remaining.zero? && !@io.eof?
108
+ end
109
+ end
110
+ private_constant :LimitedReader
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Loaders
5
+ # PDF loader running Poppler's command-line tools in subprocesses: +pdfinfo+ for page count,
6
+ # sizes, rotation and encryption, +pdftoppm -gray+ to render one page to PGM on stdout, and (optionally)
7
+ # +pdfimages -list+ to find the native resolution of scanned pages for +dpi: :auto+.
8
+ #
9
+ # Every call uses an argument array (never a shell), an absolute input path, a timeout, an address-space limit
10
+ # (Linux) and a cap on stdout. Passwords are only ever passed as the argument after +-upw+.
11
+ # Renders of different pages may run concurrently.
12
+ class PopplerLoader < Base
13
+ # A page is treated as scanned when one embedded image covers at least this fraction of it.
14
+ SCAN_COVERAGE = 0.5
15
+
16
+ # Slack allowed on top of the expected PGM size (header + rounding of the page size).
17
+ OUTPUT_SLACK = 1024
18
+
19
+ # Poppler's command-line tools keep at most this many bytes of a password.
20
+ MAX_PASSWORD_BYTES = 32
21
+
22
+ class << self
23
+ # (see Base.loader_name)
24
+ def loader_name = :poppler
25
+
26
+ # (see Base.kinds)
27
+ def kinds = [:pdf]
28
+
29
+ # (see Base.install_hint)
30
+ def install_hint
31
+ "install Poppler's command-line tools (brew install poppler / apt install poppler-utils)"
32
+ end
33
+
34
+ # (see Base.diagnostics)
35
+ def diagnostics
36
+ info = super
37
+ return info unless available?
38
+
39
+ info.merge(
40
+ tools: %i[pdfinfo pdftoppm pdfimages].to_h { |tool| [tool, Subprocess.which(ZXingFFI.config.tool_path(tool).to_s)] },
41
+ version: version
42
+ )
43
+ end
44
+
45
+ # @return [String, nil] e.g. "26.07.0"
46
+ def version
47
+ _, out, err = Subprocess.run([ZXingFFI.config.tool_path(:pdftoppm).to_s, "-v"], timeout: 10)
48
+ (out + err)[/version\s+([\d.]+)/, 1]
49
+ rescue Error
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def probe
56
+ missing = %i[pdfinfo pdftoppm].reject { |tool| Subprocess.which(ZXingFFI.config.tool_path(tool).to_s) }
57
+ return true if missing.empty?
58
+
59
+ @unavailable_reason = "#{missing.join(" and ")} not found (config.tool_paths)"
60
+ false
61
+ end
62
+ end
63
+
64
+ # @return [PopplerDocument]
65
+ def open(source, password: nil)
66
+ PopplerDocument.new(source, config, password)
67
+ end
68
+
69
+ # Parsers for Poppler's text output (kept separate for unit tests against recorded samples).
70
+ module Parser
71
+ module_function
72
+
73
+ # "Pages:" and "Encrypted:" from +pdfinfo+.
74
+ # @return [Hash] +{pages: Integer, encrypted: Boolean}+
75
+ def info(text)
76
+ pages = text[/^Pages:\s+(\d+)/, 1] or raise RenderError, "pdfinfo output has no page count"
77
+ {pages: Integer(pages), encrypted: text.match?(/^Encrypted:\s+yes/)}
78
+ end
79
+
80
+ # Per-page sizes and rotations from +pdfinfo -f 1 -l N+.
81
+ # @return [Hash{Integer => Hash}] page => +{width:, height:, rotation:}+ (unrotated CropBox, points)
82
+ def page_boxes(text)
83
+ pages = Hash.new { |h, k| h[k] = {rotation: 0} }
84
+ text.each_line do |line|
85
+ if (m = line.match(/^Page\s+(\d+)\s+size:\s+(\S+)\s+x\s+(\S+)\s+pts/))
86
+ width, height = m[2, 2].map { |value| Float(value, exception: false) }
87
+ unless [width, height].all? { |value| value&.finite? && value.positive? }
88
+ raise RenderError, "pdfinfo reports an unusable size for page #{m[1]}: #{m[2]} x #{m[3]} pts"
89
+ end
90
+
91
+ pages[Integer(m[1])].merge!(width: width, height: height)
92
+ elsif (m = line.match(/^Page\s+(\d+)\s+rot:\s+(-?\d+)/))
93
+ pages[Integer(m[1])][:rotation] = Integer(m[2]) % 360
94
+ end
95
+ end
96
+ pages.select { |_, box| box.key?(:width) }
97
+ end
98
+
99
+ # Rows of +pdfimages -list+ (masks excluded). The resolution columns are read from the end of the row:
100
+ # the "object ID" column is two tokens ("23 0") for normal images but one ("[inline]") for inline images.
101
+ # Rows that do not parse are skipped.
102
+ # @return [Array<Hash>] +{page:, type:, width:, height:, x_ppi:, y_ppi:}+
103
+ def images(text)
104
+ text.each_line.filter_map do |line|
105
+ fields = line.split
106
+ next unless fields.size >= 13 && fields[0].match?(/\A\d+\z/) && fields[2] == "image"
107
+
108
+ page, width, height = fields.values_at(0, 3, 4).map { |f| Integer(f, exception: false) }
109
+ x_ppi, y_ppi = fields[-4, 2].map { |f| Float(f, exception: false) }
110
+ next unless page && width && height && x_ppi && y_ppi
111
+
112
+ {page: page, type: "image", width: width, height: height, x_ppi: x_ppi, y_ppi: y_ppi}
113
+ end
114
+ end
115
+
116
+ # Native resolution of a scanned page: the ppi of an image covering most of it, else nil.
117
+ # @return [Float, nil]
118
+ def native_ppi(images, width_pt, height_pt, coverage: SCAN_COVERAGE)
119
+ page_area = (width_pt / 72.0) * (height_pt / 72.0)
120
+ return nil unless page_area.positive?
121
+
122
+ scans = images.select do |image|
123
+ next false unless image[:x_ppi].positive? && image[:y_ppi].positive?
124
+
125
+ (image[:width] / image[:x_ppi]) * (image[:height] / image[:y_ppi]) >= coverage * page_area
126
+ end
127
+ scans.map { |image| [image[:x_ppi], image[:y_ppi]].max }.max
128
+ end
129
+ end
130
+
131
+ # An opened PDF. Page metadata comes from one +pdfinfo+ run; each render is one +pdftoppm+ run.
132
+ class PopplerDocument < Document
133
+ def initialize(source, config, password)
134
+ super(source)
135
+ if password && password.to_s.bytesize > MAX_PASSWORD_BYTES
136
+ raise NotSupported, "Poppler's command-line tools accept passwords of at most #{MAX_PASSWORD_BYTES} bytes; " \
137
+ "this one has #{password.to_s.bytesize}. Use loader: :vips with config.vips_block_untrusted = false."
138
+ end
139
+ @config = config
140
+ @password = password
141
+ @mutex = Mutex.new
142
+ info = Parser.info(pdfinfo)
143
+ @page_count = info[:pages]
144
+ @encrypted = info[:encrypted]
145
+ end
146
+
147
+ # @return [Integer]
148
+ attr_reader :page_count
149
+
150
+ # @return [Boolean] whether the file is encrypted (it opened, so no or the right password was needed)
151
+ def encrypted? = @encrypted
152
+
153
+ # Size in points of the page as displayed (after /Rotate).
154
+ def page_info(number)
155
+ check_page!(number)
156
+ box = boxes.fetch(number) { raise RenderError, "pdfinfo reported no size for page #{number}" }
157
+ width, height = displayed_size(box)
158
+ PageInfo.new(number: number, width: width, height: height, unit: :pt, rotation: box[:rotation],
159
+ native_ppi: native_ppi(number, width, height))
160
+ end
161
+
162
+ # Renders one page to 8-bit gray at +dpi+ (default: config.default_dpi).
163
+ def render(number, dpi: nil, timeout: nil)
164
+ check_page!(number)
165
+ dpi ||= @config.default_dpi
166
+ raise ArgumentError, "dpi must be a positive number" unless dpi.is_a?(Numeric) && dpi.positive?
167
+
168
+ info = page_info(number)
169
+ width, height = Dpi.dimensions(info.width, info.height, dpi)
170
+ check_pixels!(width, height)
171
+ argv = [tool(:pdftoppm), "-gray", "-cropbox", "-r", format_dpi(dpi), "-f", number.to_s, "-l", number.to_s, *password_args, source.path]
172
+ stdout = run(argv, max_stdout: (width + 2) * (height + 2) + OUTPUT_SLACK, timeout: timeout)
173
+ decoded =
174
+ begin
175
+ ZXingFFI::Pnm.decode(stdout, max_pixels: @config.max_pixels)
176
+ rescue UnsupportedInput => e
177
+ raise RenderError.new("pdftoppm produced invalid PGM for page #{number}: #{e.message}")
178
+ end
179
+ image = Image.new(decoded.pixels, width: decoded.width, height: decoded.height)
180
+ Page.new(number: number, image: image, dpi: dpi, scale_to_base: 1.0,
181
+ metadata: {loader: :poppler, rotation: info.rotation, native_ppi: info.native_ppi})
182
+ end
183
+
184
+ private
185
+
186
+ # Per-page output ("Page N size: …" and "Page N rot: …") is ~80 bytes per page, so the cap grows with the
187
+ # page count once it is known.
188
+ def pdfinfo(*range)
189
+ cap = 1024 * 1024 + (@page_count || 0) * 256
190
+ run([tool(:pdfinfo), *range, *password_args, source.path], max_stdout: cap)
191
+ end
192
+
193
+ def boxes
194
+ @mutex.synchronize do
195
+ @boxes ||= Parser.page_boxes(pdfinfo("-f", "1", "-l", @page_count.to_s))
196
+ end
197
+ end
198
+
199
+ def displayed_size(box)
200
+ [90, 270].include?(box[:rotation]) ? [box[:height], box[:width]] : [box[:width], box[:height]]
201
+ end
202
+
203
+ def native_ppi(number, width, height)
204
+ images = @mutex.synchronize { @images ||= list_images }
205
+ Parser.native_ppi(images.select { |image| image[:page] == number }, width, height)
206
+ end
207
+
208
+ # All pages' embedded images, or [] when pdfimages is missing or fails (auto DPI then uses default_dpi).
209
+ def list_images
210
+ command = tool(:pdfimages)
211
+ return [] unless Subprocess.which(command)
212
+
213
+ Parser.images(run([command, "-list", *password_args, source.path], max_stdout: 16 * 1024 * 1024))
214
+ rescue RenderError, TimeoutError, LimitExceeded
215
+ []
216
+ end
217
+
218
+ # Integer DPIs as-is; fractional ones (huge declared pages, see Dpi.max_dpi_for) with 3 decimals.
219
+ def format_dpi(dpi)
220
+ dpi.is_a?(Integer) ? dpi.to_s : format("%.3f", dpi)
221
+ end
222
+
223
+ def password_args
224
+ @password ? ["-upw", @password.to_s] : []
225
+ end
226
+
227
+ def tool(name)
228
+ @config.tool_path(name).to_s
229
+ end
230
+
231
+ def check_pixels!(width, height)
232
+ pixels = width * height
233
+ return unless @config.max_pixels && pixels > @config.max_pixels
234
+
235
+ raise LimitExceeded.new("rendering #{width}x#{height} (#{pixels} pixels) exceeds max_pixels #{@config.max_pixels}",
236
+ limit: :max_pixels, value: pixels)
237
+ end
238
+
239
+ def run(argv, max_stdout:, timeout: nil)
240
+ status, stdout, stderr = Subprocess.run(argv, timeout: [timeout, @config.render_timeout].compact.min, max_stdout: max_stdout,
241
+ memory_limit: @config.subprocess_memory_limit)
242
+ return stdout if status.success?
243
+
244
+ raise password_error if stderr.match?(/incorrect password/i)
245
+
246
+ raise RenderError.new("#{File.basename(argv.first)} failed on #{source.name} (exit #{status.exitstatus}): #{stderr.lines.first&.strip}",
247
+ stderr: stderr, exit_status: status.exitstatus)
248
+ end
249
+
250
+ def password_error
251
+ if @password
252
+ IncorrectPassword.new("the password for #{source.name} was rejected")
253
+ else
254
+ PasswordRequired.new("#{source.name} is encrypted; pass password:")
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZXingFFI
4
+ module Loaders
5
+ # Chooses a loader for an input kind.
6
+ #
7
+ # Order comes from +config.pdf_loaders+ (PDF) or +config.image_loaders+ (everything else); an explicit
8
+ # +loader:+ overrides it. The first loader that supports the kind and is available wins.
9
+ module Registry
10
+ class << self
11
+ # @param kind [Symbol] sniffed input kind
12
+ # @param config [Config]
13
+ # @param loader [Symbol, Array<Symbol>, nil] explicit loader name(s)
14
+ # @return [Class<Base>]
15
+ # @raise [LoaderUnavailable] naming what to install
16
+ # @raise [ArgumentError] for unknown loader names
17
+ def loader_for(kind, config: ZXingFFI.config, loader: nil)
18
+ order = loader ? Array(loader) : default_order(kind, config)
19
+ tried = order.map do |name|
20
+ klass = Loaders.fetch(name)
21
+ return klass if klass.supports?(kind)
22
+
23
+ reason =
24
+ if klass.kinds.include?(kind)
25
+ klass.unavailable_reason || klass.unsupported_reason(kind) || "no #{kind} support in this build"
26
+ else
27
+ "does not read #{kind}"
28
+ end
29
+ [name, reason, klass]
30
+ end
31
+ raise LoaderUnavailable, unavailable_message(kind, tried)
32
+ end
33
+
34
+ # @return [Array<Symbol>] configured loader order for +kind+
35
+ def default_order(kind, config)
36
+ (kind == :pdf) ? config.pdf_loaders : config.image_loaders
37
+ end
38
+
39
+ private
40
+
41
+ def unavailable_message(kind, tried)
42
+ lines = tried.map { |name, reason, _| " - #{name}: #{reason}" }
43
+ # install hints only for loaders that are missing; a present loader that refuses explains why in its reason
44
+ hints = tried.filter_map { |_, _, klass| klass.install_hint if klass.kinds.include?(kind) && !klass.available? }
45
+ hints = hints.reject(&:empty?).uniq
46
+ if hints.empty?
47
+ hints << ((tried.any? { |_, _, klass| klass.kinds.include?(kind) }) ? "see the reasons above" : "no configured loader reads #{kind} input")
48
+ end
49
+ "No loader available for #{kind} input.\nTried:\n#{lines.join("\n")}\nTo fix: #{hints.join("; or ")}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end