qr_reader 0.0.1

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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/NOTICE +14 -0
  5. data/README.md +222 -0
  6. data/exe/qr_reader +66 -0
  7. data/lib/qr_reader/binarizer.rb +170 -0
  8. data/lib/qr_reader/bit_matrix.rb +120 -0
  9. data/lib/qr_reader/decoder/bitstream_parser.rb +307 -0
  10. data/lib/qr_reader/decoder/character_set.rb +163 -0
  11. data/lib/qr_reader/decoder/codewords.rb +164 -0
  12. data/lib/qr_reader/decoder/data_mask.rb +57 -0
  13. data/lib/qr_reader/decoder/format_reader.rb +204 -0
  14. data/lib/qr_reader/decoder/function_patterns.rb +62 -0
  15. data/lib/qr_reader/decoder/galois_field.rb +69 -0
  16. data/lib/qr_reader/decoder/reed_solomon.rb +312 -0
  17. data/lib/qr_reader/decoder.rb +241 -0
  18. data/lib/qr_reader/detector/alignment_grid.rb +127 -0
  19. data/lib/qr_reader/detector/alignment_pattern_finder.rb +191 -0
  20. data/lib/qr_reader/detector/candidate/dimension.rb +237 -0
  21. data/lib/qr_reader/detector/candidate/strokes.rb +126 -0
  22. data/lib/qr_reader/detector/candidate.rb +436 -0
  23. data/lib/qr_reader/detector/finder_pattern.rb +63 -0
  24. data/lib/qr_reader/detector/finder_pattern_finder.rb +199 -0
  25. data/lib/qr_reader/detector/geometry.rb +118 -0
  26. data/lib/qr_reader/detector/grid_sampler.rb +350 -0
  27. data/lib/qr_reader/detector/pattern_sets.rb +235 -0
  28. data/lib/qr_reader/detector/perspective_transform.rb +117 -0
  29. data/lib/qr_reader/detector/pure_detector.rb +197 -0
  30. data/lib/qr_reader/detector.rb +215 -0
  31. data/lib/qr_reader/errors.rb +14 -0
  32. data/lib/qr_reader/format_information.rb +79 -0
  33. data/lib/qr_reader/formats/bmp.rb +434 -0
  34. data/lib/qr_reader/formats/ccitt.rb +81 -0
  35. data/lib/qr_reader/formats/gif.rb +272 -0
  36. data/lib/qr_reader/formats/jpeg.rb +152 -0
  37. data/lib/qr_reader/formats/pdf.rb +2935 -0
  38. data/lib/qr_reader/formats/png.rb +311 -0
  39. data/lib/qr_reader/formats/pnm.rb +286 -0
  40. data/lib/qr_reader/formats/tiff.rb +1345 -0
  41. data/lib/qr_reader/formats.rb +279 -0
  42. data/lib/qr_reader/image.rb +231 -0
  43. data/lib/qr_reader/kernels/binarizer.rb +475 -0
  44. data/lib/qr_reader/kernels/ccitt.rb +554 -0
  45. data/lib/qr_reader/kernels/detector.rb +587 -0
  46. data/lib/qr_reader/kernels/image.rb +65 -0
  47. data/lib/qr_reader/kernels/jpeg.rb +3421 -0
  48. data/lib/qr_reader/kernels/lzw.rb +173 -0
  49. data/lib/qr_reader/kernels/pixels.rb +116 -0
  50. data/lib/qr_reader/kernels/png.rb +1138 -0
  51. data/lib/qr_reader/kernels.rb +14 -0
  52. data/lib/qr_reader/native_kernels.rb +130 -0
  53. data/lib/qr_reader/reader.rb +348 -0
  54. data/lib/qr_reader/result.rb +49 -0
  55. data/lib/qr_reader/symbol_version.rb +173 -0
  56. data/lib/qr_reader/version.rb +5 -0
  57. data/lib/qr_reader.rb +77 -0
  58. metadata +103 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # QrReaderKernels: the hot loops of qr_reader as plain-Ruby module functions
