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")?;