Merge approved PhotoCraft PR #3387: feat(codecs): add lossless JPEG XL export for 8/16-bit images

Port behavior and regression coverage into current modules; preserve current upstream behavior.
Integrate lossless export-only JXL and independent decoder regression matrix, preserving current JPEG/TIFF save extensions and 12-bit/arithmetic JPEG decoder dependency.
This commit is contained in:
FredApps
2026-10-29 06:34:37 +01:00
11 changed files with 320 additions and 3 deletions
Generated
+2
View File
@@ -5096,6 +5096,7 @@ dependencies = [
"image",
"image-webp",
"jpeg-encoder",
"jxl-oxide",
"photocraft-heif",
"png 0.18.1",
"proptest",
@@ -5105,6 +5106,7 @@ dependencies = [
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
"zune-jpegxl",
]
[[package]]
+1
View File
@@ -92,6 +92,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("JPEG XL", &["jxl"]),
("WebP", &["webp"]),
("TIFF", &["tif", "tiff"]),
("Targa", &["tga"]),
+13 -1
View File
@@ -1,11 +1,23 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868.
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## JPEG XL
Save As, Export As and the CLI can write a lossless, single-image `.jxl` bare
codestream at 8 or 16 bits per sample. Gray, RGB and RGBA are encoded
natively. Grayscale with alpha is expanded to RGBA at the original bit depth
to preserve both grayscale and alpha samples. For 16-bit RGBA the encoder's
incorrect default 8-bit alpha header is corrected to explicitly describe
16-bit alpha, with an independent decoder oracle checking alpha values.
The bare codestream has no ICC, EXIF, XMP or DPI boxes; the export path
converts tagged RGB to sRGB and surfaces metadata losses as fidelity warnings.
JPEG XL opening remains under review in #2868.
## WebP
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
+6
View File
@@ -33,6 +33,8 @@ zune-core = "0.5"
jpeg-encoder = "0.6"
# 12-bit and arithmetic-coded JPEG, which zune-jpeg does not decode.
craft-jpeg.workspace = true
# Pure-Rust modular JPEG XL lossless encoder.
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
@@ -46,5 +48,9 @@ proptest.workspace = true
# Oracle decoders for cross-checking in tests only.
image = { workspace = true, features = ["png", "jpeg", "tiff"] }
# Independent JPEG XL decoder oracle; not linked into the application.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
jxl-oxide = { version = "=0.12.6", default-features = false, features = ["rayon"] }
[lints]
workspace = true
+12
View File
@@ -46,6 +46,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| JPEG XL (.jxl) | **no** (see #2868) | yes | U8, U16 | Gray, RGB, RGBA (GrayA expands to RGBA) | yes | no (bare codestream) | no | no | no | no | no | `zune-jpegxl` |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in |
| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in |
@@ -73,6 +74,17 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
## Documented asymmetries and limitations
* **JPEG XL export.** Writes lossless 8/16-bit bare codestreams with `zune-jpegxl`.
Native layouts are Gray, RGB and RGBA. GrayA is expanded to RGBA at the
original depth. For 16-bit RGBA (including expanded GrayA), we correct
zune-jpegxl 0.5.2's extra-channel header, which otherwise describes the
16-bit alpha samples as 8-bit. This preserves alpha precision; the independent
jxl-oxide decoder test checks the actual gray and alpha samples. The expansion
is not a native GrayA layout. No ICC, EXIF, XMP or DPI container boxes are written; the
document pipeline converts tagged images to sRGB and reports metadata loss.
Reading remains in the separate #2868 PR. The `ASYMMETRIC_EXCEPTIONS`
entry stays until import is merged.
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Uncompressed row padding follows the header's
+1
View File
@@ -11,6 +11,7 @@ pub(crate) mod gif_palette;
pub(crate) mod heif;
pub(crate) mod iff;
pub(crate) mod jpeg;
pub(crate) mod jxl;
pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
+159
View File
@@ -0,0 +1,159 @@
//! Pure-Rust lossless JPEG XL (Modular) writer.
//!
//! Currently writes a bare JPEG XL codestream. The encoder accepts 8- or 16-bit
//! gray, RGB and RGBA samples, but does not write ICC/EXIF/XMP boxes.
//! Gray+alpha sources are expanded losslessly to RGBA by the export plan.
//! The flat export pipeline converts tagged RGB documents to sRGB first.
use std::panic::{AssertUnwindSafe, catch_unwind};
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions as ZuneOptions};
use zune_jpegxl::JxlSimpleEncoder;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::format::Format;
use crate::image::{ChannelLayout, Image, SampleType};
const F: Format = Format::Jxl;
// zune-jpegxl 0.5.2 emits ExtraChannelInfo(all_default=true) for every alpha
// channel. In JPEG XL that flag means *8-bit* alpha, even when the modular
// payload was encoded from 16-bit samples. Replace only that metadata bundle,
// leaving the entire encoded frame and its TOC untouched. The replacement is
// exactly 16 bits longer, so subsequent byte-aligned data stays aligned.
//
// This deliberately verifies the pinned encoder's header layout rather than
// trying to parse or rewrite arbitrary JPEG XL codestreams. A different header
// must fail closed, not produce a file that silently corrupts transparency.
fn correct_16_bit_alpha_header(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, CodecError> {
fn read(bytes: &[u8], pos: &mut usize, count: usize) -> Option<u32> {
let mut value = 0u32;
for bit in 0..count {
let offset = *pos;
let byte = *bytes.get(offset / 8)?;
value |= u32::from((byte >> (offset % 8)) & 1) << bit;
*pos += 1;
}
Some(value)
}
fn read_dimension(bytes: &[u8], pos: &mut usize) -> Option<u32> {
let bits = match read(bytes, pos, 2)? {
0 => 9,
1 => 13,
2 => 18,
_ => 30,
};
read(bytes, pos, bits)?.checked_add(1)
}
fn write_bits(out: &mut Vec<u8>, pos: &mut usize, count: usize, value: u32) {
for bit in 0..count {
if (*pos).is_multiple_of(8) {
out.push(0);
}
out[*pos / 8] |= (((value >> bit) & 1) as u8) << (*pos % 8);
*pos += 1;
}
}
let mut pos = 0;
let header_matches = read(bytes, &mut pos, 16) == Some(0x0aff)
&& read(bytes, &mut pos, 1) == Some(0)
&& read_dimension(bytes, &mut pos) == Some(height)
&& read(bytes, &mut pos, 3) == Some(0)
&& read_dimension(bytes, &mut pos) == Some(width)
&& read(bytes, &mut pos, 1) == Some(0) // metadata not all-default
&& read(bytes, &mut pos, 1) == Some(0) // no extra fields
&& read(bytes, &mut pos, 1) == Some(0) // integer samples
&& read(bytes, &mut pos, 2) == Some(3) // custom bit depth
&& read(bytes, &mut pos, 6) == Some(15) // 16 bits
&& read(bytes, &mut pos, 1) == Some(0) // uses >14 bits
&& read(bytes, &mut pos, 2) == Some(1); // one extra channel
let alpha_flag_pos = pos;
let header_matches = header_matches
&& read(bytes, &mut pos, 1) == Some(1) // incorrect default 8-bit alpha
&& read(bytes, &mut pos, 1) == Some(0) // not XYB
&& read(bytes, &mut pos, 1) == Some(1) // default sRGB encoding (RGBA)
&& read(bytes, &mut pos, 2) == Some(0) // no extensions
&& read(bytes, &mut pos, 1) == Some(1); // default transform
let frame_offset = pos.div_ceil(8);
let header_matches = header_matches && frame_offset <= bytes.len() && (pos..frame_offset * 8).all(|bit| bytes[bit / 8] & (1 << (bit % 8)) == 0);
if !header_matches {
return Err(CodecError::encode(F, "unexpected zune-jpegxl header for 16-bit alpha"));
}
let mut fixed = Vec::with_capacity(bytes.len() + 2);
let mut cursor = 0;
for bit in 0..alpha_flag_pos {
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
}
// JPEG XL ExtraChannelInfo, non-default, straight 16-bit alpha:
// type=kAlpha (Enum=0), bit_depth=uint16, dim_shift=0, name_len=0,
// alpha_associated=false. Adds exactly 16 bits versus all_default=true.
write_bits(&mut fixed, &mut cursor, 1, 0);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 1, 0);
write_bits(&mut fixed, &mut cursor, 2, 3);
write_bits(&mut fixed, &mut cursor, 6, 15);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 1, 0);
for bit in alpha_flag_pos + 1..frame_offset * 8 {
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
}
if cursor != (frame_offset + 2) * 8 {
return Err(CodecError::encode(F, "JPEG XL alpha header alignment mismatch"));
}
fixed.extend_from_slice(&bytes[frame_offset..]);
Ok(fixed)
}
pub(crate) fn encode(image: &Image, plan: Plan) -> Result<Vec<u8>, CodecError> {
let image = image.converted(plan.layout, plan.sample);
let space = match image.layout() {
ChannelLayout::Gray => ColorSpace::Luma,
ChannelLayout::GrayA => ColorSpace::LumaA,
ChannelLayout::Rgb => ColorSpace::RGB,
ChannelLayout::Rgba => ColorSpace::RGBA,
_ => return Err(CodecError::unsupported(F, "JPEG XL export supports grayscale and RGB, with optional alpha")),
};
let depth = match image.sample_type() {
SampleType::U8 => BitDepth::Eight,
SampleType::U16 => BitDepth::Sixteen,
_ => return Err(CodecError::unsupported(F, "JPEG XL lossless export supports 8 or 16-bit integer pixels")),
};
let (width, height) = image.dimensions();
let samples = (width as usize)
.checked_mul(height as usize)
.and_then(|n| n.checked_mul(image.layout().channels()))
.and_then(|n| n.checked_mul(image.sample_type().bytes()))
.ok_or_else(|| CodecError::encode(F, "image dimensions overflow the buffer size"))?;
if image.data().len() != samples {
return Err(CodecError::InvalidImage("incorrect JPEG XL pixel buffer length".into()));
}
// A panic in an external image encoder must not terminate the host application.
catch_unwind(AssertUnwindSafe(|| {
let options = ZuneOptions::new(width as usize, height as usize, space, depth);
let mut output = Vec::new();
JxlSimpleEncoder::new(image.data(), options).encode(&mut output).map_err(|e| CodecError::encode(F, e.to_string()))?;
if depth == BitDepth::Sixteen && image.layout() == ChannelLayout::Rgba { correct_16_bit_alpha_header(&output, width, height) } else { Ok(output) }
}))
.unwrap_or_else(|_| Err(CodecError::encode(F, "the JPEG XL encoder failed")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpha_header_correction_rejects_unexpected_or_truncated_headers() {
for bytes in [&[][..], &[0xff, 0x0a][..], &[0; 32][..]] {
assert!(correct_16_bit_alpha_header(bytes, 2, 2).is_err());
}
}
}
+12
View File
@@ -34,6 +34,8 @@ pub enum Format {
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
/// JPEG XL: lossless export-only until the separate importer lands.
Jxl,
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from).
Cineon,
/// DICOM single-frame images (uncompressed): read-only.
@@ -85,6 +87,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
),
(Format::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."),
(
Format::Heif,
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
@@ -160,6 +163,7 @@ impl Format {
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
Format::Jxl => "JPEG XL",
Format::Cineon => "Cineon",
Format::Dicom => "DICOM",
Format::Iff => "Amiga IFF",
@@ -189,6 +193,7 @@ impl Format {
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
Format::Jxl => &["jxl"],
Format::Cineon => &["cin"],
Format::Dicom => &["dcm", "dicom", "dc3"],
Format::Iff => &["iff", "lbm", "ilbm"],
@@ -216,6 +221,7 @@ impl Format {
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
Format::Jxl => "image/jxl",
Format::Cineon => "image/x-cin",
Format::Dicom => "application/dicom",
Format::Iff => "image/x-iff",
@@ -276,6 +282,8 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
// GrayA is widened to RGBA at the same depth; the 16-bit alpha header is corrected.
Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..base },
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
@@ -292,6 +300,10 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
// JPEG XL bare codestream and ISO/IEC 18181-2 container signature box.
if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n") {
return Some(Format::Jxl);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
return Some(Format::Tiff);
}
+3 -1
View File
@@ -69,7 +69,7 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
/// Decode as a specific format.
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
if !caps(format).read && format != Format::Heif {
if !caps(format).read && !matches!(format, Format::Heif | Format::Jxl) {
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
}
let l = &opts.limits;
@@ -89,6 +89,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Iff => iff::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
}?;
// Without a resolution of the format's own (PNG pHYs, TIFF tags, JPEG's metadata), the
@@ -307,6 +308,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Iff => iff::encode(image, plan),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Jxl => jxl::encode(image, plan),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Lossless JPEG XL export and honest export-only capability reporting.
use photocraft_codecs::{
ASYMMETRIC_EXCEPTIONS, ChannelLayout, CodecError, EncodeOptions, Format, Image, SampleType, caps, decode, detect, encode, from_extension,
};
#[test]
fn jpeg_xl_is_export_only_and_detects_both_signatures() {
let c = caps(Format::Jxl);
assert!(c.write && !c.read);
assert_eq!(c.depths, &[SampleType::U8, SampleType::U16]);
assert_eq!(c.layouts, &[ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Rgba]);
assert!(c.alpha && !c.icc && !c.exif && !c.xmp && !c.dpi && !c.lossy);
assert_eq!(Format::Jxl.mime_type(), "image/jxl");
assert_eq!(from_extension("image.JXL"), Some(Format::Jxl));
assert_eq!(detect(&[0xff, 0x0a, 0, 0]), Some(Format::Jxl));
assert_eq!(detect(b"\0\0\0\x0cJXL \r\n\x87\n"), Some(Format::Jxl));
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(format, reason)| *format == Format::Jxl && !reason.is_empty()));
let err = decode(&[0xff, 0x0a]);
assert!(matches!(err, Err(CodecError::Unsupported { format: Format::Jxl, .. })), "{err:?}");
}
#[test]
fn jpeg_xl_encoding_is_deterministic_and_converts_float_to_integer() {
let input =
Image::from_u8(3, 2, ChannelLayout::Rgba, vec![0, 255, 17, 127, 20, 40, 80, 255, 255, 0, 2, 0, 10, 20, 30, 64, 0, 0, 0, 1, 90, 80, 70, 128]).unwrap();
let opts = EncodeOptions::default();
let one = encode(&input, Format::Jxl, &opts).unwrap();
let two = encode(&input, Format::Jxl, &opts).unwrap();
assert_eq!(one, two);
assert_eq!(detect(&one), Some(Format::Jxl));
let float = Image::from_f32(2, 2, ChannelLayout::Rgb, &[0.0, 0.5, 1.0, 0.25, 0.5, 0.75, 0.75, 0.5, 0.25, 1.0, 0.0, 0.5]).unwrap();
let bytes = encode(&float, Format::Jxl, &opts).unwrap();
assert_eq!(detect(&bytes), Some(Format::Jxl));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn jpeg_xl_decodes_losslessly_with_independent_decoder() {
use jxl_oxide::JxlImage;
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
let channels = layout.channels();
for sample in [SampleType::U8, SampleType::U16] {
let (w, h) = (7, 5);
let count = w as usize * h as usize * channels;
let expected = match sample {
SampleType::U8 => (0..count).map(|i| (((i * 37 + 19) % 256) as u16) * 257).collect::<Vec<_>>(),
SampleType::U16 => (0..count).map(|i| ((i * 9973 + 27) % 65536) as u16).collect::<Vec<_>>(),
_ => unreachable!(),
};
let input = match sample {
SampleType::U8 => {
let data = expected.iter().map(|v| (v / 257) as u8).collect();
Image::from_u8(w, h, layout, data).unwrap()
}
SampleType::U16 => Image::from_u16(w, h, layout, &expected).unwrap(),
_ => unreachable!(),
};
// GrayA is losslessly widened to RGBA. Keep the oracle comparison
// against original gray and alpha samples, not only against a converted image.
let (output_channels, expected_output) = if layout == ChannelLayout::GrayA {
let expanded: Vec<u16> = expected.as_chunks::<2>().0.iter().flat_map(|[gray, alpha]| [*gray, *gray, *gray, *alpha]).collect();
(4, expanded)
} else {
(channels, expected.clone())
};
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
assert_eq!((decoder.width(), decoder.height()), (w, h));
let render = decoder.render_frame(0).unwrap();
let mut stream = render.stream();
assert_eq!(stream.channels() as usize, output_channels, "{layout:?} {sample:?}");
let mut output = vec![0u16; expected_output.len()];
assert_eq!(stream.write_to_buffer(&mut output), expected_output.len());
assert_eq!(output, expected_output, "{layout:?} {sample:?}");
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn jpeg_xl_preserves_sixteen_bit_alpha_across_dimension_header_sizes() {
use jxl_oxide::JxlImage;
// Exercise each size-header coding tier, and non-opaque alpha extremes.
for width in [2u32, 513, 8193] {
let height = 2u32;
let mut samples = Vec::with_capacity(width as usize * height as usize * 4);
for i in 0..(width as usize * height as usize) {
samples.extend_from_slice(&[(i as u16).wrapping_mul(127), 65535, (i as u16).wrapping_mul(9973), [0u16, 1, 32768, 65535][i % 4]]);
}
let input = Image::from_u16(width, height, ChannelLayout::Rgba, &samples).unwrap();
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
let render = decoder.render_frame(0).unwrap();
let mut stream = render.stream();
assert_eq!(stream.channels(), 4, "width={width}");
let mut output = vec![0u16; samples.len()];
assert_eq!(stream.write_to_buffer(&mut output), samples.len());
assert_eq!(output, samples, "width={width}");
}
}
#[test]
fn jpeg_xl_rejects_one_pixel_images_without_panicking() {
// zune-jpegxl currently requires at least two pixels on each axis.
let image = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![42, 100, 255]).unwrap();
assert!(matches!(encode(&image, Format::Jxl, &EncodeOptions::default()), Err(CodecError::Encode { format: Format::Jxl, .. })));
}
+1 -1
View File
@@ -16,7 +16,7 @@ use crate::state::DialogKind;
use crate::theme::Tokens;
use crate::{ExportSettings, PhotocraftApp};
const FORMATS: [(&str, &str); 6] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("ico", "ICO")];
const FORMATS: [(&str, &str); 7] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("ico", "ICO"), ("jxl", "JPEG XL")];
/// Preferences ▸ remembered dialog settings: Export As writes the settings it was used with
/// last under its command id and seeds the next dialog from them, as Fill, Liquify and the