mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 12:27:38 -06:00
* 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>
153 lines
10 KiB
Markdown
153 lines
10 KiB
Markdown
# 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.
|