# 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`. 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. ```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` | "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. * **AVIF (the only asymmetric format).** 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. * **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. * **EXIF orientation** is preserved as data but not applied to the pixels. * **JPEG** * 8-bit only. Neither decoder backend supports 12-bit. * 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.