pure_jpeg 0.3.3 → 0.4.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.
@@ -4,6 +4,16 @@ module PureJPEG
4
4
  module Huffman
5
5
  class DecodeTable
6
6
  def initialize(bits, values)
7
+ unless bits.length == 16 && bits.all? { |n| n.is_a?(Integer) && n.between?(0, 255) } &&
8
+ values.length.between?(1, 256) && bits.sum == values.length
9
+ raise DecodeError, "Invalid JPEG Huffman table lengths"
10
+ end
11
+ available = 1
12
+ bits.each do |count|
13
+ available = available * 2 - count
14
+ # JPEG reserves the all-ones code for entropy padding.
15
+ raise DecodeError, "Oversubscribed JPEG Huffman table" if available <= 0
16
+ end
7
17
  @min_code = Array.new(17, 0)
8
18
  @max_code = Array.new(17, -1)
9
19
  @val_ptr = Array.new(17, 0)
@@ -25,13 +35,15 @@ module PureJPEG
25
35
  end
26
36
 
27
37
  # Decode one Huffman symbol from the bit reader.
28
- def decode(reader)
38
+ def decode_symbol(reader)
29
39
  code = 0
30
- 1.upto(16) do |len|
40
+ len = 1
41
+ while len <= 16
31
42
  code = (code << 1) | reader.read_bit
32
43
  if @max_code[len] >= 0 && code <= @max_code[len]
33
44
  return @values[@val_ptr[len] + code - @min_code[len]]
34
45
  end
46
+ len += 1
35
47
  end
36
48
  raise PureJPEG::DecodeError, "Invalid Huffman code"
37
49
  end
@@ -6,14 +6,7 @@ module PureJPEG
6
6
  # Return the Huffman category (bit length) for a value.
7
7
  # Avoids Array allocation compared to the combined category_and_bits.
8
8
  def self.category(value)
9
- return 0 if value == 0
10
- v = value.abs
11
- cat = 0
12
- while v > 0
13
- cat += 1
14
- v >>= 1
15
- end
16
- cat
9
+ value.abs.bit_length
17
10
  end
18
11
 
19
12
  # Return the extra bits to encode for a value with the given category.
@@ -50,6 +43,7 @@ module PureJPEG
50
43
  end
51
44
 
52
45
  yield 0x00, 0 if last_nonzero < 63
46
+ nil
53
47
  end
54
48
 
55
49
  def self.each_ac_symbol(zigzag)
@@ -85,12 +79,12 @@ module PureJPEG
85
79
  end
86
80
 
87
81
  def encode_ac(zigzag, writer)
88
- self.class.each_ac_item(zigzag) do |symbol, value|
82
+ PureJPEG::Huffman::Encoder.each_ac_item(zigzag) do |symbol, value|
89
83
  code, length = @ac_table[symbol]
90
84
  writer.write_bits(code, length)
91
85
  next if symbol == 0x00 || symbol == 0xF0
92
86
 
93
- cat = self.class.category(value)
87
+ cat = symbol & 0x0F
94
88
  writer.write_bits(self.class.value_bits(value, cat), cat)
95
89
  end
96
90
  end
@@ -3,19 +3,24 @@
3
3
  module PureJPEG
4
4
  class JFIFReader
5
5
  attr_reader :width, :height, :components, :quant_tables, :huffman_tables,
6
- :restart_interval, :progressive, :scans, :icc_profile
6
+ :restart_interval, :progressive, :scans, :icc_profile,
7
+ :precision, :adobe_transform, :component_quant_tables
7
8
 
8
9
  Component = Struct.new(:id, :h_sampling, :v_sampling, :qt_id)
9
10
  ScanComponent = Struct.new(:id, :dc_table_id, :ac_table_id)
10
- Scan = Struct.new(:components, :spectral_start, :spectral_end, :successive_high, :successive_low, :data, :huffman_tables)
11
+ Scan = Struct.new(:components, :spectral_start, :spectral_end, :successive_high, :successive_low, :data, :huffman_tables) do
12
+ attr_accessor :restart_interval
13
+ end
11
14
 
12
- def initialize(data, stop_after_frame: false)
15
+ def initialize(data, stop_after_frame: false, metadata_only: false)
13
16
  @data = data.b
14
17
  @stop_after_frame = stop_after_frame
18
+ @metadata_only = metadata_only
15
19
  @pos = 0
16
20
  @quant_tables = {}
17
21
  @huffman_tables = {}
18
22
  @components = []
23
+ @component_quant_tables = {}
19
24
  @restart_interval = 0
20
25
  @progressive = false
21
26
  @scans = []
@@ -38,16 +43,20 @@ module PureJPEG
38
43
  expect_marker(0xD8) # SOI
