From 4d3e92095435dfb8c8caa974db1e6d1d7a094aa3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 22:47:47 +0000 Subject: [PATCH] Add shared JPEG XL decoder and encoder --- Cargo.lock | 15 +++ Cargo.toml | 3 + crates/jxl/Cargo.toml | 23 ++++ crates/jxl/README.md | 19 +++ crates/jxl/src/decode.rs | 130 +++++++++++++++++++ crates/jxl/src/encode.rs | 233 ++++++++++++++++++++++++++++++++++ crates/jxl/src/lib.rs | 157 +++++++++++++++++++++++ crates/jxl/tests/roundtrip.rs | 44 +++++++ 8 files changed, 624 insertions(+) create mode 100644 crates/jxl/Cargo.toml create mode 100644 crates/jxl/README.md create mode 100644 crates/jxl/src/decode.rs create mode 100644 crates/jxl/src/encode.rs create mode 100644 crates/jxl/src/lib.rs create mode 100644 crates/jxl/tests/roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index f2fbe2d03..7e6eb96fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1719,6 +1719,15 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "craft-jxl" +version = "0.1.0" +dependencies = [ + "jixel", + "jxl-oxide", + "thiserror 2.0.21", +] + [[package]] name = "craft-keymap" version = "0.1.0" @@ -5009,6 +5018,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jixel" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3" + [[package]] name = "jni" version = "0.22.4" diff --git a/Cargo.toml b/Cargo.toml index ac6f09673..a06e09f67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ craft-indd = { path = "crates/indd" } craft-jmap = { path = "crates/jmap" } craft-ical = { path = "crates/ical" } craft-jpeg = { path = "crates/jpeg" } +craft-jxl = { path = "crates/jxl" } craft-kiwi = { path = "crates/kiwi" } craft-keymap = { path = "crates/keymap" } craft-assuan = { path = "crates/assuan" } @@ -154,6 +155,8 @@ craft-web-host = { path = "crates/web-host" } craft-webp = { path = "crates/webp" } egui = { version = "0.37", default-features = false } md5 = { package = "md-5", version = "0.10", default-features = false } +jxl-oxide = { version = "0.12", default-features = false } +jixel = "=0.3.4" rayon = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/jxl/Cargo.toml b/crates/jxl/Cargo.toml new file mode 100644 index 000000000..1d2f86ecb --- /dev/null +++ b/crates/jxl/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "craft-jxl" +description = "JPEG XL image decoding and encoding with bounded input and allocation tracking." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[features] +default = ["decode", "encode"] +decode = ["dep:jxl-oxide"] +encode = ["dep:jixel"] + +[dependencies] +jxl-oxide = { workspace = true, optional = true } +thiserror = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +jixel = { workspace = true, optional = true } diff --git a/crates/jxl/README.md b/crates/jxl/README.md new file mode 100644 index 000000000..bb6036a9f --- /dev/null +++ b/crates/jxl/README.md @@ -0,0 +1,19 @@ +# craft-jxl + +JPEG XL decoding and encoding for still raster images. + +The decoder reads a codestream incrementally, checks its dimensions before loading the remainder, +and applies a caller-selected compressed-input, pixel-count, and tracked-decoder-allocation budget. +`jxl-oxide`'s allocation tracker cannot account for every allocation made by the decoder, and the +input slice is already owned by the caller. The decoder returns the first frame as interleaved +`f32` samples together with source and rendered color metadata. It does not perform an external +ICC transform; callers that need color-managed output should use the returned profiles with their +CMS. CMYK output is rejected because this crate does not bundle an external CMS. + +The encoder accepts interleaved 8-bit, 16-bit, or HDR floating-point samples. Floating-point input +is linear-light and is currently available only with the Rec. 2020 PQ option; one input unit maps +to 203 cd/m². Gray input is expanded to RGB, and opaque alpha is omitted. ICC data is embedded in +lossless output; Exif and XMP boxes are written when provided. Encoding is unavailable on +`wasm32` targets. + +`jxl-oxide` is MIT OR Apache-2.0. `jixel` is BSD-3-Clause OR Apache-2.0. diff --git a/crates/jxl/src/decode.rs b/crates/jxl/src/decode.rs new file mode 100644 index 000000000..03df2e0fe --- /dev/null +++ b/crates/jxl/src/decode.rs @@ -0,0 +1,130 @@ +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage}; + +use crate::{DecodeLimits, Decoded, Error, Layout, Metadata, Result}; + +const INPUT_CHUNK: usize = 4096; +const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; +const MIN_TRACKED_BYTES: u64 = 64 << 20; + +/// Decode the first frame, keeping the decoder's tracked allocations within `limits`. +/// +/// The allocator budget applies to allocations registered with `jxl-oxide`'s `AllocTracker`. +/// The decoder may also make untracked allocations, including the input buffer supplied by the +/// caller. Header data is fed in small chunks so the dimensions are checked before the rest of +/// the codestream is loaded. +pub fn decode(bytes: &[u8], limits: DecodeLimits) -> Result { + catch_unwind(AssertUnwindSafe(|| decode_inner(bytes, limits))) + .unwrap_or_else(|_| Err(Error::Malformed("decoder failed while reading this file".into()))) +} + +fn decode_inner(bytes: &[u8], limits: DecodeLimits) -> Result { + if bytes.len() > limits.max_input_bytes { + return Err(Error::LimitExceeded(format!("{} input bytes exceed the {} byte input limit", bytes.len(), limits.max_input_bytes))); + } + let budget = limits.max_tracked_bytes.unwrap_or_else(|| limits.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES)); + let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX)); + let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit(); + let mut rest = bytes; + let mut window = INPUT_CHUNK; + let mut image = loop { + let feed = rest.get(..window).unwrap_or(rest); + let consumed = uninit.feed_bytes(feed).map_err(decode_error)?; + rest = rest.get(consumed..).unwrap_or_default(); + match uninit.try_init().map_err(decode_error)? { + InitializeResult::Initialized(image) => break image, + InitializeResult::NeedMoreData(next) => uninit = next, + } + if consumed == 0 { + if window >= rest.len() { + return Err(Error::Malformed("truncated JPEG XL header".into())); + } + window = window.saturating_mul(2); + } + }; + + let (width, height) = (image.width(), image.height()); + if width == 0 || height == 0 { + return Err(Error::Malformed("zero image dimension".into())); + } + let pixels = u64::from(width).saturating_mul(u64::from(height)); + if pixels > limits.max_pixels { + return Err(Error::LimitExceeded(format!("{width}×{height} image exceeds the {} pixel limit", limits.max_pixels))); + } + + // `JxlImageBuilder::read` feeds the full input. Continue in bounded chunks so trailing frame + // data and boxes are not copied into an untracked buffer all at once. + while !image.is_loading_done() && !rest.is_empty() { + let consumed = image.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(decode_error)?; + if consumed == 0 { + break; + } + rest = rest.get(consumed..).unwrap_or_default(); + } + image.finalize().map_err(decode_error)?; + + let header = image.image_header(); + let bit_depth = header.metadata.bit_depth.bits_per_sample() as u8; + let float_samples = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }); + let grayscale = header.metadata.grayscale(); + let colors = if grayscale { 1 } else { 3 }; + let pixel_format = image.pixel_format(); + if pixel_format.has_black() { + return Err(Error::Unsupported("CMYK JPEG XL rendering requires an external color-management transform".into())); + } + let has_alpha = pixel_format.has_alpha(); + let layout = match (grayscale, has_alpha) { + (true, false) => Layout::Gray, + (true, true) => Layout::GrayAlpha, + (false, false) => Layout::Rgb, + (false, true) => Layout::Rgba, + }; + let keep = colors + usize::from(has_alpha); + let frame = image.render_frame(0).map_err(decode_error)?; + let buffer = frame.image_all_channels(); + let channels = buffer.channels(); + if channels < colors || (has_alpha && channels < keep) { + return Err(Error::Malformed("decoded frame has too few channels".into())); + } + let frame_pixels = (buffer.width() as u128) * (buffer.height() as u128); + if frame_pixels != u128::from(pixels) { + return Err(Error::Malformed("decoded frame dimensions do not match the image header".into())); + } + let sample_count = usize::try_from(frame_pixels.checked_mul(keep as u128).ok_or_else(|| Error::LimitExceeded("sample count overflows".into()))?) + .map_err(|_| Error::LimitExceeded("sample count exceeds addressable memory".into()))?; + let mut out = Vec::new(); + out.try_reserve_exact(sample_count).map_err(|e| Error::LimitExceeded(format!("cannot reserve decoded pixels: {e}")))?; + for pixel in buffer.buf().chunks_exact(channels) { + out.extend_from_slice(&pixel[..keep]); + } + + let metadata = Metadata { + icc: image.original_icc().map(ToOwned::to_owned), + exif: match image.aux_boxes().first_exif() { + Ok(AuxBoxData::Data(exif)) => Some(exif.payload().to_vec()), + _ => None, + }, + xmp: match image.aux_boxes().first_xml() { + AuxBoxData::Data(xmp) => Some(xmp.to_vec()), + _ => None, + }, + }; + Ok(Decoded { + width, + height, + layout, + bit_depth, + float_samples, + pixels: out, + metadata, + source_orientation: header.metadata.orientation, + rendered_cicp: image.rendered_cicp(), + rendered_icc: image.rendered_icc(), + xyb_encoded: header.metadata.xyb_encoded, + }) +} + +fn decode_error(error: impl std::fmt::Display) -> Error { + Error::Malformed(error.to_string()) +} diff --git a/crates/jxl/src/encode.rs b/crates/jxl/src/encode.rs new file mode 100644 index 000000000..83dd6caf0 --- /dev/null +++ b/crates/jxl/src/encode.rs @@ -0,0 +1,233 @@ +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use crate::{Error, ImageRef, Layout, Metadata, Result, Samples}; + +/// Encoder search effort. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Effort { + /// Quickest search, with larger output files. + Fast, + /// Balanced search. + #[default] + Normal, +} + +/// Color encoding declared by a JPEG XL image. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum JxlColour { + /// sRGB primaries and transfer curve. + #[default] + Srgb, + /// Display P3 primaries, D65, with the sRGB transfer curve. + DisplayP3, + /// Rec. 2020 primaries with the BT.709 transfer curve. + Rec2020, + /// Rec. 2020 primaries with the SMPTE ST 2084 PQ transfer curve. + Rec2020Pq, +} + +/// JPEG XL encoder settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EncodeOptions { + /// Lossy quality on a 1..=100 scale (90 is approximately visually lossless). Ignored for + /// mathematically lossless output. + pub quality: u8, + /// Use mathematically lossless Modular coding. + pub lossless: bool, + /// Encoder search effort. + pub effort: Effort, + /// Use progressive encoding; lossless output uses the Squeeze transform. + pub progressive: bool, + /// Color encoding to write. + pub colour: JxlColour, +} + +impl Default for EncodeOptions { + fn default() -> Self { + Self { quality: 90, lossless: false, effort: Effort::Normal, progressive: false, colour: JxlColour::Srgb } + } +} + +/// SDR reference white used when converting linear float input to HDR PQ, in cd/m². +pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0; + +/// Encode a still image to JPEG XL. +/// +/// Gray input is widened to RGB. Alpha is omitted when every sample is opaque. Float input is +/// encoded only with `JxlColour::Rec2020Pq`; its linear values use SDR white at 203 cd/m². +/// ICC data is embedded only for lossless output, where the explicit ICC profile takes precedence +/// over the enum color encoding. Exif and XMP are embedded when supplied. +pub fn encode(image: &ImageRef<'_>, options: EncodeOptions, metadata: &Metadata) -> Result> { + validate(image)?; + #[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] + { + let pixels = Pixels::from(image, options.colour)?; + catch_unwind(AssertUnwindSafe(|| run(&pixels, image.width as usize, image.height as usize, options, metadata))) + .unwrap_or_else(|_| Err(Error::Encode("encoder failed while writing this image".into()))) + } + #[cfg(any(target_arch = "wasm32", not(feature = "encode")))] + { + let _ = (image, options, metadata); + Err(Error::Unsupported("JPEG XL encoding is unavailable on this target".into())) + } +} + +fn validate(image: &ImageRef<'_>) -> Result<()> { + if image.width == 0 || image.height == 0 { + return Err(Error::InvalidImage("zero dimension".into())); + } + let pixels = u128::from(image.width) * u128::from(image.height); + let samples = pixels * image.layout.channels() as u128; + if samples > usize::MAX as u128 { + return Err(Error::InvalidImage("image sample count exceeds addressable memory".into())); + } + let have = match image.samples { + Samples::U8(data) => data.len(), + Samples::U16(data) => data.len(), + Samples::F32(data) => data.len(), + }; + if have < samples as usize { + return Err(Error::InvalidImage(format!("sample buffer has {have} samples, expected at least {samples}"))); + } + Ok(()) +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +enum Pixels { + U8 { data: Vec, alpha: bool }, + U16 { data: Vec, alpha: bool }, +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +impl Pixels { + fn from(image: &ImageRef<'_>, colour: JxlColour) -> Result { + let n = usize::try_from(u64::from(image.width) * u64::from(image.height)) + .map_err(|_| Error::InvalidImage("image pixel count exceeds addressable memory".into()))?; + Ok(match image.samples { + Samples::U8(samples) => { + let (data, alpha) = convert(samples, image.layout, n, |a| a == u8::MAX)?; + Pixels::U8 { data, alpha } + } + Samples::U16(samples) => { + let (data, alpha) = convert(samples, image.layout, n, |a| a == u16::MAX)?; + Pixels::U16 { data, alpha } + } + Samples::F32(samples) if colour == JxlColour::Rec2020Pq => { + let count = n.checked_mul(4).ok_or_else(|| Error::InvalidImage("pixel buffer size overflows".into()))?; + let mut encoded = Vec::new(); + encoded.try_reserve_exact(count).map_err(|e| Error::InvalidImage(format!("cannot reserve pixel buffer: {e}")))?; + for pixel in samples.chunks_exact(image.layout.channels()).take(n) { + let (rgb, alpha) = pixel_to_rgba(pixel, image.layout); + for channel in rgb { + encoded.push(pq_sample(channel)); + } + encoded.push(alpha_sample(alpha)); + } + let (data, alpha) = convert(&encoded, Layout::Rgba, n, |a| a == u16::MAX)?; + Pixels::U16 { data, alpha } + } + Samples::F32(_) => return Err(Error::InvalidImage("float samples require Rec. 2020 PQ encoding".into())), + }) + } +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +fn convert(samples: &[T], layout: Layout, pixels: usize, opaque: impl Fn(T) -> bool) -> Result<(Vec, bool)> { + let channels = layout.channels(); + let input = samples + .get(..pixels.checked_mul(channels).ok_or_else(|| Error::InvalidImage("sample count overflows".into()))?) + .ok_or_else(|| Error::InvalidImage("sample buffer is too short".into()))?; + let alpha = layout.has_alpha() && input.chunks_exact(channels).any(|p| p.last().is_some_and(|a| !opaque(*a))); + let output_channels = if alpha { 4 } else { 3 }; + let output_count = pixels.checked_mul(output_channels).ok_or_else(|| Error::InvalidImage("converted sample count overflows".into()))?; + let mut out = Vec::new(); + out.try_reserve_exact(output_count).map_err(|e| Error::InvalidImage(format!("cannot reserve converted samples: {e}")))?; + for pixel in input.chunks_exact(channels) { + match (layout, pixel) { + (Layout::Gray, [g]) => out.extend_from_slice(&[*g, *g, *g]), + (Layout::GrayAlpha, [g, a]) if alpha => out.extend_from_slice(&[*g, *g, *g, *a]), + (Layout::GrayAlpha, [g, _]) => out.extend_from_slice(&[*g, *g, *g]), + (Layout::Rgb, [r, g, b]) => out.extend_from_slice(&[*r, *g, *b]), + (Layout::Rgba, [r, g, b, a]) if alpha => out.extend_from_slice(&[*r, *g, *b, *a]), + (Layout::Rgba, [r, g, b, _]) => out.extend_from_slice(&[*r, *g, *b]), + _ => return Err(Error::InvalidImage("unsupported pixel layout".into())), + } + } + Ok((out, alpha)) +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +fn pixel_to_rgba(pixel: &[f32], layout: Layout) -> ([f32; 3], f32) { + let finite = |v: f32| if v.is_finite() { v } else { 0.0 }; + match (layout, pixel) { + (Layout::Gray, [g]) => ([finite(*g); 3], 1.0), + (Layout::GrayAlpha, [g, a]) => ([finite(*g); 3], finite(*a).clamp(0.0, 1.0)), + (Layout::Rgb, [r, g, b]) => ([finite(*r), finite(*g), finite(*b)], 1.0), + (Layout::Rgba, [r, g, b, a]) => ([finite(*r), finite(*g), finite(*b)], finite(*a).clamp(0.0, 1.0)), + _ => ([0.0; 3], 1.0), + } +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +fn pq_sample(value: f32) -> u16 { + let linear_nits = (if value.is_finite() { value.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS; + (pq_encode(linear_nits) * 65535.0).round().clamp(0.0, 65535.0) as u16 +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +fn alpha_sample(value: f32) -> u16 { + ((if value.is_finite() { value.clamp(0.0, 1.0) } else { 1.0 }) * 65535.0).round() as u16 +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +fn pq_encode(nits: f32) -> f32 { + const M1: f32 = 2610.0 / 16384.0; + const M2: f32 = 2523.0 / 4096.0 * 128.0; + const C1: f32 = 3424.0 / 4096.0; + const C2: f32 = 2413.0 / 4096.0 * 32.0; + const C3: f32 = 2392.0 / 4096.0 * 32.0; + let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1); + ((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2) +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))] +fn run(pixels: &Pixels, width: usize, height: usize, options: EncodeOptions, metadata: &Metadata) -> Result> { + use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction}; + + let colour = match options.colour { + JxlColour::Srgb => ColorEncoding::srgb(), + JxlColour::DisplayP3 => ColorEncoding::display_p3(), + JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() }, + JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(), + }; + let mut config = EncodeConfig::default() + .with_quality(f32::from(options.quality.clamp(1, 100))) + .with_color_encoding(colour) + .with_lossless(options.lossless) + .with_progressive(options.progressive) + .with_speed(match options.effort { + Effort::Fast => Speed::Fastest, + Effort::Normal => Speed::Fast, + }); + if options.colour == JxlColour::Rec2020Pq { + config = config.with_intensity_target(10_000.0); + } + if options.lossless + && let Some(icc) = &metadata.icc + { + config = config.with_icc_profile(icc.clone()); + } + if let Some(exif) = &metadata.exif { + config = config.with_exif(exif.clone()); + } + if let Some(xmp) = &metadata.xmp { + config = config.with_xmp(xmp.clone()); + } + let result = match pixels { + Pixels::U8 { data, alpha: false } => jixel::encode_image(data, width, height, &config), + Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, width, height, &config), + Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, width, height, &config), + Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, width, height, &config), + }; + result.map_err(|error| Error::Encode(error.to_string())) +} diff --git a/crates/jxl/src/lib.rs b/crates/jxl/src/lib.rs new file mode 100644 index 000000000..e8bd289f4 --- /dev/null +++ b/crates/jxl/src/lib.rs @@ -0,0 +1,157 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] +#![warn(missing_docs)] +//! JPEG XL image decoding and encoding. +//! +//! Decoding incrementally feeds the input, checks image dimensions before loading remaining +//! frames, and applies a tracked-allocation budget. The allocation tracker covers allocations +//! made through `jxl-oxide`; it cannot account for every allocation inside a decoder dependency. +//! The first image frame is returned, with orientation already applied to its pixels. + +#[cfg(feature = "decode")] +mod decode; +#[cfg(feature = "encode")] +mod encode; + +/// Pixel channel layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Layout { + /// One grayscale channel. + Gray, + /// Grayscale and straight alpha. + GrayAlpha, + /// Red, green, blue. + Rgb, + /// Red, green, blue and straight alpha. + Rgba, +} + +impl Layout { + /// Number of interleaved channels. + pub const fn channels(self) -> usize { + match self { + Layout::Gray => 1, + Layout::GrayAlpha => 2, + Layout::Rgb => 3, + Layout::Rgba => 4, + } + } + + /// Whether this layout includes alpha. + pub const fn has_alpha(self) -> bool { + matches!(self, Layout::GrayAlpha | Layout::Rgba) + } +} + +/// Sample data accepted by the encoder. +#[derive(Clone, Copy, Debug)] +pub enum Samples<'a> { + /// Unsigned 8-bit samples, already encoded using the selected color transfer curve. + U8(&'a [u8]), + /// Unsigned 16-bit samples, already encoded using the selected color transfer curve. + U16(&'a [u16]), + /// Floating point samples. These are accepted only for Rec. 2020 PQ encoding. + F32(&'a [f32]), +} + +/// A borrowed image to encode. Samples are interleaved and alpha is straight. +#[derive(Clone, Copy, Debug)] +pub struct ImageRef<'a> { + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, + /// Channel layout. + pub layout: Layout, + /// Interleaved sample data. + pub samples: Samples<'a>, +} + +/// Metadata embedded in the JPEG XL container. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Metadata { + /// ICC profile bytes, when present. + pub icc: Option>, + /// TIFF-structured Exif payload, without an `Exif\0\0` prefix. + pub exif: Option>, + /// XMP packet bytes. + pub xmp: Option>, +} + +/// JPEG XL operation error. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The image is malformed or cannot be decoded. + #[error("JPEG XL: malformed image: {0}")] + Malformed(String), + /// The image exceeds a caller supplied size or allocation limit. + #[error("JPEG XL: limit exceeded: {0}")] + LimitExceeded(String), + /// The image layout or samples are unsupported. + #[error("JPEG XL: invalid image: {0}")] + InvalidImage(String), + /// Encoding failed. + #[error("JPEG XL: encode failed: {0}")] + Encode(String), + /// The requested operation is not available in this build. + #[error("JPEG XL: unsupported: {0}")] + Unsupported(String), +} + +/// Result returned by this crate. +pub type Result = std::result::Result; + +/// Decoded first frame and source metadata. +#[cfg(feature = "decode")] +#[derive(Clone, Debug)] +pub struct Decoded { + /// Width after applying the codestream orientation. + pub width: u32, + /// Height after applying the codestream orientation. + pub height: u32, + /// Output channel layout. + pub layout: Layout, + /// Bits per sample signalled in the file. + pub bit_depth: u8, + /// Whether the source samples use a floating-point representation. + pub float_samples: bool, + /// Interleaved floating point samples in the decoder's rendered color encoding. + pub pixels: Vec, + /// Metadata from the source image. + pub metadata: Metadata, + /// Original orientation value. Pixels in `pixels` already have this orientation applied. + pub source_orientation: u32, + /// CICP value describing the rendered pixel encoding, when the decoder supplies one. + pub rendered_cicp: Option<[u8; 4]>, + /// ICC profile describing the rendered pixel encoding. + pub rendered_icc: Vec, + /// Whether the codestream uses XYB encoding. + pub xyb_encoded: bool, +} + +/// Options for bounded decoding. +#[cfg(feature = "decode")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DecodeLimits { + /// Largest accepted pixel count. Defaults to 1 gigapixel. + pub max_pixels: u64, + /// Maximum allocation budget passed to `jxl-oxide`'s tracked allocator. `None` scales the + /// budget from `max_pixels` and preserves fixed decoder overhead for small images. + pub max_tracked_bytes: Option, + /// Largest compressed input size. Defaults to `usize::MAX`. + pub max_input_bytes: usize, +} + +#[cfg(feature = "decode")] +impl Default for DecodeLimits { + fn default() -> Self { + Self { max_pixels: 1 << 30, max_tracked_bytes: None, max_input_bytes: usize::MAX } + } +} + +/// Decode the first image frame. +#[cfg(feature = "decode")] +pub use decode::decode; +/// Encode a JPEG XL image. +#[cfg(feature = "encode")] +pub use encode::{Effort, EncodeOptions, HDR_REFERENCE_WHITE_NITS, JxlColour, encode}; diff --git a/crates/jxl/tests/roundtrip.rs b/crates/jxl/tests/roundtrip.rs new file mode 100644 index 000000000..3c56afb29 --- /dev/null +++ b/crates/jxl/tests/roundtrip.rs @@ -0,0 +1,44 @@ +use craft_jxl::{DecodeLimits, EncodeOptions, ImageRef, Layout, Metadata, Samples, decode, encode}; + +#[test] +fn lossless_rgba_roundtrip_keeps_alpha_layout() { + let samples = [255, 0, 0, 255, 0, 255, 0, 64]; + let image = ImageRef { width: 2, height: 1, layout: Layout::Rgba, samples: Samples::U8(&samples) }; + let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "valid image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoded = decode(&encoded, DecodeLimits::default()); + assert!(decoded.is_ok(), "encoded image should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + + assert_eq!((decoded.width, decoded.height), (2, 1)); + assert_eq!(decoded.layout, Layout::Rgba); + assert_eq!(decoded.pixels.len(), 8); + assert_eq!(decoded.pixels[3], 1.0); + assert!((decoded.pixels[7] - 64.0 / 255.0).abs() < 0.01); +} + +#[test] +fn decoder_enforces_input_and_pixel_limits() { + let samples = [10_u8, 20, 30]; + let image = ImageRef { width: 1, height: 1, layout: Layout::Rgb, samples: Samples::U8(&samples) }; + let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "valid image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + + let input_limit = DecodeLimits { max_input_bytes: encoded.len() - 1, ..DecodeLimits::default() }; + assert!(matches!(decode(&encoded, input_limit), Err(craft_jxl::Error::LimitExceeded(_)))); + let pixel_limit = DecodeLimits { max_pixels: 0, ..DecodeLimits::default() }; + assert!(matches!(decode(&encoded, pixel_limit), Err(craft_jxl::Error::LimitExceeded(_)))); +} + +#[test] +fn malformed_input_returns_an_error() { + assert!(decode(b"not a JPEG XL file", DecodeLimits::default()).is_err()); +} + +#[test] +fn encoder_rejects_short_sample_buffers() { + let image = ImageRef { width: 2, height: 1, layout: Layout::Rgb, samples: Samples::U8(&[1, 2, 3]) }; + assert!(matches!(encode(&image, EncodeOptions::default(), &Metadata::default()), Err(craft_jxl::Error::InvalidImage(_)))); +}