Files
photocraft/crates/codecs/README.md
T
47f9306fd0 Open HEIC/HEIF photos (#355) (#372)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Docs: HEIC open is an optional build feature, on in official builds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 10:20:40 -04:00

153 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, and
`fidelity_warnings(&image, format)` lists what an export will lose before you write it. Both
functions use the same encode plan as `encode`, so a warning appears exactly when the file
changes. On the way in, `Image::warnings` lists what the decoded image doesn't show: frames or
pages left out, or data that ended early.
```rust
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. `CmykConverted` is
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-raw` decodes
and develops them, and `photocraft-io` routes them there.
* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate
behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose).
Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF
support isn't included in this build"). iPhone and Mac photos decode with `heic-rs` (pure Rust, no `unsafe`):
single pictures and grid-tiled photos, 8 to 12 bits, auxiliary alpha, ICC, EXIF and XMP. The
container's `irot`/`imir`/`clap` are 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 return `CodecError::Unsupported` or `Malformed`, never wrong pixels. Writing
needs an HEVC encoder and every mature one is C, so HEIF is listed in `ASYMMETRIC_EXCEPTIONS`.
* **AVIF.** Encoding uses `ravif`, which is pure Rust. Decoding
needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated
behind the non-default `avif` feature. In a default build it is neither readable nor writable,
so the symmetric guarantee holds. It is listed in `ASYMMETRIC_EXCEPTIONS`.
* **Lossy WebP.** There is no pure-Rust lossy WebP encoder. We always write lossless WebP, and
`webp_lossless: false` returns `CodecError::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::animation` marks
containers that can hold more frames. The decoded image then carries a
`DecodeWarning::MoreFrames` / `MorePages` in `Image::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 = false` for TIFF, and a warning is raised.
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and 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_orientation` opts 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. `Limits` are checked again on the upright
size (5–8 swap width and height), and the rotated buffer is allocated fallibly.
`Image::oriented` turns 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 `tiff` crate lacks (CCITT, JPEG-in-TIFF),
return `Unsupported`.
* **EXR**
* Reads the first valid layer at full resolution, from its data window.
* Channel names are matched by suffix, so `layer.R` counts as `R`.
* 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 `.pbm` file 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 `image` crate 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.