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,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Global configuration. Per-call options override these values.
|
|
5
|
+
#
|
|
6
|
+
# Config is read at call time: do not mutate it while a scan is running.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# ZXingFFI.configure do |c|
|
|
10
|
+
# c.max_pixels = 32_000_000
|
|
11
|
+
# c.pdf_loaders = %i[poppler]
|
|
12
|
+
# end
|
|
13
|
+
class Config
|
|
14
|
+
# Default executable names for external tools. +magick: nil+ means auto-detect
|
|
15
|
+
# (+magick+ for ImageMagick 7, +convert+/+identify+ for ImageMagick 6).
|
|
16
|
+
DEFAULT_TOOL_PATHS = {
|
|
17
|
+
pdftoppm: "pdftoppm",
|
|
18
|
+
pdfinfo: "pdfinfo",
|
|
19
|
+
pdfimages: "pdfimages",
|
|
20
|
+
magick: nil
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] explicit path to libZXing (defaults to ENV["ZXING_LIB"])
|
|
24
|
+
attr_accessor :library_path
|
|
25
|
+
# @return [Array<Symbol>] PDF loaders in order of preference
|
|
26
|
+
attr_accessor :pdf_loaders
|
|
27
|
+
# @return [Array<Symbol>] raster loaders in order of preference
|
|
28
|
+
attr_accessor :image_loaders
|
|
29
|
+
# @return [Array<Symbol>] transformers (resize/rotate) in order of preference
|
|
30
|
+
attr_accessor :transformers
|
|
31
|
+
# @return [Integer] render DPI for born-digital PDF pages
|
|
32
|
+
attr_accessor :default_dpi
|
|
33
|
+
# @return [Integer] upper bound for any render DPI
|
|
34
|
+
attr_accessor :max_dpi
|
|
35
|
+
# @return [Integer] maximum pixels per bitmap
|
|
36
|
+
attr_accessor :max_pixels
|
|
37
|
+
# @return [Integer, nil] maximum number of pages per document (nil = unlimited)
|
|
38
|
+
attr_accessor :max_pages
|
|
39
|
+
# @return [Numeric] seconds allowed per page render
|
|
40
|
+
attr_accessor :render_timeout
|
|
41
|
+
# @return [Integer] address-space limit in bytes for subprocesses (Linux only)
|
|
42
|
+
attr_accessor :subprocess_memory_limit
|
|
43
|
+
# @return [Hash{Symbol => String, nil}] executable names/paths for external tools
|
|
44
|
+
attr_accessor :tool_paths
|
|
45
|
+
# @return [Boolean] block libvips "untrusted" loaders when the installed libvips supports it
|
|
46
|
+
attr_accessor :vips_block_untrusted
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@library_path = ENV["ZXING_LIB"]
|
|
50
|
+
@pdf_loaders = %i[poppler vips]
|
|
51
|
+
@image_loaders = %i[vips image_magick pnm]
|
|
52
|
+
@transformers = %i[vips image_magick]
|
|
53
|
+
@default_dpi = 300
|
|
54
|
+
@max_dpi = 600
|
|
55
|
+
@max_pixels = 64_000_000
|
|
56
|
+
@max_pages = nil
|
|
57
|
+
@render_timeout = 60
|
|
58
|
+
@subprocess_memory_limit = 2 * 1024**3
|
|
59
|
+
@tool_paths = DEFAULT_TOOL_PATHS.dup
|
|
60
|
+
@vips_block_untrusted = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Executable configured for +tool+, falling back to the default name.
|
|
64
|
+
# @param tool [Symbol] e.g. +:pdftoppm+
|
|
65
|
+
# @return [String, nil]
|
|
66
|
+
def tool_path(tool)
|
|
67
|
+
tool_paths.fetch(tool) { DEFAULT_TOOL_PATHS[tool] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A copy of this config with some attributes replaced. Unknown keys raise ArgumentError.
|
|
71
|
+
# @return [Config]
|
|
72
|
+
def with(**overrides)
|
|
73
|
+
copy = dup
|
|
74
|
+
copy.tool_paths = tool_paths.dup
|
|
75
|
+
overrides.each do |key, value|
|
|
76
|
+
raise ArgumentError, "unknown config key: #{key.inspect}" unless respond_to?(:"#{key}=")
|
|
77
|
+
copy.public_send(:"#{key}=", value)
|
|
78
|
+
end
|
|
79
|
+
copy
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Hash]
|
|
83
|
+
def to_h
|
|
84
|
+
{
|
|
85
|
+
library_path: library_path,
|
|
86
|
+
pdf_loaders: pdf_loaders,
|
|
87
|
+
image_loaders: image_loaders,
|
|
88
|
+
transformers: transformers,
|
|
89
|
+
default_dpi: default_dpi,
|
|
90
|
+
max_dpi: max_dpi,
|
|
91
|
+
max_pixels: max_pixels,
|
|
92
|
+
max_pages: max_pages,
|
|
93
|
+
render_timeout: render_timeout,
|
|
94
|
+
subprocess_memory_limit: subprocess_memory_limit,
|
|
95
|
+
tool_paths: tool_paths,
|
|
96
|
+
vips_block_untrusted: vips_block_untrusted
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class << self
|
|
102
|
+
# @return [Config] the global configuration
|
|
103
|
+
def config
|
|
104
|
+
@config ||= Config.new
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Yields the global configuration for modification.
|
|
108
|
+
# @yieldparam config [Config]
|
|
109
|
+
def configure
|
|
110
|
+
yield config
|
|
111
|
+
config
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Restores the default configuration (mainly for tests).
|
|
115
|
+
def reset_config!
|
|
116
|
+
@config = Config.new
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Removes barcodes reported more than once for the same page, e.g. by several passes.
|
|
5
|
+
#
|
|
6
|
+
# Two barcodes are duplicates when they have the same +format+, the same +bytes+, and either
|
|
7
|
+
# - quad centers (in base-image coordinates) at most +max(10 px, 0.25 × s)+ apart, where +s+ is the size of the
|
|
8
|
+
# smaller quad — a quad's size is its shorter side, except for degenerate quads whose shorter side is under 10%
|
|
9
|
+
# of the longer one (linear codes located on a single scan line): their size is the longer side; or
|
|
10
|
+
# - the center of one lies inside the other's quad. This catches partial reads of wide symbols: a PDF417
|
|
11
|
+
# cut by a tile boundary in the tiles pass can still decode, but its quad covers only part of the symbol.
|
|
12
|
+
# Identical content at different, non-overlapping positions is not a duplicate: repeated labels are legitimate.
|
|
13
|
+
#
|
|
14
|
+
# Barcodes are duck-typed: anything with +#format+, +#bytes+ and a {Geometry::Quad} +#position+ works.
|
|
15
|
+
module Dedupe
|
|
16
|
+
# Center distance in pixels within which equal barcodes are always duplicates.
|
|
17
|
+
MIN_DISTANCE = 10
|
|
18
|
+
# Center distance, as a fraction of the smaller quad's size, within which equal barcodes are duplicates.
|
|
19
|
+
RELATIVE_DISTANCE = 0.25
|
|
20
|
+
# A quad whose shorter side is below this fraction of its longer side is degenerate.
|
|
21
|
+
DEGENERATE_RATIO = 0.1
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Drops duplicates, keeping the first occurrence of each barcode and the order of the input.
|
|
25
|
+
#
|
|
26
|
+
# Each barcode is compared only with the barcodes kept so far that have the same format and bytes.
|
|
27
|
+
#
|
|
28
|
+
# @param barcodes [Enumerable<Barcode>] in pass order (earliest pass first)
|
|
29
|
+
# @return [Array<Barcode>] a new Array
|
|
30
|
+
def call(barcodes)
|
|
31
|
+
kept = Hash.new { |groups, key| groups[key] = [] }
|
|
32
|
+
barcodes.select do |barcode|
|
|
33
|
+
group = kept[key(barcode)]
|
|
34
|
+
next false if group.any? { |other| near?(other.position, barcode.position) }
|
|
35
|
+
|
|
36
|
+
group << barcode
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param a [Barcode]
|
|
42
|
+
# @param b [Barcode]
|
|
43
|
+
# @return [Boolean] whether +a+ and +b+ are the same symbol reported twice (symmetric)
|
|
44
|
+
def duplicate?(a, b)
|
|
45
|
+
key(a) == key(b) && near?(a.position, b.position)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def key(barcode)
|
|
51
|
+
bytes = barcode.bytes
|
|
52
|
+
[barcode.format, bytes.is_a?(String) ? bytes.b : bytes]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def near?(quad, other)
|
|
56
|
+
quad.center.distance_to(other.center) <= [MIN_DISTANCE, RELATIVE_DISTANCE * [size(quad), size(other)].min].max ||
|
|
57
|
+
inside?(quad.center, other) || inside?(other.center, quad)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether +point+ lies inside +quad+ (edges included). Symbol quads are convex, so the point must be on the
|
|
61
|
+
# same side of all four edges. Degenerate quads (linear codes found on a single scan line) contain nothing.
|
|
62
|
+
def inside?(point, quad)
|
|
63
|
+
return false if quad.area < 1
|
|
64
|
+
|
|
65
|
+
corners = quad.points
|
|
66
|
+
sides = corners.each_with_index.map do |corner, i|
|
|
67
|
+
following = corners[(i + 1) % corners.size]
|
|
68
|
+
(following.x - corner.x) * (point.y - corner.y) - (following.y - corner.y) * (point.x - corner.x)
|
|
69
|
+
end
|
|
70
|
+
sides.all? { |side| side >= 0 } || sides.all? { |side| side <= 0 }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def size(quad)
|
|
74
|
+
shorter, longer = quad.side_lengths.minmax
|
|
75
|
+
(shorter < DEGENERATE_RATIO * longer) ? longer : shorter
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Builds the environment report behind {ZXingFFI.diagnostics}. Never raises for a missing
|
|
5
|
+
# library or tool: problems are reported in the Hash instead.
|
|
6
|
+
module Diagnostics
|
|
7
|
+
class << self
|
|
8
|
+
# @return [Hash]
|
|
9
|
+
def collect
|
|
10
|
+
{
|
|
11
|
+
gem_version: VERSION,
|
|
12
|
+
ruby: {engine: RUBY_ENGINE, version: RUBY_VERSION, platform: RUBY_PLATFORM},
|
|
13
|
+
library: library,
|
|
14
|
+
defaults: defaults,
|
|
15
|
+
loaders: loaders,
|
|
16
|
+
transformers: transformers,
|
|
17
|
+
config: ZXingFFI.config.to_h
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash] +{loaded:, path:, source:, version:, optional_symbols:, formats:}+ or +{loaded: false, error:}+;
|
|
22
|
+
# +source+ is where discovery found the library (see {LibraryLoader::Found#source}), e.g. +:bundled+
|
|
23
|
+
def library
|
|
24
|
+
found = Native.load!
|
|
25
|
+
{
|
|
26
|
+
loaded: true,
|
|
27
|
+
path: found.path,
|
|
28
|
+
source: found.source,
|
|
29
|
+
version: found.version,
|
|
30
|
+
optional_symbols: Native.optional_features,
|
|
31
|
+
formats: Formats.readable
|
|
32
|
+
}
|
|
33
|
+
rescue Error => e
|
|
34
|
+
{loaded: false, error: "#{e.class.name.split("::").last}: #{e.message}"}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Hash] +{library:, gem:}+ reader defaults
|
|
38
|
+
def defaults
|
|
39
|
+
library_defaults =
|
|
40
|
+
begin
|
|
41
|
+
LIBRARY_DEFAULTS
|
|
42
|
+
rescue Error => e
|
|
43
|
+
{error: e.message.lines.first.strip}
|
|
44
|
+
end
|
|
45
|
+
{library: library_defaults, gem: Reader::GEM_DEFAULTS}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Availability, version and supported kinds of each loader.
|
|
49
|
+
# @return [Hash{Symbol => Hash}]
|
|
50
|
+
def loaders
|
|
51
|
+
Loaders::NAMES.keys.to_h { |name| [name, component(Loaders, name)] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Availability and version of each transformer.
|
|
55
|
+
# @return [Hash{Symbol => Hash}]
|
|
56
|
+
def transformers
|
|
57
|
+
Transformers::NAMES.keys.to_h { |name| [name, component(Transformers, name)] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def component(namespace, name)
|
|
63
|
+
namespace.fetch(name).diagnostics
|
|
64
|
+
rescue StandardError, ScriptError => e
|
|
65
|
+
{available: false, reason: "#{e.class}: #{e.message}"}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# DPI selection and pixel-cap math for PDF pages. Pure functions.
|
|
5
|
+
module Dpi
|
|
6
|
+
# PDF user-space units per inch.
|
|
7
|
+
POINTS_PER_INCH = 72.0
|
|
8
|
+
|
|
9
|
+
# Native resolutions used by +dpi: :auto+ are clamped to this range.
|
|
10
|
+
AUTO_RANGE = (150..600)
|
|
11
|
+
|
|
12
|
+
# Smallest render resolution used for absurdly large declared pages (see {max_dpi_for}).
|
|
13
|
+
MIN_FRACTIONAL_DPI = 0.01
|
|
14
|
+
|
|
15
|
+
# @!attribute dpi [Integer, Float] DPI to render at (a Float only for pages too large for 1 DPI)
|
|
16
|
+
# @!attribute requested [Integer, Symbol] what the caller asked for
|
|
17
|
+
# @!attribute source [Symbol] +:explicit+, +:native+ (scanned page) or +:default+
|
|
18
|
+
# @!attribute capped [Boolean] lowered to respect max_pixels
|
|
19
|
+
Choice = Data.define(:dpi, :requested, :source, :capped)
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Chooses the render DPI for a page.
|
|
23
|
+
#
|
|
24
|
+
# @param requested [Integer, :auto, nil] +:auto+/nil: the scan's native ppi clamped to 150..600 (and max_dpi)
|
|
25
|
+
# for scanned pages, else +default_dpi+; Integers are honored subject to the pixel cap
|
|
26
|
+
# @param width_pt [Numeric] displayed page width in points
|
|
27
|
+
# @param height_pt [Numeric] displayed page height in points
|
|
28
|
+
# @param native_ppi [Numeric, nil]
|
|
29
|
+
# @return [Choice]
|
|
30
|
+
# @raise [LimitExceeded] when even {MIN_FRACTIONAL_DPI} would exceed max_pixels
|
|
31
|
+
def choose(requested:, width_pt:, height_pt:, native_ppi: nil, default_dpi: 300, max_dpi: 600, max_pixels: 64_000_000)
|
|
32
|
+
check_size!(width_pt, height_pt)
|
|
33
|
+
dpi, source =
|
|
34
|
+
case requested
|
|
35
|
+
when :auto, nil
|
|
36
|
+
if native_ppi&.positive?
|
|
37
|
+
[native_ppi.round.clamp([AUTO_RANGE.min, max_dpi].min, [AUTO_RANGE.max, max_dpi].min), :native]
|
|
38
|
+
else
|
|
39
|
+
[[default_dpi, max_dpi].min, :default]
|
|
40
|
+
end
|
|
41
|
+
when Integer
|
|
42
|
+
raise ArgumentError, "dpi must be positive, got #{requested}" unless requested.positive?
|
|
43
|
+
|
|
44
|
+
[requested, :explicit]
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError, "dpi must be an Integer or :auto, got #{requested.inspect}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
capped = false
|
|
50
|
+
if max_pixels && pixels(width_pt, height_pt, dpi) > max_pixels
|
|
51
|
+
dpi = max_dpi_for(width_pt, height_pt, max_pixels)
|
|
52
|
+
capped = true
|
|
53
|
+
end
|
|
54
|
+
Choice.new(dpi: dpi, requested: requested || :auto, source: source, capped: capped)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Highest DPI whose render stays within +max_pixels+: an Integer, or — for absurdly large declared pages where
|
|
58
|
+
# even 1 DPI is too much — a Float with three decimals (Poppler and libvips accept fractional resolutions).
|
|
59
|
+
# @raise [LimitExceeded] when not even {MIN_FRACTIONAL_DPI} fits
|
|
60
|
+
def max_dpi_for(width_pt, height_pt, max_pixels)
|
|
61
|
+
check_size!(width_pt, height_pt)
|
|
62
|
+
exact = POINTS_PER_INCH * Math.sqrt(max_pixels.to_f / (width_pt * height_pt))
|
|
63
|
+
dpi = exact.floor
|
|
64
|
+
dpi -= 1 while dpi.positive? && pixels(width_pt, height_pt, dpi) > max_pixels
|
|
65
|
+
return dpi if dpi >= 1
|
|
66
|
+
|
|
67
|
+
dpi = exact.floor(3)
|
|
68
|
+
dpi = (dpi - 0.001).round(3) while dpi >= MIN_FRACTIONAL_DPI && pixels(width_pt, height_pt, dpi) > max_pixels
|
|
69
|
+
return dpi if dpi >= MIN_FRACTIONAL_DPI
|
|
70
|
+
|
|
71
|
+
raise LimitExceeded.new("page of #{width_pt}x#{height_pt} pt exceeds max_pixels #{max_pixels} even at #{MIN_FRACTIONAL_DPI} DPI",
|
|
72
|
+
limit: :max_pixels, value: pixels(width_pt, height_pt, MIN_FRACTIONAL_DPI))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @raise [ArgumentError] unless both sides are finite and positive
|
|
76
|
+
def check_size!(width_pt, height_pt)
|
|
77
|
+
return if [width_pt, height_pt].all? { |side| side.is_a?(Numeric) && side.finite? && side.positive? }
|
|
78
|
+
|
|
79
|
+
raise ArgumentError, "page size must be finite and positive, got #{width_pt} x #{height_pt}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Pixel dimensions of a page rendered at +dpi+ (rounded up, like Poppler).
|
|
83
|
+
# @return [Array(Integer, Integer)]
|
|
84
|
+
def dimensions(width_pt, height_pt, dpi)
|
|
85
|
+
[(width_pt * dpi / POINTS_PER_INCH).ceil, (height_pt * dpi / POINTS_PER_INCH).ceil]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Integer] expected pixel count at +dpi+
|
|
89
|
+
def pixels(width_pt, height_pt, dpi)
|
|
90
|
+
w, h = dimensions(width_pt, height_pt, dpi)
|
|
91
|
+
w * h
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# DPI for the high-resolution pass: 2× the base, within max_dpi and the pixel cap.
|
|
95
|
+
# @return [Integer, nil] nil when that would not exceed the base DPI
|
|
96
|
+
def high_res(base_dpi, width_pt:, height_pt:, max_dpi: 600, max_pixels: 64_000_000)
|
|
97
|
+
dpi = [base_dpi * 2, max_dpi].min
|
|
98
|
+
dpi = [dpi, max_dpi_for(width_pt, height_pt, max_pixels)].min if max_pixels
|
|
99
|
+
(dpi > base_dpi) ? dpi : nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Base class for every error raised by this gem.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# No usable libZXing could be found. The message lists every path that was tried.
|
|
8
|
+
class LibraryNotFound < Error; end
|
|
9
|
+
|
|
10
|
+
# A libZXing was found but its version or symbols are not supported.
|
|
11
|
+
class IncompatibleLibrary < Error
|
|
12
|
+
# @return [String, nil] the version reported by the library, if any
|
|
13
|
+
attr_reader :version
|
|
14
|
+
|
|
15
|
+
def initialize(message = nil, version: nil)
|
|
16
|
+
@version = version
|
|
17
|
+
super(message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# An optional native feature is missing (e.g. +try_denoise+ without ZXING_EXPERIMENTAL_API).
|
|
22
|
+
class NotSupported < Error; end
|
|
23
|
+
|
|
24
|
+
# No loader (or transformer) able to handle the input is installed.
|
|
25
|
+
class LoaderUnavailable < Error; end
|
|
26
|
+
|
|
27
|
+
# The input is not a recognised image or PDF.
|
|
28
|
+
class UnsupportedInput < Error; end
|
|
29
|
+
|
|
30
|
+
# The PDF is encrypted and no password was given.
|
|
31
|
+
class PasswordRequired < Error; end
|
|
32
|
+
|
|
33
|
+
# The PDF is encrypted and the given password was rejected.
|
|
34
|
+
class IncorrectPassword < PasswordRequired; end
|
|
35
|
+
|
|
36
|
+
# An external renderer or in-process loader failed.
|
|
37
|
+
class RenderError < Error
|
|
38
|
+
# @return [String, nil] (truncated) stderr of the failed subprocess
|
|
39
|
+
attr_reader :stderr
|
|
40
|
+
# @return [Integer, nil] exit status of the failed subprocess
|
|
41
|
+
attr_reader :exit_status
|
|
42
|
+
|
|
43
|
+
def initialize(message = nil, stderr: nil, exit_status: nil)
|
|
44
|
+
@stderr = stderr
|
|
45
|
+
@exit_status = exit_status
|
|
46
|
+
super(message)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A subprocess or page exceeded its time budget.
|
|
51
|
+
class TimeoutError < Error; end
|
|
52
|
+
|
|
53
|
+
# A resource limit (pixels, pages, output size, ...) was exceeded.
|
|
54
|
+
class LimitExceeded < Error
|
|
55
|
+
# @return [Symbol, nil] name of the limit, e.g. +:max_pixels+
|
|
56
|
+
attr_reader :limit
|
|
57
|
+
# @return [Numeric, nil] the offending value
|
|
58
|
+
attr_reader :value
|
|
59
|
+
|
|
60
|
+
def initialize(message = nil, limit: nil, value: nil)
|
|
61
|
+
@limit = limit
|
|
62
|
+
@value = value
|
|
63
|
+
super(message)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ZXing_ReadBarcodes returned NULL.
|
|
68
|
+
class DecodeError < Error; end
|
|
69
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Runtime map between ZXing_BarcodeFormat values and Ruby symbols.
|
|
5
|
+
#
|
|
6
|
+
# Values are never hardcoded: the table is built from +ZXing_BarcodeFormatsList+ and
|
|
7
|
+
# +ZXing_BarcodeFormatToString+ when first needed. Symbols derive from the library's human-readable
|
|
8
|
+
# names ("EAN-13" → +:ean_13+, "QR Code" → +:qr_code+).
|
|
9
|
+
module Formats
|
|
10
|
+
# Library names of meta-formats (sets of formats), resolved at runtime; absent ones are skipped.
|
|
11
|
+
META_NAMES = %w[All AllReadable AllCreatable AllLinear AllMatrix AllGS1 AllRetail AllIndustrial].freeze
|
|
12
|
+
|
|
13
|
+
# One concrete format.
|
|
14
|
+
# @!attribute value [Integer] ZXing_BarcodeFormat value
|
|
15
|
+
# @!attribute symbol [Symbol] e.g. +:ean_13+
|
|
16
|
+
# @!attribute name [String] library name, e.g. "EAN-13"
|
|
17
|
+
# @!attribute symbology [Integer] value of the symbology family (e.g. EAN/UPC for EAN-13)
|
|
18
|
+
Info = Data.define(:value, :symbol, :name, :symbology)
|
|
19
|
+
|
|
20
|
+
# Immutable lookup tables built from the loaded library.
|
|
21
|
+
Table = Data.define(:by_value, :by_symbol, :meta, :readable, :linear, :invalid) do
|
|
22
|
+
# @return [Info, nil]
|
|
23
|
+
def info(value) = by_value[value]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@table = nil
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Normalizes a library name to our symbol: downcase, non-alphanumerics to "_", squeeze.
|
|
31
|
+
# @param name [String]
|
|
32
|
+
# @return [Symbol]
|
|
33
|
+
def normalize(name)
|
|
34
|
+
name.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "").to_sym
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Symbol>] every readable concrete format
|
|
38
|
+
def readable
|
|
39
|
+
table.readable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Array<Symbol>] readable linear (1D) formats, from the library's AllLinear meta-format
|
|
43
|
+
def linear
|
|
44
|
+
table.linear
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Symbol>] every concrete format known to the library, readable or not
|
|
48
|
+
def all
|
|
49
|
+
table.by_symbol.keys
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Hash{Symbol => Integer}] meta-formats (+:all+, +:all_readable+, +:all_linear+, …)
|
|
53
|
+
def meta
|
|
54
|
+
table.meta
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Symbol] our symbol for a library value (+:unknown+ when the library has no name for it)
|
|
58
|
+
def symbol_for(value)
|
|
59
|
+
info_for(value).symbol
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [String] the library's human-readable name for a value
|
|
63
|
+
def name_for(value)
|
|
64
|
+
info_for(value).name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Integer] library value of a concrete or meta format symbol
|
|
68
|
+
# @raise [ArgumentError] for unknown formats
|
|
69
|
+
def value_for(format)
|
|
70
|
+
return meta.fetch(:all_readable) if all?(format)
|
|
71
|
+
|
|
72
|
+
values = resolve(format)
|
|
73
|
+
raise ArgumentError, "#{format.inspect} is not a single format" unless values&.size == 1
|
|
74
|
+
|
|
75
|
+
values.first
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Converts a +formats:+ option into library values for ReaderOptions_setFormats.
|
|
79
|
+
#
|
|
80
|
+
# Accepts symbols (+:qr_code+, +:all+, +:all_linear+), the library's own strings ("QR Code", "EAN-13",
|
|
81
|
+
# "QRCode", "]Q1"), comma/pipe-separated strings, and arrays of those. +:all+ maps to AllReadable.
|
|
82
|
+
#
|
|
83
|
+
# @return [Array<Integer>, nil] nil for +:all+/+nil+ (keep the library default: every format)
|
|
84
|
+
# @raise [ArgumentError] when a name is not a known format
|
|
85
|
+
def resolve(formats)
|
|
86
|
+
return nil if formats.nil? || all?(formats)
|
|
87
|
+
|
|
88
|
+
items = Array(formats).flat_map { |f| f.is_a?(String) ? f.split(/[,|]/).map(&:strip).reject(&:empty?) : [f] }
|
|
89
|
+
raise ArgumentError, "formats must not be empty" if items.empty?
|
|
90
|
+
return nil if items.any? { |f| all?(f) }
|
|
91
|
+
|
|
92
|
+
items.map { |item| resolve_one(item) }.uniq
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Concrete readable format symbols covered by a +formats:+ option (e.g. +:ean_upc+ → +[:ean_13, :ean_8, …]+).
|
|
96
|
+
# @return [Array<Symbol>]
|
|
97
|
+
def expand(formats)
|
|
98
|
+
values = resolve(formats)
|
|
99
|
+
return readable if values.nil?
|
|
100
|
+
|
|
101
|
+
readable_set = readable
|
|
102
|
+
covered = values.flat_map do |value|
|
|
103
|
+
Native.take_formats { |count| Native.ZXing_BarcodeFormatsList(value, count) } || []
|
|
104
|
+
end
|
|
105
|
+
covered.map { |v| symbol_for(v) }.uniq.select { |s| readable_set.include?(s) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The subset of +formats+ that is linear, for the rotated pass.
|
|
109
|
+
# @return [Array<Symbol>] empty when the selection contains no linear format
|
|
110
|
+
def linear_subset(formats)
|
|
111
|
+
expand(formats) & linear
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The lookup tables (built on first use; requires the native library).
|
|
115
|
+
# @return [Table]
|
|
116
|
+
def table
|
|
117
|
+
@table || @mutex.synchronize { @table ||= build_table }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def all?(format)
|
|
123
|
+
(format.is_a?(Symbol) || format.is_a?(String)) && format.to_s.strip.casecmp?("all")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def info_for(value)
|
|
127
|
+
table.info(value) || unknown_info(value)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def unknown_info(value)
|
|
131
|
+
name = Native.take_string(Native.ZXing_BarcodeFormatToString(value)) || "Unknown"
|
|
132
|
+
Info.new(value: value, symbol: normalize(name), name: name, symbology: value)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def resolve_one(item)
|
|
136
|
+
case item
|
|
137
|
+
when Symbol
|
|
138
|
+
lookup_symbol(item) || library_value(item.to_s) || unknown_format!(item)
|
|
139
|
+
when String
|
|
140
|
+
lookup_symbol(normalize(item)) || library_value(item) || unknown_format!(item)
|
|
141
|
+
when Integer
|
|
142
|
+
return item if table.by_value.key?(item) || table.meta.value?(item)
|
|
143
|
+
|
|
144
|
+
unknown_format!(item)
|
|
145
|
+
else
|
|
146
|
+
raise ArgumentError, "formats must be symbols or strings, got #{item.inspect}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def lookup_symbol(symbol)
|
|
151
|
+
table.by_symbol[symbol]&.value || table.meta[symbol]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The library's own parser also accepts "QRCode", "qrcode", "]Q1", "EAN/UPC", …
|
|
155
|
+
def library_value(string)
|
|
156
|
+
value = Native.ZXing_BarcodeFormatFromString(string)
|
|
157
|
+
Native.last_error_message # clear the thread-local error set on failure
|
|
158
|
+
(value == table.invalid) ? nil : value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def unknown_format!(item)
|
|
162
|
+
raise ArgumentError, "unknown barcode format #{item.inspect}. Valid formats: #{readable.join(", ")}; " \
|
|
163
|
+
"meta-formats: #{meta.keys.join(", ")}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_table
|
|
167
|
+
Native.load!
|
|
168
|
+
# A name that cannot parse yields ZXing_BarcodeFormat_Invalid, whose value we learn instead of hardcoding it.
|
|
169
|
+
invalid = Native.ZXing_BarcodeFormatFromString("\u0001not-a-barcode-format")
|
|
170
|
+
Native.last_error_message
|
|
171
|
+
|
|
172
|
+
meta = META_NAMES.each_with_object({}) do |name, h|
|
|
173
|
+
value = Native.ZXing_BarcodeFormatFromString(name)
|
|
174
|
+
Native.last_error_message
|
|
175
|
+
next if value == invalid
|
|
176
|
+
|
|
177
|
+
h[normalize(Native.take_string(Native.ZXing_BarcodeFormatToString(value)) || name)] = value
|
|
178
|
+
end
|
|
179
|
+
raise IncompatibleLibrary, "libZXing lacks the All/AllReadable meta-formats" unless meta[:all] && meta[:all_readable]
|
|
180
|
+
|
|
181
|
+
by_value = list(meta[:all]).to_h do |value|
|
|
182
|
+
name = Native.take_string(Native.ZXing_BarcodeFormatToString(value))
|
|
183
|
+
[value, Info.new(value: value, symbol: normalize(name), name: name, symbology: Native.ZXing_BarcodeFormatSymbology(value))]
|
|
184
|
+
end
|
|
185
|
+
by_symbol = by_value.values.to_h { |info| [info.symbol, info] }
|
|
186
|
+
raise IncompatibleLibrary, "format names are not unique after normalization" if by_symbol.size != by_value.size
|
|
187
|
+
|
|
188
|
+
readable = list(meta[:all_readable]).map { |v| by_value.fetch(v).symbol }
|
|
189
|
+
linear = meta[:all_linear] ? list(meta[:all_linear]).map { |v| by_value.fetch(v).symbol } & readable : []
|
|
190
|
+
|
|
191
|
+
Table.new(
|
|
192
|
+
by_value: by_value.freeze,
|
|
193
|
+
by_symbol: by_symbol.freeze,
|
|
194
|
+
meta: meta.freeze,
|
|
195
|
+
readable: readable.freeze,
|
|
196
|
+
linear: linear.freeze,
|
|
197
|
+
invalid: invalid
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def list(filter)
|
|
202
|
+
Native.take_formats { |count| Native.ZXing_BarcodeFormatsList(filter, count) } ||
|
|
203
|
+
raise(IncompatibleLibrary, "ZXing_BarcodeFormatsList failed: #{Native.last_error_message}")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|