pure_jpeg 0.1.0 → 0.3.0

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.
@@ -3,22 +3,33 @@
3
3
  module PureJPEG
4
4
  # A decoded JPEG image with pixel-level access.
5
5
  #
6
- # Implements the same pixel source interface (+width+, +height+, +[x, y]+)
7
- # as encoder inputs, so a decoded image can be passed directly to
8
- # {PureJPEG.encode} for re-encoding.
6
+ # Internally stores pixels as packed integers (+r << 16 | g << 8 | b+) to
7
+ # avoid per-pixel object allocation. Implements the same pixel source
8
+ # interface (+width+, +height+, +[x, y]+) as encoder inputs, so a decoded
9
+ # image can be passed directly to {PureJPEG.encode} for re-encoding.
9
10
  class Image
10
11
  # @return [Integer] image width in pixels
11
12
  attr_reader :width
12
13
  # @return [Integer] image height in pixels
13
14
  attr_reader :height
14
15
 
16
+ # @return [Array<Integer>] flat row-major array of packed RGB integers.
17
+ # Format: +(r << 16) | (g << 8) | b+.
18
+ attr_reader :packed_pixels
19
+
20
+ # @return [String, nil] raw ICC color profile data, if present in the source JPEG
21
+ attr_reader :icc_profile
22
+
15
23
  # @param width [Integer]
16
24
  # @param height [Integer]
17
- # @param pixels [Array<Source::Pixel>] flat row-major array of pixels
18
- def initialize(width, height, pixels)
25
+ # @param packed_pixels [Array<Integer>] flat row-major array of packed RGB
26
+ # integers in the format +(r << 16) | (g << 8) | b+
27
+ # @param icc_profile [String, nil] raw ICC profile bytes
28
+ def initialize(width, height, packed_pixels, icc_profile: nil)
19
29
  @width = width
20
30
  @height = height
21
- @pixels = pixels
31
+ @packed_pixels = packed_pixels
32
+ @icc_profile = icc_profile
22
33
  end
23
34
 
24
35
  # Retrieve a pixel by coordinate.
@@ -27,7 +38,8 @@ module PureJPEG
27
38
  # @param y [Integer] row (0-based)
28
39
  # @return [Source::Pixel] pixel with +.r+, +.g+, +.b+ in 0-255
29
40
  def [](x, y)
