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 +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +29 -9
- data/exe/qr_reader +1 -1
- data/lib/qr_reader/decoder/character_set.rb +19 -1
- data/lib/qr_reader/decoder.rb +3 -1
- data/lib/qr_reader/errors.rb +1 -1
- data/lib/qr_reader/formats/pdf.rb +17 -6
- data/lib/qr_reader/formats/png.rb +11 -5
- data/lib/qr_reader/formats.rb +1 -1
- data/lib/qr_reader/kernel_digest.rb +62 -0
- data/lib/qr_reader/native_kernels.rb +2 -17
- data/lib/qr_reader/reader.rb +53 -47
- data/lib/qr_reader/version.rb +1 -1
- data/lib/qr_reader.rb +15 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 95bfd13bd094f9f117def4165ec4af69923fff4c6b45dde7140a633ecf3fd092
|
|
4
|
+
data.tar.gz: 33b8813834b0bffb325c3951948db515d7a2108f55e0487becfd63e20bf33fd5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
65
|
-
|
|
66
|
-
`width`, `height` and `pixels` (e.g. a
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/qr_reader/decoder.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/qr_reader/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1550
|
-
out <<
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
data/lib/qr_reader/formats.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
data/lib/qr_reader/reader.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
#
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
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.
|
data/lib/qr_reader/version.rb
CHANGED
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
|
|
28
|
-
# bytes (PNG, JPEG, JPEG 2000, GIF, BMP, PNM, TIFF, PDF), a
|
|
29
|
-
# ChunkyPNG-style canvas (#width, #height, #pixels)
|
|
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,
|
|
70
|
-
#
|
|
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.
|
|
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
|