Skip to main content

oxihuman_core/
image_jpeg.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure-Rust JPEG Baseline (DCT) encoder and decoder.
5//!
6//! Implements the full JFIF/JPEG Baseline process:
7//! - RGB ↔ YCbCr color space conversion
8//! - Forward and inverse 8×8 separable DCT
9//! - Standard luminance/chrominance quantization tables with quality scaling
10//! - Zigzag scan ordering
11//! - Standard JPEG Huffman tables (Annex K) with DPCM/RLE entropy coding
12//! - JFIF bitstream framing with proper marker sequences and byte stuffing
13//!
14//! Both encoder and decoder operate on raw RGB pixel buffers (3 bytes per pixel,
15//! row-major, top-to-bottom). Chroma subsampling is 4:4:4 (no subsampling).
16
17use std::fmt;
18
19// ── Public error type ────────────────────────────────────────────────────────
20
21/// Errors returned by the JPEG codec.
22#[derive(Debug)]
23pub enum JpegError {
24    Invalid(String),
25    Unsupported(String),
26    Truncated,
27}
28
29impl fmt::Display for JpegError {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            JpegError::Invalid(s) => write!(f, "Invalid JPEG: {}", s),
33            JpegError::Unsupported(s) => write!(f, "Unsupported JPEG feature: {}", s),
34            JpegError::Truncated => write!(f, "Truncated JPEG data"),
35        }
36    }
37}
38
39impl std::error::Error for JpegError {}
40
41// ── Quantization tables ──────────────────────────────────────────────────────
42
43/// Standard JPEG luminance quantization table (quality = 50 baseline).
44const LUMA_QTABLE_BASE: [u16; 64] = [
45    16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56,
46    14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68, 109, 103, 77, 24, 35, 55, 64, 81, 104, 113,
47    92, 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99,
48];
49
50/// Standard JPEG chrominance quantization table (quality = 50 baseline).
51const CHROMA_QTABLE_BASE: [u16; 64] = [
52    17, 18, 24, 47, 99, 99, 99, 99, 18, 21, 26, 66, 99, 99, 99, 99, 24, 26, 56, 99, 99, 99, 99, 99,
53    47, 66, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99,
54    99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99,
55];
56
57/// Compute scaled quantization table for the given quality factor (1..=100).
58fn make_qtable(base: &[u16; 64], quality: u8) -> [u16; 64] {
59    let q = quality.clamp(1, 100) as u32;
60    let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
61    let mut out = [0u16; 64];
62    for (i, &v) in base.iter().enumerate() {
63        let s = ((v as u32 * scale + 50) / 100).clamp(1, 255);
64        out[i] = s as u16;
65    }
66    out
67}
68
69// ── Zigzag tables ────────────────────────────────────────────────────────────
70
71/// Standard JPEG zigzag scan order — maps linear index [0..64) → (row, col).
72const ZIGZAG_ORDER: [u8; 64] = [
73    0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20,
74    13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59,
75    52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
76];
77
78// ── DCT / IDCT ───────────────────────────────────────────────────────────────
79
80/// Precomputed cosine table: COS_TABLE[u][n] = cos((2n+1)*u*π/16)
81fn cos_table() -> [[f32; 8]; 8] {
82    use std::f32::consts::PI;
83    let mut t = [[0f32; 8]; 8];
84    for (u, row) in t.iter_mut().enumerate() {
85        for (n, cell) in row.iter_mut().enumerate() {
86            *cell = ((2 * n + 1) as f32 * u as f32 * PI / 16.0).cos();
87        }
88    }
89    t
90}
91
92/// 2-D forward DCT on an 8×8 block using the separable row-then-column approach.
93///
94/// Input layout: `block[row * 8 + col]`, values already level-shifted (−128).
95/// Output layout: `out[u * 8 + v]` where u=row-freq, v=col-freq.
96///
97/// Formula: F[u][v] = (1/4)*C(u)*C(v) * Σ_x Σ_y f[x][y]*cos((2x+1)uπ/16)*cos((2y+1)vπ/16)
98/// C(0) = 1/√2, C(k≥1) = 1
99fn dct8x8(block: &[f32; 64]) -> [f32; 64] {
100    let cos = cos_table();
101    let inv_sqrt2 = 1.0_f32 / std::f32::consts::SQRT_2;
102    let mut tmp = [0f32; 64];
103
104    // Row-wise 1-D DCT (transform columns of each row into frequency domain)
105    for r in 0..8usize {
106        for u in 0..8usize {
107            let cu = if u == 0 { inv_sqrt2 } else { 1.0 };
108            let mut sum = 0.0f32;
109            for n in 0..8usize {
110                sum += block[r * 8 + n] * cos[u][n];
111            }
112            tmp[r * 8 + u] = 0.5 * cu * sum;
113        }
114    }
115
116    // Column-wise 1-D DCT (transform rows of the intermediate result)
117    let mut out = [0f32; 64];
118    for c in 0..8usize {
119        for v in 0..8usize {
120            let cv = if v == 0 { inv_sqrt2 } else { 1.0 };
121            let mut sum = 0.0f32;
122            for n in 0..8usize {
123                sum += tmp[n * 8 + c] * cos[v][n];
124            }
125            out[v * 8 + c] = 0.5 * cv * sum;
126        }
127    }
128    out
129}
130
131/// 2-D inverse DCT on an 8×8 block (separable column-then-row).
132///
133/// Input layout: `coeffs[u * 8 + v]`.
134/// Output layout: `out[row * 8 + col]`.
135fn idct8x8(coeffs: &[f32; 64]) -> [f32; 64] {
136    let cos = cos_table();
137    let inv_sqrt2 = 1.0_f32 / std::f32::consts::SQRT_2;
138
139    // Column-wise inverse 1-D DCT
140    let mut tmp = [0f32; 64];
141    for c in 0..8usize {
142        for n in 0..8usize {
143            let mut sum = 0.0f32;
144            for v in 0..8usize {
145                let cv = if v == 0 { inv_sqrt2 } else { 1.0 };
146                sum += cv * coeffs[v * 8 + c] * cos[v][n];
147            }
148            tmp[n * 8 + c] = 0.5 * sum;
149        }
150    }
151
152    // Row-wise inverse 1-D DCT
153    let mut out = [0f32; 64];
154    for r in 0..8usize {
155        for n in 0..8usize {
156            let mut sum = 0.0f32;
157            for u in 0..8usize {
158                let cu = if u == 0 { inv_sqrt2 } else { 1.0 };
159                sum += cu * tmp[r * 8 + u] * cos[u][n];
160            }
161            out[r * 8 + n] = 0.5 * sum;
162        }
163    }
164    out
165}
166
167// ── Standard JPEG Huffman tables (Annex K) ───────────────────────────────────
168
169/// Huffman table entry: (code_length_in_bits, code_word).
170type HuffEntry = (u8, u16);
171
172/// Build the canonical Huffman code table from JPEG Annex K table data.
173///
174/// `bits[i]` = number of codes of length (i+1).
175/// `huffval` = symbols in code-value order.
176/// Returns a 256-entry array where index = symbol, value = (length, code).
177fn build_huffman_table(bits: &[u8; 16], huffval: &[u8]) -> [Option<HuffEntry>; 256] {
178    let mut table = [None; 256];
179    let mut code = 0u16;
180    let mut idx = 0usize;
181    for (bit_len, &count) in bits.iter().enumerate() {
182        let length = (bit_len + 1) as u8;
183        for _ in 0..count {
184            if idx < huffval.len() {
185                table[huffval[idx] as usize] = Some((length, code));
186                code += 1;
187            }
188            idx += 1;
189        }
190        code <<= 1;
191    }
192    table
193}
194
195// DC luminance Huffman (Annex K Table K.3)
196const DC_LUMA_BITS: [u8; 16] = [0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0];
197const DC_LUMA_HUFFVAL: [u8; 12] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
198
199// DC chrominance Huffman (Annex K Table K.4)
200const DC_CHROMA_BITS: [u8; 16] = [0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0];
201const DC_CHROMA_HUFFVAL: [u8; 12] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
202
203// AC luminance Huffman (Annex K Table K.5)
204const AC_LUMA_BITS: [u8; 16] = [0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 125];
205const AC_LUMA_HUFFVAL: &[u8] = &[
206    0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
207    0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0,
208    0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
209    0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
210    0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
211    0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
212    0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
213    0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5,
214    0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
215    0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
216    0xF9, 0xFA,
217];
218
219// AC chrominance Huffman (Annex K Table K.6)
220const AC_CHROMA_BITS: [u8; 16] = [0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 119];
221const AC_CHROMA_HUFFVAL: &[u8] = &[
222    0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71,
223    0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0,
224    0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26,
225    0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48,
226    0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
227    0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87,
228    0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
229    0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3,
230    0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA,
231    0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
232    0xF9, 0xFA,
233];
234
235// ── Bitstream writer ─────────────────────────────────────────────────────────
236
237struct BitWriter {
238    buffer: Vec<u8>,
239    accumulator: u32,
240    bits_pending: u8,
241}
242
243impl BitWriter {
244    fn new() -> Self {
245        Self {
246            buffer: Vec::new(),
247            accumulator: 0,
248            bits_pending: 0,
249        }
250    }
251
252    fn write_bits(&mut self, code: u16, length: u8) {
253        self.accumulator = (self.accumulator << length) | (code as u32);
254        self.bits_pending += length;
255        while self.bits_pending >= 8 {
256            self.bits_pending -= 8;
257            let byte = ((self.accumulator >> self.bits_pending) & 0xFF) as u8;
258            self.buffer.push(byte);
259            // Byte stuffing: 0xFF must be followed by 0x00 in the scan data
260            if byte == 0xFF {
261                self.buffer.push(0x00);
262            }
263        }
264    }
265
266    fn flush(&mut self) {
267        if self.bits_pending > 0 {
268            // Pad with 1-bits (as per JPEG spec)
269            let shift = 8 - self.bits_pending;
270            let byte = (((self.accumulator << shift) | ((1u32 << shift) - 1)) & 0xFF) as u8;
271            self.buffer.push(byte);
272            if byte == 0xFF {
273                self.buffer.push(0x00);
274            }
275            self.bits_pending = 0;
276        }
277    }
278
279    fn into_bytes(self) -> Vec<u8> {
280        self.buffer
281    }
282}
283
284// ── Size category helpers ────────────────────────────────────────────────────
285
286/// Number of bits needed to represent `|v|` (size category S):
287/// 0 → 0, ±1 → 1, ±2-±3 → 2, …
288fn size_category(v: i16) -> u8 {
289    let abs = v.unsigned_abs();
290    if abs == 0 {
291        0
292    } else {
293        16 - abs.leading_zeros() as u8
294    }
295}
296
297/// Encode amplitude into the size-category representation.
298///
299/// For positive v: amplitude = v.
300/// For negative v: amplitude = v − 1 (i.e. v + 2^s - 1 − (2^s - 1) = v − 1 + 2^s).
301fn amplitude_bits(v: i16, s: u8) -> u16 {
302    if v >= 0 {
303        v as u16
304    } else {
305        // Two's-complement-like JPEG encoding: negative amplitude is v + (2^s - 1)
306        (v + ((1i16 << s) - 1)) as u16
307    }
308}
309
310// ── JPEG Encoder ─────────────────────────────────────────────────────────────
311
312/// Encode an RGB pixel buffer as JPEG.
313///
314/// `pixels` must contain `width * height * 3` bytes (RGB, row-major, top-bottom).
315/// `quality` is in the range 1..=100.
316pub fn jpeg_encode_rgb(
317    width: u32,
318    height: u32,
319    pixels: &[u8],
320    quality: u8,
321) -> Result<Vec<u8>, JpegError> {
322    let w = width as usize;
323    let h = height as usize;
324    if pixels.len() != w * h * 3 {
325        return Err(JpegError::Invalid(format!(
326            "expected {} bytes, got {}",
327            w * h * 3,
328            pixels.len()
329        )));
330    }
331    if w == 0 || h == 0 {
332        return Err(JpegError::Invalid("zero-dimension image".into()));
333    }
334
335    let luma_qt = make_qtable(&LUMA_QTABLE_BASE, quality);
336    let chroma_qt = make_qtable(&CHROMA_QTABLE_BASE, quality);
337
338    // Convert to YCbCr planes (f32)
339    let mut y_plane = vec![0f32; w * h];
340    let mut cb_plane = vec![0f32; w * h];
341    let mut cr_plane = vec![0f32; w * h];
342    for i in 0..w * h {
343        let r = pixels[i * 3] as f32;
344        let g = pixels[i * 3 + 1] as f32;
345        let b = pixels[i * 3 + 2] as f32;
346        y_plane[i] = 0.299 * r + 0.587 * g + 0.114 * b;
347        cb_plane[i] = -0.168_736 * r - 0.331_264 * g + 0.5 * b + 128.0;
348        cr_plane[i] = 0.5 * r - 0.418_688 * g - 0.081_312 * b + 128.0;
349    }
350
351    // Build Huffman encode tables
352    let dc_luma_ht = build_huffman_table(&DC_LUMA_BITS, &DC_LUMA_HUFFVAL);
353    let dc_chroma_ht = build_huffman_table(&DC_CHROMA_BITS, &DC_CHROMA_HUFFVAL);
354    let ac_luma_ht = build_huffman_table(&AC_LUMA_BITS, AC_LUMA_HUFFVAL);
355    let ac_chroma_ht = build_huffman_table(&AC_CHROMA_BITS, AC_CHROMA_HUFFVAL);
356
357    // Encode scan data
358    let mut bw = BitWriter::new();
359    let mut prev_dc_y = 0i16;
360    let mut prev_dc_cb = 0i16;
361    let mut prev_dc_cr = 0i16;
362
363    // Number of 8×8 MCUs (padded)
364    let mcu_cols = w.div_ceil(8);
365    let mcu_rows = h.div_ceil(8);
366
367    for mcu_row in 0..mcu_rows {
368        for mcu_col in 0..mcu_cols {
369            // Extract and encode each channel
370            for ch in 0..3 {
371                let plane = match ch {
372                    0 => y_plane.as_slice(),
373                    1 => cb_plane.as_slice(),
374                    _ => cr_plane.as_slice(),
375                };
376                let qt = if ch == 0 { &luma_qt } else { &chroma_qt };
377                let dc_ht = if ch == 0 { &dc_luma_ht } else { &dc_chroma_ht };
378                let ac_ht = if ch == 0 { &ac_luma_ht } else { &ac_chroma_ht };
379                let prev_dc = match ch {
380                    0 => &mut prev_dc_y,
381                    1 => &mut prev_dc_cb,
382                    _ => &mut prev_dc_cr,
383                };
384
385                // Fill 8×8 block with level-shifted values (pad at edges by replication)
386                let mut block = [0f32; 64];
387                for by in 0..8 {
388                    for bx in 0..8 {
389                        let px = (mcu_col * 8 + bx).min(w - 1);
390                        let py = (mcu_row * 8 + by).min(h - 1);
391                        block[by * 8 + bx] = plane[py * w + px] - 128.0;
392                    }
393                }
394
395                // Forward DCT
396                let dct_coeffs = dct8x8(&block);
397
398                // Quantize in zigzag order
399                let mut quant = [0i16; 64];
400                for (zz, &pos) in ZIGZAG_ORDER.iter().enumerate() {
401                    let coeff = dct_coeffs[pos as usize];
402                    let q = qt[zz] as f32;
403                    quant[zz] = (coeff / q).round() as i16;
404                }
405
406                // DC coefficient — DPCM
407                let dc_diff = quant[0] - *prev_dc;
408                *prev_dc = quant[0];
409                let s = size_category(dc_diff);
410                let (dc_len, dc_code) = dc_ht[s as usize]
411                    .ok_or_else(|| JpegError::Invalid("DC Huffman missing".into()))?;
412                bw.write_bits(dc_code, dc_len);
413                if s > 0 {
414                    bw.write_bits(amplitude_bits(dc_diff, s), s);
415                }
416
417                // AC coefficients — RLE
418                let mut run = 0u8;
419                for (k, &ac) in quant[1..].iter().enumerate().map(|(i, v)| (i + 1, v)) {
420                    if ac == 0 {
421                        if k == 63 {
422                            // EOB
423                            let (len, code) = ac_ht[0x00]
424                                .ok_or_else(|| JpegError::Invalid("AC EOB missing".into()))?;
425                            bw.write_bits(code, len);
426                        } else {
427                            run += 1;
428                            if run == 16 {
429                                // ZRL: run of 16 zeros
430                                let (len, code) = ac_ht[0xF0]
431                                    .ok_or_else(|| JpegError::Invalid("AC ZRL missing".into()))?;
432                                bw.write_bits(code, len);
433                                run = 0;
434                            }
435                        }
436                    } else {
437                        let s = size_category(ac);
438                        let symbol = (run << 4) | s;
439                        let (ac_len, ac_code) = ac_ht[symbol as usize]
440                            .ok_or_else(|| JpegError::Invalid("AC Huffman missing".into()))?;
441                        bw.write_bits(ac_code, ac_len);
442                        bw.write_bits(amplitude_bits(ac, s), s);
443                        run = 0;
444                    }
445                }
446            }
447        }
448    }
449    bw.flush();
450    let scan_data = bw.into_bytes();
451
452    // ── Assemble JFIF bitstream ──────────────────────────────────────────────
453    let mut out = Vec::new();
454
455    // SOI
456    out.extend_from_slice(&[0xFF, 0xD8]);
457
458    // APP0 (JFIF marker)
459    let app0: &[u8] = &[
460        0xFF, 0xE0, 0x00, 0x10, // marker, length=16
461        0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
462        0x01, 0x01, // version 1.1
463        0x00, // aspect ratio units (0=no units)
464        0x00, 0x01, // Xdensity = 1
465        0x00, 0x01, // Ydensity = 1
466        0x00, 0x00, // thumbnail 0×0
467    ];
468    out.extend_from_slice(app0);
469
470    // DQT — Quantization tables (table 0 = luma, table 1 = chroma)
471    for (id, qt) in [(&luma_qt, 0u8), (&chroma_qt, 1u8)] {
472        // length = 2 (length field) + 1 (precision+id) + 64 = 67
473        let seg_len: u16 = 2 + 1 + 64;
474        out.extend_from_slice(&[0xFF, 0xDB]);
475        out.extend_from_slice(&seg_len.to_be_bytes());
476        out.push(qt); // precision=0 (8-bit) | table id
477        for &v in id.iter() {
478            out.push(v.min(255) as u8);
479        }
480    }
481
482    // SOF0 — Baseline DCT frame header
483    // length = 2 + 1 + 2 + 2 + 1 + 3*(1+1+1) = 17
484    let sof0_len: u16 = 17;
485    out.extend_from_slice(&[0xFF, 0xC0]);
486    out.extend_from_slice(&sof0_len.to_be_bytes());
487    out.push(8); // precision = 8 bits
488    out.extend_from_slice(&(height as u16).to_be_bytes());
489    out.extend_from_slice(&(width as u16).to_be_bytes());
490    out.push(3); // number of components
491                 // Y: component id=1, sampling factors=1:1, qtable=0
492    out.extend_from_slice(&[1, 0x11, 0]);
493    // Cb: component id=2, sampling factors=1:1, qtable=1
494    out.extend_from_slice(&[2, 0x11, 1]);
495    // Cr: component id=3, sampling factors=1:1, qtable=1
496    out.extend_from_slice(&[3, 0x11, 1]);
497
498    // DHT — Huffman tables (DC luma, DC chroma, AC luma, AC chroma)
499    write_dht(&mut out, 0x00, &DC_LUMA_BITS, &DC_LUMA_HUFFVAL);
500    write_dht(&mut out, 0x01, &DC_CHROMA_BITS, &DC_CHROMA_HUFFVAL);
501    write_dht(&mut out, 0x10, &AC_LUMA_BITS, AC_LUMA_HUFFVAL);
502    write_dht(&mut out, 0x11, &AC_CHROMA_BITS, AC_CHROMA_HUFFVAL);
503
504    // SOS — Start of scan
505    // length = 2 + 1 + 3*2 + 3 = 12
506    let sos_len: u16 = 12;
507    out.extend_from_slice(&[0xFF, 0xDA]);
508    out.extend_from_slice(&sos_len.to_be_bytes());
509    out.push(3); // components in scan
510    out.extend_from_slice(&[1, 0x00]); // Y: DC=0, AC=0
511    out.extend_from_slice(&[2, 0x11]); // Cb: DC=1, AC=1
512    out.extend_from_slice(&[3, 0x11]); // Cr: DC=1, AC=1
513    out.extend_from_slice(&[0, 63, 0]); // spectral selection 0..63, Ah/Al=0
514
515    // Scan data (already byte-stuffed by BitWriter)
516    out.extend_from_slice(&scan_data);
517
518    // EOI
519    out.extend_from_slice(&[0xFF, 0xD9]);
520
521    Ok(out)
522}
523
524/// Write a single DHT (Define Huffman Table) segment.
525fn write_dht(out: &mut Vec<u8>, tc_th: u8, bits: &[u8; 16], huffval: &[u8]) {
526    let total_codes: u16 = bits.iter().map(|&b| b as u16).sum();
527    // length = 2 + 1 + 16 + total_codes
528    let seg_len = 2u16 + 1 + 16 + total_codes;
529    out.extend_from_slice(&[0xFF, 0xC4]);
530    out.extend_from_slice(&seg_len.to_be_bytes());
531    out.push(tc_th);
532    out.extend_from_slice(bits);
533    out.extend_from_slice(huffval);
534}
535
536// ── JPEG Decoder ─────────────────────────────────────────────────────────────
537
538/// Decode a JPEG bitstream to raw RGB pixels.
539///
540/// Returns a [`super::image_codec::RawDecodeResult`] with width, height and RGB bytes.
541pub fn jpeg_decode(bytes: &[u8]) -> Result<super::image_codec::RawDecodeResult, JpegError> {
542    // Verify SOI
543    if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
544        return Err(JpegError::Invalid("missing SOI marker".into()));
545    }
546
547    // Parse markers to collect tables and geometry
548    let mut ctx = DecodeContext::new();
549    ctx.parse_markers(bytes)?;
550
551    // Decode the actual scan data
552    let rgb_pixels = ctx.decode_scan()?;
553
554    Ok(super::image_codec::RawDecodeResult {
555        width: ctx.image_width,
556        height: ctx.image_height,
557        pixels: rgb_pixels,
558    })
559}
560
561// ── Decode context ───────────────────────────────────────────────────────────
562
563/// Huffman decoder tree node.
564#[derive(Clone)]
565struct HuffNode {
566    /// For leaf nodes: symbol (0..=255). For internal: 0 (unused).
567    symbol: u8,
568    /// For leaf nodes: code length. 0 indicates not-a-leaf.
569    is_leaf: bool,
570    left: Option<Box<HuffNode>>,
571    right: Option<Box<HuffNode>>,
572}
573
574impl HuffNode {
575    fn internal() -> Self {
576        Self {
577            symbol: 0,
578            is_leaf: false,
579            left: None,
580            right: None,
581        }
582    }
583}
584
585/// Build a Huffman decode tree from (length, code, symbol) triples.
586fn build_huffman_decode_tree(bits: &[u8; 16], huffval: &[u8]) -> Result<HuffNode, JpegError> {
587    let mut root = HuffNode::internal();
588    let mut code = 0u16;
589    let mut idx = 0usize;
590
591    for (bit_len_0, &count) in bits.iter().enumerate() {
592        let depth = bit_len_0 + 1;
593        for _ in 0..count {
594            if idx >= huffval.len() {
595                break;
596            }
597            let sym = huffval[idx];
598            idx += 1;
599            // Insert this (code, depth, sym) into the tree
600            insert_huffman_code(&mut root, code, depth as u8, sym)?;
601            code += 1;
602        }
603        code <<= 1;
604    }
605    Ok(root)
606}
607
608/// Recursively insert a canonical code into the Huffman decode tree.
609fn insert_huffman_code(
610    node: &mut HuffNode,
611    code: u16,
612    depth: u8,
613    sym: u8,
614) -> Result<(), JpegError> {
615    if depth == 0 {
616        node.symbol = sym;
617        node.is_leaf = true;
618        return Ok(());
619    }
620    let go_right = (code >> (depth - 1)) & 1 == 1;
621    if go_right {
622        if node.right.is_none() {
623            node.right = Some(Box::new(HuffNode::internal()));
624        }
625        if let Some(ref mut child) = node.right {
626            insert_huffman_code(child, code & ((1 << (depth - 1)) - 1), depth - 1, sym)?;
627        }
628    } else {
629        if node.left.is_none() {
630            node.left = Some(Box::new(HuffNode::internal()));
631        }
632        if let Some(ref mut child) = node.left {
633            insert_huffman_code(child, code & ((1 << (depth - 1)) - 1), depth - 1, sym)?;
634        }
635    }
636    Ok(())
637}
638
639/// JPEG component metadata from SOF0.
640/// `h_samp` and `v_samp` are parsed for completeness; this codec operates in 4:4:4 mode.
641#[derive(Clone, Default)]
642struct Component {
643    id: u8,
644    #[allow(dead_code)]
645    h_samp: u8,
646    #[allow(dead_code)]
647    v_samp: u8,
648    qt_id: u8,
649    dc_ht_id: u8,
650    ac_ht_id: u8,
651}
652
653/// Accumulated decode state.
654struct DecodeContext {
655    image_width: usize,
656    image_height: usize,
657    /// Sample precision in bits; parsed from SOF0 but fixed at 8 for baseline JPEG.
658    #[allow(dead_code)]
659    precision: u8,
660    components: Vec<Component>,
661    /// qtables[id] = 64-entry table
662    qtables: [[u16; 64]; 4],
663    /// Tracks which qtable slots were populated.
664    #[allow(dead_code)]
665    qtable_present: [bool; 4],
666    /// dc_huffman_trees[id], ac_huffman_trees[id]
667    dc_trees: [Option<HuffNode>; 4],
668    ac_trees: [Option<HuffNode>; 4],
669    /// Raw scan data bytes (after removing byte stuffing)
670    scan_data: Vec<u8>,
671}
672
673impl DecodeContext {
674    fn new() -> Self {
675        Self {
676            image_width: 0,
677            image_height: 0,
678            precision: 8,
679            components: Vec::new(),
680            qtables: [[0u16; 64]; 4],
681            qtable_present: [false; 4],
682            dc_trees: [None, None, None, None],
683            ac_trees: [None, None, None, None],
684            scan_data: Vec::new(),
685        }
686    }
687
688    fn parse_markers(&mut self, bytes: &[u8]) -> Result<(), JpegError> {
689        let mut pos = 2; // skip SOI
690        while pos + 1 < bytes.len() {
691            if bytes[pos] != 0xFF {
692                return Err(JpegError::Invalid(format!(
693                    "expected 0xFF marker at position {}",
694                    pos
695                )));
696            }
697            let marker = bytes[pos + 1];
698            pos += 2;
699
700            match marker {
701                0xD8 => {}     // SOI (shouldn't appear again)
702                0xD9 => break, // EOI
703                0xE0..=0xEF => {
704                    // APPn — skip
705                    let seg_len = read_u16_be(bytes, pos)? as usize;
706                    pos += seg_len;
707                }
708                0xDB => {
709                    // DQT
710                    let seg_len = read_u16_be(bytes, pos)? as usize;
711                    let seg_end = pos + seg_len;
712                    let mut cur = pos + 2;
713                    while cur < seg_end {
714                        let pq_tq = *bytes.get(cur).ok_or(JpegError::Truncated)?;
715                        let precision = (pq_tq >> 4) & 0x0F;
716                        let table_id = (pq_tq & 0x0F) as usize;
717                        cur += 1;
718                        if precision == 0 {
719                            // 8-bit precision
720                            if cur + 64 > bytes.len() {
721                                return Err(JpegError::Truncated);
722                            }
723                            for i in 0..64 {
724                                self.qtables[table_id][i] = bytes[cur + i] as u16;
725                            }
726                            cur += 64;
727                        } else {
728                            // 16-bit precision
729                            if cur + 128 > bytes.len() {
730                                return Err(JpegError::Truncated);
731                            }
732                            for i in 0..64 {
733                                self.qtables[table_id][i] = u16::from_be_bytes([
734                                    bytes[cur + i * 2],
735                                    bytes[cur + i * 2 + 1],
736                                ]);
737                            }
738                            cur += 128;
739                        }
740                        self.qtable_present[table_id] = true;
741                    }
742                    pos = seg_end;
743                }
744                0xC0 | 0xC1 => {
745                    // SOF0 or SOF1 (baseline / extended sequential)
746                    let seg_len = read_u16_be(bytes, pos)? as usize;
747                    let seg = bytes.get(pos..pos + seg_len).ok_or(JpegError::Truncated)?;
748                    self.precision = seg[2];
749                    self.image_height = u16::from_be_bytes([seg[3], seg[4]]) as usize;
750                    self.image_width = u16::from_be_bytes([seg[5], seg[6]]) as usize;
751                    let ncomp = seg[7] as usize;
752                    self.components = Vec::with_capacity(ncomp);
753                    for i in 0..ncomp {
754                        let base = 8 + i * 3;
755                        let comp = Component {
756                            id: seg[base],
757                            h_samp: (seg[base + 1] >> 4) & 0x0F,
758                            v_samp: seg[base + 1] & 0x0F,
759                            qt_id: seg[base + 2],
760                            dc_ht_id: 0,
761                            ac_ht_id: 0,
762                        };
763                        self.components.push(comp);
764                    }
765                    pos += seg_len;
766                }
767                0xC4 => {
768                    // DHT
769                    let seg_len = read_u16_be(bytes, pos)? as usize;
770                    let seg_end = pos + seg_len;
771                    let mut cur = pos + 2;
772                    while cur < seg_end {
773                        let tc_th = *bytes.get(cur).ok_or(JpegError::Truncated)?;
774                        let tc = (tc_th >> 4) & 0x0F; // 0=DC, 1=AC
775                        let th = (tc_th & 0x0F) as usize;
776                        cur += 1;
777                        if cur + 16 > bytes.len() {
778                            return Err(JpegError::Truncated);
779                        }
780                        let mut bits = [0u8; 16];
781                        bits.copy_from_slice(&bytes[cur..cur + 16]);
782                        cur += 16;
783                        let total_codes: usize = bits.iter().map(|&b| b as usize).sum();
784                        if cur + total_codes > bytes.len() {
785                            return Err(JpegError::Truncated);
786                        }
787                        let huffval = &bytes[cur..cur + total_codes];
788                        cur += total_codes;
789                        let tree = build_huffman_decode_tree(&bits, huffval)?;
790                        if tc == 0 {
791                            self.dc_trees[th] = Some(tree);
792                        } else {
793                            self.ac_trees[th] = Some(tree);
794                        }
795                    }
796                    pos = seg_end;
797                }
798                0xDA => {
799                    // SOS — read scan header, then collect remaining bytes
800                    let seg_len = read_u16_be(bytes, pos)? as usize;
801                    let seg_end = pos + seg_len;
802                    let seg = bytes.get(pos..seg_end).ok_or(JpegError::Truncated)?;
803                    let ncomp = seg[2] as usize;
804                    for i in 0..ncomp {
805                        let comp_id = seg[3 + i * 2];
806                        let ht_byte = seg[3 + i * 2 + 1];
807                        let dc_id = ht_byte >> 4;
808                        let ac_id = ht_byte & 0x0F;
809                        // Match to component list by id
810                        for comp in &mut self.components {
811                            if comp.id == comp_id {
812                                comp.dc_ht_id = dc_id;
813                                comp.ac_ht_id = ac_id;
814                            }
815                        }
816                    }
817                    pos = seg_end;
818                    // Collect entropy-coded data (up to next unescaped 0xFF marker)
819                    self.scan_data = collect_scan_data(bytes, pos)?;
820                    break;
821                }
822                0xFE | 0xC2..=0xC3 | 0xC5..=0xCF => {
823                    // COM, progressive / other SOF variants — skip
824                    if pos + 2 > bytes.len() {
825                        return Err(JpegError::Truncated);
826                    }
827                    let seg_len = read_u16_be(bytes, pos)? as usize;
828                    if marker >= 0xC2 {
829                        return Err(JpegError::Unsupported(format!(
830                            "non-baseline SOF marker 0x{:02X}",
831                            marker
832                        )));
833                    }
834                    pos += seg_len;
835                }
836                0xDD => {
837                    // DRI — restart interval (skip for now, we don't use restart markers)
838                    let seg_len = read_u16_be(bytes, pos)? as usize;
839                    pos += seg_len;
840                }
841                0xD0..=0xD7 => {
842                    // RST markers — no length field
843                }
844                _ => {
845                    // Unknown marker — skip using length field if present
846                    if pos + 2 <= bytes.len() {
847                        let seg_len = read_u16_be(bytes, pos)? as usize;
848                        pos += seg_len;
849                    } else {
850                        break;
851                    }
852                }
853            }
854        }
855        Ok(())
856    }
857
858    fn decode_scan(&self) -> Result<Vec<u8>, JpegError> {
859        if self.image_width == 0 || self.image_height == 0 {
860            return Err(JpegError::Invalid("SOF0 not found".into()));
861        }
862        if self.components.len() != 3 {
863            return Err(JpegError::Unsupported(format!(
864                "{} components (only 3-component YCbCr supported)",
865                self.components.len()
866            )));
867        }
868
869        let w = self.image_width;
870        let h = self.image_height;
871        let mcu_cols = w.div_ceil(8);
872        let mcu_rows = h.div_ceil(8);
873
874        // Allocate output planes
875        let mut y_plane = vec![0i32; mcu_cols * 8 * mcu_rows * 8];
876        let mut cb_plane = vec![0i32; mcu_cols * 8 * mcu_rows * 8];
877        let mut cr_plane = vec![0i32; mcu_cols * 8 * mcu_rows * 8];
878
879        // Build a bitstream reader over scan_data
880        let mut br = BitReader::new(&self.scan_data);
881        let stride = mcu_cols * 8;
882
883        let mut prev_dc = [0i32; 3];
884
885        for mcu_row in 0..mcu_rows {
886            for mcu_col in 0..mcu_cols {
887                for (ch, comp) in self.components.iter().enumerate() {
888                    let dc_tree = self.dc_trees[comp.dc_ht_id as usize]
889                        .as_ref()
890                        .ok_or_else(|| JpegError::Invalid("DC Huffman tree missing".into()))?;
891                    let ac_tree = self.ac_trees[comp.ac_ht_id as usize]
892                        .as_ref()
893                        .ok_or_else(|| JpegError::Invalid("AC Huffman tree missing".into()))?;
894                    let qt = &self.qtables[comp.qt_id as usize];
895
896                    // Decode DC coefficient
897                    let dc_size = decode_huffman_symbol(&mut br, dc_tree)? as usize;
898                    let dc_diff = if dc_size == 0 {
899                        0i32
900                    } else {
901                        let raw = br.read_bits(dc_size)?;
902                        decode_signed_coeff(raw as i32, dc_size as u8)
903                    };
904                    prev_dc[ch] += dc_diff;
905                    let dc_val = prev_dc[ch];
906
907                    // Decode 63 AC coefficients
908                    let mut zz_coeffs = [0i16; 64];
909                    zz_coeffs[0] = dc_val as i16;
910                    let mut k = 1usize;
911                    while k < 64 {
912                        let sym = decode_huffman_symbol(&mut br, ac_tree)?;
913                        if sym == 0x00 {
914                            // EOB
915                            break;
916                        }
917                        if sym == 0xF0 {
918                            // ZRL: 16 zeros
919                            k += 16;
920                            continue;
921                        }
922                        let run_len = ((sym >> 4) & 0x0F) as usize;
923                        let ac_size = (sym & 0x0F) as usize;
924                        k += run_len;
925                        if k >= 64 {
926                            break;
927                        }
928                        if ac_size > 0 {
929                            let raw = br.read_bits(ac_size)?;
930                            zz_coeffs[k] = decode_signed_coeff(raw as i32, ac_size as u8) as i16;
931                        }
932                        k += 1;
933                    }
934
935                    // Dequantize + inverse zigzag → 8×8 block in raster order
936                    let mut coeffs = [0f32; 64];
937                    for (zz, &pos) in ZIGZAG_ORDER.iter().enumerate() {
938                        coeffs[pos as usize] = zz_coeffs[zz] as f32 * qt[zz] as f32;
939                    }
940
941                    // IDCT
942                    let spatial = idct8x8(&coeffs);
943
944                    // Place into output plane
945                    let plane = match ch {
946                        0 => &mut y_plane,
947                        1 => &mut cb_plane,
948                        _ => &mut cr_plane,
949                    };
950                    for by in 0..8 {
951                        for bx in 0..8 {
952                            let px = mcu_col * 8 + bx;
953                            let py = mcu_row * 8 + by;
954                            if px < stride && py < mcu_rows * 8 {
955                                // Level shift + clamp
956                                let val = (spatial[by * 8 + bx] + 128.0).round() as i32;
957                                plane[py * stride + px] = val.clamp(0, 255);
958                            }
959                        }
960                    }
961                }
962            }
963        }
964
965        // Convert YCbCr → RGB and pack into output buffer
966        let mut pixels = vec![0u8; w * h * 3];
967        for py in 0..h {
968            for px in 0..w {
969                let idx = py * stride + px;
970                let y = y_plane[idx] as f32;
971                let cb = cb_plane[idx] as f32 - 128.0;
972                let cr = cr_plane[idx] as f32 - 128.0;
973
974                let r = (y + 1.402 * cr).round() as i32;
975                let g = (y - 0.344_136 * cb - 0.714_136 * cr).round() as i32;
976                let b = (y + 1.772 * cb).round() as i32;
977
978                let out_idx = (py * w + px) * 3;
979                pixels[out_idx] = r.clamp(0, 255) as u8;
980                pixels[out_idx + 1] = g.clamp(0, 255) as u8;
981                pixels[out_idx + 2] = b.clamp(0, 255) as u8;
982            }
983        }
984
985        Ok(pixels)
986    }
987}
988
989// ── Bitstream reader ─────────────────────────────────────────────────────────
990
991struct BitReader<'a> {
992    data: &'a [u8],
993    pos: usize,
994    buffer: u32,
995    bits_avail: u8,
996}
997
998impl<'a> BitReader<'a> {
999    fn new(data: &'a [u8]) -> Self {
1000        Self {
1001            data,
1002            pos: 0,
1003            buffer: 0,
1004            bits_avail: 0,
1005        }
1006    }
1007
1008    fn read_bits(&mut self, n: usize) -> Result<u32, JpegError> {
1009        while self.bits_avail < n as u8 {
1010            let byte = self.next_byte()?;
1011            self.buffer = (self.buffer << 8) | byte as u32;
1012            self.bits_avail += 8;
1013        }
1014        self.bits_avail -= n as u8;
1015        let val = (self.buffer >> self.bits_avail) & ((1 << n) - 1);
1016        Ok(val)
1017    }
1018
1019    fn next_byte(&mut self) -> Result<u8, JpegError> {
1020        if self.pos >= self.data.len() {
1021            return Err(JpegError::Truncated);
1022        }
1023        let b = self.data[self.pos];
1024        self.pos += 1;
1025        Ok(b)
1026    }
1027}
1028
1029/// Walk a Huffman tree one bit at a time to decode a symbol.
1030fn decode_huffman_symbol(br: &mut BitReader<'_>, tree: &HuffNode) -> Result<u8, JpegError> {
1031    let mut node = tree;
1032    loop {
1033        if node.is_leaf {
1034            return Ok(node.symbol);
1035        }
1036        let bit = br.read_bits(1)?;
1037        if bit == 0 {
1038            node = node
1039                .left
1040                .as_deref()
1041                .ok_or_else(|| JpegError::Invalid("Huffman tree: null left child".into()))?;
1042        } else {
1043            node = node
1044                .right
1045                .as_deref()
1046                .ok_or_else(|| JpegError::Invalid("Huffman tree: null right child".into()))?;
1047        }
1048    }
1049}
1050
1051/// Decode a signed coefficient from its raw magnitude bits and size category.
1052fn decode_signed_coeff(raw: i32, size: u8) -> i32 {
1053    // If the MSB of the raw value is 0, the number is negative.
1054    // The encoded negative amplitude is: negative + (2^size - 1)
1055    let threshold = 1 << (size - 1);
1056    if raw < threshold {
1057        raw - ((1 << size) - 1)
1058    } else {
1059        raw
1060    }
1061}
1062
1063// ── Scan data collector ───────────────────────────────────────────────────────
1064
1065/// Extract entropy-coded data from the scan, removing byte stuffing (0xFF 0x00 → 0xFF).
1066fn collect_scan_data(bytes: &[u8], start: usize) -> Result<Vec<u8>, JpegError> {
1067    let mut data = Vec::new();
1068    let mut i = start;
1069    while i < bytes.len() {
1070        let b = bytes[i];
1071        if b == 0xFF {
1072            if i + 1 >= bytes.len() {
1073                break;
1074            }
1075            let next = bytes[i + 1];
1076            if next == 0x00 {
1077                // Byte stuffing — output 0xFF
1078                data.push(0xFF);
1079                i += 2;
1080            } else if next == 0xD9 {
1081                // EOI
1082                break;
1083            } else if (0xD0..=0xD7).contains(&next) {
1084                // RST marker — skip
1085                i += 2;
1086            } else {
1087                // Some other marker — stop
1088                break;
1089            }
1090        } else {
1091            data.push(b);
1092            i += 1;
1093        }
1094    }
1095    Ok(data)
1096}
1097
1098// ── Utility ──────────────────────────────────────────────────────────────────
1099
1100fn read_u16_be(bytes: &[u8], pos: usize) -> Result<u16, JpegError> {
1101    if pos + 2 > bytes.len() {
1102        return Err(JpegError::Truncated);
1103    }
1104    Ok(u16::from_be_bytes([bytes[pos], bytes[pos + 1]]))
1105}
1106
1107// ── Tests ────────────────────────────────────────────────────────────────────
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112
1113    fn solid_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
1114        let n = (width * height) as usize * 3;
1115        let mut buf = Vec::with_capacity(n);
1116        for _ in 0..(width * height) as usize {
1117            buf.push(r);
1118            buf.push(g);
1119            buf.push(b);
1120        }
1121        buf
1122    }
1123
1124    #[test]
1125    fn test_jpeg_encode_returns_jfif_magic() {
1126        let pixels = solid_rgb(8, 8, 100, 150, 200);
1127        let encoded = jpeg_encode_rgb(8, 8, &pixels, 90).expect("encode failed");
1128        assert_eq!(&encoded[..2], &[0xFF, 0xD8]);
1129    }
1130
1131    #[test]
1132    fn test_jpeg_encode_ends_with_eoi() {
1133        let pixels = solid_rgb(8, 8, 80, 80, 80);
1134        let encoded = jpeg_encode_rgb(8, 8, &pixels, 75).expect("encode failed");
1135        let n = encoded.len();
1136        assert!(n >= 2);
1137        assert_eq!(&encoded[n - 2..], &[0xFF, 0xD9]);
1138    }
1139
1140    #[test]
1141    fn test_jpeg_roundtrip_8x8() {
1142        let pixels = solid_rgb(8, 8, 120, 80, 200);
1143        let encoded = jpeg_encode_rgb(8, 8, &pixels, 90).expect("encode");
1144        let decoded = jpeg_decode(&encoded).expect("decode");
1145        assert_eq!(decoded.width, 8);
1146        assert_eq!(decoded.height, 8);
1147        assert_eq!(decoded.pixels.len(), 8 * 8 * 3);
1148        for i in 0..8 * 8 {
1149            let dr = (decoded.pixels[i * 3] as i16 - 120i16).abs();
1150            let dg = (decoded.pixels[i * 3 + 1] as i16 - 80i16).abs();
1151            let db = (decoded.pixels[i * 3 + 2] as i16 - 200i16).abs();
1152            assert!(dr <= 8, "R channel error {} at pixel {}", dr, i);
1153            assert!(dg <= 8, "G channel error {} at pixel {}", dg, i);
1154            assert!(db <= 8, "B channel error {} at pixel {}", db, i);
1155        }
1156    }
1157
1158    #[test]
1159    fn test_jpeg_roundtrip_gradient_16x16() {
1160        let w = 16usize;
1161        let h = 16usize;
1162        let mut pixels = vec![0u8; w * h * 3];
1163        for y in 0..h {
1164            for x in 0..w {
1165                let r = (x * 255 / (w - 1)) as u8;
1166                let idx = (y * w + x) * 3;
1167                pixels[idx] = r;
1168                pixels[idx + 1] = 100;
1169                pixels[idx + 2] = 50;
1170            }
1171        }
1172        let encoded = jpeg_encode_rgb(w as u32, h as u32, &pixels, 90).expect("encode");
1173        let decoded = jpeg_decode(&encoded).expect("decode");
1174        assert_eq!(decoded.width, w);
1175        assert_eq!(decoded.height, h);
1176        // Verify gradient is preserved: left column < right column (with tolerance)
1177        let left_r = decoded.pixels[0] as i16;
1178        let right_r = decoded.pixels[(w - 1) * 3] as i16;
1179        assert!(
1180            right_r > left_r,
1181            "gradient not preserved: left={} right={}",
1182            left_r,
1183            right_r
1184        );
1185        // Verify each pixel is within ±15 of original
1186        for y in 0..h {
1187            for x in 0..w {
1188                let orig_r = (x * 255 / (w - 1)) as i16;
1189                let decoded_r = decoded.pixels[(y * w + x) * 3] as i16;
1190                let err = (decoded_r - orig_r).abs();
1191                assert!(
1192                    err <= 15,
1193                    "R error {} at ({},{}) expected {}",
1194                    err,
1195                    x,
1196                    y,
1197                    orig_r
1198                );
1199            }
1200        }
1201    }
1202
1203    #[test]
1204    fn test_jpeg_invalid_magic_returns_error() {
1205        let result = jpeg_decode(&[0x00, 0x01, 0x02]);
1206        assert!(result.is_err());
1207    }
1208
1209    #[test]
1210    fn test_jpeg_quality_50_smaller_than_quality_90() {
1211        let pixels = solid_rgb(16, 16, 128, 64, 32);
1212        let enc50 = jpeg_encode_rgb(16, 16, &pixels, 50).expect("encode q50");
1213        let enc90 = jpeg_encode_rgb(16, 16, &pixels, 90).expect("encode q90");
1214        assert!(
1215            enc50.len() < enc90.len(),
1216            "q50 size {} not < q90 size {}",
1217            enc50.len(),
1218            enc90.len()
1219        );
1220    }
1221
1222    #[test]
1223    fn test_jpeg_decode_empty_returns_error() {
1224        let result = jpeg_decode(&[]);
1225        assert!(result.is_err());
1226    }
1227}