39
44
 
40
45
  loop do
46
+ # Header-only input is useful for metadata queries, even without SOS.
47
+ return if @metadata_only && @width && @pos == @data.bytesize
41
48
  marker = read_marker
42
49
  case marker
43
50
  when 0xE2 # APP2 (may contain ICC profile)
44
51
  parse_app2
45
- when 0xE0, 0xE1, 0xE3..0xEF # APP0, APP1, APP3-APP15
52
+ when 0xEE # Adobe APP14
53
+ parse_app14
54
+ when 0xE0, 0xE1, 0xE3..0xED, 0xEF # Other application markers
46
55
  skip_segment
47
56
  when 0xDB # DQT
48
- parse_dqt
57
+ @metadata_only ? skip_segment : parse_dqt
49
58
  when 0xC4 # DHT
50
- parse_dht
59
+ @metadata_only ? skip_segment : parse_dht
51
60
  when 0xC0 # SOF0 (baseline)
52
61
  parse_sof0
53
62
  return if @stop_after_frame
@@ -56,17 +65,30 @@ module PureJPEG
56
65
  @progressive = true
57
66
  return if @stop_after_frame
58
67
  when 0xDA # SOS
68
+ return if @metadata_only
59
69
  scan = parse_sos
60
70
  scan.data = extract_scan_data
61
71
  scan.huffman_tables = @huffman_tables.dup
72
+ scan.restart_interval = @restart_interval
73
+ # A component keeps the quantizer selected by its first scan, even
74
+ # if another scan later redefines that table ID for other components.
75
+ scan.components.each do |sc|
76
+ comp = @components.find { |c| c.id == sc.id }
77
+ if comp && !@component_quant_tables.key?(comp.id)
78
+ @component_quant_tables[comp.id] = @quant_tables[comp.qt_id]
79
+ end
80
+ end
62
81
  @scans << scan
63
- return unless @progressive
64
82
  when 0xFE # COM (comment)
65
83
  skip_segment
66
84
  when 0xDD # DRI (restart interval)
67
85
  parse_dri
68
86
  when 0xD9 # EOI
69
87
  return
88
+ when 0xC1, 0xC3, 0xC5..0xCB, 0xCD..0xCF
89
+ raise DecodeError, "Unsupported JPEG frame marker: 0x#{marker.to_s(16)}"
90
+ when 0xD0..0xD8, 0x00, 0x01
91
+ raise DecodeError, "Unexpected JPEG marker: 0x#{marker.to_s(16)}"
70
92
  else
71
93
  skip_segment
72
94
  end
@@ -100,13 +122,24 @@ module PureJPEG
100
122
 
101
123
  ICC_PROFILE_SIG = "ICC_PROFILE\0".b
102
124
 
125
+ def parse_app14
126
+ end_pos = read_segment_end
127
+ if end_pos - @pos >= 12 && @data[@pos, 5] == "Adobe"
128
+ @adobe_transform = @data.getbyte(@pos + 11)
129
+ end
130
+ @pos = end_pos
131
+ end
132
+
103
133
  def parse_app2
104
134
  end_pos = read_segment_end
105
135
 
106
136
  if (end_pos - @pos) >= 14 && @data[@pos, 12] == ICC_PROFILE_SIG
107
137
  @pos += 12
108
138
  seq_no = read_byte
109
- _total = read_byte
139
+ total = read_byte
140
+ @icc_invalid = true if total == 0 || seq_no == 0 || seq_no > total ||
141
+ (@icc_total && @icc_total != total) || @icc_chunks.key?(seq_no)
142
+ @icc_total = total
110
143
  @icc_chunks[seq_no] = @data[@pos, end_pos - @pos]
111
144
  end
112
145
 
@@ -115,6 +148,7 @@ module PureJPEG
115
148
 
116
149
  def assemble_icc_profile
117
150
  return if @icc_chunks.empty?
151
+ return if @icc_invalid || @icc_chunks.length != @icc_total
118
152
 
119
153
  @icc_profile = @icc_chunks.sort_by(&:first).map(&:last).join.b
120
154
  end
@@ -130,6 +164,9 @@ module PureJPEG
130
164
  info = read_byte
131
165
  precision = (info >> 4) & 0x0F # 0 = 8-bit, 1 = 16-bit
132
166
  table_id = info & 0x0F
167
+ unless precision <= 1 && table_id <= 3
168
+ raise DecodeError, "Invalid JPEG quantization table definition"
169
+ end
133
170
  bytes_per_value = precision == 0 ? 1 : 2
134
171
  ensure_remaining_in_segment!(end_pos, 64 * bytes_per_value, "DQT")
135
172
 
