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