30
- @pixels[y * @width + x]
41
+ color = @packed_pixels[y * @width + x]
42
+ Source::Pixel.new((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF)
31
43
  end
32
44
 
33
45
  # Set a pixel by coordinate.
@@ -37,7 +49,8 @@ module PureJPEG
37
49
  # @param pixel [Source::Pixel] replacement pixel
38
50
  # @return [Source::Pixel]
39
51
  def []=(x, y, pixel)
40
- @pixels[y * @width + x] = pixel
52
+ @packed_pixels[y * @width + x] = (pixel.r << 16) | (pixel.g << 8) | pixel.b
53
+ pixel
41
54
  end
42
55
 
43
56
  # Iterate over every pixel in the image.
@@ -53,5 +66,24 @@ module PureJPEG
53
66
  end
54
67
  end
55
68
  end
69
+
70
+ # Iterate over every pixel without allocating Pixel structs.
71
+ #
72
+ # @yieldparam x [Integer] column
73
+ # @yieldparam y [Integer] row
74
+ # @yieldparam r [Integer] red component (0-255)
75
+ # @yieldparam g [Integer] green component (0-255)
76
+ # @yieldparam b [Integer] blue component (0-255)
77
+ # @return [void]
78
+ def each_rgb
79
+ i = 0
80
+ @height.times do |y|
81
+ @width.times do |x|
82
+ color = @packed_pixels[i]
83
+ yield x, y, (color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF
84
+ i += 1
85
+ end
86
+ end
87
+ end
56
88
  end
57
89
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureJPEG
4
+ # Lightweight metadata returned by {.info}.
5
+ Info = Struct.new(:width, :height, :component_count, :progressive, :icc_profile, keyword_init: true)
6
+ end
@@ -3,20 +3,33 @@
3
3
  module PureJPEG
4
4
  class JFIFReader
5
5
  attr_reader :width, :height, :components, :quant_tables, :huffman_tables,
6
- :scan_components, :scan_data, :restart_interval
6
+ :restart_interval, :progressive, :scans, :icc_profile
7
7
 
8
8
  Component = Struct.new(:id, :h_sampling, :v_sampling, :qt_id)
9
9
  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)
10
11
 
11
- def initialize(data)
12
+ def initialize(data, stop_after_frame: false)
12
13
  @data = data.b
14
+ @stop_after_frame = stop_after_frame
13
15
  @pos = 0
14
16
  @quant_tables = {}
15
17
  @huffman_tables = {}
16
18
  @components = []
17
- @scan_components = []
18
19
  @restart_interval = 0
20
+ @progressive = false
21
+ @scans = []
22
+ @icc_chunks = {}
19
23
  parse
24
+ assemble_icc_profile
25
+ end
26
+
27
+ def scan_components
28
+ @scans.first&.components || []
29
+ end
30
+
31
+ def scan_data
32
+ @scans.first&.data || "".b
20
33
  end
21
34
 
22
35
  private
@@ -27,7 +40,9 @@ module PureJPEG
27
40
  loop do
28
41
  marker = read_marker
29
42
  case marker
30
- when 0xE0..0xEF # APP0-APP15
43
+ when 0xE2 # APP2 (may contain ICC profile)
44
+ parse_app2
45
+ when 0xE0, 0xE1, 0xE3..0xEF # APP0, APP1, APP3-APP15
31
46
  skip_segment
32
47
  when 0xDB # DQT
33
48
  parse_dqt
@@ -35,14 +50,23 @@ module PureJPEG
35
50
  parse_dht
36
51
  when 0xC0 # SOF0 (baseline)
37
52
  parse_sof0
53
+ return if @stop_after_frame
54
+ when 0xC2 # SOF2 (progressive)
55
+ parse_sof0
56
+ @progressive = true
57
+ return if @stop_after_frame
38
58
  when 0xDA # SOS
39
- parse_sos
40
- extract_scan_data
41
- return
59
+ scan = parse_sos
60
+ scan.data = extract_scan_data
61
+ scan.huffman_tables = @huffman_tables.dup
62
+ @scans << scan
63
+ return unless @progressive
42
64
  when 0xFE # COM (comment)
43
65
  skip_segment
44
66
  when 0xDD # DRI (restart interval)
45
67
  parse_dri
68
+ when 0xD9 # EOI
69
+ return
46
70
  else
47
71
  skip_segment
48
72
  end
@@ -50,6 +74,7 @@ module PureJPEG
50
74
  end
51
75
 
52
76
  def read_byte
77
+ raise PureJPEG::DecodeError, "Unexpected end of JPEG data" if @pos >= @data.bytesize
53
78
  byte = @data.getbyte(@pos)
54
79
  @pos += 1
55
80
  byte
@@ -61,7 +86,7 @@ module PureJPEG
61
86
 
62
87
  def read_marker
63
88
  byte = read_byte
64
- raise "Expected 0xFF, got 0x#{byte.to_s(16)}" unless byte == 0xFF
89
+ raise PureJPEG::DecodeError, "Expected 0xFF, got 0x#{byte.to_s(16)}" unless byte == 0xFF
65
90
  # Skip padding 0xFF bytes
66
91
  code = read_byte
67
92
  code = read_byte while code == 0xFF
@@ -70,7 +95,29 @@ module PureJPEG
70
95
 
71
96
  def expect_marker(expected)
72
97
  marker = read_marker
73
- raise "Expected marker 0x#{expected.to_s(16)}, got 0x#{marker.to_s(16)}" unless marker == expected
98
+ raise PureJPEG::DecodeError, "Expected marker 0x#{expected.to_s(16)}, got 0x#{marker.to_s(16)}" unless marker == expected
99
+ end
100
+
101
+ ICC_PROFILE_SIG = "ICC_PROFILE\0".b
102
+
103
+ def parse_app2
104
+ length = read_u16
105
+ end_pos = @pos + length - 2
106
+
107
+ if length >= 16 && @data[@pos, 12] == ICC_PROFILE_SIG
108
+ @pos += 12
109
+ seq_no = read_byte
110
+ _total = read_byte
111
+ @icc_chunks[seq_no] = @data[@pos, end_pos - @pos]
112
+ end
113
+
114
+ @pos = end_pos
115
+ end
116
+
117
+ def assemble_icc_profile
118
+ return if @icc_chunks.empty?
119
+
120
+ @icc_profile = @icc_chunks.sort_by(&:first).map(&:last).join.b
74
121
  end
75
122
 
76
123
  def skip_segment
@@ -136,7 +183,7 @@ module PureJPEG
136
183
  read_u16 # length
137
184
  num_components = read_byte
138
185
 
139
- @scan_components = Array.new(num_components) do
186
+ components = Array.new(num_components) do
140
187
  id = read_byte
141
188
  tables = read_byte
142
189
  dc_id = (tables >> 4) & 0x0F
@@ -144,8 +191,12 @@ module PureJPEG
144
191
  ScanComponent.new(id, dc_id, ac_id)
145
192
  end
146
193
 
147
- # Spectral selection and approximation (ignored for baseline)
148
- 3.times { read_byte }
194
+ ss = read_byte # spectral selection start
195
+ se = read_byte # spectral selection end
196
+ ahl = read_byte # successive approximation
197
+ ah = (ahl >> 4) & 0x0F
198
+ al = ahl & 0x0F
199
+ Scan.new(components, ss, se, ah, al, nil)
149
200
  end
150
201
 
151
202
  def parse_dri
@@ -156,18 +207,20 @@ module PureJPEG
156
207
  # Extract entropy-coded scan data (everything from current position to EOI marker).
157
208
  def extract_scan_data
158
209
  start = @pos
210
+ len = @data.bytesize
159
211
  # Scan forward looking for a marker that isn't a stuffing byte or restart
160
- while @pos < @data.bytesize - 1
161
- if @data.getbyte(@pos) == 0xFF
162
- next_byte = @data.getbyte(@pos + 1)
163
- # 0x00 is byte stuffing, 0xD0-0xD7 are restart markers — all part of scan data
164
- if next_byte != 0x00 && !(next_byte >= 0xD0 && next_byte <= 0xD7) && next_byte != 0xFF
165
- break
166
- end
212
+ while @pos < len - 1
213
+ found = @data.index("\xFF".b, @pos)
214
+ break unless found && found < len - 1
215
+ @pos = found
216
+ next_byte = @data.getbyte(@pos + 1)
217
+ # 0x00 is byte stuffing, 0xD0-0xD7 are restart markers, 0xFF is padding — all part of scan data
218
+ if next_byte != 0x00 && !(next_byte >= 0xD0 && next_byte <= 0xD7) && next_byte != 0xFF
219
+ break
167
220
  end
168
- @pos += 1
221
+ @pos += 2
169
222
  end
170
- @scan_data = @data[start...@pos]
223
+ @data[start...@pos]
171
224
  end
172
225
  end
173
226
  end
@@ -19,22 +19,25 @@ module PureJPEG
19
19
 
20
20
  # @param image [ChunkyPNG::Image] the source PNG image
21
21
  def initialize(image)
22
- @image = image
23
22
  @width = image.width
24
23
  @height = image.height
24
+ @packed_pixels = image.pixels
25
25
  end
26
26
 
27
+ # @return [Array<Integer>] flat row-major array of packed RGBA integers
28
+ attr_reader :packed_pixels
29
+
27
30
  # Retrieve a pixel at the given coordinate.
28
31
  #
29
32
  # @param x [Integer] column (0-based)
30
33
  # @param y [Integer] row (0-based)
31
34
  # @return [Pixel]
32
35
  def [](x, y)
33
- color = @image[x, y]
36
+ color = @packed_pixels[y * @width + x]
34
37
  Pixel.new(
35
- ChunkyPNG::Color.r(color),
36
- ChunkyPNG::Color.g(color),
37
- ChunkyPNG::Color.b(color)
38
+ (color >> 24) & 0xFF,
39
+ (color >> 16) & 0xFF,
40
+ (color >> 8) & 0xFF
38
41
  )
39
42
  end
40
43
  end
@@ -27,7 +27,8 @@ module PureJPEG
27
27
  def initialize(width, height, &block)
28
28
  @width = width
29
29
  @height = height
30
- @pixels = Array.new(width * height)
30
+ black = Pixel.new(0, 0, 0)
31
+ @pixels = Array.new(width * height, black)
31
32
 
32
33
  if block
33
34
  height.times do |y|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PureJPEG
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/pure_jpeg.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "pure_jpeg/huffman/encoder"
16
16
  require_relative "pure_jpeg/huffman/decoder"
17
17
  require_relative "pure_jpeg/jfif_writer"
18
18
  require_relative "pure_jpeg/jfif_reader"
19
+ require_relative "pure_jpeg/info"
19
20
  require_relative "pure_jpeg/image"
20
21
  require_relative "pure_jpeg/encoder"
21
22
  require_relative "pure_jpeg/decoder"
@@ -25,6 +26,12 @@ require_relative "pure_jpeg/decoder"
25
26
  # Supports baseline DCT (SOF0) with 8-bit precision, grayscale and YCbCr
26
27
  # color (4:2:0 chroma subsampling), and standard Huffman tables (Annex K).
27
28
  module PureJPEG
29
+ # Raised when decoding invalid or unsupported JPEG data.
30
+ class DecodeError < StandardError; end
31
+
32
+ # Maximum image dimension (width or height) allowed for encoding and decoding.
33
+ MAX_DIMENSION = 8192
34
+
28
35
  # Encode a pixel source as a JPEG.
29
36
  #
30
37
  # @param source [#width, #height, #[]] any object responding to +width+,
@@ -57,4 +64,27 @@ module PureJPEG
57
64
  def self.read(path_or_data)
58
65
  Decoder.decode(path_or_data)
59
66
  end
67
+
68
+ # Read JPEG dimensions and basic frame metadata without decoding scan data.
69
+ #
70
+ # @param path_or_data [String] a file path or raw JPEG bytes
71
+ # @return [Info] image metadata parsed from the frame header
72
+ def self.info(path_or_data)
73
+ data = if path_or_data.is_a?(String) && !path_or_data.start_with?("\xFF\xD8".b) && File.exist?(path_or_data)
74
+ File.binread(path_or_data)
75
+ else
76
+ path_or_data.b
77
+ end
78
+
79
+ jfif = JFIFReader.new(data, stop_after_frame: true)
80
+ raise DecodeError, "JPEG frame header not found" unless jfif.width && jfif.height
81
+
82
+ Info.new(
83
+ width: jfif.width,
84
+ height: jfif.height,
85
+ component_count: jfif.components.length,
86
+ progressive: jfif.progressive,
87
+ icc_profile: jfif.icc_profile
88
+ )
89
+ end
60
90
  end
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Cooper
@@ -57,6 +57,7 @@ files:
57
57
  - lib/pure_jpeg/huffman/encoder.rb
58
58
  - lib/pure_jpeg/huffman/tables.rb
59
59
  - lib/pure_jpeg/image.rb
60
+ - lib/pure_jpeg/info.rb
60
61
  - lib/pure_jpeg/jfif_reader.rb
61
62
  - lib/pure_jpeg/jfif_writer.rb
62
63
  - lib/pure_jpeg/quantization.rb
@@ -78,7 +79,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
78
79
  requirements:
79
80
  - - ">="
80
81
  - !ruby/object:Gem::Version
81
- version: 2.7.0
82
+ version: 3.0.0
82
83
  required_rubygems_version: !ruby/object:Gem::Requirement
83
84
  requirements:
84
85
  - - ">="