From 51b9454b71a1980f06ed46bb2dabb4978f68fa3d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:03:16 +0700 Subject: [PATCH 01/11] feat(codecs): encode lossless JPEG XL using pure Rust --- Cargo.lock | 2 ++ crates/codecs/Cargo.toml | 6 ++++ crates/codecs/src/codecs/jxl.rs | 50 +++++++++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/format.rs | 20 ++++++++++++- crates/codecs/src/lib.rs | 4 ++- 6 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 crates/codecs/src/codecs/jxl.rs diff --git a/Cargo.lock b/Cargo.lock index fde4ac01b8..19f41c5e57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4307,6 +4307,8 @@ dependencies = [ "tiff", "zune-core 0.5.3", "zune-jpeg 0.5.15", + "zune-jpegxl", + "jxl-oxide", ] [[package]] diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index a55dcf1213..e5c2413f0c 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -31,6 +31,8 @@ image-webp = "0.2" zune-jpeg = "0.5" zune-core = "0.5" jpeg-encoder = "0.6" +# Pure-Rust modular JPEG XL lossless encoder, pinned in Cargo.lock already (raw test fixtures). +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. @@ -42,5 +44,9 @@ proptest.workspace = true # Oracle decoders for cross-checking in tests only. image = { workspace = true, features = ["png", "jpeg", "tiff"] } +# Independent decoder oracle for verifying lossless output; not linked into the app. +[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/src/codecs/jxl.rs b/crates/codecs/src/codecs/jxl.rs new file mode 100644 index 0000000000..169a1f30ef --- /dev/null +++ b/crates/codecs/src/codecs/jxl.rs @@ -0,0 +1,50 @@ +//! Pure-Rust lossless JPEG XL (Modular) writer. +//! +//! Currently writes a bare JPEG XL codestream. The encoder accepts 8- or 16-bit +//! gray, gray+alpha, RGB and RGBA samples, but does not write ICC/EXIF/XMP boxes. +//! 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; + +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()))?; + Ok(output) + })) + .unwrap_or_else(|_| Err(CodecError::encode(F, "the JPEG XL encoder failed"))) +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 3a6352ed82..ab0dabecd8 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod heif; pub(crate) mod jpeg; +pub(crate) mod jxl; pub(crate) mod png; pub(crate) mod pnm; pub(crate) mod tiff; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index c40e3ef167..ff367bf3e4 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -23,6 +23,8 @@ pub enum Format { Avif, /// HEIF/HEIC (HEVC-coded): read-only; see [`ASYMMETRIC_EXCEPTIONS`]. Heif, + /// Lossless JPEG XL: encode-only until the separate JPEG XL decoder lands. + Jxl, } /// What a format can hold **and** what this crate reads/writes for it. @@ -59,6 +61,11 @@ 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 encoding uses the pure-Rust zune-jpegxl encoder. Opening JXL files is + tracked separately; read support is not available in this build.", + ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -76,7 +83,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 14] = [ + pub const ALL: [Format; 15] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -91,6 +98,7 @@ impl Format { Format::Hdr, Format::Avif, Format::Heif, + Format::Jxl, ]; pub fn caps(self) -> FormatCaps { @@ -119,6 +127,7 @@ impl Format { Format::Hdr => "Radiance HDR", Format::Avif => "AVIF", Format::Heif => "HEIF", + Format::Jxl => "JPEG XL", } } @@ -140,6 +149,7 @@ impl Format { Format::Hdr => &["hdr"], Format::Avif => &["avif"], Format::Heif => &["heic", "heif", "hif"], + Format::Jxl => &["jxl"], } } @@ -159,6 +169,7 @@ impl Format { Format::Hdr => "image/vnd.radiance", Format::Avif => "image/avif", Format::Heif => "image/heif", + Format::Jxl => "image/jxl", } } @@ -205,6 +216,9 @@ pub fn caps(format: Format) -> FormatCaps { Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base }, + // The modular encoder is lossless for integer gray/RGB(A). Its bare codestream + // has no ICC/EXIF/XMP container boxes: callers must convert to sRGB. + Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: RGB_GRAY, ..base }, Format::Heif => { FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base } } @@ -221,6 +235,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 cc8d0558b2..0e5a4f0554 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -38,7 +38,7 @@ pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use half::f16; -use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp}; +use crate::codecs::{exr, heif, jpeg, jxl, png, pnm, tiff, via_image, webp}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -71,6 +71,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Pnm => pnm::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 @@ -205,6 +206,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts), 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), } } From aee67e8194d2c9e53fda0573cf4b8d8d7566bc1c Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:06:29 +0700 Subject: [PATCH 02/11] feat(export): expose JPEG XL Save As and Export As, document limitations and test output --- Cargo.lock | 2 +- apps/photocraft/src/services.rs | 1 + book/src/formats/raster-formats.md | 11 ++++- crates/codecs/README.md | 10 +++++ crates/codecs/src/lib.rs | 2 +- crates/codecs/tests/jxl_export.rs | 68 +++++++++++++++++++++++++++++ crates/ui-egui/src/export_dialog.rs | 2 +- 7 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 crates/codecs/tests/jxl_export.rs diff --git a/Cargo.lock b/Cargo.lock index 19f41c5e57..b79dd5e61b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4298,6 +4298,7 @@ dependencies = [ "image", "image-webp", "jpeg-encoder", + "jxl-oxide", "photocraft-heif", "png 0.18.1", "proptest", @@ -4308,7 +4309,6 @@ dependencies = [ "zune-core 0.5.3", "zune-jpeg 0.5.15", "zune-jpegxl", - "jxl-oxide", ] [[package]] diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index a0ce1fa560..c5539289ff 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -56,6 +56,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("OpenRaster", &["ora"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), + ("JPEG XL", &["jxl"]), ("WebP", &["webp"]), ("TIFF", &["tif"]), ("Targa", &["tga"]), diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 4b7f4dec16..8ea8e6c752 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -1,11 +1,20 @@ # 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 supports 8/16-bit integer gray and RGB images with optional alpha through the pure-Rust `zune-jpegxl` encoder. Flat JPEG XL import is not yet available (see PR #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: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. 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` +codestream with 8- or 16-bit grayscale, grayscale+alpha, RGB or RGBA samples. +The pure-Rust encoder writes the bare codestream (without ICC, EXIF, XMP or DPI +metadata boxes), so export converts non-sRGB tagged images to sRGB and surfaces +metadata losses as fidelity warnings. Opening ordinary JPEG XL files remains a +separate feature 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/README.md b/crates/codecs/README.md index 1795be17bb..8e40638270 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -46,6 +46,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | +| JPEG XL (.jxl) | **no** (see #2868) | yes | U8, U16 | Gray, GrayA, RGB, RGBA | yes | no (bare codestream) | no | no | no | no | no | `zune-jpegxl` | "Native" means the data is stored and read back without conversion. Anything else is converted by the encode plan, and `fidelity_warnings` reports the conversion when it loses information: @@ -62,6 +63,15 @@ the same. ## Documented asymmetries and limitations +* **JPEG XL export** writes lossless modular `.jxl` codestreams in pure Rust with + `zune-jpegxl`, preserving 8/16-bit integer pixels and optional alpha for gray/RGB + sources. The writer does not embed ICC, EXIF or XMP boxes; untagged RGB images are + converted to sRGB by the document export path and `fidelity_warnings` report + dropped metadata. JPEG XL *reading* is not available on this branch; the + independent read-support PR #2868 has not merged. JPEG XL therefore remains + listed in `ASYMMETRIC_EXCEPTIONS` until an importer lands. + + * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 0e5a4f0554..dd36d687d6 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -59,7 +59,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; diff --git a/crates/codecs/tests/jxl_export.rs b/crates/codecs/tests/jxl_export.rs new file mode 100644 index 0000000000..2bb3441f1a --- /dev/null +++ b/crates/codecs/tests/jxl_export.rs @@ -0,0 +1,68 @@ +//! 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::GrayA, 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(1, 1, ChannelLayout::Rgb, &[0.0, 0.5, 1.0]).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!(), + }; + 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, channels, "{layout:?} {sample:?}"); + let mut output = vec![0u16; count]; + assert_eq!(stream.write_to_buffer(&mut output), count); + assert_eq!(output, expected, "{layout:?} {sample:?}"); + } + } +} diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index f6284fe3b6..f271636ec0 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -11,7 +11,7 @@ use crate::state::DialogKind; use crate::theme::Tokens; use crate::{ExportSettings, PhotocraftApp}; -const FORMATS: [(&str, &str); 5] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA")]; +const FORMATS: [(&str, &str); 6] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("jxl", "JPEG XL")]; pub fn open(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; From a2a8fa0668cc0566e8112e6c860cd354ba3c2f32 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:07:49 +0700 Subject: [PATCH 03/11] test(jxl): exercise float conversion with valid minimum encoder dimensions --- crates/codecs/tests/jxl_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codecs/tests/jxl_export.rs b/crates/codecs/tests/jxl_export.rs index 2bb3441f1a..dc27c59775 100644 --- a/crates/codecs/tests/jxl_export.rs +++ b/crates/codecs/tests/jxl_export.rs @@ -26,7 +26,7 @@ fn jpeg_xl_encoding_is_deterministic_and_converts_float_to_integer() { let two = encode(&input, Format::Jxl, &opts).unwrap(); assert_eq!(one, two); assert_eq!(detect(&one), Some(Format::Jxl)); - let float = Image::from_f32(1, 1, ChannelLayout::Rgb, &[0.0, 0.5, 1.0]).unwrap(); + 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)); } From c09e289d0a18de9f4ae36c3debaacc4c97da6f13 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:09:46 +0700 Subject: [PATCH 04/11] test(jxl): pin graceful rejection of encoder's 1px dimension limit --- crates/codecs/tests/jxl_export.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/codecs/tests/jxl_export.rs b/crates/codecs/tests/jxl_export.rs index dc27c59775..456cb19f31 100644 --- a/crates/codecs/tests/jxl_export.rs +++ b/crates/codecs/tests/jxl_export.rs @@ -66,3 +66,10 @@ fn jpeg_xl_decodes_losslessly_with_independent_decoder() { } } } + +#[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, .. }))); +} From 9e82e7a314e5be6b26ce60456ab0adac1dfe78e0 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:58:58 +0700 Subject: [PATCH 05/11] fix(jxl): write accurate 16-bit extra alpha channel metadata --- crates/codecs/src/codecs/jxl.rs | 106 +++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/codecs/jxl.rs b/crates/codecs/src/codecs/jxl.rs index 92c187acab..bbb14c09ec 100644 --- a/crates/codecs/src/codecs/jxl.rs +++ b/crates/codecs/src/codecs/jxl.rs @@ -17,6 +17,106 @@ 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 % 8 == 0 { + 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() { @@ -45,7 +145,11 @@ pub(crate) fn encode(image: &Image, plan: Plan) -> Result, CodecError> { 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()))?; - Ok(output) + 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"))) } From c99e5de0e28649277b1566e23adf5e533a6b43b7 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:59:59 +0700 Subject: [PATCH 06/11] style(jxl): fix format registration rustfmt mismatch --- crates/codecs/src/format.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 56e73333c8..5664c81da8 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -63,10 +63,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::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 \ @@ -225,8 +222,8 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base } } Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, - // Native GrayA16 encoding loses alpha precision with zune-jpegxl 0.5.2. - // The ordinary encode plan widens GrayA to RGBA at the original depth. + // GrayA is widened to RGBA at the original depth. For RGBA16, the writer + // corrects zune-jpegxl 0.5.2's incorrect 8-bit alpha channel header. Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..base }, } } From a317ab02c5ed2475c6103c9e5fcd9c69e7ccb4bb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:02 +0700 Subject: [PATCH 07/11] docs(jxl): describe 16-bit alpha header correction --- crates/codecs/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d95e760d99..9ea197689c 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -66,9 +66,11 @@ the same. * **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 because native GrayA16 did not preserve alpha in an independent - decoder test. The expansion preserves sample values but is not a native - GrayA layout. No ICC, EXIF, XMP or DPI container boxes are written; 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. From b94d49e5d3ef553afbdd8660a77c185055350751 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:05 +0700 Subject: [PATCH 08/11] docs(jxl): clarify 16-bit alpha lossless behavior --- book/src/formats/raster-formats.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 4741a92ba3..78c815c8e6 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -11,8 +11,9 @@ A TIFF saved by Photoshop with its layers carries them in two private tags: 3772 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; the current encoder's native -GrayA16 path does not pass an independent lossless round-trip check. +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. From 17ee754eb431835c60ec076174b6049562229abf Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:08 +0700 Subject: [PATCH 09/11] test(jxl): cover 16-bit alpha at JPEG XL dimension boundaries --- crates/codecs/tests/jxl_export.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/codecs/tests/jxl_export.rs b/crates/codecs/tests/jxl_export.rs index aaf0aaa571..0f63a8e957 100644 --- a/crates/codecs/tests/jxl_export.rs +++ b/crates/codecs/tests/jxl_export.rs @@ -78,6 +78,35 @@ fn jpeg_xl_decodes_losslessly_with_independent_decoder() { } } +#[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. From 6eb48e4e3a31556ba5fb05a91b4ca6319edca699 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:04:46 +0700 Subject: [PATCH 10/11] style(jxl): keep header validation closure rustfmt-clean --- crates/codecs/src/codecs/jxl.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/codecs/src/codecs/jxl.rs b/crates/codecs/src/codecs/jxl.rs index bbb14c09ec..df4f2cbc9e 100644 --- a/crates/codecs/src/codecs/jxl.rs +++ b/crates/codecs/src/codecs/jxl.rs @@ -82,9 +82,7 @@ fn correct_16_bit_alpha_header(bytes: &[u8], width: u32, height: u32) -> Result< 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 - }); + && (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")); } From 1f1de7619518b009f61aed862d89d980810a85e1 Mon Sep 17 00:00:00 2001 From: Ni-zav Date: Wed, 14 Oct 2026 23:11:17 +0700 Subject: [PATCH 11/11] fix(clippy): use is_multiple_of in JXL bit writer --- crates/codecs/src/codecs/jxl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codecs/src/codecs/jxl.rs b/crates/codecs/src/codecs/jxl.rs index a550167353..9b9c525fde 100644 --- a/crates/codecs/src/codecs/jxl.rs +++ b/crates/codecs/src/codecs/jxl.rs @@ -50,7 +50,7 @@ fn correct_16_bit_alpha_header(bytes: &[u8], width: u32, height: u32) -> Result< fn write_bits(out: &mut Vec, pos: &mut usize, count: usize, value: u32) { for bit in 0..count { - if *pos % 8 == 0 { + if (*pos).is_multiple_of(8) { out.push(0); } out[*pos / 8] |= (((value >> bit) & 1) as u8) << (*pos % 8);