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