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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +14 -0
- data/README.md +222 -0
- data/exe/qr_reader +66 -0
- data/lib/qr_reader/binarizer.rb +170 -0
- data/lib/qr_reader/bit_matrix.rb +120 -0
- data/lib/qr_reader/decoder/bitstream_parser.rb +307 -0
- data/lib/qr_reader/decoder/character_set.rb +163 -0
- data/lib/qr_reader/decoder/codewords.rb +164 -0
- data/lib/qr_reader/decoder/data_mask.rb +57 -0
- data/lib/qr_reader/decoder/format_reader.rb +204 -0
- data/lib/qr_reader/decoder/function_patterns.rb +62 -0
- data/lib/qr_reader/decoder/galois_field.rb +69 -0
- data/lib/qr_reader/decoder/reed_solomon.rb +312 -0
- data/lib/qr_reader/decoder.rb +241 -0
- data/lib/qr_reader/detector/alignment_grid.rb +127 -0
- data/lib/qr_reader/detector/alignment_pattern_finder.rb +191 -0
- data/lib/qr_reader/detector/candidate/dimension.rb +237 -0
- data/lib/qr_reader/detector/candidate/strokes.rb +126 -0
- data/lib/qr_reader/detector/candidate.rb +436 -0
- data/lib/qr_reader/detector/finder_pattern.rb +63 -0
- data/lib/qr_reader/detector/finder_pattern_finder.rb +199 -0
- data/lib/qr_reader/detector/geometry.rb +118 -0
- data/lib/qr_reader/detector/grid_sampler.rb +350 -0
- data/lib/qr_reader/detector/pattern_sets.rb +235 -0
- data/lib/qr_reader/detector/perspective_transform.rb +117 -0
- data/lib/qr_reader/detector/pure_detector.rb +197 -0
- data/lib/qr_reader/detector.rb +215 -0
- data/lib/qr_reader/errors.rb +14 -0
- data/lib/qr_reader/format_information.rb +79 -0
- data/lib/qr_reader/formats/bmp.rb +434 -0
- data/lib/qr_reader/formats/ccitt.rb +81 -0
- data/lib/qr_reader/formats/gif.rb +272 -0
- data/lib/qr_reader/formats/jpeg.rb +152 -0
- data/lib/qr_reader/formats/pdf.rb +2935 -0
- data/lib/qr_reader/formats/png.rb +311 -0
- data/lib/qr_reader/formats/pnm.rb +286 -0
- data/lib/qr_reader/formats/tiff.rb +1345 -0
- data/lib/qr_reader/formats.rb +279 -0
- data/lib/qr_reader/image.rb +231 -0
- data/lib/qr_reader/kernels/binarizer.rb +475 -0
- data/lib/qr_reader/kernels/ccitt.rb +554 -0
- data/lib/qr_reader/kernels/detector.rb +587 -0
- data/lib/qr_reader/kernels/image.rb +65 -0
- data/lib/qr_reader/kernels/jpeg.rb +3421 -0
- data/lib/qr_reader/kernels/lzw.rb +173 -0
- data/lib/qr_reader/kernels/pixels.rb +116 -0
- data/lib/qr_reader/kernels/png.rb +1138 -0
- data/lib/qr_reader/kernels.rb +14 -0
- data/lib/qr_reader/native_kernels.rb +130 -0
- data/lib/qr_reader/reader.rb +348 -0
- data/lib/qr_reader/result.rb +49 -0
- data/lib/qr_reader/symbol_version.rb +173 -0
- data/lib/qr_reader/version.rb +5 -0
- data/lib/qr_reader.rb +77 -0
- metadata +103 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require_relative "../kernels/png"
|
|
5
|
+
|
|
6
|
+
module QrReader
|
|
7
|
+
module Formats
|
|
8
|
+
# PNG decoder: every colour type and bit depth (1/2/4/8/16), PLTE + tRNS,
|
|
9
|
+
# alpha composited over white, Adam7 interlacing, any number of IDAT chunks.
|
|
10
|
+
# Ancillary chunks (gamma, colour profiles, text, APNG frames, ...) are
|
|
11
|
+
# ignored, and chunk CRCs are not verified (like most lenient decoders).
|
|
12
|
+
#
|
|
13
|
+
# The image data is inflated as a stream and handed to the PNG kernel
|
|
14
|
+
# (QrReaderKernels.png_rows, kernels/png.rb) about a megabyte at a time:
|
|
15
|
+
# the kernel unfilters the rows and converts them to luminance in one
|
|
16
|
+
# pass, so the whole inflated image never exists at once. Palette and
|
|
17
|
+
# grayscale images of up to 8 bits come out of the kernel as unfiltered
|
|
18
|
+
# rows; 1 and 4-bit samples are spread to one character per sample with
|
|
19
|
+
# String#unpack1 ("B" / "H", C speed) and everything is mapped to
|
|
20
|
+
# luminance with a single String#tr at the end. 16-bit samples keep
|
|
21
|
+
# their high byte.
|
|
22
|
+
module PNG
|
|
23
|
+
SIGNATURE = "\x89PNG\r\n\x1A\n".b
|
|
24
|
+
|
|
25
|
+
# colour type => [channels, allowed bit depths]
|
|
26
|
+
COLOR_TYPES = {
|
|
27
|
+
0 => [1, [1, 2, 4, 8, 16]], # grayscale
|
|
28
|
+
2 => [3, [8, 16]], # RGB
|
|
29
|
+
3 => [1, [1, 2, 4, 8]], # palette
|
|
30
|
+
4 => [2, [8, 16]], # grayscale + alpha
|
|
31
|
+
6 => [4, [8, 16]] # RGBA
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# Adam7 passes: [x0, y0, dx, dy].
|
|
35
|
+
ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]].freeze
|
|
36
|
+
|
|
37
|
+
# Bytes standing for the sample values 0, 1, ... after String#unpack1
|
|
38
|
+
# spread 1-bit ("B") and 4-bit ("H") rows: the String#tr sources.
|
|
39
|
+
UNPACKED_CODES = { 1 => "01".bytes.freeze, 4 => "0123456789abcdef".bytes.freeze }.freeze
|
|
40
|
+
|
|
41
|
+
# Compressed bytes fed to zlib at a time (bounds the work done for
|
|
42
|
+
# zip bombs: the output of one slice is at most ~66 MB, streamed).
|
|
43
|
+
INFLATE_SLICE = 1 << 16
|
|
44
|
+
# Inflated bytes collected before a kernel call (bounds memory).
|
|
45
|
+
KERNEL_SLICE = 1 << 20
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def decode(bytes)
|
|
49
|
+
Decoder.new(Support.binary(bytes)).decode
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dimensions(bytes)
|
|
53
|
+
Decoder.new(Support.binary(bytes), header_only: true).dimensions
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Parses the chunk structure and produces the luminance image.
|
|
58
|
+
class Decoder
|
|
59
|
+
def initialize(bytes, header_only: false)
|
|
60
|
+
@bytes = bytes
|
|
61
|
+
raise ImageError, "not a PNG file" unless bytes.start_with?(SIGNATURE)
|
|
62
|
+
|
|
63
|
+
read_chunks(header_only)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dimensions
|
|
67
|
+
[@width, @height]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def decode
|
|
71
|
+
raise ImageError, "PNG has no image data (IDAT)" if @idat.empty?
|
|
72
|
+
raise ImageError, "palette PNG without PLTE chunk" if @color_type == 3 && @palette.nil?
|
|
73
|
+
|
|
74
|
+
setup_conversion
|
|
75
|
+
w = @width
|
|
76
|
+
h = @height
|
|
77
|
+
if @interlaced
|
|
78
|
+
raw = inflate_all(ADAM7.sum { |pass| pass_size(pass) })
|
|
79
|
+
out = QrReaderKernels.png_adam7(raw, w, h, @depth, @channels, @key)
|
|
80
|
+
codes = ALL_BYTES
|
|
81
|
+
else
|
|
82
|
+
out = decode_stream
|
|
83
|
+
codes = ALL_BYTES
|
|
84
|
+
out, codes = unpack_samples(out) if @depth < 8
|
|
85
|
+
end
|
|
86
|
+
Support.translate!(out, codes.first(@tr_to.size), @tr_to) if @tr_to
|
|
87
|
+
Image.new(w, h, out)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def read_chunks(header_only)
|
|
93
|
+
bytes = @bytes
|
|
94
|
+
size = bytes.bytesize
|
|
95
|
+
pos = 8
|
|
96
|
+
@idat = []
|
|
97
|
+
while pos + 8 <= size
|
|
98
|
+
length, type = bytes.unpack("Na4", offset: pos)
|
|
99
|
+
body_pos = pos + 8
|
|
100
|
+
if body_pos + length > size
|
|
101
|
+
# Once image data has started, keep what is there: the pixel
|
|
102
|
+
# data may be complete (decode raises if it is not).
|
|
103
|
+
raise ImageError, "truncated PNG chunk #{type.inspect}" if @idat.empty? && type != "IDAT"
|
|
104
|
+
|
|
105
|
+
@idat << [body_pos, size - body_pos] if type == "IDAT"
|
|
106
|
+
break
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if pos == 8
|
|
110
|
+
raise UnsupportedImageError, "Apple-optimised (CgBI) PNGs are not supported" if type == "CgBI"
|
|
111
|
+
raise ImageError, "PNG does not start with IHDR" unless type == "IHDR"
|
|
112
|
+
|
|
113
|
+
read_header(bytes.byteslice(body_pos, length))
|
|
114
|
+
return if header_only
|
|
115
|
+
else
|
|
116
|
+
case type
|
|
117
|
+
when "IDAT" then @idat << [body_pos, length]
|
|
118
|
+
when "PLTE" then @palette ||= bytes.byteslice(body_pos, length - length % 3)
|
|
119
|
+
when "tRNS" then @trns ||= bytes.byteslice(body_pos, length)
|
|
120
|
+
when "IEND" then break
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
pos = body_pos + length + 4 # skip CRC
|
|
124
|
+
end
|
|
125
|
+
raise ImageError, "truncated PNG (no IHDR)" unless @width
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def read_header(ihdr)
|
|
129
|
+
raise ImageError, "invalid PNG IHDR chunk" if ihdr.bytesize < 13
|
|
130
|
+
|
|
131
|
+
@width, @height, @depth, @color_type, compression, filter, interlace = ihdr.unpack("NNCCCCC")
|
|
132
|
+
@channels, depths = COLOR_TYPES[@color_type]
|
|
133
|
+
raise ImageError, "invalid PNG colour type #{@color_type}" unless @channels
|
|
134
|
+
unless depths.include?(@depth)
|
|
135
|
+
raise ImageError, "invalid PNG bit depth #{@depth} for colour type #{@color_type}"
|
|
136
|
+
end
|
|
137
|
+
raise ImageError, "unknown PNG compression/filter method" unless compression.zero? && filter.zero?
|
|
138
|
+
raise ImageError, "unknown PNG interlace method #{interlace}" if interlace > 1
|
|
139
|
+
raise ImageError, "PNG dimensions exceed 2^31 - 1" if @width > 0x7FFFFFFF || @height > 0x7FFFFFFF
|
|
140
|
+
|
|
141
|
+
Support.check_dimensions(@width, @height, "PNG")
|
|
142
|
+
@interlaced = interlace == 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def row_bytes(width)
|
|
146
|
+
(width * @channels * @depth + 7) >> 3
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Raw (filtered) byte count of an Adam7 pass.
|
|
150
|
+
def pass_size((x0, y0, dx, dy))
|
|
151
|
+
pw = (@width - x0 + dx - 1) / dx
|
|
152
|
+
ph = (@height - y0 + dy - 1) / dy
|
|
153
|
+
pw <= 0 || ph <= 0 ? 0 : ph * (1 + row_bytes(pw))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Chooses the colour key passed to the kernel and the final
|
|
157
|
+
# String#tr mapping of sample values (@tr_to[value] = luminance).
|
|
158
|
+
def setup_conversion
|
|
159
|
+
depth = @depth
|
|
160
|
+
trns = @trns
|
|
161
|
+
@key = "".b
|
|
162
|
+
case @color_type
|
|
163
|
+
when 0
|
|
164
|
+
key = trns.unpack1("n") if trns && trns.bytesize >= 2
|
|
165
|
+
max = (1 << depth) - 1
|
|
166
|
+
if depth == 16
|
|
167
|
+
@key = trns.byteslice(0, 2) if key
|
|
168
|
+
elsif depth == 8
|
|
169
|
+
if key && key <= 255
|
|
170
|
+
@tr_to = ALL_BYTES.dup
|
|
171
|
+
@tr_to[key] = 255
|
|
172
|
+
end
|
|
173
|
+
else
|
|
174
|
+
@tr_to = Array.new(max + 1) { |v| v * 255 / max }
|
|
175
|
+
@tr_to[key] = 255 if key && key <= max
|
|
176
|
+
end
|
|
177
|
+
when 2
|
|
178
|
+
if trns && trns.bytesize >= 6
|
|
179
|
+
rgb = trns.unpack("nnn")
|
|
180
|
+
# (an 8-bit image cannot contain a key beyond 255: no key)
|
|
181
|
+
@key = depth == 16 ? trns.byteslice(0, 6) : (rgb.max <= 255 ? rgb.pack("C3") : "".b)
|
|
182
|
+
end
|
|
183
|
+
when 3
|
|
184
|
+
@tr_to = Support.palette_lumas(@palette, alpha: trns).first(1 << depth)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Packed 1, 2 and 4-bit rows -> one byte per sample, and the bytes
|
|
189
|
+
# that stand for the sample values 0, 1, ...
|
|
190
|
+
def unpack_samples(packed)
|
|
191
|
+
w = @width
|
|
192
|
+
depth = @depth
|
|
193
|
+
len = row_bytes(w)
|
|
194
|
+
if depth == 2
|
|
195
|
+
return [QrReaderKernels.png_unpack_rows(packed, @height, w, len, 2), ALL_BYTES]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
format = depth == 1 ? "B" : "H"
|
|
199
|
+
if (w * depth) % 8 == 0
|
|
200
|
+
out = packed.unpack1("#{format}*")
|
|
201
|
+
else
|
|
202
|
+
out = Support.buffer(w * @height)
|
|
203
|
+
spec = "#{format}#{w}"
|
|
204
|
+
off = 0
|
|
205
|
+
while off < packed.bytesize
|
|
206
|
+
out << packed.unpack1(spec, offset: off)
|
|
207
|
+
off += len
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
[out.b, UNPACKED_CODES[depth]]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Inflates the image data in slices and feeds complete rows to the
|
|
214
|
+
# kernel; returns the converted image (width * height bytes, or the
|
|
215
|
+
# unfiltered rows of gray/palette images of less than 8 bits).
|
|
216
|
+
def decode_stream
|
|
217
|
+
w = @width
|
|
218
|
+
rows_left = @height
|
|
219
|
+
len = row_bytes(w)
|
|
220
|
+
stride = len + 1
|
|
221
|
+
@row_out = @channels == 1 && @depth <= 8 ? len : w
|
|
222
|
+
batch = [KERNEL_SLICE / stride, 1].max * stride
|
|
223
|
+
out = Support.buffer([@row_out * @height, compressed_size * 1032 + 1024].min)
|
|
224
|
+
prev = "".b
|
|
225
|
+
pending = nil
|
|
226
|
+
each_inflated do |data|
|
|
227
|
+
next if @enough
|
|
228
|
+
|
|
229
|
+
if pending
|
|
230
|
+
pending << data
|
|
231
|
+
else
|
|
232
|
+
pending = +data
|
|
233
|
+
end
|
|
234
|
+
next if pending.bytesize < batch
|
|
235
|
+
|
|
236
|
+
n = [pending.bytesize / stride, rows_left].min
|
|
237
|
+
prev = convert_rows(pending, n, w, prev, out)
|
|
238
|
+
rows_left -= n
|
|
239
|
+
@enough = rows_left.zero?
|
|
240
|
+
pending = @enough ? nil : pending.byteslice(n * stride, pending.bytesize - n * stride)
|
|
241
|
+
end
|
|
242
|
+
if rows_left.positive?
|
|
243
|
+
n = pending ? pending.bytesize / stride : 0
|
|
244
|
+
truncated!(@height - rows_left + n, @height, "rows") if n < rows_left
|
|
245
|
+
|
|
246
|
+
convert_rows(pending, rows_left, w, prev, out)
|
|
247
|
+
end
|
|
248
|
+
out
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def truncated!(got, expected, unit)
|
|
252
|
+
raise ImageError, "corrupt PNG image data (zlib: #{@zlib_error.message})" if @zlib_error
|
|
253
|
+
|
|
254
|
+
raise ImageError, "truncated PNG image data (#{got} of #{expected} #{unit})"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Runs the kernel on +n+ rows at the start of +raw+, appends their
|
|
258
|
+
# output to +out+; returns the last unfiltered row.
|
|
259
|
+
def convert_rows(raw, n, w, prev, out)
|
|
260
|
+
res = QrReaderKernels.png_rows(raw, 0, n, w, @depth, @channels, prev, @key)
|
|
261
|
+
size = n * @row_out
|
|
262
|
+
out << res.byteslice(0, size)
|
|
263
|
+
res.byteslice(size, res.bytesize - size)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def compressed_size
|
|
267
|
+
@idat.sum { |_, length| length }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Yields the inflated IDAT payload in pieces, until the consumer
|
|
271
|
+
# sets @enough.
|
|
272
|
+
def each_inflated(&block)
|
|
273
|
+
zstream = Zlib::Inflate.new
|
|
274
|
+
@idat.each do |pos, length|
|
|
275
|
+
stop = pos + length
|
|
276
|
+
while pos < stop
|
|
277
|
+
return if @enough
|
|
278
|
+
|
|
279
|
+
n = [stop - pos, INFLATE_SLICE].min
|
|
280
|
+
zstream.inflate(@bytes.byteslice(pos, n), &block)
|
|
281
|
+
pos += n
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
rescue Zlib::Error => e
|
|
285
|
+
# Data inflated before the error was used; what is missing is
|
|
286
|
+
# reported as truncation by the caller.
|
|
287
|
+
@zlib_error = e
|
|
288
|
+
ensure
|
|
289
|
+
if zstream
|
|
290
|
+
zstream.reset # an unfinished stream would warn on close
|
|
291
|
+
zstream.close
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Inflates the whole IDAT payload; returns at least +expected+ bytes.
|
|
296
|
+
def inflate_all(expected)
|
|
297
|
+
# Deflate expands at most 1032:1, so a few bytes of data that
|
|
298
|
+
# claim a huge image never reserve (and touch) the claimed size.
|
|
299
|
+
out = Support.buffer([expected, compressed_size * 1032 + 1024].min)
|
|
300
|
+
each_inflated do |data|
|
|
301
|
+
out << data unless @enough
|
|
302
|
+
@enough = out.bytesize >= expected
|
|
303
|
+
end
|
|
304
|
+
truncated!(out.bytesize, expected, "bytes") if out.bytesize < expected
|
|
305
|
+
out
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
private_constant :Decoder
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QrReader
|
|
4
|
+
module Formats
|
|
5
|
+
# Netpbm decoder: PBM/PGM/PPM in plain (P1-P3) and raw (P4-P6) form with
|
|
6
|
+
# any maxval up to 65535 and comments anywhere in the header, and PAM (P7)
|
|
7
|
+
# with the BLACKANDWHITE, GRAYSCALE and RGB tuple types (and their _ALPHA
|
|
8
|
+
# variants, composited over white). Only the first image of a multi-image
|
|
9
|
+
# file is read. Samples are scaled to 8 bits with rounding, except that
|
|
10
|
+
# maxval 65535 keeps the high byte (like PNG).
|
|
11
|
+
module PNM
|
|
12
|
+
# Whitespace and comments (atomic, so a failed match cannot backtrack
|
|
13
|
+
# through them), then the next header token.
|
|
14
|
+
HEADER_INT = /\G(?>(?:\s|#[^\r\n]*)*)(\d+)/n
|
|
15
|
+
PAM_LINE = /\G(?>(?:\s|#[^\r\n]*)*)([A-Za-z_]+)[ \t]*([^\r\n]*)/n
|
|
16
|
+
COMMENT = /#[^\r\n]*/n
|
|
17
|
+
LINE_END = /[\r\n]/n
|
|
18
|
+
NOT_PLAIN_SAMPLE = /[^0-9 \t\n\v\f\r]/n
|
|
19
|
+
LEADING_INT = /\A\s*(\d+)/n
|
|
20
|
+
PBM_LUMA = "\xFF\x00".b # "0" white, "1" black
|
|
21
|
+
SEPARATORS = WHITESPACE.bytes.freeze
|
|
22
|
+
|
|
23
|
+
# PAM tuple type => channel count.
|
|
24
|
+
TUPLE_TYPES = {
|
|
25
|
+
"BLACKANDWHITE" => 1, "GRAYSCALE" => 1, "RGB" => 3,
|
|
26
|
+
"BLACKANDWHITE_ALPHA" => 2, "GRAYSCALE_ALPHA" => 2, "RGB_ALPHA" => 4
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def decode(bytes)
|
|
31
|
+
Decoder.new(Support.binary(bytes)).decode
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dimensions(bytes)
|
|
35
|
+
Decoder.new(Support.binary(bytes)).dimensions
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parses the header on creation; @pos then points at the raster.
|
|
40
|
+
class Decoder
|
|
41
|
+
def initialize(bytes)
|
|
42
|
+
raise ImageError, "not a PNM file" unless PNM_MAGIC.match?(bytes)
|
|
43
|
+
|
|
44
|
+
@bytes = bytes
|
|
45
|
+
@kind = bytes.getbyte(1) - 48
|
|
46
|
+
@pos = 2
|
|
47
|
+
if @kind == 7
|
|
48
|
+
raise UnsupportedImageError, "XV thumbnail images (P7 332) are not supported" if bytes.start_with?("P7 332")
|
|
49
|
+
|
|
50
|
+
read_pam_header
|
|
51
|
+
else
|
|
52
|
+
@width = read_int
|
|
53
|
+
@height = read_int
|
|
54
|
+
@maxval = @kind == 1 || @kind == 4 ? 1 : read_int
|
|
55
|
+
@channels = @kind == 3 || @kind == 6 ? 3 : 1
|
|
56
|
+
end
|
|
57
|
+
raise ImageError, "invalid PNM maxval #{@maxval}" unless @maxval.between?(1, 65_535)
|
|
58
|
+
|
|
59
|
+
Support.check_dimensions(@width, @height, "PNM")
|
|
60
|
+
skip_raster_separator if @kind >= 4 && @kind <= 6
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dimensions
|
|
64
|
+
[@width, @height]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def decode
|
|
68
|
+
gray =
|
|
69
|
+
case @kind
|
|
70
|
+
when 1 then plain_bits
|
|
71
|
+
when 2, 3 then luminance(plain_samples)
|
|
72
|
+
when 4 then packed_bits
|
|
73
|
+
else raw_luminance
|
|
74
|
+
end
|
|
75
|
+
Image.new(@width, @height, gray)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def read_int
|
|
81
|
+
m = HEADER_INT.match(@bytes, @pos) or raise ImageError, "invalid or truncated PNM header"
|
|
82
|
+
raise ImageError, "invalid PNM header value" if m[1].bytesize > 10
|
|
83
|
+
|
|
84
|
+
@pos = m.end(0)
|
|
85
|
+
m[1].to_i
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Exactly one whitespace byte separates the header from raw rasters.
|
|
89
|
+
# A comment may also follow the last header value directly; then the
|
|
90
|
+
# end of its line is the separator (as in libnetpbm and ImageMagick).
|
|
91
|
+
def skip_raster_separator
|
|
92
|
+
byte = @bytes.getbyte(@pos)
|
|
93
|
+
if byte == 35 # "#"
|
|
94
|
+
eol = @bytes.byteindex(LINE_END, @pos) or raise ImageError, "truncated PNM header"
|
|
95
|
+
@pos = eol + 1
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
raise ImageError, "truncated PNM header" unless SEPARATORS.include?(byte)
|
|
99
|
+
|
|
100
|
+
@pos += 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def read_pam_header
|
|
104
|
+
fields = {}
|
|
105
|
+
tuple_type = []
|
|
106
|
+
loop do
|
|
107
|
+
m = PAM_LINE.match(@bytes, @pos) or raise ImageError, "invalid or truncated PAM header"
|
|
108
|
+
@pos = m.end(0)
|
|
109
|
+
key = m[1].upcase
|
|
110
|
+
case key
|
|
111
|
+
when "ENDHDR" then break
|
|
112
|
+
when "TUPLTYPE" then tuple_type << m[2].strip
|
|
113
|
+
when "WIDTH", "HEIGHT", "DEPTH", "MAXVAL"
|
|
114
|
+
value = LEADING_INT.match(m[2]) or raise ImageError, "invalid PAM #{key}"
|
|
115
|
+
raise ImageError, "invalid PAM #{key}" if value[1].bytesize > 10
|
|
116
|
+
|
|
117
|
+
fields[key] = value[1].to_i
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
@pos += 1 if @bytes.getbyte(@pos) == 13
|
|
121
|
+
@pos += 1 if @bytes.getbyte(@pos) == 10
|
|
122
|
+
|
|
123
|
+
missing = %w[WIDTH HEIGHT DEPTH MAXVAL] - fields.keys
|
|
124
|
+
raise ImageError, "PAM header lacks #{missing.join(', ')}" unless missing.empty?
|
|
125
|
+
|
|
126
|
+
@width, @height, @maxval = fields.values_at("WIDTH", "HEIGHT", "MAXVAL")
|
|
127
|
+
@channels = fields["DEPTH"]
|
|
128
|
+
check_tuple_type(tuple_type.join(" "))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Unknown tuple types are inferred from the depth, except that four
|
|
132
|
+
# channels of unknown meaning are most likely CMYK.
|
|
133
|
+
def check_tuple_type(type)
|
|
134
|
+
raise ImageError, "invalid PAM depth 0" if @channels.zero?
|
|
135
|
+
raise UnsupportedImageError, "PAM depth #{@channels} is not supported" if @channels > 4
|
|
136
|
+
|
|
137
|
+
expected = TUPLE_TYPES[type]
|
|
138
|
+
if expected && expected != @channels
|
|
139
|
+
raise ImageError, "PAM tuple type #{type} does not have depth #{@channels}"
|
|
140
|
+
end
|
|
141
|
+
return unless !expected && @channels == 4 && !type.empty?
|
|
142
|
+
|
|
143
|
+
raise UnsupportedImageError, "PAM tuple type #{type.inspect} is not supported"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def sample_count
|
|
147
|
+
@width * @height * @channels
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Luminance of a raw (binary) raster. Samples of maxval 65535 are
|
|
151
|
+
# read in place (their high bytes); other maxvals are scaled first.
|
|
152
|
+
def raw_luminance
|
|
153
|
+
wide = @maxval > 255
|
|
154
|
+
size = sample_count * (wide ? 2 : 1)
|
|
155
|
+
raw = @bytes.byteslice(@pos, size)
|
|
156
|
+
raise ImageError, "truncated PNM pixel data" if raw.nil? || raw.bytesize < size
|
|
157
|
+
|
|
158
|
+
if !wide
|
|
159
|
+
luminance(@maxval == 255 ? raw : Support.translate!(raw, ALL_BYTES, scale_table(255)))
|
|
160
|
+
elsif @maxval == 65_535
|
|
161
|
+
luminance(raw, 2)
|
|
162
|
+
else
|
|
163
|
+
luminance(scale16(raw))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Big-endian 16-bit samples (maxval 256..65534) scaled to 8 bits.
|
|
168
|
+
def scale16(raw)
|
|
169
|
+
max = @maxval
|
|
170
|
+
table = scale_table(max)
|
|
171
|
+
step = @width * @channels
|
|
172
|
+
out = Support.buffer(raw.bytesize / 2)
|
|
173
|
+
off = 0
|
|
174
|
+
@height.times do # row by row, so that YJIT compiles scale16_row
|
|
175
|
+
scale16_row(raw, off, step, table, max, out)
|
|
176
|
+
off += 2 * step
|
|
177
|
+
end
|
|
178
|
+
out
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def scale16_row(raw, i, n, table, max, out)
|
|
182
|
+
stop = i + 2 * n
|
|
183
|
+
while i < stop
|
|
184
|
+
v = (raw.getbyte(i) << 8) | raw.getbyte(i + 1)
|
|
185
|
+
out << (v >= max ? 255 : table[v])
|
|
186
|
+
i += 2
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# 8-bit samples of a plain (ASCII) raster. Digits and whitespace
|
|
191
|
+
# only (the usual case) are split at C speed, anything else is
|
|
192
|
+
# scanned for numbers.
|
|
193
|
+
def plain_samples
|
|
194
|
+
text = @bytes.byteslice(@pos, @bytes.bytesize - @pos).gsub(COMMENT, "")
|
|
195
|
+
values = NOT_PLAIN_SAMPLE.match?(text) ? text.scan(/\d+/) : text.split
|
|
196
|
+
n = sample_count
|
|
197
|
+
raise ImageError, "truncated PNM pixel data" if values.size < n
|
|
198
|
+
|
|
199
|
+
scale_values(values.first(n).map!(&:to_i))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Sample value -> 8 bits; values above maxval saturate. Maxval 65535
|
|
203
|
+
# keeps the high byte, the same as raw rasters and 16-bit PNG.
|
|
204
|
+
def scale_table(top)
|
|
205
|
+
max = @maxval
|
|
206
|
+
return Array.new(top + 1) { |v| v >> 8 } if max == 65_535
|
|
207
|
+
|
|
208
|
+
Array.new(top + 1) { |v| v >= max ? 255 : (v * 255 + max / 2) / max }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def scale_values(values)
|
|
212
|
+
max = @maxval
|
|
213
|
+
table = scale_table(max)
|
|
214
|
+
n = values.size
|
|
215
|
+
out = Support.buffer(n)
|
|
216
|
+
step = @width * @channels
|
|
217
|
+
i = 0
|
|
218
|
+
while i < n # row by row, so that YJIT compiles scale_row
|
|
219
|
+
scale_row(values, i, [i + step, n].min, table, max, out)
|
|
220
|
+
i += step
|
|
221
|
+
end
|
|
222
|
+
out
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def scale_row(values, i, stop, table, max, out)
|
|
226
|
+
while i < stop
|
|
227
|
+
v = values[i]
|
|
228
|
+
out << (v >= max ? 255 : table[v])
|
|
229
|
+
i += 1
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Gray, gray+alpha, RGB or RGBA samples -> luminance. +size+ is the
|
|
234
|
+
# sample size in bytes: of 16-bit samples only the high byte is read.
|
|
235
|
+
def luminance(samples, size = 1)
|
|
236
|
+
return samples if @channels == 1 && size == 1
|
|
237
|
+
|
|
238
|
+
w = @width
|
|
239
|
+
step = w * @channels * size
|
|
240
|
+
out = Support.buffer(w * @height)
|
|
241
|
+
table = Support.over_white_table if @channels.even?
|
|
242
|
+
off = 0
|
|
243
|
+
@height.times do
|
|
244
|
+
luminance_row(samples, off, size, table, out)
|
|
245
|
+
off += step
|
|
246
|
+
end
|
|
247
|
+
out
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def luminance_row(samples, off, s, table, out)
|
|
251
|
+
w = @width
|
|
252
|
+
case @channels
|
|
253
|
+
when 1 then Rows.high_bytes(samples, off, w, out) # 16-bit only: 8-bit gray is returned as is
|
|
254
|
+
when 2 then Rows.gray_alpha(samples, off, w, out, table, s, 2 * s)
|
|
255
|
+
when 3 then Rows.rgb(samples, off, w, out, 0, s, 2 * s, 3 * s)
|
|
256
|
+
else Rows.rgba(samples, off, w, out, table, 0, s, 2 * s, 3 * s, 4 * s)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# P1: "1" is black; digits need not be separated.
|
|
261
|
+
def plain_bits
|
|
262
|
+
n = @width * @height
|
|
263
|
+
bits = @bytes.byteslice(@pos, @bytes.bytesize - @pos).gsub(COMMENT, "").delete(WHITESPACE).byteslice(0, n)
|
|
264
|
+
raise ImageError, "truncated PBM pixel data" if bits.bytesize < n || bits.count("01") < n
|
|
265
|
+
|
|
266
|
+
bits.tr!("01", PBM_LUMA)
|
|
267
|
+
bits
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# P4: rows of MSB-first bits padded to whole bytes; 1 is black.
|
|
271
|
+
def packed_bits
|
|
272
|
+
w = @width
|
|
273
|
+
stride = (w + 7) >> 3
|
|
274
|
+
raise ImageError, "truncated PBM pixel data" if @pos + stride * @height > @bytes.bytesize
|
|
275
|
+
|
|
276
|
+
out = Support.buffer(w * @height)
|
|
277
|
+
format = "B#{w}"
|
|
278
|
+
@height.times { |y| out << @bytes.unpack1(format, offset: @pos + y * stride) }
|
|
279
|
+
out.tr!("01", PBM_LUMA)
|
|
280
|
+
out
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
private_constant :Decoder
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|