4
+ # over Strings, Integers and Arrays. They run as they are on any Ruby; the
5
+ # optional qr_reader-native gem compiles the same files with Spinel and
6
+ # swaps in the native versions (see DESIGN.md, "Kernels").
7
+ require_relative "kernels/image"
8
+ require_relative "kernels/pixels"
9
+ require_relative "kernels/png"
10
+ require_relative "kernels/jpeg"
11
+ require_relative "kernels/ccitt"
12
+ require_relative "kernels/lzw"
13
+ require_relative "kernels/binarizer"
14
+ require_relative "kernels/detector"
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ # Optional native kernels from the qr_reader-native gem.
5
+ #
6
+ # qr_reader-native contains the files under lib/qr_reader/kernels compiled
7
+ # to C by Spinel. When it is installed (and QR_READER_NATIVE is not "0"),
8
+ # its entry points replace the pure-Ruby QrReaderKernels methods - but only
9
+ # if it was built from exactly the kernel sources of this qr_reader version
10
+ # (compared by digest). Otherwise everything keeps running as pure Ruby.
11
+ module NativeKernels
12
+ # Which native entries are used (measured with tmp/kernel_bench-style
13
+ # comparisons on real inputs; entries not listed stay pure Ruby):
14
+ # faster in every mode,
15
+ ALWAYS = %i[binarize_hybrid binarize_local_mean finder_scan ccitt_decode lzw_decode downscale
16
+ jpeg_render jpeg_info].freeze
17
+ # only faster than Ruby without YJIT (on par or slower with it),
18
+ WITHOUT_YJIT = %i[png_rows png_adam7 png_unpack_rows pixels_luma].freeze
19
+ # not used: JPEG entropy decoding compiled by the current Spinel slows
20
+ # down superlinearly with image size (native/README.md).
21
+ NEVER = %i[jpeg_luma jpeg_luma_keep].freeze
22
+
23
+ @active = false
24
+ @pure = {}
25
+
26
+ class << self
27
+ # True when native kernels are in use.
28
+ def active?
29
+ @active
30
+ end
31
+
32
+ # Why native kernels are not in use (nil when they are).
33
+ attr_reader :reason
34
+
35
+ # The original pure-Ruby kernel methods, by name (for comparisons).
36
+ def pure(name)
37
+ @pure.fetch(name.to_sym) { QrReaderKernels.method(name) }
38
+ end
39
+
40
+ # Loads and installs the native kernels if available; returns active?.
41
+ def install
42
+ return @active if @active
43
+
44
+ if ENV["QR_READER_NATIVE"] == "0"
45
+ @reason = "disabled by QR_READER_NATIVE=0"
46
+ return false
47
+ end
48
+ begin
49
+ require "qr_reader_native/qr_reader_native"
50
+ rescue LoadError => e
51
+ @reason = "qr_reader-native is not installed (#{e.message})"
52
+ return false
53
+ end
54
+
55
+ expected = digest(QrReaderNative.kernel_files)
56
+ if expected != QrReaderNative.kernels_digest
57
+ @reason = "qr_reader-native #{QrReaderNative.kernels_digest[0, 12]} was built from other kernel " \
58
+ "sources than qr_reader #{VERSION} (#{expected[0, 12] if expected}); using pure Ruby"
59
+ warn "qr_reader: #{@reason}" if $VERBOSE || ENV["QR_READER_NATIVE"] == "1"
60
+ return false
61
+ end
62
+
63
+ names = QrReaderNative.singleton_methods(false) - %i[kernels_digest kernel_files]
64
+ names.each do |name|
65
+ next unless QrReaderKernels.respond_to?(name)
66
+
67
+ if ALWAYS.include?(name) then replace(name)
68
+ elsif WITHOUT_YJIT.include?(name) then replace(name, unless_yjit: true)
69
+ end
70
+ end
71
+ @reason = nil
72
+ @active = true
73
+ end
74
+
75
+ private
76
+
77
+ def replace(name, unless_yjit: false)
78
+ pure = @pure[name] = QrReaderKernels.method(name)
79
+ native = QrReaderNative.method(name)
80
+ # Strings come back from the extension tagged UTF-8; kernels work on bytes.
81
+ if unless_yjit
82
+ QrReaderKernels.define_singleton_method(name) do |*args|
83
+ # YJIT may be enabled after loading (e.g. by Rails), so check per call.
84
+ NativeKernels.yjit? ? pure.call(*args) : NativeKernels.binary(native.call(*args))
85
+ end
86
+ else
87
+ QrReaderKernels.define_singleton_method(name) do |*args|
88
+ NativeKernels.binary(native.call(*args))
89
+ end
90
+ end
91
+ end
92
+
93
+ public
94
+
95
+ def yjit?
96
+ defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? true : false
97
+ end
98
+
99
+ # Result of a native entry with Strings re-tagged as binary (internal).
100
+ def binary(value)
101
+ case value
102
+ when String then value.force_encoding(Encoding::BINARY)
103
+ when Array then value.each { |v| v.force_encoding(Encoding::BINARY) if v.is_a?(String) }
104
+ else value
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ # SHA-256 over the given files (paths relative to lib/), nil if one is missing.
111
+ def digest(files)
112
+ require "digest"
113
+ lib = File.expand_path("..", __dir__)
114
+ sha = Digest::SHA256.new
115
+ files.each do |rel|
116
+ path = File.join(lib, rel)
117
+ return nil unless File.file?(path)
118
+
119
+ sha << rel << "\0" << File.binread(path) << "\0"
120
+ end
121
+ sha.hexdigest
122
+ end
123
+ end
124
+ end
125
+
126
+ # True when the native kernels of the qr_reader-native gem are in use.
127
+ def self.native?
128
+ NativeKernels.active?
129
+ end
130
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ # Orchestrates image loading, binarization, detection and decoding.
5
+ #
6
+ # Strategies run cheapest first and stop at the first decodable symbol
7
+ # (unless reading all codes), so the common case stays fast while hard
8
+ # images still get the expensive treatment when +try_harder+ is set.
9
+ #
10
+ # Large images (photos, page scans) are searched through a resolution
11
+ # pyramid, coarsest level first: a code that fills a good part of the image
12
+ # is found quickly at low resolution, a small code on a 600 DPI page scan is
13
+ # still found at full resolution.
14
+ class Reader
15
+ # The coarsest pyramid level is the largest power-of-two reduction whose
16
+ # longer side is still at least this many pixels.
17
+ PYRAMID_MIN_SIDE = 800
18
+ # The exhaustive (try_harder) strategies only run on pyramid levels up to
19
+ # this size: at full resolution of a 12 MP photo they cost seconds and
20
+ # mostly chase texture noise, while the fast strategies still run there.
21
+ HARD_MAX_SIDE = 2600
22
+ # Pixels at most this far from square (width / height) are used as they are.
23
+ SQUARE_TOLERANCE = 0.15
24
+ # Upper bound on alternative samplings tried per candidate.
25
+ MAX_ALTERNATIVES = 6
26
+
27
+ attr_reader :try_harder, :inverted
28
+
29
+ # try_harder: keep trying slower strategies when the fast ones find nothing.
30
+ # inverted: true, false or :auto - also look for light-on-dark symbols.
31
+ def initialize(try_harder: true, inverted: :auto)
32
+ @try_harder = try_harder
33
+ @inverted = inverted
34
+ end
35
+
36
+ # First decoded Result, or nil.
37
+ def read(input)
38
+ each_result(input, false) { |result| return result }
39
+ nil
40
+ end
41
+
42
+ # All decoded Results (distinct symbols, all pages), possibly empty.
43
+ def read_all(input)
44
+ results = []
45
+ each_result(input, true) { |result| results << result }
46
+ results
47
+ end
48
+
49
+ # Yields each distinct Result as soon as it is decoded. Returns an
50
+ # Enumerator when no block is given.
51
+ def scan(input, &block)
52
+ return enum_for(:scan, input) unless block
53
+
54
+ each_result(input, true, &block)
55
+ self
56
+ end
57
+
58
+ class << self
59
+ # Converts any supported input into an Image (the first page of
60
+ # multi-page documents).
61
+ def load_image(input)
62
+ return input if input.is_a?(Image)
63
+
64
+ bytes = input_bytes(input)
65
+ if bytes
66
+ # Formats.decode reads the first page of TIFF and PDF too; this also
67
+ # finds PDFs with junk before their header.
68
+ if container_format(bytes) == :pdf
69
+ Formats::PDF.each_image(bytes) { |image, _info| return image }
70
+ end
71
+ return Formats.decode(bytes)
72
+ end
73
+
74
+ if input.respond_to?(:width) && input.respond_to?(:height) && input.respond_to?(:pixels)
75
+ # ChunkyPNG-style canvas: pixels are 0xRRGGBBAA integers.
76
+ return Image.from_pixels(input.width, input.height, input.pixels.pack("N*"), :rgba)
77
+ end
78
+
79
+ raise ArgumentError, "unsupported input #{input.class}: expected a path, IO, image bytes or QrReader::Image"
80
+ end
81
+
82
+ # Encoded image bytes for paths, IOs and byte strings; nil for other objects.
83
+ def input_bytes(input)
84
+ if input.is_a?(String)
85
+ # A short String naming an existing file is a path, even if it
86
+ # starts like image data ("GIF89a.gif", "P5 scan.pgm").
87
+ short = input.bytesize <= 4096 && !input.include?("\0")
88
+ return File.binread(input) if short && File.file?(input)
89
+ return input.b if Formats.format_of(input) || container_format(input)
90
+ raise ImageError, "unrecognised image data" unless short
91
+
92
+ File.binread(input) # raises Errno::ENOENT etc. like File.read
93
+ elsif input.respond_to?(:to_path)
94
+ File.binread(input.to_path)
95
+ elsif input.respond_to?(:read)
96
+ data = input.read
97
+ raise ImageError, "IO returned no data" unless data
98
+
99
+ data.b
100
+ end
101
+ end
102
+
103
+ # :tiff or :pdf for multi-page containers, else nil.
104
+ def container_format(bytes)
105
+ head = bytes.byteslice(0, 4)
106
+ return :tiff if ["II*\0", "MM\0*", "II+\0", "MM\0+"].include?(head)
107
+ return :pdf if bytes.byteslice(0, 1024)&.include?("%PDF-")
108
+
109
+ nil
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # Yields decoded results (deduplicated) until the caller stops iterating.
116
+ def each_result(input, multi, &block)
117
+ @multi = multi
118
+ @emit = block
119
+ @seen = []
120
+ @y_scale = 1.0
121
+
122
+ if input.is_a?(Image)
123
+ scan_pyramid(input, nil)
124
+ return
125
+ end
126
+
127
+ bytes = self.class.input_bytes(input)
128
+ unless bytes
129
+ scan_pyramid(self.class.load_image(input), nil)
130
+ return
131
+ end
132
+
133
+ @y_scale = 1.0
134
+ case self.class.container_format(bytes)
135
+ when :tiff
136
+ # Pages that cannot be decoded are skipped.
137
+ Formats::TIFF.each_image(bytes) do |image, info|
138
+ return if scan_page(image, info) && !multi
139
+ end
140
+ when :pdf
141
+ # Plain JPEG page images come undecoded, for the reduced-size pass.
142
+ Formats::PDF.each_image(bytes, raw_jpeg: true) do |image, info|
143
+ found = if info[:format] == :jpeg
144
+ scan_jpeg_or_full(image, info[:page], info[:pixel_aspect])
145
+ else
146
+ scan_page(image, info)
147
+ end
148
+ return if found && !multi
149
+ end
150
+ else
151
+ if Formats.format_of(bytes) == :jpeg
152
+ scan_jpeg_or_full(bytes, nil, 1.0)
153
+ else
154
+ scan_pyramid(Formats.decode(bytes), nil)
155
+ end
156
+ end
157
+ end
158
+
159
+ # One page of a TIFF or PDF, resampled to square pixels if needed.
160
+ def scan_page(image, info)
161
+ image, @y_scale = square_pixels(image, info[:pixel_aspect])
162
+ scan_pyramid(image, info[:page])
163
+ ensure
164
+ @y_scale = 1.0
165
+ end
166
+
167
+ def scan_jpeg_or_full(bytes, page, aspect)
168
+ aspect ||= 1.0
169
+ if (scale = jpeg_prescale(bytes)) && (aspect - 1.0).abs < SQUARE_TOLERANCE
170
+ scan_jpeg(bytes, scale, page)
171
+ else
172
+ scan_page(Formats::JPEG.decode(bytes), page: page, pixel_aspect: aspect)
173
+ end
174
+ end
175
+
176
+ # Big JPEGs: the coarsest pyramid level comes straight from a reduced-size
177
+ # decode; the coefficients are kept, so the full resolution (only needed
178
+ # when that finds nothing) costs IDCTs but no second entropy decoding.
179
+ def scan_jpeg(bytes, scale, page)
180
+ @page = page
181
+ @tried = {}
182
+ small, coefficients = Formats::JPEG.decode_keeping_coefficients(bytes, scale: scale)
183
+ return true if run_strategies(small, scale.to_f, quick: true) && !@multi
184
+
185
+ small = nil
186
+ full = coefficients ? coefficients.image(scale: 1) : Formats::JPEG.decode(bytes)
187
+ coefficients = nil
188
+ scan_pyramid(full, page, skip_factor: scale)
189
+ end
190
+
191
+ # [image with square pixels, factor mapping its rows back to the original].
192
+ # Fax pages (204 x 98 dpi) have pixels about twice as tall as wide: their
193
+ # rows are repeated so that finder patterns are square again.
194
+ def square_pixels(image, aspect)
195
+ return [image, 1.0] if aspect.nil? || aspect >= 1.0 - SQUARE_TOLERANCE
196
+
197
+ height = (image.height / aspect).round
198
+ [image.stretch_rows(height), image.height.to_f / height]
199
+ end
200
+
201
+ # Reduced decode scale (2, 4 or 8) for big JPEGs, or nil.
202
+ def jpeg_prescale(bytes)
203
+ w, h = Formats::JPEG.dimensions(bytes)
204
+ factors = pyramid_factors(w > h ? w : h)
205
+ factors.size > 1 ? [factors.first, 8].min : nil
206
+ rescue Error
207
+ nil
208
+ end
209
+
210
+ # Power-of-two reduction factors, coarsest first, ending with 1.
211
+ def pyramid_factors(longest)
212
+ f = 1
213
+ f *= 2 while longest / (f * 2) >= PYRAMID_MIN_SIDE
214
+ factors = []
215
+ while f >= 1
216
+ factors << f
217
+ f /= 2
218
+ end
219
+ factors
220
+ end
221
+
222
+ # Quick strategies on every pyramid level (coarse to fine), then the
223
+ # expensive ones (coarse to fine). Returns true if anything was found.
224
+ def scan_pyramid(image, page, skip_factor: nil)
225
+ @page = page
226
+ @tried = {}
227
+ longest = image.width > image.height ? image.width : image.height
228
+ factors = pyramid_factors(longest)
229
+ levels = { 1 => image }
230
+ level = lambda do |f|
231
+ levels[f] ||= level.call(f / 2).downscale(2)
232
+ end
233
+
234
+ found = false
235
+ factors.each do |f|
236
+ next if f == skip_factor
237
+
238
+ @tried = {}
239
+ found = true if run_strategies(level.call(f), f.to_f, quick: true)
240
+ return true if found && !@multi
241
+ end
242
+ return found unless @try_harder
243
+
244
+ factors.each do |f|
245
+ next if longest / f > HARD_MAX_SIDE
246
+
247
+ @tried = {}
248
+ found = true if run_strategies(level.call(f), f.to_f, quick: false, skip_quick: true)
249
+ return true if found && !@multi
250
+ end
251
+ found
252
+ end
253
+
254
+ # quick: normal hybrid binarization + detection (both polarities).
255
+ # Otherwise exhaustive row scanning and the global-histogram binarizer
256
+ # (skip_quick: the quick passes already ran on this image).
257
+ def run_strategies(image, factor, quick:, skip_quick: false)
258
+ polarities = case @inverted
259
+ when true then [true]
260
+ when false then [false]
261
+ else [false, true]
262
+ end
263
+ passes = []
264
+ polarities.each { |inv| passes << [:hybrid, inv, false] } unless skip_quick
265
+ unless quick
266
+ polarities.each do |inv|
267
+ passes << [:hybrid, inv, true]
268
+ passes << [:global, inv, true]
269
+ end
270
+ end
271
+
272
+ binarized = {}
273
+ found = false
274
+ passes.each do |kind, inv, harder|
275
+ bits = binarized[[kind, inv]] ||= begin
276
+ base = binarized[[kind, false]] ||=
277
+ kind == :hybrid ? Binarizer.hybrid(image) : Binarizer.global_histogram(image)
278
+ inv ? base.invert : base
279
+ end
280
+
281
+ if (!harder || kind == :global) && (candidate = Detector.detect_pure(bits)) && try_candidate(candidate, factor)
282
+ found = true
283
+ return true unless @multi
284
+ end
285
+
286
+ Detector.new(bits, image: image).each_candidate(try_harder: harder, multi: @multi) do |cand|
287
+ next unless try_candidate(cand, factor)
288
+
289
+ found = true
290
+ return true unless @multi
291
+ end
292
+ end
293
+ found
294
+ end
295
+
296
+ # Decodes a candidate (and its alternatives); emits and returns the
297
+ # result, or nil. Candidates already tried at the same place are skipped.
298
+ def try_candidate(candidate, factor)
299
+ key = candidate_key(candidate)
300
+ return nil if key && @tried[key]
301
+
302
+ @tried[key] = true if key
303
+ result = Decoder.decode(candidate.bits)
304
+ unless result
305
+ candidate.alternatives.each_with_index do |bits, i|
306
+ break if i >= MAX_ALTERNATIVES
307
+
308
+ result = Decoder.decode(bits)
309
+ break if result
310
+ end
311
+ end
312
+ return nil unless result
313
+
314
+ y_factor = factor * @y_scale
315
+ points = candidate.points&.map { |x, y| [x * factor, y * y_factor] }
316
+ # A mirrored symbol was decoded from the transposed grid: its top-right
317
+ # and bottom-left corners are the grid's bottom-left and top-right.
318
+ points = [points[0], points[3], points[2], points[1]] if points && result.mirrored
319
+ result = result.with(points: points, page: @page)
320
+ return result if @seen.any? { |other| same_symbol?(other, result) }
321
+
322
+ @seen << result
323
+ @emit.call(result)
324
+ result
325
+ end
326
+
327
+ def candidate_key(candidate)
328
+ pts = candidate.points
329
+ return nil unless pts
330
+
331
+ [candidate.bits.width] + pts.flat_map { |x, y| [(x / 2.0).round, (y / 2.0).round] }
332
+ end
333
+
334
+ # Two results describe the same symbol if they are on the same page, their
335
+ # payloads match and their centres are close (or positions are unknown).
336
+ def same_symbol?(a, b)
337
+ return false unless a.page == b.page && a.raw_bytes == b.raw_bytes
338
+ return true unless a.points && b.points
339
+
340
+ ax = a.points.sum { |p| p[0] } / 4.0
341
+ ay = a.points.sum { |p| p[1] } / 4.0
342
+ bx = b.points.sum { |p| p[0] } / 4.0
343
+ by = b.points.sum { |p| p[1] } / 4.0
344
+ size = Math.hypot(a.points[1][0] - a.points[0][0], a.points[1][1] - a.points[0][1])
345
+ Math.hypot(ax - bx, ay - by) < [size / 2, 10].max
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ # Structured Append header (ISO/IEC 18004 §8.3.12): this symbol is
5
+ # +index+ (0-based) of +count+ symbols; +parity+ is the XOR of all data bytes.
6
+ StructuredAppend = Data.define(:index, :count, :parity)
7
+
8
+ # One decoded data segment. +mode+ is :numeric, :alphanumeric, :byte,
9
+ # :kanji or :hanzi; +text+ is the decoded UTF-8 string; +eci+ the ECI
10
+ # designator in effect (nil if none).
11
+ Segment = Data.define(:mode, :text, :eci)
12
+
13
+ # A decoded QR code.
14
+ #
15
+ # text - decoded content as a UTF-8 String
16
+ # raw_bytes - raw payload bytes (binary String) before character set decoding
17
+ # version - symbol version, 1..40
18
+ # ec_level - error correction level: :l, :m, :q or :h (as in rqrcode)
19
+ # mask - data mask pattern, 0..7
20
+ # points - [[x, y], ...] corners in image pixel coordinates:
21
+ # top-left, top-right, bottom-right, bottom-left (nil if unknown)
22
+ # mirrored - true if the symbol was read mirrored
23
+ # errors_corrected - number of codewords repaired by Reed-Solomon
24
+ # eci - first ECI designator found, or nil
25
+ # fnc1 - nil, :first or [:second, application_indicator]
26
+ # structured_append - StructuredAppend or nil
27
+ # symbology_identifier - AIM identifier, e.g. "]Q1"
28
+ # segments - Array of Segment
29
+ # page - 0-based page/image index for multi-page inputs (TIFF, PDF), else nil
30
+ Result = Data.define(:text, :raw_bytes, :version, :ec_level, :mask, :points, :mirrored,
31
+ :errors_corrected, :eci, :fnc1, :structured_append,
32
+ :symbology_identifier, :segments, :page) do
33
+ def initialize(text:, raw_bytes:, version:, ec_level:, mask:, points: nil, mirrored: false,
34
+ errors_corrected: 0, eci: nil, fnc1: nil, structured_append: nil,
35
+ symbology_identifier: "]Q1", segments: [], page: nil)
36
+ super
37
+ end
38
+
39
+ alias_method :to_s, :text
40
+
41
+ def mirrored?
42
+ mirrored
43
+ end
44
+
45
+ def structured_append?
46
+ !structured_append.nil?
47
+ end
48
+ end
49
+ end