pure_jpeg 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6eacb8a616f95a52625f6f5acb3c8c137306c5dcf3636e93e2e715287b429655
4
- data.tar.gz: dade5c2d3b9603bb7089635977ea2e38827da1a3912c820152367f3ca643c5f9
3
+ metadata.gz: 7a0015f811a2250264bfa73727aa37af15fee1af10e4897243045f2f4f54ae07
4
+ data.tar.gz: 5085ab8c4bd1d9941c94e116b39ea7ca38f658fdf0e48523cae65fc913f765d0
5
5
  SHA512:
6
- metadata.gz: ccf7a06b88c08f14ca70d944ddcc753795217e3bfbca00874484ee6f3c2a360d8cede768c4469f5b92d2789bf7bcc71d61b22f67d76fb98774af28f88132c248
7
- data.tar.gz: d868a5d4f7db3b20a504bc09f9908169fd35c269b511e619e083957fd6b4bf90a3cf22985a5e6d81289a02306ba75677b94b8ff9f6d741acc7b2047979d0f2c6
6
+ metadata.gz: b507962b2ec9650e743b365b8ace5ddb2a9c1d04de126206ac6062c9da46001dc3e8a1f04df356187b3a2458f3383625d864fe12ee0b3c5e3df589755aa540aa
7
+ data.tar.gz: 7bf019ea4702bbd7379ad3a1d295acaadd47b185815461b810c08c33299248235b326e9d0cdcfbebe23646f32014487c55212517a9e8a246d4fd5d40891eb62f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ Fixes:
6
+
7
+ - Fixed shared `Pixel` instance bug in decoder that could corrupt pixel data
8
+ - Encoder validates return values from `quantization_modifier` blocks
9
+
10
+ ## 0.3.0
11
+
12
+ New features:
13
+
14
+ - `PureJPEG.info` for reading dimensions and metadata without full decode
15
+ - ICC color profile extraction (available on `Info` and `Image`)
16
+ - Optional image-specific optimized Huffman tables (`optimize_huffman: true`)
17
+
18
+ Fixes:
19
+
20
+ - Decoder validates Huffman table, quantization table, and component references with clear error messages
21
+ - Color decoding looks up Y/Cb/Cr components by ID instead of assuming SOF array order
22
+ - Support for non-standard component IDs (e.g. 0, 1, 2 as used by some Adobe tools)
23
+ - Explicit error for unsupported component counts (e.g. CMYK)
24
+ - Encoder no longer holds file handle open during encoding
25
+
3
26
  ## 0.2.0
4
27
 
5
28
  New features:
data/README.md CHANGED
@@ -79,7 +79,8 @@ PureJPEG.encode(source,
79
79
  luminance_table: nil, # custom 64-element quantization table for Y
80
80
  chrominance_table: nil, # custom 64-element quantization table for Cb/Cr
81
81
  quantization_modifier: nil, # proc(table, :luminance/:chrominance) -> modified table
82
- scramble_quantization: false # intentionally misordered quant tables (creative effect)
82
+ scramble_quantization: false, # intentionally misordered quant tables (creative effect)
83
+ optimize_huffman: false # slower 2-pass encode, usually smaller files
83
84
  )
84
85
  ```
85
86
 
@@ -135,6 +136,16 @@ pixel.b # => 97
135
136
  image = PureJPEG.read(jpeg_bytes)
136
137
  ```
137
138
 
139
+ ### Read dimensions and metadata only
140
+
141
+ ```ruby
142
+ info = PureJPEG.info("photo.jpg")
143
+ info.width # => 1024
144
+ info.height # => 768
145
+ info.component_count # => 3
146
+ info.progressive # => false
147
+ ```
148
+
138
149
  ### Iterating pixels
139
150
 
140
151
  ```ruby
@@ -171,7 +182,8 @@ Encoding:
171
182
  - 8-bit precision
172
183
  - Grayscale (1 component) and YCbCr color (3 components)
173
184
  - 4:2:0 chroma subsampling (color) or no subsampling (grayscale)
174
- - Standard Huffman tables (Annex K)
185
+ - Standard Huffman tables (Annex K) by default
186
+ - Optional image-specific optimized Huffman tables
175
187
 
176
188
  Decoding:
177
189
  - Baseline DCT (SOF0) and Progressive DCT (SOF2)
@@ -182,14 +194,17 @@ Decoding:
182
194
 
183
195
  Not supported: arithmetic coding, 12-bit precision, EXIF/ICC profile preservation, adding a default background for transparent sources (see what happens above!). Largely because I don't need these, but they are all do-able, especially with how loosely coupled this library is internally. Raise an issue if you really care about them!
184
196
 
197
+ Possible future improvements: AAN/fixed-point DCT (but it's a LOT of work), ICC profile rendering/conversion.
198
+
185
199
  ## Performance
186
200
 
187
- On a 1024x1024 image (Ruby 4.0.1 on my M1 Max):
201
+ On a 1024x1024 image (Ruby 4.0.1 on my M5):
188
202
 
189
203
  | Operation | Time |
190
204
  |-----------|------|
191
- | Encode (color, q85) | ~1.7s |
192
- | Decode (color) | ~1.8s |
205
+ | Encode (color, q85) | ~1.2s |
206
+ | Decode (baseline) | ~1.2s |
207
+ | Decode (progressive) | ~1.3s |
193
208
 
194
209
  Both the encoder and decoder use a separable DCT with a precomputed cosine matrix and reuse all per-block buffers to minimize GC pressure. Pixel data is stored as packed integers internally to avoid per-pixel object allocation.
195
210
 
@@ -198,17 +213,17 @@ Both the encoder and decoder use a separable DCT with a precomputed cosine matri
198
213
  ```
199
214
  bundle install
200
215
  rake test # run the test suite
201
- rake benchmark # benchmark encoding (3 runs against examples/a.png)
216
+ rake benchmark # benchmark encoding and decoding (3 runs each)
202
217
  rake profile # CPU profile with StackProf (requires the stackprof gem)
