zenjxl_decoder/api/convenience.rs
1// Copyright (c) the JPEG XL Project Authors. All rights reserved.
2//
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file.
5
6//! High-level convenience API for decoding JXL images.
7//!
8//! For most use cases, [`decode`] is all you need:
9//!
10//! ```no_run
11//! let data = std::fs::read("image.jxl").unwrap();
12//! let image = zenjxl_decoder::decode(&data).unwrap();
13//! let (w, h) = (image.width, image.height);
14//! let rgba: &[u8] = &image.data;
15//! ```
16//!
17//! Use [`read_header`] to inspect metadata without decoding pixels.
18//!
19//! For streaming input, incremental decoding, or fine-grained control over
20//! pixel format and color management, use the lower-level [`JxlDecoder`]
21//! typestate API instead.
22//!
23//! [`JxlDecoder`]: super::JxlDecoder
24
25use super::{
26 GainMapBundle, JxlBasicInfo, JxlColorProfile, JxlColorType, JxlDataFormat, JxlDecoder,
27 JxlDecoderLimits, JxlDecoderOptions, JxlOutputBuffer, JxlPixelFormat, ProcessingResult, states,
28};
29use crate::error::{Error, Result};
30use crate::headers::extra_channels::ExtraChannel;
31use crate::image::{OwnedRawImage, Rect};
32
33/// A decoded JXL image with interleaved RGBA (or GrayAlpha) u8 pixel data.
34#[non_exhaustive]
35pub struct JxlImage {
36 /// Image width in pixels.
37 pub width: usize,
38 /// Image height in pixels.
39 pub height: usize,
40 /// Interleaved pixel data: RGBA or GrayAlpha, row-major, tightly packed.
41 /// Length = `width * height * channels` where channels is 4 (RGBA) or 2 (GrayAlpha).
42 pub data: Vec<u8>,
43 /// Number of channels per pixel (4 for RGBA, 2 for GrayAlpha).
44 pub channels: usize,
45 /// True if the source image is grayscale (output is GrayAlpha).
46 pub is_grayscale: bool,
47 /// Image metadata from the file header.
48 pub info: JxlBasicInfo,
49 /// The color profile of the output pixels.
50 pub output_profile: JxlColorProfile,
51 /// The color profile embedded in the file.
52 pub embedded_profile: JxlColorProfile,
53 /// HDR gain map bundle from a `jhgm` container box, if present.
54 ///
55 /// Captured whether the box precedes or follows the codestream. For
56 /// animations, boxes that follow the codestream are not read (decoding
57 /// stops after the first frame).
58 pub gain_map: Option<GainMapBundle>,
59 /// Raw EXIF data from the `Exif` container box (TIFF header offset stripped).
60 /// `None` for bare codestreams or files without an `Exif` box.
61 ///
62 /// Captured whether the box precedes or follows the codestream. For
63 /// animations, boxes that follow the codestream are not read (decoding
64 /// stops after the first frame).
65 pub exif: Option<Vec<u8>>,
66 /// Raw XMP data from the `xml ` container box.
67 /// `None` for bare codestreams or files without an `xml ` box.
68 ///
69 /// Captured whether the box precedes or follows the codestream. For
70 /// animations, boxes that follow the codestream are not read (decoding
71 /// stops after the first frame).
72 pub xmp: Option<Vec<u8>>,
73}
74
75/// Image metadata extracted from the file header, without decoding pixels.
76#[non_exhaustive]
77pub struct JxlImageInfo {
78 /// Image metadata (dimensions, bit depth, orientation, extra channels, animation).
79 pub info: JxlBasicInfo,
80 /// The color profile embedded in the file.
81 pub embedded_profile: JxlColorProfile,
82}
83
84/// Decode a JXL image from a byte slice to RGBA u8 pixels.
85///
86/// For grayscale images, returns GrayAlpha u8 (2 channels).
87/// For color images, returns RGBA u8 (4 channels).
88/// Alpha is always included; images without alpha get opaque (255) alpha.
89///
90/// Decodes only the first frame. For animation support, use the
91/// [`JxlDecoder`](super::JxlDecoder) streaming API.
92///
93/// Uses default security limits and parallel decoding (if the `threads`
94/// feature is enabled). For custom limits or cancellation, use
95/// [`decode_with`].
96///
97/// # Example
98///
99/// ```no_run
100/// let data = std::fs::read("photo.jxl").unwrap();
101/// let image = zenjxl_decoder::decode(&data).unwrap();
102/// assert_eq!(image.data.len(), image.width * image.height * image.channels);
103/// ```
104pub fn decode(data: &[u8]) -> Result<JxlImage> {
105 decode_with(data, JxlDecoderOptions::default())
106}
107
108/// Decode a JXL image with custom decoder options.
109///
110/// Same output format as [`decode`] (RGBA u8 or GrayAlpha u8), but allows
111/// configuring security limits, cancellation, parallel mode, and CMS.
112pub fn decode_with(data: &[u8], options: JxlDecoderOptions) -> Result<JxlImage> {
113 let mut input: &[u8] = data;
114
115 // Phase 1: Initialized → WithImageInfo (parse header + ICC)
116 let decoder = JxlDecoder::<states::Initialized>::new(options);
117 let mut decoder = match decoder.process(&mut input)? {
118 ProcessingResult::Complete { result } => result,
119 ProcessingResult::NeedsMoreInput { .. } => {
120 return Err(Error::OutOfBounds(0));
121 }
122 };
123
124 let info = decoder.basic_info().clone();
125 let embedded_profile = decoder.embedded_color_profile().clone();
126 let (width, height) = info.size;
127
128 // Determine color type: add alpha to whatever the image naturally is
129 let is_grayscale = decoder.current_pixel_format().color_type.is_grayscale();
130 let color_type = if is_grayscale {
131 JxlColorType::GrayscaleAlpha
132 } else {
133 JxlColorType::Rgba
134 };
135 let channels = color_type.samples_per_pixel();
136
137 // Find main alpha channel for interleaving
138 let main_alpha = info
139 .extra_channels
140 .iter()
141 .position(|ec| ec.ec_type == ExtraChannel::Alpha);
142
143 let u8_format = JxlDataFormat::U8 { bit_depth: 8 };
144
145 // Set pixel format: interleave alpha into color channels, keep other extras as u8
146 let pixel_format = JxlPixelFormat {
147 color_type,
148 color_data_format: Some(u8_format),
149 extra_channel_format: info
150 .extra_channels
151 .iter()
152 .enumerate()
153 .map(|(i, _)| {
154 if Some(i) == main_alpha {
155 None // interleaved into RGBA/GrayAlpha
156 } else {
157 Some(u8_format)
158 }
159 })
160 .collect(),
161 };
162 decoder.set_pixel_format(pixel_format);
163
164 let output_profile = decoder.output_color_profile().clone();
165
166 // Count non-interleaved extra channels (everything except the main alpha)
167 let extra_count = info.extra_channels.len() - usize::from(main_alpha.is_some());
168
169 // Phase 2: WithImageInfo → WithFrameInfo (parse frame header)
170 let decoder = match decoder.process(&mut input)? {
171 ProcessingResult::Complete { result } => result,
172 ProcessingResult::NeedsMoreInput { .. } => {
173 return Err(Error::OutOfBounds(0));
174 }
175 };
176
177 // Allocate output buffers
178 let row_bytes = width * channels; // 1 byte per sample for u8
179 let mut output = OwnedRawImage::new_uninit((row_bytes, height))?;
180 #[cfg(feature = "threads")]
181 output.prefault_parallel();
182
183 let mut extra_outputs: Vec<OwnedRawImage> = (0..extra_count)
184 .map(|_| OwnedRawImage::new_uninit((width, height)))
185 .collect::<Result<_>>()?;
186
187 // Phase 3: WithFrameInfo → decode pixels
188 let mut bufs: Vec<JxlOutputBuffer<'_>> = std::iter::once(&mut output)
189 .chain(extra_outputs.iter_mut())
190 .map(|img| {
191 let rect = Rect {
192 size: img.byte_size(),
193 origin: (0, 0),
194 };
195 JxlOutputBuffer::from_image_rect_mut(img.get_rect_mut(rect))
196 })
197 .collect();
198
199 let mut decoder = match decoder.process(&mut input, &mut bufs)? {
200 ProcessingResult::Complete { result } => result,
201 ProcessingResult::NeedsMoreInput { .. } => {
202 return Err(Error::OutOfBounds(0));
203 }
204 };
205
206 // Phase 4: drain trailing container boxes, then extract box metadata.
207 // The jhgm (gain map), Exif, and xml boxes may follow the codestream —
208 // jxl-encoder's `append_gain_map_bundle` writes `signature + ftyp + jxlc
209 // + jhgm` — and the box parser only reaches them by processing past the
210 // final frame (#20). Animations stop after the first frame, so boxes
211 // trailing a multi-frame codestream stay unread; use the low-level
212 // [`JxlDecoder`] API to collect those.
213 let (gain_map, exif, xmp) = if !decoder.has_more_frames() && !input.is_empty() {
214 match decoder.process(&mut input)? {
215 ProcessingResult::Complete { mut result } => take_box_metadata(&mut result),
216 ProcessingResult::NeedsMoreInput { mut fallback, .. } => {
217 take_box_metadata(&mut fallback)
218 }
219 }
220 } else {
221 take_box_metadata(&mut decoder)
222 };
223
224 // Copy to tightly packed Vec<u8>
225 let total_bytes = row_bytes * height;
226 let mut pixels = Vec::with_capacity(total_bytes);
227 for y in 0..height {
228 pixels.extend_from_slice(output.row(y));
229 }
230
231 Ok(JxlImage {
232 width,
233 height,
234 data: pixels,
235 channels,
236 is_grayscale,
237 info,
238 output_profile,
239 embedded_profile,
240 gain_map,
241 exif,
242 xmp,
243 })
244}
245
246/// Takes the gain map, EXIF, and XMP captured by the box parser, in one call
247/// usable from any decoder state.
248fn take_box_metadata<S: states::JxlState>(
249 decoder: &mut JxlDecoder<S>,
250) -> (Option<GainMapBundle>, Option<Vec<u8>>, Option<Vec<u8>>) {
251 (
252 decoder.take_gain_map(),
253 decoder.take_exif(),
254 decoder.take_xmp(),
255 )
256}
257
258/// Reconstruct the original JPEG bytes from a JXL file that carries a JBRD
259/// (JPEG Bitstream Reconstruction Data) box — i.e. a JXL produced by lossless
260/// JPEG transcoding (`jxl_encoder::LosslessConfig::encode_jpeg_transcode`).
261///
262/// Returns `Ok(Some(bytes))` with the **byte-exact** original JPEG when the
263/// file carries reconstruction data, or `Ok(None)` when it does not (a JXL that
264/// was not produced from a JPEG). This is the pure-Rust counterpart to
265/// `djxl <in.jxl> <out.jpg> --reconstruct_jpeg`.
266///
267/// Requires the `jpeg` cargo feature.
268///
269/// # Example
270///
271/// ```no_run
272/// let jxl = std::fs::read("photo.jxl").unwrap();
273/// if let Some(jpeg) = zenjxl_decoder::reconstruct_jpeg(&jxl).unwrap() {
274/// std::fs::write("photo_reconstructed.jpg", &jpeg).unwrap();
275/// }
276/// ```
277#[cfg(feature = "jpeg")]
278pub fn reconstruct_jpeg(data: &[u8]) -> Result<Option<Vec<u8>>> {
279 reconstruct_jpeg_with(data, JxlDecoderOptions::default())
280}
281
282/// Reconstruct the original JPEG bytes with custom decoder options.
283///
284/// See [`reconstruct_jpeg`]. Drives a full frame decode (reconstruction needs
285/// the captured quantized coefficients) and then returns the JBRD-reconstructed
286/// JPEG. The decoded pixels themselves are discarded.
287#[cfg(feature = "jpeg")]
288pub fn reconstruct_jpeg_with(data: &[u8], options: JxlDecoderOptions) -> Result<Option<Vec<u8>>> {
289 let mut input: &[u8] = data;
290
291 // Phase 1: parse header.
292 let decoder = JxlDecoder::<states::Initialized>::new(options);
293 let mut decoder = match decoder.process(&mut input)? {
294 ProcessingResult::Complete { result } => result,
295 ProcessingResult::NeedsMoreInput { .. } => return Err(Error::OutOfBounds(0)),
296 };
297
298 let info = decoder.basic_info().clone();
299 let (width, height) = info.size;
300
301 // Output format mirrors `decode_with`: natural color type + interleaved
302 // alpha. The pixels are discarded — we only need a complete frame decode so
303 // the JBRD coefficient capture runs.
304 let is_grayscale = decoder.current_pixel_format().color_type.is_grayscale();
305 let color_type = if is_grayscale {
306 JxlColorType::GrayscaleAlpha
307 } else {
308 JxlColorType::Rgba
309 };
310 let channels = color_type.samples_per_pixel();
311 let main_alpha = info
312 .extra_channels
313 .iter()
314 .position(|ec| ec.ec_type == ExtraChannel::Alpha);
315 let u8_format = JxlDataFormat::U8 { bit_depth: 8 };
316 let pixel_format = JxlPixelFormat {
317 color_type,
318 color_data_format: Some(u8_format),
319 extra_channel_format: info
320 .extra_channels
321 .iter()
322 .enumerate()
323 .map(|(i, _)| {
324 if Some(i) == main_alpha {
325 None
326 } else {
327 Some(u8_format)
328 }
329 })
330 .collect(),
331 };
332 decoder.set_pixel_format(pixel_format);
333 let extra_count = info.extra_channels.len() - usize::from(main_alpha.is_some());
334
335 // Phase 2: parse frame header.
336 let decoder = match decoder.process(&mut input)? {
337 ProcessingResult::Complete { result } => result,
338 ProcessingResult::NeedsMoreInput { .. } => return Err(Error::OutOfBounds(0)),
339 };
340
341 // Phase 3: decode the frame into throwaway buffers.
342 let row_bytes = width * channels;
343 let mut output = OwnedRawImage::new_uninit((row_bytes, height))?;
344 let mut extra_outputs: Vec<OwnedRawImage> = (0..extra_count)
345 .map(|_| OwnedRawImage::new_uninit((width, height)))
346 .collect::<Result<_>>()?;
347 let mut bufs: Vec<JxlOutputBuffer<'_>> = core::iter::once(&mut output)
348 .chain(extra_outputs.iter_mut())
349 .map(|img| {
350 let rect = Rect {
351 size: img.byte_size(),
352 origin: (0, 0),
353 };
354 JxlOutputBuffer::from_image_rect_mut(img.get_rect_mut(rect))
355 })
356 .collect();
357
358 let mut decoder = match decoder.process(&mut input, &mut bufs)? {
359 ProcessingResult::Complete { result } => result,
360 ProcessingResult::NeedsMoreInput { .. } => return Err(Error::OutOfBounds(0)),
361 };
362
363 // Phase 4: drain the remaining input so the box parser consumes the trailing
364 // Exif / xml metadata boxes (which follow the codestream in a JPEG-transcode
365 // container). Only then can `take_jpeg_reconstruction` stitch EXIF/XMP back
366 // into the reconstructed APPn markers. A JBRD transcode has a single frame,
367 // so this drains to `NeedsMoreInput` once the boxes are parsed.
368 loop {
369 if input.is_empty() {
370 break;
371 }
372 let before = input.len();
373 match decoder.process(&mut input)? {
374 ProcessingResult::NeedsMoreInput { fallback, .. } => {
375 decoder = fallback;
376 if input.len() == before {
377 break; // no progress — avoid spinning
378 }
379 }
380 // Unexpected second frame; the trailing boxes before it are parsed.
381 ProcessingResult::Complete { mut result } => {
382 return Ok(result.take_jpeg_reconstruction());
383 }
384 }
385 }
386
387 Ok(decoder.take_jpeg_reconstruction())
388}
389
390/// Read image metadata without decoding pixels.
391///
392/// Parses the file header and ICC profile. Returns dimensions, bit depth,
393/// orientation, extra channel info, animation info, and color profile.
394///
395/// This is fast (~1μs for sRGB images, ~7μs for images with ICC profiles).
396///
397/// # Example
398///
399/// ```no_run
400/// let data = std::fs::read("photo.jxl").unwrap();
401/// let header = zenjxl_decoder::read_header(&data).unwrap();
402/// let (w, h) = header.info.size;
403/// println!("{w}x{h}");
404/// ```
405pub fn read_header(data: &[u8]) -> Result<JxlImageInfo> {
406 read_header_with(data, JxlDecoderLimits::default())
407}
408
409/// Read image metadata with custom security limits.
410pub fn read_header_with(data: &[u8], limits: JxlDecoderLimits) -> Result<JxlImageInfo> {
411 let mut input: &[u8] = data;
412 let options = JxlDecoderOptions {
413 limits,
414 ..JxlDecoderOptions::default()
415 };
416 let decoder = JxlDecoder::<states::Initialized>::new(options);
417 let decoder = match decoder.process(&mut input)? {
418 ProcessingResult::Complete { result } => result,
419 ProcessingResult::NeedsMoreInput { .. } => {
420 return Err(Error::OutOfBounds(0));
421 }
422 };
423
424 Ok(JxlImageInfo {
425 info: decoder.basic_info().clone(),
426 embedded_profile: decoder.embedded_color_profile().clone(),
427 })
428}