diff --git a/Cargo.lock b/Cargo.lock index 8f4fd3a185..71d25d8d1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index c4350141fb..585d475071 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -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"]), diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index e1a2cc23fa..13ab740a86 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -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. diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 062f7c4f98..69e3bd78b5 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -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 diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 29d4d1234d..43bf4ea361 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -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 diff --git a/crates/codecs/src/codecs.rs b/crates/codecs/src/codecs.rs index efdbbf8229..4c4c22388c 100644 --- a/crates/codecs/src/codecs.rs +++ b/crates/codecs/src/codecs.rs @@ -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; diff --git a/crates/codecs/src/codecs/jxl.rs b/crates/codecs/src/codecs/jxl.rs new file mode 100644 index 0000000000..11591c9300 --- /dev/null +++ b/crates/codecs/src/codecs/jxl.rs @@ -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, CodecError> { + fn read(bytes: &[u8], pos: &mut usize, count: usize) -> Option { + 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 { + 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, 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, 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()); + } + } +} diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 54f3ecd64e..e999cf5273 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -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 { 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); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 1ae285e7ce..c49daa61a3 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -69,7 +69,7 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result { /// Decode as a specific format. pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result { // 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 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), } } diff --git a/crates/codecs/tests/jxl_export.rs b/crates/codecs/tests/jxl_export.rs new file mode 100644 index 0000000000..8875cbce80 --- /dev/null +++ b/crates/codecs/tests/jxl_export.rs @@ -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::>(), + SampleType::U16 => (0..count).map(|i| ((i * 9973 + 27) % 65536) as u16).collect::>(), + _ => 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 = 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, .. }))); +} diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 4e75f54a16..e473504e4b 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -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