203
218
  ```
204
219
 
205
220
  ## AI Disclosure
206
221
 
207
- **Claude Code did the majority of the work.** The math of JPEG encoding/decoding is beyond me, except 'getting it' at a high level. I understand it like I understand the engine in my car :-)
222
+ **Claude Code did the majority of the work.** The math of JPEG encoding/decoding is beyond me, except 'getting it' at a high level. I understand it like I understand the engine in my car :-) *Later update: OpenAI Codex is also reviewing and adding features now. It feels stronger in many areas.*
208
223
 
209
- **I have read all of the code produced.** The algorithms are above my paygrade, but I'm OK with what has been produced, and I manually fixed a variety of stylistic things along the way. For example, CC seems to like wrapping entire functions in `if` statements rather than bailing on the opposite condition.
224
+ **I have read all of the code produced up to v0.2.0.** The algorithms are above my paygrade, but I'm OK with what has been produced, and I manually fixed a variety of stylistic things along the way. For example, CC seems to like wrapping entire functions in `if` statements rather than bailing on the opposite condition. *Later update: I have not read the ICC and optimized Huffman code yet, but it is heavily tested.*
210
225
 
211
- **CC needed a lot of guidance.** Its initial JPEG algorithm was somewhat naive and output odd looking JPEGs akin to those of my Kodak digital camera from 2001. After some back and forth and image comparisons, we figured out it was doing the quantization entirely wrong (specifically not using the zigzag approach during quanitization but just going in raster order). I *like* this aesthetic, but fixed it up so that it works as a generally usable JPEG library, while adding ways to customize things so you can recreate the effect, if preferred (see `CREATIVE.md` for more on that).
226
+ **CC needed a lot of guidance.** Its initial JPEG algorithm was somewhat naive and output odd looking JPEGs akin to those of my [Casio QV-10 digital camera](https://medium.com/people-gadgets/the-gadget-we-miss-the-casio-qv-10-digital-camera-c25ab786ce49) from the late 1990s. After some back and forth and image comparisons, we figured out it was doing the quantization entirely wrong (specifically not using the zigzag approach during quanitization but just going in raster order). I *like* this aesthetic, but fixed it up so that it works as a generally usable JPEG library, while adding ways to customize things so you can recreate the effect, if preferred (see `CREATIVE.md` for more on that).
212
227
 
213
228
  **CC is lazy.** The initial implementation was VERY SLOW. It took 15 seconds to turn a 1024x1024 PNG into a JPEG, so we went down the profiling rabbit hole and found many optimizations to make it ~6x faster. CC is poor at considering the role of Ruby's GC when implementing low level algorithms and needs some prodding to make the correct optimizations. CC is also lazy to the point of recommending that you just use another language (e.g. Go or Rust) rather than do a pure Ruby version of something - despite it being possible with some extra work.
214
229
 
@@ -27,6 +27,8 @@ module PureJPEG
27
27
 
28
28
  def decode
29
29
  jfif = JFIFReader.new(@data)
30
+ @icc_profile = jfif.icc_profile
31
+ validate_dimensions!(jfif.width, jfif.height)
30
32
  return decode_progressive(jfif) if jfif.progressive
31
33
 
32
34
  width = jfif.width
@@ -90,10 +92,8 @@ module PureJPEG
90
92
  end
91
93
 
92
94
  jfif.scan_components.each do |sc|
93
- comp = comp_info[sc.id]
94
- dc_tab = dc_tables[sc.dc_table_id]
95
- ac_tab = ac_tables[sc.ac_table_id]
96
- qt = jfif.quant_tables[comp.qt_id]
95
+ comp, dc_tab, ac_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables)
96
+ qt = fetch_quant_table!(jfif, comp)
97
97
  ch = channels[comp.id]
98
98
 
99
99
  comp.v_sampling.times do |bv|
@@ -122,13 +122,20 @@ module PureJPEG
122
122
  num_components = jfif.components.length
123
123
  if num_components == 1
124
124
  assemble_grayscale(width, height, channels, jfif.components[0])
125
- else
125
+ elsif num_components == 3
126
126
  assemble_color(width, height, channels, jfif.components, max_h, max_v)
127
+ else
128
+ raise DecodeError, "Unsupported number of components: #{num_components}"
127
129
  end
128
130
  end
129
131
 
130
132
  private
131
133
 
134
+ def validate_dimensions!(width, height)
135
+ raise DecodeError, "Invalid image dimensions: #{width}x#{height}" if width <= 0 || height <= 0
136
+ raise DecodeError, "Image too large: #{width}x#{height} (max #{MAX_DIMENSION}x#{MAX_DIMENSION})" if width > MAX_DIMENSION || height > MAX_DIMENSION
137
+ end
138
+
132
139
  # --- Progressive JPEG decoding ---
133
140
 
134
141
  def decode_progressive(jfif)
@@ -203,7 +210,7 @@ module PureJPEG
203
210
  spatial = Array.new(64, 0.0)
204
211
 
205
212
  jfif.components.each do |c|
206
- qt = jfif.quant_tables[c.qt_id]
213
+ qt = fetch_quant_table!(jfif, c)
207
214
  ch = channels[c.id]
208
215
  coeff_buf = coeffs[c.id]
209
216
  bx_count, by_count = comp_blocks[c.id]
@@ -224,17 +231,17 @@ module PureJPEG
224
231
  num_components = jfif.components.length
225
232
  if num_components == 1
226
233
  assemble_grayscale(width, height, channels, jfif.components[0])
227
- else
234
+ elsif num_components == 3
228
235
  assemble_color(width, height, channels, jfif.components, max_h, max_v)
236
+ else
237
+ raise DecodeError, "Unsupported number of components: #{num_components}"
229
238
  end
230
239
  end
231
240
 
232
241
  def prog_scan_non_interleaved(reader, scan, comp_info, dc_tables, ac_tables,
233
242
  coeffs, comp_blocks, restart_interval, ss, se, ah, al)
234
243
  sc = scan.components[0]
235
- comp = comp_info[sc.id]
236
- dc_tab = dc_tables[sc.dc_table_id]
237
- ac_tab = ac_tables[sc.ac_table_id]
244
+ comp, dc_tab, ac_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables, require_ac: ss > 0)
238
245
  coeff_buf = coeffs[comp.id]
239
246
  bx_count, by_count = comp_blocks[comp.id]
240
247
 
@@ -284,8 +291,7 @@ module PureJPEG
284
291
  end
285
292
 
286
293
  scan.components.each do |sc|
287
- comp = comp_info[sc.id]
288
- dc_tab = dc_tables[sc.dc_table_id]
294
+ comp, dc_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables, require_ac: false)
289
295
  coeff_buf = coeffs[comp.id]
290
296
  bx_count = comp_blocks[comp.id][0]
291
297
 
@@ -463,6 +469,28 @@ module PureJPEG
463
469
  end
464
470
  end
465
471
 
472
+ def resolve_scan_references!(scan_component, comp_info, dc_tables, ac_tables, require_ac: true)
473
+ comp = comp_info[scan_component.id]
474
+ raise DecodeError, "Scan references unknown component id #{scan_component.id}" unless comp
475
+
476
+ dc_tab = dc_tables[scan_component.dc_table_id]
477
+ raise DecodeError, "Component #{scan_component.id} references missing DC Huffman table #{scan_component.dc_table_id}" unless dc_tab
478
+
479
+ if require_ac
480
+ ac_tab = ac_tables[scan_component.ac_table_id]
481
+ raise DecodeError, "Component #{scan_component.id} references missing AC Huffman table #{scan_component.ac_table_id}" unless ac_tab
482
+ end
483
+
484
+ [comp, dc_tab, ac_tab]
485
+ end
486
+
487
+ def fetch_quant_table!(jfif, comp)
488
+ qt = jfif.quant_tables[comp.qt_id]
489
+ raise DecodeError, "Component #{comp.id} references missing quantization table #{comp.qt_id}" unless qt
490
+
491
+ qt
492
+ end
493
+
466
494
  def assemble_grayscale(width, height, channels, comp)
467
495
  ch = channels[comp.id]
468
496
  pixels = Array.new(width * height)
@@ -474,17 +502,16 @@ module PureJPEG
474
502
  pixels[dst_row + x] = (v << 16) | (v << 8) | v
475
503
  end
476
504
  end
477
- Image.new(width, height, pixels)
505
+ Image.new(width, height, pixels, icc_profile: @icc_profile)
478
506
  end
479
507
 
480
508
  def assemble_color(width, height, channels, components, max_h, max_v)
481
509
  # Upsample chroma channels if needed and convert YCbCr to RGB
482
- y_ch = channels[components[0].id]
483
- cb_ch = channels[components[1].id]
484
- cr_ch = channels[components[2].id]
510
+ y_comp, cb_comp, cr_comp = resolve_color_components(components)
485
511
 
486
- cb_comp = components[1]
487
- cr_comp = components[2]
512
+ y_ch = channels[y_comp.id]
513
+ cb_ch = channels[cb_comp.id]
514
+ cr_ch = channels[cr_comp.id]
488
515
 
489
516
  pixels = Array.new(width * height)
490
517
 
@@ -518,7 +545,16 @@ module PureJPEG
518
545
  end
519
546
  end
520
547
 
521
- Image.new(width, height, pixels)
548
+ Image.new(width, height, pixels, icc_profile: @icc_profile)
549
+ end
550
+
551
+ def resolve_color_components(components)
552
+ by_id = components.each_with_object({}) { |comp, memo| memo[comp.id] = comp }
553
+ if by_id[1] && by_id[2] && by_id[3]
554
+ [by_id[1], by_id[2], by_id[3]]
555
+ else
556
+ components
557
+ end
522
558
  end
523
559
  end
524
560
  end
@@ -15,6 +15,8 @@ module PureJPEG
15
15
  attr_reader :quality
16
16
  # @return [Boolean] whether grayscale mode is enabled
17
17
  attr_reader :grayscale
18
+ # @return [Boolean] whether image-specific Huffman tables are generated
19
+ attr_reader :optimize_huffman
18
20
 
19
21
  # Create a new encoder for the given pixel source.
20
22
  #
@@ -34,12 +36,16 @@ module PureJPEG
34
36
  # @param scramble_quantization [Boolean] write quantization tables in raster
35
37
  # order instead of zigzag (non-spec-compliant; recreates the "early digicam"
36
38
  # artifact look when decoded by standard viewers)
39
+ # @param optimize_huffman [Boolean] build image-specific Huffman tables with
40
+ # an additional analysis pass (default false)
37
41
  def initialize(source, quality: 85, grayscale: false, chroma_quality: nil,
38
42
  luminance_table: nil, chrominance_table: nil,
39
- quantization_modifier: nil, scramble_quantization: false)
43
+ quantization_modifier: nil, scramble_quantization: false,
44
+ optimize_huffman: false)
40
45
  @source = source
41
46
  @quality = quality
42
47
  @grayscale = grayscale
48
+ @optimize_huffman = optimize_huffman
43
49
  @chroma_quality = chroma_quality || quality
44
50
  validate_qtable!(luminance_table, "luminance_table") if luminance_table
45
51
  validate_qtable!(chrominance_table, "chrominance_table") if chrominance_table
@@ -54,7 +60,7 @@ module PureJPEG
54
60
  # @param path [String] output file path
55
61
  # @return [void]
56
62
  def write(path)
57
- File.open(path, "wb") { |f| encode(f) }
63
+ File.binwrite(path, to_bytes)
58
64
  end
59
65
 
60
66
  # Return the encoded JPEG as a binary string.
@@ -70,17 +76,27 @@ module PureJPEG
70
76
 
71
77
  def build_lum_qtable
72
78
  table = @luminance_table || Quantization.scale_table(Quantization::LUMINANCE_BASE, quality)
73
- table = @quantization_modifier.call(table, :luminance) if @quantization_modifier
79
+ table = apply_quantization_modifier(table, :luminance) if @quantization_modifier
74
80
  table
75
81
  end
76
82
 
77
83
  def build_chr_qtable
78
84
  table = @chrominance_table || Quantization.scale_table(Quantization::CHROMINANCE_BASE, @chroma_quality)
79
- table = @quantization_modifier.call(table, :chrominance) if @quantization_modifier
85
+ table = apply_quantization_modifier(table, :chrominance) if @quantization_modifier
80
86
  table
81
87
  end
82
88
 
89
+ def apply_quantization_modifier(table, channel)
90
+ modified = @quantization_modifier.call(table, channel)
91
+ validate_qtable!(modified, "quantization_modifier result for #{channel}")
92
+ modified
93
+ end
94
+
83
95
  def validate_qtable!(table, name)
96
+ unless table.respond_to?(:length) && table.respond_to?(:all?)
97
+ raise ArgumentError, "#{name} must be a 64-element array of integers between 1 and 255"
98
+ end
99
+
84
100
  raise ArgumentError, "#{name} must have exactly 64 elements (got #{table.length})" unless table.length == 64
85
101
  unless table.all? { |v| v.is_a?(Integer) && v >= 1 && v <= 255 }
86
102
  raise ArgumentError, "#{name} elements must be integers between 1 and 255"
@@ -91,61 +107,127 @@ module PureJPEG
91
107
  width = source.width
92
108
  height = source.height
93
109
 
110
+ raise ArgumentError, "Width must be a positive integer (got #{width.inspect})" unless width.is_a?(Integer) && width > 0
111
+ raise ArgumentError, "Height must be a positive integer (got #{height.inspect})" unless height.is_a?(Integer) && height > 0
112
+ raise ArgumentError, "Width #{width} exceeds maximum of #{MAX_DIMENSION}" if width > MAX_DIMENSION
113
+ raise ArgumentError, "Height #{height} exceeds maximum of #{MAX_DIMENSION}" if height > MAX_DIMENSION
114
+
94
115
  lum_qtable = build_lum_qtable
95
- lum_dc = Huffman.build_table(Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES)
96
- lum_ac = Huffman.build_table(Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES)
97
- lum_huff = Huffman::Encoder.new(lum_dc, lum_ac)
98
116
 
99
117
  if grayscale
100
- scan_data = encode_grayscale(width, height, lum_qtable, lum_huff)
101
- write_grayscale_jfif(io, width, height, lum_qtable, scan_data)
118
+ y_data = extract_luminance(width, height)
119
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values =
120
+ if optimize_huffman
121
+ counter = collect_grayscale_frequencies(y_data, width, height, lum_qtable)
122
+ dc_bits, dc_values = Huffman.optimize_table(counter.dc_frequencies)
123
+ ac_bits, ac_values = Huffman.optimize_table(counter.ac_frequencies)
124
+ [dc_bits, dc_values, ac_bits, ac_values]
125
+ else
126
+ [Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES,
127
+ Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES]
128
+ end
129
+
130
+ lum_huff = Huffman::Encoder.new(
131
+ Huffman.build_table(lum_dc_bits, lum_dc_values),
132
+ Huffman.build_table(lum_ac_bits, lum_ac_values)
133
+ )
134
+
135
+ scan_data = encode_grayscale_data(y_data, width, height, lum_qtable, lum_huff)
136
+ write_grayscale_jfif(io, width, height, lum_qtable, scan_data,
137
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values)
102
138
  else
103
139
  chr_qtable = build_chr_qtable
104
- chr_dc = Huffman.build_table(Huffman::DC_CHROMINANCE_BITS, Huffman::DC_CHROMINANCE_VALUES)
105
- chr_ac = Huffman.build_table(Huffman::AC_CHROMINANCE_BITS, Huffman::AC_CHROMINANCE_VALUES)
106
- chr_huff = Huffman::Encoder.new(chr_dc, chr_ac)
140
+ y_data, cb_data, cr_data = extract_ycbcr(width, height)
141
+ sub_w = (width + 1) / 2
142
+ sub_h = (height + 1) / 2
143
+ cb_sub = downsample(cb_data, width, height, sub_w, sub_h)
144
+ cr_sub = downsample(cr_data, width, height, sub_w, sub_h)
145
+
146
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
147
+ chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values =
148
+ if optimize_huffman
149
+ lum_counter, chr_counter = collect_color_frequencies(
150
+ y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qtable, chr_qtable
151
+ )
152
+ dc_bits, dc_values = Huffman.optimize_table(lum_counter.dc_frequencies)
153
+ ac_bits, ac_values = Huffman.optimize_table(lum_counter.ac_frequencies)
154
+ chr_dc_bits, chr_dc_values = Huffman.optimize_table(chr_counter.dc_frequencies)
155
+ chr_ac_bits, chr_ac_values = Huffman.optimize_table(chr_counter.ac_frequencies)
156
+ [dc_bits, dc_values, ac_bits, ac_values, chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values]
157
+ else
158
+ [Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES,
159
+ Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES,
160
+ Huffman::DC_CHROMINANCE_BITS, Huffman::DC_CHROMINANCE_VALUES,
161
+ Huffman::AC_CHROMINANCE_BITS, Huffman::AC_CHROMINANCE_VALUES]
162
+ end
107
163
 
108
- scan_data = encode_color(width, height, lum_qtable, chr_qtable, lum_huff, chr_huff)
109
- write_color_jfif(io, width, height, lum_qtable, chr_qtable, scan_data)
164
+ lum_huff = Huffman::Encoder.new(
165
+ Huffman.build_table(lum_dc_bits, lum_dc_values),
166
+ Huffman.build_table(lum_ac_bits, lum_ac_values)
167
+ )
168
+ chr_huff = Huffman::Encoder.new(
169
+ Huffman.build_table(chr_dc_bits, chr_dc_values),
170
+ Huffman.build_table(chr_ac_bits, chr_ac_values)
171
+ )
172
+
173
+ scan_data = encode_color_data(
174
+ y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qtable, chr_qtable, lum_huff, chr_huff
175
+ )
176
+ write_color_jfif(io, width, height, lum_qtable, chr_qtable, scan_data,
177
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
178
+ chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values)
110
179
  end
111
180
  end
112
181
 
113
182
  # --- Grayscale encoding ---
114
183
 
115
- def encode_grayscale(width, height, qtable, huff)
116
- y_data = extract_luminance(width, height)
184
+ def collect_grayscale_frequencies(y_data, width, height, qtable)
185
+ counter = Huffman::FrequencyCounter.new
186
+ each_grayscale_block(y_data, width, height, qtable) do |zbuf|
187
+ counter.observe_block(zbuf, :y)
188
+ end
189
+ counter
190
+ end
191
+
192
+ def encode_grayscale_data(y_data, width, height, qtable, huff)
193
+ bit_writer = BitWriter.new
194
+ prev_dc = 0
195
+
196
+ each_grayscale_block(y_data, width, height, qtable) do |zbuf|
197
+ prev_dc = huff.encode_block(zbuf, prev_dc, bit_writer)
198
+ end
199
+
200
+ bit_writer.flush
201
+ bit_writer.bytes
202
+ end
203
+
204
+ def each_grayscale_block(y_data, width, height, qtable)
117
205
  padded_w = (width + 7) & ~7
118
206
  padded_h = (height + 7) & ~7
119
207
 
120
- # Reusable buffers
121
208
  block = Array.new(64, 0.0)
122
209
  temp = Array.new(64, 0.0)
123
210
  dct = Array.new(64, 0.0)
124
211
  qbuf = Array.new(64, 0)
125
212
  zbuf = Array.new(64, 0)
126
213
 
127
- bit_writer = BitWriter.new
128
- prev_dc = 0
129
-
130
214
  (0...padded_h).step(8) do |by|
131
215
  (0...padded_w).step(8) do |bx|
132
216
  extract_block_into(y_data, width, height, bx, by, block)
133
- prev_dc = encode_block(block, temp, dct, qbuf, zbuf, qtable, huff, prev_dc, bit_writer)
217
+ transform_block(block, temp, dct, qbuf, zbuf, qtable)
218
+ yield zbuf
134
219
  end
135
220
  end
136
-
137
- bit_writer.flush
138
- bit_writer.bytes
139
221
  end
140
222
 
141
- def write_grayscale_jfif(io, width, height, qtable, scan_data)
223
+ def write_grayscale_jfif(io, width, height, qtable, scan_data, dc_bits, dc_values, ac_bits, ac_values)
142
224
  jfif = JFIFWriter.new(io, scramble_quantization: @scramble_quantization)
143
225
  jfif.write_soi
144
226
  jfif.write_app0
145
227
  jfif.write_dqt(qtable, 0)
146
228
  jfif.write_sof0(width, height, [[1, 1, 1, 0]])
147
- jfif.write_dht(0, 0, Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES)
148
- jfif.write_dht(1, 0, Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES)
229
+ jfif.write_dht(0, 0, dc_bits, dc_values)
230
+ jfif.write_dht(1, 0, ac_bits, ac_values)
149
231
  jfif.write_sos([[1, 0, 0]])
150
232
  jfif.write_scan_data(scan_data)
151
233
  jfif.write_eoi
@@ -153,69 +235,97 @@ module PureJPEG
153
235
 
154
236
  # --- Color encoding (YCbCr 4:2:0) ---
155
237
 
156
- def encode_color(width, height, lum_qt, chr_qt, lum_huff, chr_huff)
157
- y_data, cb_data, cr_data = extract_ycbcr(width, height)
238
+ def collect_color_frequencies(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt)
239
+ lum_counter = Huffman::FrequencyCounter.new
240
+ chr_counter = Huffman::FrequencyCounter.new
241
+
242
+ each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt) do |component, zbuf|
243
+ case component
244
+ when :y
245
+ lum_counter.observe_block(zbuf, :y)
246
+ when :cb
247
+ chr_counter.observe_block(zbuf, :cb)
248
+ when :cr
249
+ chr_counter.observe_block(zbuf, :cr)
250
+ end
251
+ end
158
252
 
159
- sub_w = (width + 1) / 2
160
- sub_h = (height + 1) / 2
161
- cb_sub = downsample(cb_data, width, height, sub_w, sub_h)
162
- cr_sub = downsample(cr_data, width, height, sub_w, sub_h)
253
+ [lum_counter, chr_counter]
254
+ end
255
+
256
+ def encode_color_data(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt, lum_huff, chr_huff)
257
+ bit_writer = BitWriter.new
258
+ prev_dc_y = 0
259
+ prev_dc_cb = 0
260
+ prev_dc_cr = 0
261
+
262
+ each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt) do |component, zbuf|
263
+ case component
264
+ when :y
265
+ prev_dc_y = lum_huff.encode_block(zbuf, prev_dc_y, bit_writer)
266
+ when :cb
267
+ prev_dc_cb = chr_huff.encode_block(zbuf, prev_dc_cb, bit_writer)
268
+ when :cr
269
+ prev_dc_cr = chr_huff.encode_block(zbuf, prev_dc_cr, bit_writer)
270
+ end
271
+ end
163
272
 
273
+ bit_writer.flush
274
+ bit_writer.bytes
275
+ end
276
+
277
+ def each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt)
164
278
  mcu_w = (width + 15) & ~15
165
279
  mcu_h = (height + 15) & ~15
166
280
 
167
- # Reusable buffers
168
281
  block = Array.new(64, 0.0)
169
282
  temp = Array.new(64, 0.0)
170
283
  dct = Array.new(64, 0.0)
171
284
  qbuf = Array.new(64, 0)
172
285
  zbuf = Array.new(64, 0)
173
286
 
174
- bit_writer = BitWriter.new
175
- prev_dc_y = 0
176
- prev_dc_cb = 0
177
- prev_dc_cr = 0
178
-
179
287
  (0...mcu_h).step(16) do |my|
180
288
  (0...mcu_w).step(16) do |mx|
181
- # 4 luminance blocks
182
289
  extract_block_into(y_data, width, height, mx, my, block)
183
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
290
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
291
+ yield :y, zbuf
184
292
 
185
293
  extract_block_into(y_data, width, height, mx + 8, my, block)
186
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
294
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
295
+ yield :y, zbuf
187
296
 
188
297
  extract_block_into(y_data, width, height, mx, my + 8, block)
189
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
298
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
299
+ yield :y, zbuf
190
300
 
191
301
  extract_block_into(y_data, width, height, mx + 8, my + 8, block)
192
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
302
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
303
+ yield :y, zbuf
193
304
 
194
- # 1 Cb block
195
305
  extract_block_into(cb_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
196
- prev_dc_cb = encode_block(block, temp, dct, qbuf, zbuf, chr_qt, chr_huff, prev_dc_cb, bit_writer)
306
+ transform_block(block, temp, dct, qbuf, zbuf, chr_qt)
307
+ yield :cb, zbuf
197
308
 
198
- # 1 Cr block
199
309
  extract_block_into(cr_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
200
- prev_dc_cr = encode_block(block, temp, dct, qbuf, zbuf, chr_qt, chr_huff, prev_dc_cr, bit_writer)
310
+ transform_block(block, temp, dct, qbuf, zbuf, chr_qt)
311
+ yield :cr, zbuf
201
312
  end
202
313
  end
203
-
204
- bit_writer.flush
205
- bit_writer.bytes
206
314
  end
207
315
 
208
- def write_color_jfif(io, width, height, lum_qt, chr_qt, scan_data)
316
+ def write_color_jfif(io, width, height, lum_qt, chr_qt, scan_data,
317
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
318
+ chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values)
209
319
  jfif = JFIFWriter.new(io, scramble_quantization: @scramble_quantization)
210
320
  jfif.write_soi
211
321
  jfif.write_app0
212
322
  jfif.write_dqt(lum_qt, 0)
213
323
  jfif.write_dqt(chr_qt, 1)
214
324
  jfif.write_sof0(width, height, [[1, 2, 2, 0], [2, 1, 1, 1], [3, 1, 1, 1]])
215
- jfif.write_dht(0, 0, Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES)
216
- jfif.write_dht(1, 0, Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES)
217
- jfif.write_dht(0, 1, Huffman::DC_CHROMINANCE_BITS, Huffman::DC_CHROMINANCE_VALUES)
218
- jfif.write_dht(1, 1, Huffman::AC_CHROMINANCE_BITS, Huffman::AC_CHROMINANCE_VALUES)
325
+ jfif.write_dht(0, 0, lum_dc_bits, lum_dc_values)
326
+ jfif.write_dht(1, 0, lum_ac_bits, lum_ac_values)
327
+ jfif.write_dht(0, 1, chr_dc_bits, chr_dc_values)
328
+ jfif.write_dht(1, 1, chr_ac_bits, chr_ac_values)
219
329
  jfif.write_sos([[1, 0, 0], [2, 1, 1], [3, 1, 1]])
220
330
  jfif.write_scan_data(scan_data)
221
331
  jfif.write_eoi
@@ -223,11 +333,11 @@ module PureJPEG
223
333
 
224
334
  # --- Shared block pipeline (all buffers pre-allocated) ---
225
335
 
226
- def encode_block(block, temp, dct, qbuf, zbuf, qtable, huff, prev_dc, bit_writer)
336
+ def transform_block(block, temp, dct, qbuf, zbuf, qtable)
227
337
  DCT.forward!(block, temp, dct)
228
338
  Quantization.quantize!(dct, qtable, qbuf)
229
339
  Zigzag.reorder!(qbuf, zbuf)
230
- huff.encode_block(zbuf, prev_dc, bit_writer)
340
+ zbuf
231
341
  end
232
342
 
233
343
  # --- Pixel extraction ---
@@ -3,6 +3,56 @@
3
3
  module PureJPEG
4
4
  module Huffman
5
5
  class Encoder
6
+ def self.category_and_bits(value)
7
+ return [0, 0] if value == 0
8
+ abs_val = value.abs
9
+ cat = 0
10
+ v = abs_val
11
+ while v > 0
12
+ cat += 1
13
+ v >>= 1
14
+ end
15
+ bits = value > 0 ? value : value + (1 << cat) - 1
16
+ [cat, bits]
17
+ end
18
+
19
+ def self.each_ac_item(zigzag)
20
+ last_nonzero = 63
21
+ last_nonzero -= 1 while last_nonzero > 0 && zigzag[last_nonzero] == 0
22
+
23
+ if last_nonzero == 0
24
+ yield 0x00, 0
25
+ return
26
+ end
27
+
28
+ i = 1
29
+ while i <= last_nonzero
30
+ run = 0
31
+ while i <= last_nonzero && zigzag[i] == 0
32
+ run += 1
33
+ i += 1
34
+ end
35
+
36
+ while run >= 16
37
+ yield 0xF0, 0
38
+ run -= 16
39
+ end
40
+
41
+ value = zigzag[i]
42
+ cat, = category_and_bits(value)
43
+ yield (run << 4) | cat, value
44
+ i += 1
45
+ end
46
+
47
+ yield 0x00, 0 if last_nonzero < 63
48
+ end
49
+
50
+ def self.each_ac_symbol(zigzag)
51
+ each_ac_item(zigzag) do |symbol, _value|
52
+ yield symbol
53
+ end
54
+ end
55
+
6
56
  def initialize(dc_table, ac_table)
7
57
  @dc_table = dc_table
8
58
  @ac_table = ac_table
@@ -23,65 +73,43 @@ module PureJPEG
23
73
  private
24
74
 
25
75
  def encode_dc(diff, writer)
26
- cat, bits = category_and_bits(diff)
76
+ cat, bits = self.class.category_and_bits(diff)
27
77
  code, length = @dc_table[cat]
28
78
  writer.write_bits(code, length)
29
79
  writer.write_bits(bits, cat) if cat > 0
30
80
  end
31
81
 
32
82
  def encode_ac(zigzag, writer)
33
- last_nonzero = 63
34
- last_nonzero -= 1 while last_nonzero > 0 && zigzag[last_nonzero] == 0
83
+ self.class.each_ac_item(zigzag) do |symbol, value|
84
+ code, length = @ac_table[symbol]
85
+ writer.write_bits(code, length)
86
+ next if symbol == 0x00 || symbol == 0xF0
35
87
 
36
- if last_nonzero == 0
37
- # All AC coefficients are zero (AC starts at index 1)
38
- eob = @ac_table[0x00]
39
- writer.write_bits(eob[0], eob[1])
40
- return
88
+ cat, bits = self.class.category_and_bits(value)
89
+ writer.write_bits(bits, cat)
41
90
  end
91
+ end
92
+ end
42
93
 
43
- i = 1
44
- while i <= last_nonzero
45
- run = 0
46
- while i <= last_nonzero && zigzag[i] == 0
47
- run += 1
48
- i += 1
49
- end
94
+ class FrequencyCounter
95
+ attr_reader :dc_frequencies, :ac_frequencies
50
96
 
51
- # Emit ZRL (16 zeros) symbols as needed
52
- while run >= 16
53
- zrl = @ac_table[0xF0]
54
- writer.write_bits(zrl[0], zrl[1])
55
- run -= 16
56
- end
97
+ def initialize
98
+ @dc_frequencies = Array.new(256, 0)
99
+ @ac_frequencies = Array.new(256, 0)
100
+ @prev_dc = Hash.new(0)
101
+ end
57
102
 
58
- cat, bits = category_and_bits(zigzag[i])
59
- symbol = (run << 4) | cat
60
- code, length = @ac_table[symbol]
61
- writer.write_bits(code, length)
62
- writer.write_bits(bits, cat) if cat > 0
63
- i += 1
64
- end
103
+ def observe_block(zigzag, state_key)
104
+ diff = zigzag[0] - @prev_dc[state_key]
105
+ @prev_dc[state_key] = zigzag[0]
65
106
 
66
- # EOB if we didn't reach position 63
67
- if last_nonzero < 63
68
- eob = @ac_table[0x00]
69
- writer.write_bits(eob[0], eob[1])
70
- end
71
- end
107
+ cat, = Encoder.category_and_bits(diff)
108
+ @dc_frequencies[cat] += 1
72
109
 
73
- # Returns [category, encoded_bits] for a coefficient value.
74
- def category_and_bits(value)
75
- return [0, 0] if value == 0
76
- abs_val = value.abs
77
- cat = 0
78
- v = abs_val
79
- while v > 0
80
- cat += 1
81
- v >>= 1
110
+ Encoder.each_ac_symbol(zigzag) do |symbol|
111
+ @ac_frequencies[symbol] += 1
82
112
  end
83
- bits = value > 0 ? value : value + (1 << cat) - 1
84
- [cat, bits]
85
113
  end
86
114
  end
87
115
  end
@@ -81,5 +81,96 @@ module PureJPEG
81
81
 
82
82
  table
83
83
  end
84
+
85
+ # Build a JPEG canonical Huffman table definition from symbol frequencies.
86
+ # Returns [bits, values], where bits has 16 entries for code lengths 1..16.
87
+ def self.optimize_table(frequencies)
88
+ lengths = build_code_lengths(frequencies)
89
+ counts = length_counts(lengths)
90
+ trim_counts_to_jpeg_limit!(counts)
91
+
92
+ symbols = (0...256).select { |symbol| frequencies[symbol].positive? }
93
+ symbols.sort_by! { |symbol| [-frequencies[symbol], symbol] }
94
+
95
+ bits = Array.new(16, 0)
96
+ values = []
97
+ index = 0
98
+
99
+ 1.upto(16) do |length|
100
+ count = counts[length]
101
+ bits[length - 1] = count
102
+ count.times do
103
+ values << symbols[index]
104
+ index += 1
105
+ end
106
+ end
107
+
108
+ [bits.freeze, values.freeze]
109
+ end
110
+
111
+ def self.build_code_lengths(frequencies)
112
+ nodes = []
113
+ 256.times do |symbol|
114
+ freq = frequencies[symbol]
115
+ nodes << { freq: freq, symbol: symbol } if freq.positive?
116
+ end
117
+ nodes << { freq: 1, symbol: 256 }
118
+
119
+ while nodes.length > 1
120
+ nodes.sort_by! do |node|
121
+ [node[:freq], node[:symbol] || 257]
122
+ end
123
+ left = nodes.shift
124
+ right = nodes.shift
125
+ nodes << { freq: left[:freq] + right[:freq], left: left, right: right }
126
+ end
127
+
128
+ lengths = Array.new(257, 0)
129
+ assign_code_lengths(nodes.first, 0, lengths)
130
+ lengths
131
+ end
132
+ private_class_method :build_code_lengths
133
+
134
+ def self.assign_code_lengths(node, depth, lengths)
135
+ if node[:symbol]
136
+ lengths[node[:symbol]] = depth.zero? ? 1 : depth
137
+ return
138
+ end
139
+
140
+ assign_code_lengths(node[:left], depth + 1, lengths)
141
+ assign_code_lengths(node[:right], depth + 1, lengths)
142
+ end
143
+ private_class_method :assign_code_lengths
144
+
145
+ def self.length_counts(lengths)
146
+ counts = Array.new([lengths.max + 1, 33].max, 0)
147
+ lengths.each do |length|
148
+ counts[length] += 1 if length.positive?
149
+ end
150
+ counts
151
+ end
152
+ private_class_method :length_counts
153
+
154
+ def self.trim_counts_to_jpeg_limit!(counts)
155
+ max_length = counts.length - 1
156
+ while max_length > 16
157
+ while counts[max_length].positive?
158
+ j = max_length - 2
159
+ j -= 1 while j.positive? && counts[j].zero?
160
+ raise ArgumentError, "Unable to limit Huffman code lengths" unless j.positive?
161
+
162
+ counts[max_length] -= 2
163
+ counts[max_length - 1] += 1
164
+ counts[j + 1] += 2
165
+ counts[j] -= 1
166
+ end
167
+ max_length -= 1
168
+ end
169
+
170
+ max_length = 16
171
+ max_length -= 1 while max_length.positive? && counts[max_length].zero?
172
+ counts[max_length] -= 1
173
+ end
174
+ private_class_method :trim_counts_to_jpeg_limit!
84
175
  end
85
176
  end
@@ -17,14 +17,19 @@ module PureJPEG
17
17
  # Format: +(r << 16) | (g << 8) | b+.
18
18
  attr_reader :packed_pixels
19
19
 
20
+ # @return [String, nil] raw ICC color profile data, if present in the source JPEG
21
+ attr_reader :icc_profile
22
+
20
23
  # @param width [Integer]
21
24
  # @param height [Integer]
22
25
  # @param packed_pixels [Array<Integer>] flat row-major array of packed RGB
23
26
  # integers in the format +(r << 16) | (g << 8) | b+
24
- def initialize(width, height, packed_pixels)
27
+ # @param icc_profile [String, nil] raw ICC profile bytes
28
+ def initialize(width, height, packed_pixels, icc_profile: nil)
25
29
  @width = width
26
30
  @height = height
27
31
  @packed_pixels = packed_pixels
32
+ @icc_profile = icc_profile
28
33
  end
29
34
 
30
35
  # Retrieve a pixel by coordinate.
@@ -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,14 +3,15 @@
3
3
  module PureJPEG
4
4
  class JFIFReader
5
5
  attr_reader :width, :height, :components, :quant_tables, :huffman_tables,
6
- :restart_interval, :progressive, :scans
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
10
  Scan = Struct.new(:components, :spectral_start, :spectral_end, :successive_high, :successive_low, :data, :huffman_tables)
11
11
 
12
- def initialize(data)
12
+ def initialize(data, stop_after_frame: false)
13
13
  @data = data.b
14
+ @stop_after_frame = stop_after_frame
14
15
  @pos = 0
15
16
  @quant_tables = {}
16
17
  @huffman_tables = {}
@@ -18,7 +19,9 @@ module PureJPEG
18
19
  @restart_interval = 0
19
20
  @progressive = false
20
21
  @scans = []
22
+ @icc_chunks = {}
21
23
  parse
24
+ assemble_icc_profile
22
25
  end
23
26
 
24
27
  def scan_components
@@ -37,7 +40,9 @@ module PureJPEG
37
40
  loop do
38
41
  marker = read_marker
39
42
  case marker
40
- 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
41
46
  skip_segment
42
47
  when 0xDB # DQT
43
48
  parse_dqt
@@ -45,9 +50,11 @@ module PureJPEG
45
50
  parse_dht
46
51
  when 0xC0 # SOF0 (baseline)
47
52
  parse_sof0
53
+ return if @stop_after_frame
48
54
  when 0xC2 # SOF2 (progressive)
49
55
  parse_sof0
50
56
  @progressive = true
57
+ return if @stop_after_frame
51
58
  when 0xDA # SOS
52
59
  scan = parse_sos
53
60
  scan.data = extract_scan_data
@@ -91,6 +98,28 @@ module PureJPEG
91
98
  raise PureJPEG::DecodeError, "Expected marker 0x#{expected.to_s(16)}, got 0x#{marker.to_s(16)}" unless marker == expected
92
99
  end
93
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
121
+ end
122
+
94
123
  def skip_segment
95
124
  length = read_u16
96
125
  @pos += length - 2
@@ -27,8 +27,7 @@ module PureJPEG
27
27
  def initialize(width, height, &block)
28
28
  @width = width
29
29
  @height = height
30
- black = Pixel.new(0, 0, 0)
31
- @pixels = Array.new(width * height, black)
30
+ @pixels = Array.new(width * height) { Pixel.new(0, 0, 0) }
32
31
 
33
32
  if block
34
33
  height.times do |y|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PureJPEG
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
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"
@@ -28,6 +29,9 @@ module PureJPEG
28
29
  # Raised when decoding invalid or unsupported JPEG data.
29
30
  class DecodeError < StandardError; end
30
31
 
32
+ # Maximum image dimension (width or height) allowed for encoding and decoding.
33
+ MAX_DIMENSION = 8192
34
+
31
35
  # Encode a pixel source as a JPEG.
32
36
  #
33
37
  # @param source [#width, #height, #[]] any object responding to +width+,
@@ -60,4 +64,27 @@ module PureJPEG
60
64
  def self.read(path_or_data)
61
65
  Decoder.decode(path_or_data)
62
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
63
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.2.0
4
+ version: 0.3.1
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
@@ -85,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
86
  - !ruby/object:Gem::Version
86
87
  version: '0'
87
88
  requirements: []
88
- rubygems_version: 3.6.9
89
+ rubygems_version: 4.0.3
89
90
  specification_version: 4
90
91
  summary: Pure Ruby JPEG encoder and decoder
91
92
  test_files: []