@@ -137,6 +174,7 @@ module PureJPEG
137
174
  64.times do |i|
138
175
  zigzag_table[i] = precision == 0 ? read_byte : read_u16
139
176
  end
177
+ raise DecodeError, "Zero JPEG quantization value" if zigzag_table.include?(0)
140
178
  # DQT stores values in zigzag order; convert to raster order
141
179
  table = Array.new(64)
142
180
  64.times { |i| table[Zigzag::ORDER[i]] = zigzag_table[i] }
@@ -152,20 +190,27 @@ module PureJPEG
152
190
  info = read_byte
153
191
  table_class = (info >> 4) & 0x0F # 0 = DC, 1 = AC
154
192
  table_id = info & 0x0F
193
+ unless table_class <= 1 && table_id <= 3
194
+ raise DecodeError, "Invalid JPEG Huffman table definition"
195
+ end
155
196
 
156
197
  bits = Array.new(16) { read_byte }
157
198
  total = bits.sum
158
199
  ensure_remaining_in_segment!(end_pos, total, "DHT")
159
200
  values = Array.new(total) { read_byte }
201
+ if values.any? { |value| table_class == 0 ? value > 11 : (value & 15) > 10 }
202
+ raise DecodeError, "Invalid JPEG Huffman symbol"
203
+ end
160
204
 
161
205
  @huffman_tables[[table_class, table_id]] = { bits: bits, values: values }
162
206
  end
163
207
  end
164
208
 
165
209
  def parse_sof0
210
+ raise DecodeError, "Multiple JPEG frame headers are unsupported" if @width
166
211
  end_pos = read_segment_end
167
212
  ensure_remaining_in_segment!(end_pos, 6, "SOF")
168
- read_byte # precision (always 8 for baseline)
213
+ @precision = read_byte
169
214
  @height = read_u16
170
215
  @width = read_u16
171
216
  num_components = read_byte
@@ -179,6 +224,13 @@ module PureJPEG
179
224
  qt_id = read_byte
180
225
  Component.new(id, h, v, qt_id)
181
226
  end
227
+ raise DecodeError, "JPEG frame has no components" if @components.empty?
228
+ if @components.map(&:id).uniq.length != @components.length
229
+ raise DecodeError, "Duplicate JPEG component id"
230
+ end
231
+ unless @components.all? { |c| c.h_sampling.between?(1, 4) && c.v_sampling.between?(1, 4) }
232
+ raise DecodeError, "Invalid JPEG sampling factors"
233
+ end
182
234
  @pos = end_pos
183
235
  end
184
236
 
@@ -234,19 +286,23 @@ module PureJPEG
234
286
  def extract_scan_data
235
287
  start = @pos
236
288
  len = @data.bytesize
237
- # Scan forward looking for a marker that isn't a stuffing byte or restart
238
- while @pos < len - 1
289
+ # Preserve the first FF of a marker's fill run for read_marker. Stuffed
290
+ # bytes and restarts belong to the entropy stream, segment markers do not.
291
+ while @pos < len
239
292
  found = @data.index("\xFF".b, @pos)
240
- break unless found && found < len - 1
241
- @pos = found
242
- next_byte = @data.getbyte(@pos + 1)
243
- # 0x00 is byte stuffing, 0xD0-0xD7 are restart markers, 0xFF is padding — all part of scan data
244
- if next_byte != 0x00 && !(next_byte >= 0xD0 && next_byte <= 0xD7) && next_byte != 0xFF
245
- break
293
+ raise DecodeError, "Unexpected end of JPEG scan" unless found
294
+ marker_pos = found + 1
295
+ marker_pos += 1 while marker_pos < len && @data.getbyte(marker_pos) == 0xFF
296
+ raise DecodeError, "Unexpected end of JPEG scan" if marker_pos == len
297
+ code = @data.getbyte(marker_pos)
298
+ if code == 0x00 || code.between?(0xD0, 0xD7)
299
+ @pos = marker_pos + 1
300
+ else
301
+ @pos = found
302
+ return @data[start...found]
246
303
  end
247
- @pos += 2
248
304
  end
249
- @data[start...@pos]
305
+ raise DecodeError, "Unexpected end of JPEG scan"
250
306
  end
251
307
  end
252
308
  end
@@ -58,5 +58,33 @@ module PureJPEG
58
58
  64.times { |i| out[i] = block[i] * table[i] }
59
59
  out
60
60
  end
