* Open HEIC/HEIF photos (#355) iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no HEIF code, so .heic was missing from File > Open and failed to decode. Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust, forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha, ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF Orientation is rewritten to 1, so photos are never turned twice. Limits are checked against the declared size before decoding. Read-only (no pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS. File > Open (desktop, web) and the batch list accept heic/heif/hif. Tests compare against Apple's own decode and a 16-bit source, cover orientation, metadata, limits and every truncation; fixtures from heic-rs and pillow-heif are attributed in ATTRIBUTION.md. Co-Authored-By: Claude Opus 5.5 <[email protected]> * HEIF: pin heic-rs exactly and add a decode_heif fuzz target Review follow-up: heic-rs is young and parses untrusted files, so pin it (=0.1.1) and fuzz the HEIF path. The target covers both orientation paths and is meant to run with -timeout so a hang is reported, not only a panic. It is added to the CI fuzz matrix. Co-Authored-By: Claude Opus 5.5 <[email protected]> * HEIF: a heic-rs panic is a malformed-file error, not a crash Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on malformed files: - in its box parser (boxes.rs:130), reachable from a 72-byte file, even through the metadata-only probe; - during HEVC reconstruction (hevc/decode/recon.rs:70). The background job path catches panics, but the synchronous ones (jobs off, the CLI) did not. The HEIF codec now runs the decoder under catch_unwind and reports a panic as CodecError::Malformed, as gpu/health.rs already does around wgpu. The fuzz target also calls heic-rs directly, so its panics still reach the fuzzer. The 72-byte input is a regression test, which fails without the guard. Co-Authored-By: Claude Opus 5.5 <[email protected]> * HEIF: move the decoder into an optional photocraft-heif crate The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit, ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded, Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif between standalone crates. The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs (tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with `--features corpus,heif` (`cargo xtask test-corpus` enables both). Co-Authored-By: Claude Opus 5.5 <[email protected]> * Build CI and official binaries with HEIC support (--features heif) CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with `--features heif`. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Docs: HEIC open is an optional build feature, on in official builds Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
10 KiB
photocraft-codecs
Codecs for flat raster image formats, written in pure Rust. The crate is standalone: it depends on no other
workspace crate and defines its own Image type. It also builds for wasm32-unknown-unknown and
does no I/O: decoders take &[u8] and encoders return Vec<u8>.
It follows the "avoid GIMP's hole" rules in plan/architecture.md §1.1:
- Symmetric. Every format we write, we can also read. The only exceptions are listed in
ASYMMETRIC_EXCEPTIONS, each with a reason. The test suite checks this. - Depth-preserving. An image can hold U8, U16, F16 or F32 samples, and each format keeps what it can hold.
- Metadata-preserving. ICC, EXIF, XMP, DPI and text are kept wherever the format supports them.
- Honest.
caps(format)says what each format holds, andfidelity_warnings(&image, format)lists what an export will lose before you write it. Both functions use the same encode plan asencode, so a warning appears exactly when the file changes. On the way in,Image::warningslists what the decoded image doesn't show: frames or pages left out, or data that ended early.
use photocraft_codecs::*;
let img = decode(&bytes)?; // detect + decode (default Limits)
for w in fidelity_warnings(&img, Format::Jpeg) { // e.g. "16-bit will be reduced to 8-bit"
println!("{w}");
}
let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
Capability matrix (default build)
| Format | Read | Write | Depths (native) | Layouts (native) | Alpha | ICC | EXIF | XMP | DPI | Text | Lossy write | Backend |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| PNG | yes | yes | U8, U16 | Gray, GrayA, RGB, RGBA | yes | yes (iCCP) | yes (eXIf) | yes (iTXt XML:com.adobe.xmp) |
yes (pHYs) | yes (tEXt/zTXt/iTXt) | no | png |
| JPEG | yes | yes | U8 | Gray, RGB, CMYK | no | yes (multi-segment APP2) | yes (APP1) | yes (APP1) | yes (JFIF) | no | yes | zune-jpeg / jpeg-encoder |
| TIFF | yes | yes | U8, U16, F32 | all six (CMYK, CMYK+A included) | yes | yes (tag 34675) | no | yes (tag 700) | yes | yes (Description, Make, Model, Software, DateTime, Artist, Copyright) | no | tiff |
| WebP | yes | yes (lossless only) | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | no | image-webp |
| GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | image |
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | image |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | image |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | image |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM CMYK/CMYK_ALPHA) |
yes (PAM) | no | no | no | no | no | no | built in |
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | image |
| 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 |
| 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 |
"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:
- A float image written to an integer format goes to U16 where the format supports it. Values
outside 0..1 raise
RangeClipped. - Integer data written to a float-only format (EXR, HDR) becomes F32 without loss.
- CMYK is converted to RGB with a naive formula that is not colour-managed.
CmykConvertedis raised and the ICC profile is dropped, because a CMYK profile does not describe RGB data. - Gray written to an RGB-only format is expanded to RGB without loss, so no warning is raised.
Image::convert applies the same ICC rule: it keeps the profile only when the colour model stays
the same.
Documented asymmetries and limitations
-
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-rawdecodes and develops them, andphotocraft-ioroutes them there. -
HEIF/HEIC (read-only, feature
heif). Decoding lives in the optionalphotocraft-heifcrate behind the non-defaultheiffeature (official PhotoCraft builds enable it; distributors choose). Without it, HEIF is still detected and decoding returnsCodecError::Unsupported("HEIC/HEIF support isn't included in this build"). iPhone and Mac photos decode withheic-rs(pure Rust, nounsafe): single pictures and grid-tiled photos, 8 to 12 bits, auxiliary alpha, ICC, EXIF and XMP. The container'sirot/imir/clapare applied and EXIF Orientation is rewritten to 1 (HEIF's EXIF tag only repeats the container's, and applying both would turn the photo twice). Grayscale decodes to RGB. Image sequences, overlays and identity derivations, multilayer HEVC and some 4:2:2/4:4:4 streams returnCodecError::UnsupportedorMalformed, never wrong pixels. Writing needs an HEVC encoder and every mature one is C, so HEIF is listed inASYMMETRIC_EXCEPTIONS. -
AVIF. Encoding uses
ravif, which is pure Rust. Decoding needsdav1d, which is C. AVIF is therefore read-unsupported, and write support is gated behind the non-defaultaviffeature. In a default build it is neither readable nor writable, so the symmetric guarantee holds. It is listed inASYMMETRIC_EXCEPTIONS. -
Lossy WebP. There is no pure-Rust lossy WebP encoder. We always write lossless WebP, and
webp_lossless: falsereturnsCodecError::Unsupported. We can read both lossy and lossless files. -
Animation and multi-page files (APNG, animated GIF/WebP, multi-page TIFF): only the first frame or page is decoded, and a single frame is written.
FormatCaps::animationmarks containers that can hold more frames. The decoded image then carries aDecodeWarning::MoreFrames/MorePagesinImage::warnings, with the total when the file states it (frames and TIFF directories are counted without decoding them; reduced-resolution TIFF directories and transparency masks are not pages). -
EXIF in TIFF is stored as a sub-IFD rather than a blob, so it is not preserved yet.
caps.exif = falsefor TIFF, and a warning is raised. -
Orientation (EXIF tag 274 in JPEG, PNG
eXIfand WebP; the IFD0 tag in TIFF) is applied on decode, like Photoshop: the pixels come back upright and the EXIF/XMP orientation is rewritten to 1 (DecodeOptions::keep_orientationopts out). Encoders always write Orientation = 1 (upright_exif,upright_xmp, which change only the tag's value, kept in its own type), so upright pixels are never rotated twice. Parsing is strict: an IFD0 inside the 8-byte header or cut off before its next-IFD pointer, an Orientation that is not exactly one SHORT or LONG, or an out-of-range value reads as 1; with duplicate entries the first wins.Limitsare checked again on the upright size (5–8 swap width and height), and the rotated buffer is allocated fallibly.Image::orientedturns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in release). -
JPEG
- 8-bit only. Neither decoder backend supports 12-bit.
- A file cut off inside its image data decodes leniently (a baseline JPEG's missing rows come
out grey) but never silently: the image carries
DecodeWarning::Truncated. A file that ends before any scan data is an error. - CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an APP14 marker.
- Text is not written because there is no COM-segment support.
- EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error. Extended XMP is not written.
-
TIFF decode
- Supports 1, 2 and 4-bit gray (best effort, not yet covered by tests), 8/16/32-bit integers (32-bit is reduced to U16), F16, F32 and F64 (converted to F32), and WhiteIsZero.
- Palette and YCbCr TIFFs, and compressions that the
tiffcrate lacks (CCITT, JPEG-in-TIFF), returnUnsupported.
-
EXR
- Reads the first valid layer at full resolution, from its data window.
- Channel names are matched by suffix, so
layer.Rcounts asR. - Subsampled channels and deep data are unsupported.
- Only lossless compressions are offered for writing: None, RLE, ZIP1, ZIP16 and PIZ.
-
Netpbm writing picks the subtype from the image: float gives PFM, gray gives P5, RGB gives P6, and anything with alpha or CMYK gives P7 (PAM). Writing a
.pbmfile therefore produces whichever of these fits the data. -
Colour conversions (CMYK↔RGB, RGB→gray) are naive. Colour-managed conversion belongs in
photocraft-color.
Limits (decompression bombs)
DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc }, .. } guards
decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is
also passed to the underlying decoders. A violation returns CodecError::LimitExceeded. The
defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).
Tests
cargo test -p photocraft-codecs covers:
- the full round-trip matrix (every writable format × 4 sample types × 6 layouts), exact for lossless formats and above a PSNR threshold for lossy ones;
- metadata preservation per format, checked against
caps; - magic-number detection, including negatives;
- fidelity warnings, and a cross-check that the warnings match the actual round-trip result;
- the symmetric-capability guarantee;
- malformed input: truncation, bit flips and proptest random bytes;
- Adam7 PNG against progressive PNG and against the
imagecrate as an oracle; - limit enforcement.
If corpus/pngsuite/*.png exists at the repo root, every file in it is compared against the
image crate's decoder. Without it, that test is skipped.