From 4d3e92095435dfb8c8caa974db1e6d1d7a094aa3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 22:47:47 +0000 Subject: [PATCH 1/4] 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(_)))); +} From 8fcb1915158399660197c43a9355767d37c4a658 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 23:06:05 +0000 Subject: [PATCH 2/4] Preserve JPEG XL color rendering options --- Cargo.lock | 1 + crates/jxl/Cargo.toml | 3 + crates/jxl/README.md | 6 + crates/jxl/src/decode.rs | 60 +++++-- crates/jxl/src/encode.rs | 5 +- crates/jxl/src/lib.rs | 15 +- crates/jxl/tests/roundtrip.rs | 328 +++++++++++++++++++++++++++++++++- 7 files changed, 401 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e6eb96fd..5dc50eab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,7 @@ dependencies = [ name = "craft-jxl" version = "0.1.0" dependencies = [ + "craft-cms", "jixel", "jxl-oxide", "thiserror 2.0.21", diff --git a/crates/jxl/Cargo.toml b/crates/jxl/Cargo.toml index 1d2f86ecb..45ed44f12 100644 --- a/crates/jxl/Cargo.toml +++ b/crates/jxl/Cargo.toml @@ -19,5 +19,8 @@ encode = ["dep:jixel"] jxl-oxide = { workspace = true, optional = true } thiserror = { workspace = true } +[dev-dependencies] +craft-cms = { 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 index bb6036a9f..a3a8c6fa4 100644 --- a/crates/jxl/README.md +++ b/crates/jxl/README.md @@ -10,6 +10,12 @@ input slice is already owned by the caller. The decoder returns the first frame 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. +`decode_with_color` can request linear Rec. 2020 for enum-encoded images. XYB is rendered through +linear sRGB and converted to Rec. 2020 to avoid the known wide-gamut XYB rendering issue in +`jxl-oxide`. If a source embeds an ICC profile, the decoder keeps the source-profile samples and +returns the profile; it does not claim to transform ICC data without a CMS. `decode` retains the +decoder's default rendering target. + 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 diff --git a/crates/jxl/src/decode.rs b/crates/jxl/src/decode.rs index 03df2e0fe..7568da7ac 100644 --- a/crates/jxl/src/decode.rs +++ b/crates/jxl/src/decode.rs @@ -1,8 +1,9 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; +use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage}; -use crate::{DecodeLimits, Decoded, Error, Layout, Metadata, Result}; +use crate::{DecodeLimits, Decoded, Error, Layout, Metadata, OutputColor, Result}; const INPUT_CHUNK: usize = 4096; const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; @@ -15,11 +16,16 @@ const MIN_TRACKED_BYTES: u64 = 64 << 20; /// 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))) + decode_with_color(bytes, limits, OutputColor::DecoderDefault) +} + +/// Decode the first image frame using an explicit rendered-pixel color target. +pub fn decode_with_color(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) -> Result { + catch_unwind(AssertUnwindSafe(|| decode_inner(bytes, limits, output_color))) .unwrap_or_else(|_| Err(Error::Malformed("decoder failed while reading this file".into()))) } -fn decode_inner(bytes: &[u8], limits: DecodeLimits) -> Result { +fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) -> 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))); } @@ -64,10 +70,27 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits) -> Result { } 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 (bit_depth, float_samples, grayscale, source_orientation, xyb_encoded) = { + let metadata = &image.image_header().metadata; + ( + metadata.bit_depth.bits_per_sample() as u8, + matches!(metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }), + metadata.grayscale(), + metadata.orientation, + metadata.xyb_encoded, + ) + }; + let source_icc = image.original_icc().map(ToOwned::to_owned); + let via_srgb = output_color == OutputColor::LinearRec2020 && source_icc.is_none() && !grayscale && xyb_encoded; + if output_color == OutputColor::LinearRec2020 && source_icc.is_none() { + image.request_color_encoding(EnumColourEncoding { + colour_space: if grayscale { ColourSpace::Grey } else { ColourSpace::Rgb }, + white_point: WhitePoint::D65, + primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 }, + tf: TransferFunction::Linear, + rendering_intent: RenderingIntent::Relative, + }); + } let colors = if grayscale { 1 } else { 3 }; let pixel_format = image.pixel_format(); if pixel_format.has_black() { @@ -87,7 +110,11 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits) -> Result { 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); + let (rendered_width, rendered_height) = ( + u32::try_from(buffer.width()).map_err(|_| Error::LimitExceeded("rendered width exceeds 32-bit dimensions".into()))?, + u32::try_from(buffer.height()).map_err(|_| Error::LimitExceeded("rendered height exceeds 32-bit dimensions".into()))?, + ); + let frame_pixels = u128::from(rendered_width) * u128::from(rendered_height); if frame_pixels != u128::from(pixels) { return Err(Error::Malformed("decoded frame dimensions do not match the image header".into())); } @@ -96,11 +123,18 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits) -> Result { 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) { + let start = out.len(); out.extend_from_slice(&pixel[..keep]); + if via_srgb { + let [r, g, b] = [out[start], out[start + 1], out[start + 2]]; + out[start] = 0.627_403_9 * r + 0.329_283_04 * g + 0.043_313_067 * b; + out[start + 1] = 0.069_097_29 * r + 0.919_540_4 * g + 0.011_362_316 * b; + out[start + 2] = 0.016_391_44 * r + 0.088_013_31 * g + 0.895_595_25 * b; + } } let metadata = Metadata { - icc: image.original_icc().map(ToOwned::to_owned), + icc: source_icc, exif: match image.aux_boxes().first_exif() { Ok(AuxBoxData::Data(exif)) => Some(exif.payload().to_vec()), _ => None, @@ -111,17 +145,17 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits) -> Result { }, }; Ok(Decoded { - width, - height, + width: rendered_width, + height: rendered_height, layout, bit_depth, float_samples, pixels: out, metadata, - source_orientation: header.metadata.orientation, + source_orientation, rendered_cicp: image.rendered_cicp(), rendered_icc: image.rendered_icc(), - xyb_encoded: header.metadata.xyb_encoded, + xyb_encoded, }) } diff --git a/crates/jxl/src/encode.rs b/crates/jxl/src/encode.rs index 83dd6caf0..caf77957c 100644 --- a/crates/jxl/src/encode.rs +++ b/crates/jxl/src/encode.rs @@ -1,6 +1,9 @@ +#[cfg(not(target_arch = "wasm32"))] use std::panic::{AssertUnwindSafe, catch_unwind}; -use crate::{Error, ImageRef, Layout, Metadata, Result, Samples}; +#[cfg(not(target_arch = "wasm32"))] +use crate::Layout; +use crate::{Error, ImageRef, Metadata, Result, Samples}; /// Encoder search effort. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/jxl/src/lib.rs b/crates/jxl/src/lib.rs index e8bd289f4..141239171 100644 --- a/crates/jxl/src/lib.rs +++ b/crates/jxl/src/lib.rs @@ -26,6 +26,19 @@ pub enum Layout { Rgba, } +/// Color encoding requested for rendered pixels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OutputColor { + /// Use `jxl-oxide`'s default rendering target and report its rendered profile. + #[default] + DecoderDefault, + /// For files with enum color encoding, render linear Rec. 2020. XYB files render to linear + /// sRGB first and are converted to Rec. 2020 to avoid a `jxl-oxide` wide-gamut XYB issue. + /// Files with an embedded ICC profile remain rendered in that profile's space; the source + /// ICC is returned in [`Metadata::icc`] because this crate does not bundle an ICC CMS. + LinearRec2020, +} + impl Layout { /// Number of interleaved channels. pub const fn channels(self) -> usize { @@ -151,7 +164,7 @@ impl Default for DecodeLimits { /// Decode the first image frame. #[cfg(feature = "decode")] -pub use decode::decode; +pub use decode::{decode, decode_with_color}; /// 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 index 3c56afb29..4aff84de1 100644 --- a/crates/jxl/tests/roundtrip.rs +++ b/crates/jxl/tests/roundtrip.rs @@ -1,10 +1,49 @@ -use craft_jxl::{DecodeLimits, EncodeOptions, ImageRef, Layout, Metadata, Samples, decode, encode}; +use craft_jxl::{ + DecodeLimits, EncodeOptions, HDR_REFERENCE_WHITE_NITS, ImageRef, JxlColour, Layout, Metadata, OutputColor, Samples, decode, decode_with_color, + encode, +}; + +fn raw_codestream(bytes: &[u8]) -> Vec { + if bytes.starts_with(b"\xff\x0a") { + return bytes.to_vec(); + } + let mut raw = Vec::new(); + let mut at = 12_usize; + while at.checked_add(8).is_some_and(|end| end <= bytes.len()) { + let Some(size) = bytes.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes) else { break }; + let end = if size == 0 { bytes.len() } else { at.saturating_add(size as usize) }; + let Some(kind) = bytes.get(at + 4..at + 8) else { break }; + if end < at + 8 || end > bytes.len() { + break; + } + let header = if kind == b"jxlp" { 12 } else { 8 }; + if (kind == b"jxlc" || kind == b"jxlp") + && let Some(data) = bytes.get(at + header..end) + { + raw.extend_from_slice(data); + } + if end <= at { + break; + } + at = end; + } + raw +} + +fn box_bytes(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); +} #[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()); + let exif = b"II*\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x01\0\0\0\0\0\0\0"; + let xmp = b""; + let metadata = Metadata { exif: Some(exif.to_vec()), xmp: Some(xmp.to_vec()), ..Metadata::default() }; + let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &metadata); assert!(encoded.is_ok(), "valid image should encode: {encoded:?}"); let Ok(encoded) = encoded else { return }; let decoded = decode(&encoded, DecodeLimits::default()); @@ -16,6 +55,8 @@ fn lossless_rgba_roundtrip_keeps_alpha_layout() { assert_eq!(decoded.pixels.len(), 8); assert_eq!(decoded.pixels[3], 1.0); assert!((decoded.pixels[7] - 64.0 / 255.0).abs() < 0.01); + assert_eq!(decoded.metadata.exif.as_deref(), Some(exif.as_slice())); + assert_eq!(decoded.metadata.xmp.as_deref(), Some(xmp.as_slice())); } #[test] @@ -42,3 +83,286 @@ 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(_)))); } + +#[test] +fn lossless_gray_input_is_expanded_to_rgb() { + let samples = [0, 40, 128, 255]; + let image = ImageRef { width: 4, height: 1, layout: Layout::Gray, samples: Samples::U8(&samples) }; + let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "valid grayscale image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoded = decode(&encoded, DecodeLimits::default()); + assert!(decoded.is_ok(), "grayscale image should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + assert_eq!(decoded.layout, Layout::Rgb); + assert_eq!(decoded.pixels.len(), 4 * 3); + let (pixels, remainder) = decoded.pixels.as_chunks::<3>(); + assert!(remainder.is_empty()); + for (pixel, gray) in pixels.iter().zip(samples) { + let expected = f32::from(gray) / 255.0; + assert!(pixel.iter().all(|channel| (channel - expected).abs() < 0.01)); + } +} + +#[test] +fn lossless_u16_samples_keep_their_bit_depth_and_values() { + let samples: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect(); + let image = ImageRef { width: 40, height: 30, layout: Layout::Rgb, samples: Samples::U16(&samples) }; + let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "valid 16-bit image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoded = decode(&encoded, DecodeLimits::default()); + assert!(decoded.is_ok(), "16-bit image should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + assert_eq!(decoded.bit_depth, 16); + assert_eq!(decoded.layout, Layout::Rgb); + assert_eq!(decoded.pixels.len(), samples.len()); + for (got, sample) in decoded.pixels.iter().zip(samples) { + let expected = f32::from(sample) / 65535.0; + assert!((got - expected).abs() < 1e-5, "16-bit value differs: {got} vs {expected}"); + } +} + +#[test] +fn enum_encoded_pixels_can_be_rendered_as_linear_rec2020() { + let samples = [180, 40, 20, 10, 140, 60, 20, 80, 180]; + let image = ImageRef { width: 3, height: 1, layout: Layout::Rgb, samples: Samples::U8(&samples) }; + let encoded = encode(&image, EncodeOptions { lossless: true, colour: JxlColour::Rec2020, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "valid image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoded = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020); + assert!(decoded.is_ok(), "valid image should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + + assert!(!decoded.xyb_encoded, "lossless enum encoding should use Modular coding"); + assert_eq!(decoded.layout, Layout::Rgb); + assert_eq!((decoded.width, decoded.height), (3, 1)); + assert!(decoded.pixels.iter().all(|sample| sample.is_finite())); + assert!(decoded.rendered_icc.len() > 128, "requested Rec. 2020 linear target has a rendered profile"); +} + +#[test] +fn xybcoded_pixels_use_the_linear_srgb_workaround_before_rec2020() { + let samples = [ + 250, 5, 5, 128, 10, 240, 10, 255, 5, 10, 250, 255, 190, 130, 40, 255, 120, 20, 170, 255, 20, 160, 180, 255, 240, 230, 10, 255, 80, 90, 100, + 255, + ]; + let image = ImageRef { width: 4, height: 2, layout: Layout::Rgba, samples: Samples::U8(&samples) }; + let encoded = encode(&image, EncodeOptions { quality: 95, colour: JxlColour::Srgb, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "valid image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoded = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020); + assert!(decoded.is_ok(), "valid image should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + + assert!(decoded.xyb_encoded, "lossy RGB JPEG XL should use XYB"); + assert_eq!(decoded.layout, Layout::Rgba); + assert_eq!(decoded.pixels.len(), 4 * 2 * 4); + let (decoded_pixels, decoded_remainder) = decoded.pixels.as_chunks::<4>(); + assert!(decoded_remainder.is_empty()); + assert!(decoded_pixels.iter().all(|pixel| (0.0..=1.0).contains(&pixel[3]))); + assert!(decoded.pixels.iter().all(|sample| sample.is_finite())); + + let reference = jxl_oxide::JxlImage::builder().read(encoded.as_slice()); + assert!(reference.is_ok(), "reference decoder should accept the encoded image: {reference:?}"); + let Ok(mut reference) = reference else { return }; + reference.request_color_encoding(jxl_oxide::color::EnumColourEncoding { + colour_space: jxl_oxide::color::ColourSpace::Rgb, + white_point: jxl_oxide::color::WhitePoint::D65, + primaries: jxl_oxide::color::Primaries::Srgb, + tf: jxl_oxide::color::TransferFunction::Linear, + rendering_intent: jxl_oxide::color::RenderingIntent::Relative, + }); + let reference_frame = reference.render_frame(0); + assert!(reference_frame.is_ok(), "reference frame should render: {reference_frame:?}"); + let Ok(reference_frame) = reference_frame else { return }; + let reference = reference_frame.image_all_channels(); + assert_eq!(reference.channels(), 4); + let (actual_pixels, actual_remainder) = decoded.pixels.as_chunks::<4>(); + let (reference_pixels, reference_remainder) = reference.buf().as_chunks::<4>(); + assert!(actual_remainder.is_empty()); + assert!(reference_remainder.is_empty()); + for (actual, linear_srgb) in actual_pixels.iter().zip(reference_pixels) { + let [r, g, b] = [linear_srgb[0], linear_srgb[1], linear_srgb[2]]; + let expected = [ + 0.627_403_9 * r + 0.329_283_04 * g + 0.043_313_067 * b, + 0.069_097_29 * r + 0.919_540_4 * g + 0.011_362_316 * b, + 0.016_391_44 * r + 0.088_013_31 * g + 0.895_595_25 * b, + ]; + for (got, want) in actual[..3].iter().zip(expected) { + assert!((got - want).abs() < 1e-6, "linear Rec. 2020 channel differs: {got} vs {want}"); + } + assert_eq!(actual[3], linear_srgb[3], "color conversion leaves alpha unchanged"); + } +} + +#[test] +fn icc_encoded_pixels_keep_the_embedded_profile_under_linear_target_request() { + let samples = [180, 40, 20, 10, 140, 60, 20, 80, 180]; + let image = ImageRef { width: 3, height: 1, layout: Layout::Rgb, samples: Samples::U8(&samples) }; + let icc = craft_cms::Builtin::DisplayP3.profile().to_bytes(); + let metadata = Metadata { icc: Some(icc.as_ref().clone()), ..Metadata::default() }; + let encoded = encode(&image, EncodeOptions { lossless: true, colour: JxlColour::DisplayP3, ..EncodeOptions::default() }, &metadata); + assert!(encoded.is_ok(), "valid image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoder_default = decode(&encoded, DecodeLimits::default()); + let linear_request = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020); + assert!(decoder_default.is_ok(), "ICC image should decode: {decoder_default:?}"); + assert!(linear_request.is_ok(), "ICC image should decode: {linear_request:?}"); + let (Ok(decoder_default), Ok(linear_request)) = (decoder_default, linear_request) else { return }; + + assert_eq!(decoder_default.metadata.icc.as_deref(), Some(icc.as_slice())); + assert_eq!(linear_request.metadata.icc.as_deref(), Some(icc.as_slice())); + assert_eq!(decoder_default.pixels, linear_request.pixels, "without a bundled CMS, the requested target preserves ICC-space samples"); +} + +#[test] +fn lossy_quality_effort_and_progressive_options_produce_decodable_images() { + let (width, height) = (160_u32, 120_u32); + let mut samples = Vec::with_capacity(width as usize * height as usize * 4); + for y in 0..height { + for x in 0..width { + let fx = x as f32 / width as f32; + let fy = y as f32 / height as f32; + samples.extend_from_slice(&[(fx * 255.0) as u8, (fy * 255.0) as u8, ((1.0 - fx) * 200.0) as u8, (((fx + fy) * 0.5) * 255.0) as u8]); + } + } + let image = ImageRef { width, height, layout: Layout::Rgba, samples: Samples::U8(&samples) }; + let encode_options = |quality, effort, progressive| EncodeOptions { quality, effort, progressive, ..EncodeOptions::default() }; + let hi = encode(&image, encode_options(95, craft_jxl::Effort::Normal, false), &Metadata::default()); + let lo = encode(&image, encode_options(40, craft_jxl::Effort::Normal, false), &Metadata::default()); + assert!(hi.is_ok(), "high-quality image should encode: {hi:?}"); + assert!(lo.is_ok(), "low-quality image should encode: {lo:?}"); + let (Ok(hi), Ok(lo)) = (hi, lo) else { return }; + assert!(lo.len() < hi.len(), "quality 40 output {} should be smaller than quality 95 output {}", lo.len(), hi.len()); + + for bytes in [&hi, &lo] { + let decoded = decode(bytes, DecodeLimits::default()); + assert!(decoded.is_ok(), "encoded image should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + assert_eq!((decoded.width, decoded.height), (width, height)); + assert_eq!(decoded.layout, Layout::Rgba); + let threshold = if bytes.len() == hi.len() { 38.0 } else { 28.0 }; + let psnr = psnr8(&samples, &decoded.pixels, 4); + assert!(psnr > threshold, "PSNR {psnr:.2} dB must exceed {threshold:.1} dB"); + } + + for effort in [craft_jxl::Effort::Fast, craft_jxl::Effort::Normal] { + for progressive in [false, true] { + let encoded = encode(&image, encode_options(90, effort, progressive), &Metadata::default()); + assert!(encoded.is_ok(), "{effort:?} progressive={progressive} should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoded = decode(&encoded, DecodeLimits::default()); + assert!(decoded.is_ok(), "{effort:?} progressive={progressive} should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + let psnr = psnr8(&samples, &decoded.pixels, 4); + assert!(psnr > 34.0, "{effort:?} progressive={progressive} PSNR {psnr:.2} dB"); + } + } +} + +fn psnr8(source: &[u8], decoded: &[f32], channels: usize) -> f64 { + let mut squared_error = 0.0; + for (source, decoded) in source.chunks_exact(channels).zip(decoded.chunks_exact(channels)) { + for channel in 0..3 { + let difference = f64::from(source[channel]) - f64::from(decoded[channel] * 255.0); + squared_error += difference * difference; + } + } + let samples = (source.len() / channels * 3) as f64; + let mse = squared_error / samples; + if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0 * 255.0 / mse).log10() } +} + +#[test] +fn rec2020_pq_float_samples_are_written_as_hdr() { + let (width, height) = (64_u32, 32_u32); + let samples: Vec<f32> = + (0..(width * height) as usize).flat_map(|i| [if i % (width as usize) < width as usize / 2 { 1.0 } else { 4.0 }; 3]).collect(); + let image = ImageRef { width, height, layout: Layout::Rgb, samples: Samples::F32(&samples) }; + for lossless in [false, true] { + let encoded = + encode(&image, EncodeOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "HDR image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let decoded = jxl_oxide::JxlImage::builder().read(encoded.as_slice()); + assert!(decoded.is_ok(), "reference decoder should read HDR output: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + let metadata = &decoded.image_header().metadata; + assert_eq!(metadata.tone_mapping.intensity_target, 10_000.0); + assert!(format!("{:?}", metadata.colour_encoding).contains("Pq"), "{:?}", metadata.colour_encoding); + let frame = decoded.render_frame(0); + assert!(frame.is_ok(), "reference decoder should render HDR output: {frame:?}"); + let Ok(frame) = frame else { return }; + let buffer = frame.image_all_channels(); + let channels = buffer.channels(); + assert!(channels >= 3); + let sample_at = |x: usize| buffer.buf()[((height as usize / 2) * width as usize + x) * channels + 1]; + for (x, nits) in [(width as usize / 4, HDR_REFERENCE_WHITE_NITS), (width as usize * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] { + let encoded_pq = pq_encode(nits); + let actual = sample_at(x); + assert!((actual - encoded_pq).abs() < 0.005, "lossless={lossless}: {nits} nits encoded as {actual}, expected {encoded_pq}"); + } + } + assert!(matches!(encode(&image, EncodeOptions::default(), &Metadata::default()), Err(craft_jxl::Error::InvalidImage(_)))); +} + +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) +} + +#[test] +fn bounded_feeds_keep_metadata_after_the_final_progressive_part() { + let samples: Vec<u8> = (0..8 * 8 * 4).map(|i| (i * 13 % 256) as u8).collect(); + let image = ImageRef { width: 8, height: 8, layout: Layout::Rgba, samples: Samples::U8(&samples) }; + let encoded = encode(&image, EncodeOptions { lossless: true, progressive: true, ..EncodeOptions::default() }, &Metadata::default()); + assert!(encoded.is_ok(), "valid image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + let raw = raw_codestream(&encoded); + assert!(raw.starts_with(b"\xff\x0a"), "extract the JXL codestream from the encoder output"); + + let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec(); + box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl "); + let split = raw.len() / 2; + let mut part = 0_u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[..split]); + box_bytes(&mut bytes, b"jxlp", &part); + let padding = vec![0; 4096usize.saturating_sub(bytes.len() + 8)]; + box_bytes(&mut bytes, b"free", &padding); + let mut part = 0x8000_0001_u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[split..]); + box_bytes(&mut bytes, b"jxlp", &part); + let exif = b"II*\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x01\0\0\0\0\0\0\0"; + let mut exif_box = vec![0; 4]; + exif_box.extend_from_slice(exif); + box_bytes(&mut bytes, b"Exif", &exif_box); + let xmp = b"<xmp/>"; + box_bytes(&mut bytes, b"xml ", xmp); + assert!(bytes.len() < 8192, "metadata fixture should fit in two decoder feeds"); + + let reference = jxl_oxide::JxlImage::builder().read(bytes.as_slice()); + assert!(reference.is_ok(), "reference decoder should read the fixture: {reference:?}"); + let Ok(reference) = reference else { return }; + let reference_exif = match reference.aux_boxes().first_exif() { + Ok(jxl_oxide::AuxBoxData::Data(data)) => Some(data.payload().to_vec()), + _ => None, + }; + let reference_xmp = match reference.aux_boxes().first_xml() { + jxl_oxide::AuxBoxData::Data(data) => Some(data.to_vec()), + _ => None, + }; + assert!(reference_exif.is_some(), "reference decoder should preserve the Exif box"); + assert!(reference_xmp.is_some(), "reference decoder should preserve the XMP box"); + let (Some(reference_exif), Some(reference_xmp)) = (reference_exif, reference_xmp) else { return }; + let decoded = decode(&bytes, DecodeLimits::default()); + assert!(decoded.is_ok(), "shared decoder should read the fixture: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + assert_eq!(decoded.metadata.exif.as_deref(), Some(reference_exif.as_slice())); + assert_eq!(decoded.metadata.xmp.as_deref(), Some(reference_xmp.as_slice())); +} From ade25d64eb1e2246ef970016fc5d8a221712d0d5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" <dylan@dylanmtaylor.com> Date: Fri, 13 Nov 2026 23:13:26 +0000 Subject: [PATCH 3/4] Expose structured JPEG XL pixel limits --- crates/jxl/src/decode.rs | 2 +- crates/jxl/src/lib.rs | 10 ++++++++++ crates/jxl/tests/roundtrip.rs | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/jxl/src/decode.rs b/crates/jxl/src/decode.rs index 7568da7ac..4b23d8d2f 100644 --- a/crates/jxl/src/decode.rs +++ b/crates/jxl/src/decode.rs @@ -56,7 +56,7 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - } 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))); + return Err(Error::PixelLimitExceeded { width, height, max_pixels: limits.max_pixels }); } // `JxlImageBuilder::read` feeds the full input. Continue in bounded chunks so trailing frame diff --git a/crates/jxl/src/lib.rs b/crates/jxl/src/lib.rs index 141239171..02ba61b34 100644 --- a/crates/jxl/src/lib.rs +++ b/crates/jxl/src/lib.rs @@ -100,6 +100,16 @@ pub enum Error { /// The image exceeds a caller supplied size or allocation limit. #[error("JPEG XL: limit exceeded: {0}")] LimitExceeded(String), + /// The encoded dimensions exceed the caller's pixel-count limit. + #[error("JPEG XL: image dimensions {width}×{height} exceed the {max_pixels} pixel limit")] + PixelLimitExceeded { + /// Encoded image width. + width: u32, + /// Encoded image height. + height: u32, + /// Maximum accepted pixel count. + max_pixels: u64, + }, /// The image layout or samples are unsupported. #[error("JPEG XL: invalid image: {0}")] InvalidImage(String), diff --git a/crates/jxl/tests/roundtrip.rs b/crates/jxl/tests/roundtrip.rs index 4aff84de1..3df1e90f2 100644 --- a/crates/jxl/tests/roundtrip.rs +++ b/crates/jxl/tests/roundtrip.rs @@ -70,7 +70,7 @@ fn decoder_enforces_input_and_pixel_limits() { 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(_)))); + assert!(matches!(decode(&encoded, pixel_limit), Err(craft_jxl::Error::PixelLimitExceeded { width: 1, height: 1, max_pixels: 0 }))); } #[test] From d61671b6ded40929dcaacaaa658512d9f7981fc4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" <dylan@dylanmtaylor.com> Date: Fri, 13 Nov 2026 23:47:44 +0000 Subject: [PATCH 4/4] Preserve JPEG XL metadata and associated alpha --- crates/jxl/src/decode.rs | 109 +++++++++++++++++++++++++++++++--- crates/jxl/src/lib.rs | 6 ++ crates/jxl/tests/roundtrip.rs | 57 ++++++++++++++++++ 3 files changed, 165 insertions(+), 7 deletions(-) diff --git a/crates/jxl/src/decode.rs b/crates/jxl/src/decode.rs index 4b23d8d2f..955774b23 100644 --- a/crates/jxl/src/decode.rs +++ b/crates/jxl/src/decode.rs @@ -1,7 +1,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; -use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; -use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage}; +use jxl_oxide::color::{ColourEncoding, ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; +use jxl_oxide::{AllocTracker, AuxBoxData, ExtraChannelType, InitializeResult, JxlImage}; use crate::{DecodeLimits, Decoded, Error, Layout, Metadata, OutputColor, Result}; @@ -70,7 +70,7 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - } image.finalize().map_err(decode_error)?; - let (bit_depth, float_samples, grayscale, source_orientation, xyb_encoded) = { + let (bit_depth, float_samples, grayscale, source_orientation, xyb_encoded, extra_channels, colour_encoding) = { let metadata = &image.image_header().metadata; ( metadata.bit_depth.bits_per_sample() as u8, @@ -78,11 +78,29 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - metadata.grayscale(), metadata.orientation, metadata.xyb_encoded, + metadata.ec_info.iter().map(|info| info.ty).collect::<Vec<_>>(), + metadata.colour_encoding.clone(), ) }; let source_icc = image.original_icc().map(ToOwned::to_owned); + let has_source_icc = source_icc.is_some(); + if has_black_channel(&extra_channels) || image.pixel_format().has_black() { + return Err(Error::Unsupported("CMYK JPEG XL rendering requires an external color-management transform".into())); + } + let alpha_channel = first_alpha(&extra_channels); + let alpha_associated = alpha_channel.is_some_and(|(_, associated)| associated); let via_srgb = output_color == OutputColor::LinearRec2020 && source_icc.is_none() && !grayscale && xyb_encoded; - if output_color == OutputColor::LinearRec2020 && source_icc.is_none() { + // Associated samples must be divided while still in their coded nonlinear space. Leave these + // frames in their own encoding so the caller can apply its color transform after unpremultiplying. + // XYB and unsupported transfer curves stay on the renderer's linear target path; those cases + // are premultiplied after decoding instead. + let enum_source_has_supported_curve = matches!( + &colour_encoding, + ColourEncoding::Enum(encoding) if enum_encoding_has_supported_curve(encoding) + ); + let preserve_source_encoding = + output_color == OutputColor::LinearRec2020 && alpha_associated && !xyb_encoded && (source_icc.is_some() || enum_source_has_supported_curve); + if output_color == OutputColor::LinearRec2020 && source_icc.is_none() && !preserve_source_encoding { image.request_color_encoding(EnumColourEncoding { colour_space: if grayscale { ColourSpace::Grey } else { ColourSpace::Rgb }, white_point: WhitePoint::D65, @@ -96,7 +114,7 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - 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 has_alpha = alpha_channel.is_some(); let layout = match (grayscale, has_alpha) { (true, false) => Layout::Gray, (true, true) => Layout::GrayAlpha, @@ -110,6 +128,11 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - if channels < colors || (has_alpha && channels < keep) { return Err(Error::Malformed("decoded frame has too few channels".into())); } + let color_channels = channels + .checked_sub(extra_channels.len()) + .filter(|&count| count >= colors) + .ok_or_else(|| Error::Malformed("decoded frame has too few color channels".into()))?; + let alpha_index = alpha_channel.map(|(index, _)| color_channels + index); let (rendered_width, rendered_height) = ( u32::try_from(buffer.width()).map_err(|_| Error::LimitExceeded("rendered width exceeds 32-bit dimensions".into()))?, u32::try_from(buffer.height()).map_err(|_| Error::LimitExceeded("rendered height exceeds 32-bit dimensions".into()))?, @@ -124,7 +147,19 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - 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) { let start = out.len(); - out.extend_from_slice(&pixel[..keep]); + out.extend_from_slice(&pixel[..colors]); + if let Some(index) = alpha_index { + let alpha_value = pixel.get(index).copied().ok_or_else(|| Error::Malformed("decoded frame is missing its alpha channel".into()))?; + if preserve_source_encoding && alpha_associated { + let divisor = alpha_value.clamp(0.0, 1.0); + if divisor > 0.0 { + for channel in &mut out[start..] { + *channel /= divisor; + } + } + } + out.push(alpha_value); + } if via_srgb { let [r, g, b] = [out[start], out[start + 1], out[start + 2]]; out[start] = 0.627_403_9 * r + 0.329_283_04 * g + 0.043_313_067 * b; @@ -136,7 +171,9 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - let metadata = Metadata { icc: source_icc, exif: match image.aux_boxes().first_exif() { - Ok(AuxBoxData::Data(exif)) => Some(exif.payload().to_vec()), + Ok(AuxBoxData::Data(exif)) => { + usize::try_from(exif.tiff_header_offset()).ok().and_then(|offset| exif.payload().get(offset..)).map(ToOwned::to_owned) + } _ => None, }, xmp: match image.aux_boxes().first_xml() { @@ -144,6 +181,7 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - _ => None, }, }; + let pixels_linear_rec2020 = output_color == OutputColor::LinearRec2020 && !has_source_icc && !preserve_source_encoding; Ok(Decoded { width: rendered_width, height: rendered_height, @@ -156,9 +194,66 @@ fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) - rendered_cicp: image.rendered_cicp(), rendered_icc: image.rendered_icc(), xyb_encoded, + pixels_linear_rec2020, + pixels_premultiplied: alpha_associated && !preserve_source_encoding, }) } +fn first_alpha(channels: &[ExtraChannelType]) -> Option<(usize, bool)> { + channels.iter().enumerate().find_map(|(index, channel)| match channel { + ExtraChannelType::Alpha { alpha_associated } => Some((index, *alpha_associated)), + _ => None, + }) +} + +fn has_black_channel(channels: &[ExtraChannelType]) -> bool { + channels.iter().any(|channel| matches!(channel, ExtraChannelType::Black)) +} + +fn enum_encoding_has_supported_curve(encoding: &EnumColourEncoding) -> bool { + matches!(encoding.colour_space, ColourSpace::Rgb | ColourSpace::Grey) + && match &encoding.tf { + TransferFunction::Linear | TransferFunction::Srgb | TransferFunction::Bt709 | TransferFunction::Dci => true, + TransferFunction::Gamma { g, .. } => *g > 0, + _ => false, + } +} + fn decode_error(error: impl std::fmt::Display) -> Error { Error::Malformed(error.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + const STRAIGHT: ExtraChannelType = ExtraChannelType::Alpha { alpha_associated: false }; + const ASSOCIATED: ExtraChannelType = ExtraChannelType::Alpha { alpha_associated: true }; + const SPOT: ExtraChannelType = ExtraChannelType::SpotColour { red: 1.0, green: 0.0, blue: 0.0, solidity: 1.0 }; + + #[test] + fn alpha_is_selected_by_type_and_order() { + assert_eq!(first_alpha(&[SPOT, STRAIGHT]), Some((1, false))); + assert_eq!(first_alpha(&[STRAIGHT, SPOT]), Some((0, false))); + assert_eq!(first_alpha(&[ExtraChannelType::Depth, ASSOCIATED, STRAIGHT]), Some((1, true))); + assert_eq!(first_alpha(&[ExtraChannelType::Black, SPOT, ExtraChannelType::Depth]), None); + } + + #[test] + fn black_extra_channel_is_detected_by_type() { + assert!(has_black_channel(&[SPOT, ExtraChannelType::Black])); + assert!(!has_black_channel(&[SPOT, STRAIGHT])); + } + + #[test] + fn only_modeled_nonlinear_enum_encodings_are_preserved_for_unpremultiplication() { + let mut encoding = EnumColourEncoding::srgb(RenderingIntent::Relative); + assert!(enum_encoding_has_supported_curve(&encoding)); + encoding.tf = TransferFunction::Pq; + assert!(!enum_encoding_has_supported_curve(&encoding)); + encoding.tf = TransferFunction::Linear; + assert!(enum_encoding_has_supported_curve(&encoding)); + encoding.colour_space = ColourSpace::Xyb; + assert!(!enum_encoding_has_supported_curve(&encoding)); + } +} diff --git a/crates/jxl/src/lib.rs b/crates/jxl/src/lib.rs index 02ba61b34..fcdd96199 100644 --- a/crates/jxl/src/lib.rs +++ b/crates/jxl/src/lib.rs @@ -150,6 +150,12 @@ pub struct Decoded { pub rendered_icc: Vec<u8>, /// Whether the codestream uses XYB encoding. pub xyb_encoded: bool, + /// Whether `pixels` are linear Rec. 2020. This is false when associated alpha requires keeping + /// a modeled nonlinear source encoding so the caller can unpremultiply before its color transform. + pub pixels_linear_rec2020: bool, + /// Whether color samples in `pixels` remain associated with alpha. When true, divide them by + /// alpha after any necessary transfer conversion; at zero alpha the color values are undefined. + pub pixels_premultiplied: bool, } /// Options for bounded decoding. diff --git a/crates/jxl/tests/roundtrip.rs b/crates/jxl/tests/roundtrip.rs index 3df1e90f2..2bc4cf8e1 100644 --- a/crates/jxl/tests/roundtrip.rs +++ b/crates/jxl/tests/roundtrip.rs @@ -59,6 +59,63 @@ fn lossless_rgba_roundtrip_keeps_alpha_layout() { assert_eq!(decoded.metadata.xmp.as_deref(), Some(xmp.as_slice())); } +#[test] +fn exif_payload_starts_at_its_declared_tiff_header_offset() { + let image = ImageRef { width: 2, height: 1, layout: Layout::Rgb, samples: Samples::U8(&[10, 20, 30, 40, 50, 60]) }; + 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 raw = raw_codestream(&encoded); + let exif = b"II*\0\x08\0\0\0\0\0\0\0"; + + for offset in [0_u32, 6, 64] { + let mut exif_box = offset.to_be_bytes().to_vec(); + exif_box.resize(4 + offset as usize, 0xab); + exif_box.extend_from_slice(exif); + let mut container = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec(); + box_bytes(&mut container, b"ftyp", b"jxl \0\0\0\0jxl "); + box_bytes(&mut container, b"jxlc", &raw); + box_bytes(&mut container, b"Exif", &exif_box); + + let decoded = decode(&container, DecodeLimits::default()); + assert!(decoded.is_ok(), "container with Exif offset {offset} should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + assert_eq!(decoded.metadata.exif.as_deref(), Some(exif.as_slice()), "TIFF header offset {offset}"); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn associated_alpha_is_unpremultiplied_before_color_conversion() { + let straight = [[200_u8, 100, 50, 128], [30, 180, 240, 192], [100, 80, 60, 255], [220, 110, 40, 160]]; + let mut premultiplied = Vec::with_capacity(straight.len() * 4); + for pixel in &straight { + let alpha = f32::from(pixel[3]) / 255.0; + premultiplied.extend(pixel[..3].iter().map(|channel| (f32::from(*channel) * alpha).round() as u8)); + premultiplied.push(pixel[3]); + } + let metadata = jixel::FlMeta { alpha_premultiplied: true, ..jixel::FlMeta::srgb() }; + let encoded = jixel::encode_fast_lossless(&premultiplied, 2, 2, jixel::ColorSpace::Rgb, true, &metadata); + assert!(encoded.is_ok(), "associated-alpha image should encode: {encoded:?}"); + let Ok(encoded) = encoded else { return }; + + let decoded = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020); + assert!(decoded.is_ok(), "associated-alpha image should decode: {decoded:?}"); + let Ok(decoded) = decoded else { return }; + assert_eq!(decoded.layout, Layout::Rgba); + assert!(!decoded.pixels_linear_rec2020, "the source encoding is needed for the caller's color transform"); + assert!(!decoded.pixels_premultiplied, "associated color samples must be divided before nonlinear conversion"); + + for (i, source) in straight.iter().enumerate() { + let alpha = f32::from(source[3]) / 255.0; + assert!((decoded.pixels[i * 4 + 3] - alpha).abs() < 1e-6); + for (channel, &value) in source[..3].iter().enumerate() { + let expected = f32::from(value) / 255.0; + assert!((decoded.pixels[i * 4 + channel] - expected).abs() < 0.01, "pixel {i}, channel {channel}"); + } + } +} + #[test] fn decoder_enforces_input_and_pixel_limits() { let samples = [10_u8, 20, 30];