Add shared JPEG XL decoder and encoder

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 22:47:47 +00:00
1 parent b5b8a7750d
commit 4d3e920954
8 files changed
+624

No files matched your search

Generated
+15
View File
@@ -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"
+3
View File
@@ -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"
+23
View File
@@ -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 }
+19
View File
@@ -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.
+130
View File
@@ -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<Decoded> {
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<Decoded> {
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())
}
+233
View File
@@ -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<Vec<u8>> {
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<u8>, alpha: bool },
U16 { data: Vec<u16>, alpha: bool },
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
impl Pixels {
fn from(image: &ImageRef<'_>, colour: JxlColour) -> Result<Self> {
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<T: Copy>(samples: &[T], layout: Layout, pixels: usize, opaque: impl Fn(T) -> bool) -> Result<(Vec<T>, 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<Vec<u8>> {
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()))
}
+157
View File
@@ -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<Vec<u8>>,
/// TIFF-structured Exif payload, without an `Exif\0\0` prefix.
pub exif: Option<Vec<u8>>,
/// XMP packet bytes.
pub xmp: Option<Vec<u8>>,
}
/// 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<T> = std::result::Result<T, Error>;
/// 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<f32>,
/// 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<u8>,
/// 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<u64>,
/// 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};
+44
View File
@@ -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(_))));
}