qr_reader 0.0.2 → 0.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 676cc0ecf84f68ef07fc1b05b6c473c301eb4b82ee8ab615dc30bb036030834f
4
- data.tar.gz: c7b893b1828be74bb1f4feed942b325b34b8183dc7c318919fb891f84b64b24d
3
+ metadata.gz: 95bfd13bd094f9f117def4165ec4af69923fff4c6b45dde7140a633ecf3fd092
4
+ data.tar.gz: 33b8813834b0bffb325c3951948db515d7a2108f55e0487becfd63e20bf33fd5
5
5
  SHA512:
6
- metadata.gz: '095f7dead62ad0f612d662155e30f2ff11b8b5120d8e74114f8ea55fca78286e5609994794e69b596f055b1c52cb101607ad36ad1434536505428e48c94ee2a1'
7
- data.tar.gz: f9b0da7897ad79f9162f1e16fafc592d36421dab0521354110685a9f9e11a57720ec8e9173915b969f023d6c9a37fdd2cf68f14db018c3c304b0f5c552540304
6
+ metadata.gz: 3341a29636745a3fe5712e9e3c402b77eae8c4f56ee8fef45a40f2c65e1e4b8c86974e59a18a29c9142f2925ab3643d647c70089a0fbe5cbb7440c35fb956c78
7
+ data.tar.gz: 4790cd51e562e400bdb0e4ef080883ff36505412797447b39b0d8c00e575c42ea7cd4190f7725cd82a70849f5b8ead9f40f5af5bc482ee8025bd578b2b1880d3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.0.3] - 2026-10-16
6
+
7
+ - **Breaking:** a `String` input is always a file path, never image data;
8
+ pass image bytes as an IO (`QrReader.decode(StringIO.new(bytes))`, or the
9
+ upload's `Tempfile` directly), like `File.read` and `PDF::Reader.new`.
10
+ Before, Strings were sniffed: bytes that spelled the path of an existing
11
+ file (e.g. an uploaded "image" containing `/etc/...` or the path of a
12
+ server-side image with a QR code) were read from disk, and the error for
13
+ unrecognised data echoed the file's first bytes. A `String` that looks like
14
+ image data now raises `ArgumentError` with that hint.
15
+ - PDF: a document that draws no image and no code-like vector group (e.g. a
16
+ text-only invoice page) has no codes: `decode_all` returns `[]` and
17
+ `decode` `nil` instead of raising `UnsupportedImageError` "PDF contains no
18
+ raster images". PDFs whose images all fail to decode still raise.
19
+ - PDF: data behind a `%PDF-` header without a page tree raises `ImageError`
20
+ "corrupt PDF (no pages found)" instead of "PDF contains no raster images".
21
+ - ruby.wasm: PNG and PDF (Flate) images no longer crash the VM ("null
22
+ function or function signature mismatch"). Zlib is no longer called with a
23
+ block, because Ruby code that allocates inside zlib's callback crashes
24
+ ruby.wasm; data is inflated in 16 KB slices instead.
25
+ - TruffleRuby: Shift_JIS (Kanji) and EUC-KR payloads are converted correctly;
26
+ `String#encode` on the whole text mis-converted some of them there (and
27
+ could raise on Shift_JIS). They are converted one character at a time,
28
+ with the same result as before on MRI.
29
+ - JRuby: `Decoder.diagnose` no longer raises `TypeError` when no attempt
30
+ succeeds.
31
+ - `QrReader.read!`: like `read`, but raises `NotFoundError` when no code is
32
+ found (the `Result` counterpart of `decode!`).
33
+ - qr_reader-native: the check that the native kernels were built from the
34
+ same sources now compares code tokens, not file bytes, so comments and
35
+ layout in the kernel files no longer switch the native kernels off.
36
+ - Releases (qr_reader and qr_reader-native) are signed with Sigstore and
37
+ pushed with their attestations, so RubyGems.org shows their provenance.
38
+ - Tested on JRuby 10 and TruffleRuby 25 as well as MRI 3.2–4.0; the tests'
39
+ time limits are scaled on JRuby and TruffleRuby (JIT warmup).
40
+
5
41
  ## [0.0.2] - 2026-10-13
6
42
 
7
43
  - JPEG 2000: pure-Ruby decoder for all of Part 1 (`Formats::JPEG2000`), used
data/README.md CHANGED
@@ -21,6 +21,9 @@ code.points # => [[x, y], ...] corners: top-left, top-right, bottom-rig
21
21
  QrReader.scan("scans.tiff") do |code| # lazily, page by page
22
22
  puts "page #{code.page}: #{code.text}"
23
23
  end
24
+
25
+ QrReader.decode(params[:file].tempfile) # an upload: any IO works
26
+ QrReader.decode(StringIO.new(bytes)) # image bytes you already hold
24
27
  ```
25
28
 
26
29
  ## Installation
@@ -34,6 +37,10 @@ no native libraries — it also runs on Rubies without C extensions. Enable YJIT
34
37
  for the best speed (`RUBY_YJIT_ENABLE=1`, `ruby --yjit`, or
35
38
  `RubyVM::YJIT.enable` at boot).
36
39
 
40
+ It runs on every major Ruby implementation. The full test suite passes on MRI
41
+ (CRuby) 3.2, 3.3, 3.4 and 4.0, JRuby 10 and TruffleRuby 25, and ruby.wasm
42
+ (Ruby 4.0) decodes PNGs and PDFs.
43
+
37
44
  ### Optional: native kernels (qr_reader-native)
38
45
 
39
46
  The hot loops (image decoding, binarization, pattern scanning) are written as
@@ -52,19 +59,27 @@ without the gem — or with `QR_READER_NATIVE=0` — the same code runs as Ruby.
52
59
 
53
60
  ## Usage
54
61
 
62
+ The `decode` methods return the text of a code; the `read` methods return a
63
+ `QrReader::Result` with the text plus metadata (version, position, page, ...).
64
+
55
65
  | method | returns |
56
66
  |---|---|
57
67
  | `QrReader.decode(input)` | text of the first code, or `nil` |
58
68
  | `QrReader.decode!(input)` | text of the first code, or raises `QrReader::NotFoundError` |
59
69
  | `QrReader.decode_all(input)` | texts of all codes (all pages) |
60
70
  | `QrReader.read(input)` | first code as a `QrReader::Result`, or `nil` |
71
+ | `QrReader.read!(input)` | first code as a `QrReader::Result`, or raises `QrReader::NotFoundError` |
61
72
  | `QrReader.read_all(input)` | all codes as `QrReader::Result`s |
62
73
  | `QrReader.scan(input) { \|result\| }` | yields codes as they are found; an `Enumerator` without a block |
63
74
 
64
- `input` can be a file path (`String` or `Pathname`), an `IO`, a binary
65
- `String` with the encoded image, a `QrReader::Image`, or any object with
66
- `width`, `height` and `pixels` (e.g. a `ChunkyPNG::Image`). Raw pixel buffers
67
- can be wrapped with `QrReader::Image.from_pixels(width, height, bytes, :rgba)`
75
+ `input` can be a file path (`String` or `Pathname`), an `IO` with the encoded
76
+ image (a `File`, an upload's `Tempfile`, `StringIO.new(bytes)`), a
77
+ `QrReader::Image`, or any object with `width`, `height` and `pixels` (e.g. a
78
+ `ChunkyPNG::Image`). As with `File.read` or `PDF::Reader.new`, a `String` is
79
+ always a path and never image data: image bytes go in as an IO. A `String`
80
+ that looks like image data raises `ArgumentError` instead of being guessed at,
81
+ so bytes from a client can never be taken for a path on the server. Raw pixel
82
+ buffers can be wrapped with `QrReader::Image.from_pixels(width, height, bytes, :rgba)`
68
83
  (also `:rgb`, `:bgr`, `:bgra`, `:gray`, ...).
69
84
 
70
85
  Options (for every method, and `QrReader::Reader.new` for reuse):
@@ -86,9 +101,11 @@ A `QrReader::Result` is an immutable `Data` object:
86
101
  | `mirrored?`, `errors_corrected` | read mirrored? / number of repaired codewords |
87
102
  | `eci`, `segments`, `structured_append`, `fnc1`, `symbology_identifier` | encoding details |
88
103
 
89
- Errors: `QrReader::ImageError` (unreadable image data),
90
- `QrReader::UnsupportedImageError` (e.g. WebP, HEIC, encrypted PDF),
91
- `Errno::ENOENT` for missing files.
104
+ Errors: `QrReader::ImageError` (unreadable image data, corrupt PDF),
105
+ `QrReader::UnsupportedImageError` (e.g. WebP, HEIC, encrypted PDF, a PDF
106
+ whose images all fail to decode), `Errno::ENOENT` for missing files, `ArgumentError` for image bytes passed
107
+ as a `String` (use `StringIO.new(bytes)`). A PDF that draws nothing that could
108
+ be a code (a text-only page) is not an error: it just has no codes.
92
109
 
93
110
  ### Command line
94
111
 
@@ -217,13 +234,16 @@ SPINEL_HOME=~/workspace/spinel bundle exec rake native:generate
217
234
  bundle exec rake native:compile native:test
218
235
  ```
219
236
 
220
- CI fails when the generated sources are out of date.
237
+ CI fails when the generated sources are out of date. Only code counts:
238
+ comments, blank lines and spacing in the kernel files can change without
239
+ regenerating.
221
240
 
222
241
  ### Releasing
223
242
 
224
243
  Releases are published from GitHub Actions with RubyGems
225
244
  [trusted publishing](https://guides.rubygems.org/trusted-publishing/) — no API
226
- key is stored anywhere. One tag releases both gems, qr_reader and
245
+ key is stored anywhere — and each gem carries a Sigstore attestation, so
246
+ RubyGems.org shows which repository, workflow and commit built it. One tag releases both gems, qr_reader and
227
247
  qr_reader-native, at the same version. Bump `QrReader::VERSION`, update
228
248
  `CHANGELOG.md`, then:
229
249
 
data/exe/qr_reader CHANGED
@@ -31,7 +31,7 @@ end
31
31
  status = 0
32
32
  reader = QrReader::Reader.new(try_harder: options[:try_harder], inverted: options[:inverted])
33
33
  files.each do |file|
34
- input = file == "-" ? $stdin.binmode.read : file
34
+ input = file == "-" ? $stdin.binmode : file
35
35
  results = begin
36
36
  options[:all] ? reader.read_all(input) : [reader.read(input)].compact
37
37
  rescue QrReader::Error, SystemCallError => e
@@ -87,7 +87,9 @@ module QrReader
87
87
  # Windows-31J one by one, so the rest keep their JIS X 0208 mapping.
88
88
  # Ruby maps 0x815C to U+2014 EM DASH; ZXing and zxing-cpp (like
89
89
  # Unicode's SHIFTJIS.TXT and Windows-31J) to U+2015 HORIZONTAL BAR.
90
- bytes.encode(UTF_8, SHIFT_JIS, invalid: :replace, fallback: WINDOWS_31J_FALLBACK).tr("\u2014", "\u2015")
90
+ by_char(bytes, SHIFT_JIS, WINDOWS_31J_FALLBACK).tr("\u2014", "\u2015")
91
+ when Encoding::EUC_KR
92
+ by_char(bytes, Encoding::EUC_KR)
91
93
  when GB18030
92
94
  # Ruby uses the GB 18030-2000 private-use mappings for 11 GBK codes
93
95
  # that GB 18030-2005 (and zxing-cpp) map to standard characters.
@@ -100,6 +102,22 @@ module QrReader
100
102
  end
101
103
  end
102
104
 
105
+ # Converts +bytes+ to UTF-8 one character at a time; invalid characters
106
+ # become U+FFFD, characters +encoding+ cannot map go to +fallback+ (or
107
+ # U+FFFD). For Shift_JIS and EUC-KR this gives exactly what String#encode
108
+ # with invalid/undef: :replace gives on MRI, but String#encode on the
109
+ # whole string mis-converts some of their input on TruffleRuby 25
110
+ # (Shift_JIS even when valid, and it can return invalid UTF-8).
111
+ def by_char(bytes, encoding, fallback = nil)
112
+ bytes.dup.force_encoding(encoding).each_char.map do |char|
113
+ next "\uFFFD" unless char.valid_encoding?
114
+
115
+ char.encode(UTF_8)
116
+ rescue Encoding::UndefinedConversionError
117
+ fallback ? fallback.call(char) : "\uFFFD"
118
+ end.join
119
+ end
120
+
103
121
  # ZXing's Latin-1 vs. Shift_JIS heuristics (bytes known not to be UTF-8).
104
122
  def guess_latin1_or_shift_jis(bytes)
105
123
  can_be_latin1 = true
@@ -63,7 +63,9 @@ module QrReader
63
63
  result = run(bits, bit_matrix.width, try_mirrored, diags)
64
64
  return diags.find { |diag| diag[:result].equal?(result) } if result
65
65
 
66
- diags.max_by.with_index { |diag, i| [STATUSES.index(diag[:status]), -i] }
66
+ # each_with_index.max_by, not max_by.with_index: JRuby passes the latter's
67
+ # block one [diag, i] Array.
68
+ diags.each_with_index.max_by { |diag, i| [STATUSES.index(diag[:status]), -i] }.first
67
69
  end
68
70
 
69
71
  private
@@ -9,6 +9,6 @@ module QrReader
9
9
  # The image format was recognised but is not supported (e.g. arithmetic-coded JPEG).
10
10
  class UnsupportedImageError < ImageError; end
11
11
 
12
- # Raised by QrReader.decode! when no QR code could be read.
12
+ # Raised by QrReader.decode! and QrReader.read! when no QR code could be read.
13
13
  class NotFoundError < Error; end
14
14
  end
@@ -129,6 +129,10 @@ module QrReader
129
129
 
130
130
  document = guard { Document.new(binary(bytes)) }
131
131
  pages = guard { document.pages }
132
+ # Every PDF has at least one page; none found means the document
133
+ # structure is broken (or this is not really a PDF).
134
+ raise ImageError, "corrupt PDF (no pages found)" if pages.empty?
135
+
132
136
  failures = []
133
137
  count = 0
134
138
  pages.each_with_index do |page, index|
@@ -156,7 +160,9 @@ module QrReader
156
160
  dpi: [dpi, dpi], vector: true }
157
161
  end
158
162
  end
159
- raise no_images(failures) if count.zero?
163
+ # A PDF that draws nothing readable (text only) simply has no
164
+ # images; one whose images all failed to decode is an error.
165
+ raise no_images(failures) if count.zero? && !failures.empty?
160
166
 
161
167
  nil
162
168
  end
@@ -217,8 +223,6 @@ module QrReader
217
223
  end
218
224
 
219
225
  def no_images(failures)
220
- return UnsupportedImageError.new("PDF contains no raster images") if failures.empty?
221
-
222
226
  reason = failures.first.message
223
227
  if failures.any?(UnsupportedImageError)
224
228
  UnsupportedImageError.new("PDF contains no decodable raster image (#{reason})")
@@ -1500,6 +1504,8 @@ module QrReader
1500
1504
  NONZERO = /[^\x00]/n
1501
1505
  HEX_DIGITS = "^0-9A-Fa-f"
1502
1506
  WHITESPACE = "\x00\t\n\f\r "
1507
+ # Compressed bytes per Zlib::Inflate#inflate call (see inflate_with).
1508
+ INFLATE_SLICE = 16_384
1503
1509
 
1504
1510
  class << self
1505
1511
  # Applies +filters+ in order; the output of the last one is cut
@@ -1542,14 +1548,19 @@ module QrReader
1542
1548
 
1543
1549
  # Inflated data (partial for truncated or damaged streams), nil if
1544
1550
  # nothing could be inflated.
1551
+ # The data is fed in slices instead of through the block form of
1552
+ # Zlib::Inflate#inflate: Ruby code running inside zlib's callback
1553
+ # (allocating, collecting garbage) crashes ruby.wasm. The slices
1554
+ # also bound how far the output can run past +limit+.
1545
1555
  def inflate_with(zstream, data, limit)
1546
1556
  out = String.new(encoding: Encoding::BINARY)
1547
1557
  full = false
1558
+ pos = 0
1548
1559
  begin
1549
- zstream.inflate(data) do |chunk|
1550
- out << chunk
1560
+ while pos < data.bytesize && !full
1561
+ out << zstream.inflate(data.byteslice(pos, INFLATE_SLICE))
1562
+ pos += INFLATE_SLICE
1551
1563
  full = out.bytesize >= limit
1552
- break if full
1553
1564
  end
1554
1565
  rescue Zlib::Error
1555
1566
  failed = true
@@ -40,7 +40,7 @@ module QrReader
40
40
 
41
41
  # Compressed bytes fed to zlib at a time (bounds the work done for
42
42
  # zip bombs: the output of one slice is at most ~66 MB, streamed).
43
- INFLATE_SLICE = 1 << 16
43
+ INFLATE_SLICE = 16_384
44
44
  # Inflated bytes collected before a kernel call (bounds memory).
45
45
  KERNEL_SLICE = 1 << 20
46
46
 
@@ -268,8 +268,11 @@ module QrReader
268
268
  end
269
269
 
270
270
  # Yields the inflated IDAT payload in pieces, until the consumer
271
- # sets @enough.
272
- def each_inflated(&block)
271
+ # sets @enough. Not through the block form of Zlib::Inflate#inflate:
272
+ # Ruby code running inside zlib's callback (allocating, collecting
273
+ # garbage) crashes ruby.wasm, so each slice's output is yielded after
274
+ # the C call has returned.
275
+ def each_inflated
273
276
  zstream = Zlib::Inflate.new
274
277
  @idat.each do |pos, length|
275
278
  stop = pos + length
@@ -277,14 +280,17 @@ module QrReader
277
280
  return if @enough
278
281
 
279
282
  n = [stop - pos, INFLATE_SLICE].min
280
- zstream.inflate(@bytes.byteslice(pos, n), &block)
283
+ data = zstream.inflate(@bytes.byteslice(pos, n))
281
284
  pos += n
285
+ yield data unless data.empty?
282
286
  end
283
287
  end
284
288
  rescue Zlib::Error => e
285
- # Data inflated before the error was used; what is missing is
289
+ # Data inflated before the error is used; what is missing is
286
290
  # reported as truncation by the caller.
287
291
  @zlib_error = e
292
+ rest = zstream.flush_next_out.to_s
293
+ yield rest unless rest.empty? || @enough
288
294
  ensure
289
295
  if zstream
290
296
  zstream.reset # an unfinished stream would warn on close
@@ -100,7 +100,7 @@ module QrReader
100
100
 
101
101
  def first_pdf_image(bytes)
102
102
  PDF.each_image(bytes) { |image, _info| return image }
103
- raise UnsupportedImageError, "PDF contains no decodable raster image"
103
+ raise UnsupportedImageError, "PDF contains no raster image"
104
104
  end
105
105
 
106
106
  def unrecognized(bytes)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ # Fingerprint of the kernel sources that qr_reader-native was compiled from
5
+ # (stored in the native gem by script/native_generate.rb, compared by
6
+ # NativeKernels at load time).
7
+ #
8
+ # It hashes the Ruby tokens rather than the file bytes, so comments, blank
9
+ # lines and spacing can change without switching the native kernels off;
10
+ # any change to the code itself still does. "# spinel-sample:" comments
11
+ # count as code: they give Spinel the argument types.
12
+ module KernelDigest
13
+ # Tokens that carry no code: spacing, comments, =begin/=end blocks.
14
+ SKIPPED = %i[on_sp on_ignored_sp on_comment on_embdoc_beg on_embdoc on_embdoc_end].freeze
15
+ # A newline, whether Ruby treats it as a statement end or not.
16
+ LINE_BREAKS = %i[on_nl on_ignored_nl].freeze
17
+ SPINEL_COMMENT = /\A#\s*spinel-/
18
+ LINE_BREAK = "\n"
19
+
20
+ module_function
21
+
22
+ # SHA-256 over +files+ (paths relative to +lib+), nil if one is missing.
23
+ def digest(files, lib)
24
+ require "digest"
25
+ sha = Digest::SHA256.new
26
+ files.each do |rel|
27
+ path = File.join(lib, rel)
28
+ return nil unless File.file?(path)
29
+
30
+ sha << rel << "\0"
31
+ tokens(File.read(path, encoding: Encoding::UTF_8)).each { |token| sha << token << "\0" }
32
+ end
33
+ sha.hexdigest
34
+ end
35
+
36
+ # The code tokens of +source+ as "type\0text" Strings. Every run of line
37
+ # breaks (newlines, blank lines, comments, which end in the newline they
38
+ # take from Ripper) becomes one LINE_BREAK, so a trailing comment or an
39
+ # extra blank line does not change the result but joining two
40
+ # statements into one line does.
41
+ def tokens(source)
42
+ require "ripper"
43
+ out = []
44
+ Ripper.lex(source).each do |_position, type, text, _state|
45
+ if type == :on_comment && text.match?(SPINEL_COMMENT)
46
+ out << "spinel\0#{text.strip}"
47
+ line_break(out)
48
+ elsif LINE_BREAKS.include?(type) || (SKIPPED.include?(type) && text.end_with?("\n"))
49
+ line_break(out)
50
+ elsif !SKIPPED.include?(type)
51
+ out << "#{type}\0#{text}"
52
+ end
53
+ end
54
+ out.pop if out.last == LINE_BREAK
55
+ out
56
+ end
57
+
58
+ def line_break(out)
59
+ out << LINE_BREAK unless out.empty? || out.last == LINE_BREAK
60
+ end
61
+ end
62
+ end
@@ -52,7 +52,8 @@ module QrReader
52
52
  return false
53
53
  end
54
54
 
55
- expected = digest(QrReaderNative.kernel_files)
55
+ require_relative "kernel_digest"
56
+ expected = KernelDigest.digest(QrReaderNative.kernel_files, File.expand_path("..", __dir__))
56
57
  if expected != QrReaderNative.kernels_digest
57
58
  @reason = "qr_reader-native #{QrReaderNative.kernels_digest[0, 12]} was built from other kernel " \
58
59
  "sources than qr_reader #{VERSION} (#{expected[0, 12] if expected}); using pure Ruby"
@@ -104,22 +105,6 @@ module QrReader
104
105
  else value
105
106
  end
106
107
  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
108
  end
124
109
  end
125
110
 
@@ -36,16 +36,21 @@ module QrReader
36
36
  @inverted = inverted
37
37
  end
38
38
 
39
+ # The input is a file path (String or Pathname), an IO with encoded image
40
+ # bytes (a File, StringIO.new(bytes), an upload's Tempfile), a
41
+ # QrReader::Image or a ChunkyPNG-style canvas. A String is always a path,
42
+ # never image data.
43
+
39
44
  # First decoded Result, or nil.
40
45
  def read(input)
41
- each_result(input, false) { |result| return result }
46
+ each_result(self.class.source(input), false) { |result| return result }
42
47
  nil
43
48
  end
44
49
 
45
50
  # All decoded Results (distinct symbols, all pages), possibly empty.
46
51
  def read_all(input)
47
52
  results = []
48
- each_result(input, true) { |result| results << result }
53
+ each_result(self.class.source(input), true) { |result| results << result }
49
54
  results
50
55
  end
51
56
 
@@ -54,52 +59,43 @@ module QrReader
54
59
  def scan(input, &block)
55
60
  return enum_for(:scan, input) unless block
56
61
 
57
- each_result(input, true, &block)
62
+ each_result(self.class.source(input), true, &block)
58
63
  self
59
64
  end
60
65
 
61
66
  class << self
62
- # Converts any supported input into an Image (the first page of
63
- # multi-page documents).
67
+ # Converts any supported input (see #read) into an Image (the first
68
+ # page of multi-page documents).
64
69
  def load_image(input)
65
- return input if input.is_a?(Image)
66
-
67
- bytes = input_bytes(input)
68
- if bytes
69
- # Formats.decode reads the first page of TIFF and PDF too; this also
70
- # finds PDFs with junk before their header.
71
- if container_format(bytes) == :pdf
72
- Formats::PDF.each_image(bytes) { |image, _info| return image }
73
- end
74
- return Formats.decode(bytes)
75
- end
70
+ source = source(input)
71
+ return source if source.is_a?(Image)
76
72
 
77
- if input.respond_to?(:width) && input.respond_to?(:height) && input.respond_to?(:pixels)
78
- # ChunkyPNG-style canvas: pixels are 0xRRGGBBAA integers.
79
- return Image.from_pixels(input.width, input.height, input.pixels.pack("N*"), :rgba)
73
+ # Formats.decode reads the first page of TIFF and PDF too; this also
74
+ # finds PDFs with junk before their header.
75
+ if container_format(source) == :pdf
76
+ Formats::PDF.each_image(source) { |image, _info| return image }
80
77
  end
81
-
82
- raise ArgumentError, "unsupported input #{input.class}: expected a path, IO, image bytes or QrReader::Image"
78
+ Formats.decode(source)
83
79
  end
84
80
 
85
- # Encoded image bytes for paths, IOs and byte strings; nil for other objects.
86
- def input_bytes(input)
87
- if input.is_a?(String)
88
- # A short String naming an existing file is a path, even if it
89
- # starts like image data ("GIF89a.gif", "P5 scan.pgm").
90
- short = input.bytesize <= 4096 && !input.include?("\0")
91
- return File.binread(input) if short && File.file?(input)
92
- return input.b if Formats.format_of(input) || container_format(input)
93
- raise ImageError, "unrecognised image data" unless short
94
-
95
- File.binread(input) # raises Errno::ENOENT etc. like File.read
96
- elsif input.respond_to?(:to_path)
97
- File.binread(input.to_path)
98
- elsif input.respond_to?(:read)
99
- data = input.read
100
- raise ImageError, "IO returned no data" unless data
101
-
102
- data.b
81
+ # The input as encoded image bytes (a binary String) or an Image.
82
+ def source(input)
83
+ case input
84
+ when Image then input
85
+ when String then path_bytes(input)
86
+ else
87
+ # IOs first: since Ruby 3.2 every IO has #to_path ($stdin's is "<STDIN>").
88
+ if input.respond_to?(:read)
89
+ data = input.read or raise ImageError, "IO returned no data"
90
+ data.b
91
+ elsif input.respond_to?(:to_path)
92
+ File.binread(input.to_path)
93
+ elsif input.respond_to?(:width) && input.respond_to?(:height) && input.respond_to?(:pixels)
94
+ # ChunkyPNG-style canvas: pixels are 0xRRGGBBAA integers.
95
+ Image.from_pixels(input.width, input.height, input.pixels.pack("N*"), :rgba)
96
+ else
97
+ raise ArgumentError, "unsupported input #{input.class}: expected a file path, an IO or a QrReader::Image"
98
+ end
103
99
  end
104
100
  end
105
101
 
@@ -111,11 +107,27 @@ module QrReader
111
107
 
112
108
  nil
113
109
  end
110
+
111
+ private
112
+
113
+ # A positional String is a path and nothing else. Image bytes passed by
114
+ # mistake fail here instead of being read (or worse: bytes that spell a
115
+ # path being read as one), so the mix-up shows up on the first try.
116
+ def path_bytes(path)
117
+ return File.binread(path) if !path.include?("\0") && File.file?(path)
118
+ if path.include?("\0") || Formats.format_of(path) || container_format(path)
119
+ raise ArgumentError, "expected a file path, got a String that looks like image data " \
120
+ "(#{path.bytesize} bytes); pass image bytes as an IO: StringIO.new(bytes)"
121
+ end
122
+
123
+ File.binread(path) # raises Errno::ENOENT etc. like File.read
124
+ end
114
125
  end
115
126
 
116
127
  private
117
128
 
118
- # Yields decoded results (deduplicated) until the caller stops iterating.
129
+ # Yields decoded results (deduplicated) until the caller stops iterating;
130
+ # +input+ is a #source (encoded bytes or an Image).
119
131
  def each_result(input, multi, &block)
120
132
  @multi = multi
121
133
  @emit = block
@@ -127,13 +139,7 @@ module QrReader
127
139
  return
128
140
  end
129
141
 
130
- bytes = self.class.input_bytes(input)
131
- unless bytes
132
- scan_pyramid(self.class.load_image(input), nil)
133
- return
134
- end
135
-
136
- @y_scale = 1.0
142
+ bytes = input
137
143
  case self.class.container_format(bytes)
138
144
  when :tiff
139
145
  # Pages that cannot be decoded are skipped.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QrReader
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.3"
5
5
  end
data/lib/qr_reader.rb CHANGED
@@ -24,9 +24,14 @@ require_relative "qr_reader/native_kernels"
24
24
  # QrReader.read("label.png") # => #<data QrReader::Result text=..., version=3, ...>
25
25
  # QrReader.scan("scans.tiff") { |code| puts "page #{code.page}: #{code.text}" }
26
26
  #
27
- # Every method accepts a file path (String or Pathname), an IO, encoded image
28
- # bytes (PNG, JPEG, JPEG 2000, GIF, BMP, PNM, TIFF, PDF), a QrReader::Image, or a
29
- # ChunkyPNG-style canvas (#width, #height, #pixels), plus these options:
27
+ # Every method takes a file path (String or Pathname), an IO with encoded
28
+ # image bytes (PNG, JPEG, JPEG 2000, GIF, BMP, PNM, TIFF, PDF), a
29
+ # QrReader::Image, or a ChunkyPNG-style canvas (#width, #height, #pixels). A
30
+ # String is always a path, never image data; wrap bytes in an IO:
31
+ #
32
+ # QrReader.decode(StringIO.new(bytes))
33
+ #
34
+ # Options:
30
35
  #
31
36
  # try_harder: true - fall back to slower strategies when the fast ones find nothing
32
37
  # inverted: :auto - also look for light-on-dark codes (true: only those, false: never)
@@ -52,6 +57,11 @@ module QrReader
52
57
  Reader.new(**options).read(input)
53
58
  end
54
59
 
60
+ # Like #read, but raises NotFoundError when no code can be read.
61
+ def read!(input, **options)
62
+ read(input, **options) or raise NotFoundError, "no QR code found"
63
+ end
64
+
55
65
  # All QR codes found (on all pages) as Results, possibly empty.
56
66
  def read_all(input, **options)
57
67
  Reader.new(**options).read_all(input)
@@ -66,8 +76,8 @@ module QrReader
66
76
  nil
67
77
  end
68
78
 
69
- # Loads an image (path, IO, encoded bytes, Image or ChunkyPNG-style canvas)
70
- # as a grayscale QrReader::Image; multi-page documents yield their first page.
79
+ # Loads an image (path, IO, Image or ChunkyPNG-style canvas) as a
80
+ # grayscale QrReader::Image; multi-page documents yield their first page.
71
81
  def load_image(input)
72
82
  Reader.load_image(input)
73
83
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qr_reader
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andi Idogawa
@@ -72,6 +72,7 @@ files:
72
72
  - lib/qr_reader/formats/pnm.rb
73
73
  - lib/qr_reader/formats/tiff.rb
74
74
  - lib/qr_reader/image.rb
75
+ - lib/qr_reader/kernel_digest.rb
75
76
  - lib/qr_reader/kernels.rb
76
77
  - lib/qr_reader/kernels/binarizer.rb
77
78
  - lib/qr_reader/kernels/ccitt.rb