feat(export): expose JPEG XL Save As and Export As, document limitations and test output

This commit is contained in:
Nigel Ihza Firdausy
2026-10-11 17:06:29 +07:00
parent 51b9454b71
commit aee67e8194
7 changed files with 92 additions and 4 deletions
Generated
+1 -1
View File
@@ -4298,6 +4298,7 @@ dependencies = [
"image", "image",
"image-webp", "image-webp",
"jpeg-encoder", "jpeg-encoder",
"jxl-oxide",
"photocraft-heif", "photocraft-heif",
"png 0.18.1", "png 0.18.1",
"proptest", "proptest",
@@ -4308,7 +4309,6 @@ dependencies = [
"zune-core 0.5.3", "zune-core 0.5.3",
"zune-jpeg 0.5.15", "zune-jpeg 0.5.15",
"zune-jpegxl", "zune-jpegxl",
"jxl-oxide",
] ]
[[package]] [[package]]
+1
View File
@@ -56,6 +56,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenRaster", &["ora"]), ("OpenRaster", &["ora"]),
("PNG", &["png"]), ("PNG", &["png"]),
("JPEG", &["jpg"]), ("JPEG", &["jpg"]),
("JPEG XL", &["jxl"]),
("WebP", &["webp"]), ("WebP", &["webp"]),
("TIFF", &["tif"]), ("TIFF", &["tif"]),
("Targa", &["tga"]), ("Targa", &["tga"]),
+10 -1
View File
@@ -1,11 +1,20 @@
# Raster formats # 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 ## 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`. 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 ## 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. 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.
+10
View File
@@ -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` | | 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` | | 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` | | 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 "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: the encode plan, and `fidelity_warnings` reports the conversion when it loses information:
@@ -62,6 +63,15 @@ the same.
## Documented asymmetries and limitations ## 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 * **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 with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there. and develops them, and `photocraft-io` routes them there.
+1 -1
View File
@@ -59,7 +59,7 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
/// Decode as a specific format. /// Decode as a specific format.
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> { 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. // 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")); return Err(CodecError::unsupported(format, "decoding is not available for this format"));
} }
let l = &opts.limits; let l = &opts.limits;
+68
View File
@@ -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::<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!(),
};
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:?}");
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::state::DialogKind;
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::{ExportSettings, PhotocraftApp}; 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<u64, String> { pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
let st = app.session.active().ok_or("no document")?; let st = app.session.active().ok_or("no document")?;