pith_jpeg/lib.rs
1//! JPEG decoding for baseline (SOF0/SOF1) and progressive (SOF2) profiles.
2//!
3//! Part of the `pith` zero-dependency hashing suite: this crate depends
4//! only on `pith-digest`, `pith-math` and `pith-image`, so the
5//! whole suite resolves without a single registry package.
6//!
7//! # Scope
8//!
9//! Decodes JFIF/EXIF-style JPEG streams: 8-bit precision, Huffman entropy
10//! coding, 1–3 components, sampling factors up to 2×2 per component
11//! (4:4:4, 4:2:2, 4:4:0, 4:2:0 and friends), restart markers, and the full
12//! progressive script (spectral selection + successive approximation,
13//! interleaved DC scans, EOBRUN). Arithmetic-coded frames (SOF9–SOF11,
14//! SOF13–SOF15), lossless frames (SOF3/SOF7/SOF11/SOF15), differential
15//! frames (SOF5–SOF7/13–15), 12-bit precision and CMYK output are refused
16//! with [`Error::Unsupported`] naming the exact coding process.
17//!
18//! # Fidelity contract
19//!
20//! The integer pipeline is a transcription of libjpeg-turbo's `islow`
21//! IDCT (jidctint.c), `fancy` chroma upsampling (jdsample.c) and
22//! fixed-point YCbCr→RGB (jdcolor.c), so output is **byte-exact** with
23//! `libjpeg -dct int -nosmooth` (which is what Pillow produces). The only
24//! deliberate divergence is arithmetic on adversarially large
25//! coefficients: intermediate sums are `i64`, where libjpeg's `int`
26//! accumulators would wrap.
27//!
28//! `pith_math::idct2`/`idct2_2d` is *not* used on the decode path: it is
29//! an orthonormal DCT-III in `f64` whose rounding differs from JPEG's
30//! fixed-point `islow`, so substituting it would break byte-exactness with
31//! the reference decoder. It is used in this crate's test-suite as an
32//! independent oracle that the transcription stays inside the documented
33//! tolerance (see `tests/` and `src/idct.rs`).
34
35// `unsafe` is denied everywhere except `ffi`, the C ABI surface the
36// language SDKs bind through: raw pointers exist only at that boundary,
37// and every exported function is a documented `unsafe extern "C"` fn.
38#![deny(unsafe_code)]
39#![deny(missing_docs)]
40
41extern crate alloc;
42
43mod baseline;
44mod bits;
45mod color;
46mod huffman;
47mod idct;
48mod parser;
49mod progressive;
50mod upsample;
51
52pub mod ffi;
53pub mod reference;
54
55use alloc::vec::Vec;
56
57use pith_digest::{Error, Result};
58use pith_image::raster::{Gray, Image, Rgb};
59
60use parser::{Frame, Marker, Parser};
61
62/// A decoded JPEG image: single-component frames yield [`Image<Gray>`],
63/// three-component frames yield [`Image<Rgb>`].
64#[derive(Clone, Debug)]
65pub enum Jpeg {
66 /// Grayscale (`Image<Gray, u8>`).
67 Gray(Image<Gray, u8>),
68 /// Color (`Image<Rgb, u8>`).
69 Rgb(Image<Rgb, u8>),
70}
71
72impl Jpeg {
73 /// Image width in pixels.
74 pub fn width(&self) -> u32 {
75 match self {
76 Jpeg::Gray(i) => i.width(),
77 Jpeg::Rgb(i) => i.width(),
78 }
79 }
80
81 /// Image height in pixels.
82 pub fn height(&self) -> u32 {
83 match self {
84 Jpeg::Gray(i) => i.height(),
85 Jpeg::Rgb(i) => i.height(),
86 }
87 }
88}
89
90/// Decodes a JPEG byte stream into pixels.
91///
92/// Returns [`Error::Unsupported`] for arithmetic-coded, lossless,
93/// differential, hierarchical, 12-bit-precision or CMYK/YCCK files — the
94/// message names the coding process — and [`Error::Truncated`] /
95/// [`Error::BadValue`] for malformed structure. This function never
96/// panics on untrusted input.
97pub fn decode(data: &[u8]) -> Result<Jpeg> {
98 let mut p = Parser::new(data);
99 p.soi()?;
100
101 let mut tables = parser::Tables::default();
102 let mut frame: Option<Frame> = None;
103 let mut saw_scan = false;
104 let mut saw_eoi = false;
105
106 while !saw_eoi {
107 let marker = p.next_marker()?;
108 match marker {
109 Marker::Eoi => saw_eoi = true,
110 Marker::App(_) | Marker::Com => {
111 let seg = p.segment()?;
112 if let Marker::App(14) = marker {
113 parser::parse_app14(seg, &mut tables);
114 }
115 }
116 Marker::Dqt => parser::parse_dqt(p.segment()?, &mut tables)?,
117 Marker::Dht => parser::parse_dht(p.segment()?, &mut tables)?,
118 Marker::Dri => {
119 let seg = p.segment()?;
120 if seg.len() < 2 {
121 return Err(Error::truncated("DRI segment", 2, seg.len()));
122 }
123 tables.restart_interval = u16::from_be_bytes([seg[0], seg[1]]) as usize;
124 }
125 Marker::Sof(m) => {
126 let seg = p.segment()?;
127 frame = Some(parser::parse_sof(m, seg)?);
128 }
129 Marker::Sos => {
130 if frame.is_none() {
131 return Err(Error::BadValue("SOS before SOF"));
132 }
133 let seg = p.segment()?;
134 let f = frame.as_mut().expect("checked above");
135 if f.progressive {
136 progressive::decode_scan(&mut p, seg, f, &tables)?;
137 } else {
138 if saw_scan {
139 return Err(Error::BadValue("second SOS in sequential JPEG"));
140 }
141 baseline::decode_scan(&mut p, seg, f, &tables)?;
142 }
143 saw_scan = true;
144 }
145 Marker::Dnl => {
146 // DNL carries a late height definition; height==0 was
147 // already refused at SOF, so the segment is skippable.
148 let _ = p.maybe_segment();
149 }
150 Marker::Rst(_) => {
151 // Stray restart marker between scans: no body, ignore.
152 }
153 Marker::Dac => {
154 let _ = p.segment()?;
155 return Err(Error::Unsupported(
156 "arithmetic entropy coding (DAC present)",
157 ));
158 }
159 Marker::Dhp | Marker::Exp | Marker::Jpg | Marker::JpgExtension(_) => {
160 let _ = p.maybe_segment();
161 }
162 }
163 }
164
165 let frame = frame.ok_or(Error::BadValue("no frame (SOF) found"))?;
166 if !saw_scan {
167 return Err(Error::Truncated {
168 what: "scan data",
169 needed: 1,
170 found: 0,
171 });
172 }
173 finish(&frame, &tables)
174}
175
176/// Runs IDCT on every decoded block, upsamples components to full
177/// resolution and converts color.
178fn finish(frame: &Frame, tables: &parser::Tables) -> Result<Jpeg> {
179 // Bound all intermediate allocation by the same ceiling the output
180 // Image enforces (plus slack for the ×4 coefficient expansion and
181 // MCU padding).
182 let sample_cap = pith_image::raster::MAX_BUFFER_BYTES;
183 let total_coefs: usize = frame.comps.iter().map(|c| c.coefs.len()).sum();
184 if total_coefs > sample_cap {
185 return Err(Error::too_large("coefficient buffers", sample_cap));
186 }
187
188 // Decode every block through islow IDCT into the component plane
189 // (padded to whole blocks; only `down_w`/`down_h` samples are real).
190 let mut planes: Vec<Vec<u8>> = Vec::with_capacity(frame.comps.len());
191 for comp in frame.comps.iter() {
192 let qt = tables.quant[comp.tq as usize]
193 .as_ref()
194 .ok_or(Error::BadValue("scan uses missing quantization table"))?;
195 let pw = comp.blocks_w * 8;
196 let ph = comp.blocks_h * 8;
197 if pw.checked_mul(ph).is_none_or(|n| n > sample_cap * 4) {
198 return Err(Error::too_large("component plane", sample_cap * 4));
199 }
200 let mut plane = alloc::vec![0u8; pw * ph];
201 for by in 0..comp.blocks_h {
202 for bx in 0..comp.blocks_w {
203 let bi = by * comp.blocks_w + bx;
204 let block = &comp.coefs[bi * 64..bi * 64 + 64];
205 idct::dequant_idct_into(block, qt, &mut plane, pw, bx * 8, by * 8);
206 }
207 }
208 planes.push(plane);
209 }
210
211 // Upsample every component to frame resolution.
212 let mut full: Vec<Vec<u8>> = Vec::with_capacity(frame.comps.len());
213 for (comp, plane) in frame.comps.iter().zip(planes.iter()) {
214 full.push(upsample::to_full_size(
215 comp,
216 plane,
217 comp.blocks_w * 8,
218 frame,
219 )?);
220 }
221
222 color::convert(frame, &full, tables.adobe_transform)
223}