61
+
62
+ # Quantize raster-order coefficients directly into zigzag order.
63
+ def self.quantize_zigzag!(block, table, out)
64
+ order = Zigzag::ORDER
65
+ i = 0
66
+ while i < 64
67
+ j = order[i]
68
+ v = block[j]; t = table[j]
69
+ out[i] = if v >= 0
70
+ (v + (t >> 1)) / t
71
+ else
72
+ -((-v + (t >> 1)) / t)
73
+ end
74
+ i += 1
75
+ end
76
+ out
77
+ end
78
+
79
+ # Dequantize zigzag coefficients directly into raster order. The offset
80
+ # allows progressive decoding to read from its full coefficient buffer.
81
+ def self.dequantize_zigzag!(block, table, out, offset = 0)
82
+ order = Zigzag::ORDER
83
+ 64.times do |i|
84
+ j = order[i]
85
+ out[j] = block[offset + i] * table[j]
86
+ end
87
+ out
88
+ end
61
89
  end
62
90
  end
@@ -27,15 +27,16 @@ module PureJPEG
27
27
  def initialize(width, height, &block)
28
28
  @width = width
29
29
  @height = height
30
- @pixels = Array.new(width * height) { Pixel.new(0, 0, 0) }
31
-
32
30
  if block
31
+ @pixels = Array.new(width * height)
33
32
  height.times do |y|
34
33
  width.times do |x|
35
34
  r, g, b = block.call(x, y)
36
35
  @pixels[y * width + x] = Pixel.new(r, g, b)
37
36
  end
38
37
  end
38
+ else
39
+ @pixels = Array.new(width * height) { Pixel.new(0, 0, 0) }
39
40
  end
40
41
  end
41
42
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PureJPEG
4
- VERSION = "0.3.3"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/pure_jpeg.rb CHANGED
@@ -21,10 +21,9 @@ require_relative "pure_jpeg/image"
21
21
  require_relative "pure_jpeg/encoder"
22
22
  require_relative "pure_jpeg/decoder"
23
23
 
24
- # Pure Ruby JPEG encoder and decoder with no native dependencies.
25
- #
26
- # Supports baseline DCT (SOF0) with 8-bit precision, grayscale and YCbCr
27
- # color (4:2:0 chroma subsampling), and standard Huffman tables (Annex K).
24
+ # Pure Ruby JPEG encoder and decoder with no native dependencies.
25
+ # Encodes baseline DCT (SOF0), and decodes baseline and progressive (SOF2)
26
+ # 8-bit grayscale and YCbCr JPEGs.
28
27
  module PureJPEG
29
28
  # Raised when decoding invalid or unsupported JPEG data.
30
29
  class DecodeError < StandardError; end
@@ -70,15 +69,15 @@ module PureJPEG
70
69
  # Read JPEG dimensions and basic frame metadata without decoding scan data.
71
70
  #
72
71
  # @param path_or_data [String] a file path or raw JPEG bytes
73
- # @return [Info] image metadata parsed from the frame header
72
+ # @return [Info] frame metadata and complete ICC profile from headers before SOS
74
73
  def self.info(path_or_data)
75
- data = if path_or_data.is_a?(String) && !path_or_data.start_with?("\xFF\xD8".b) && File.exist?(path_or_data)
74
+ data = if path_or_data.is_a?(String) && !path_or_data.b.include?("\0") && !path_or_data.b.start_with?("\xFF\xD8".b) && File.exist?(path_or_data)
76
75
  File.binread(path_or_data)
77
76
  else
78
77
  path_or_data.b
79
78
  end
80
79
 
81
- jfif = JFIFReader.new(data, stop_after_frame: true)
80
+ jfif = JFIFReader.new(data, metadata_only: true)
82
81
  raise DecodeError, "JPEG frame header not found" unless jfif.width && jfif.height
83
82
 
84
83
  Info.new(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pure_jpeg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Cooper
@@ -37,7 +37,8 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '5.0'
40
- description: A pure Ruby baseline JPEG encoder and decoder with no native dependencies.
40
+ description: A pure Ruby baseline JPEG encoder and baseline/progressive JPEG decoder
41
+ with no native dependencies.
41
42
  email:
42
43
  - git@peterc.org
43
44
  executables: []
@@ -45,7 +46,9 @@ extensions: []
45
46
  extra_rdoc_files: []
46
47
  files:
47
48
  - CHANGELOG.md
49
+ - CREATIVE.md
48
50
  - LICENSE
51
+ - LICENSE-IJG
49
52
  - README.md
50
53
  - lib/pure_jpeg.rb
51
54
  - lib/pure_jpeg/bit_reader.rb
@@ -69,6 +72,7 @@ files:
69
72
  homepage: https://github.com/peterc/pure_jpeg
70
73
  licenses:
71
74
  - MIT
75
+ - IJG
72
76
  metadata:
73
77
  source_code_uri: https://github.com/peterc/pure_jpeg
74
78
  changelog_uri: https://github.com/peterc/pure_jpeg/blob/main/CHANGELOG.md