8.0 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.
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.
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. -
AVIF (the only asymmetric format). 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. -
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), so upright pixels are never rotated twice. Malformed or out-of-range values read as 1.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.
- 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.