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