Files
photocraft/crates/codecs
1a4f867958 TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab (#4949)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-15 00:48:20 -04:00
..

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 "Avoiding GIMP's hole" rules in docs/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.
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 U8 RGB, RGBA yes yes yes yes no no yes (VP8, when webp_lossless is off) image-webp / built-in VP8
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
DDS yes no U8 Gray, GrayA, RGB, RGBA yes no no no no no n/a built in
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
FITS yes yes U8, U16, F32 Gray, RGB no no no no no yes (header cards) no built in
DPX yes yes U8, U16 (10/12-bit code values read and written exactly through U16) Gray, RGB, RGBA yes no no no no DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) no built in
Cineon yes no U16 (10-bit code values) Gray, RGB no no no no no no n/a built in
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.

Palette BMP and PNG pixels decode as RGB(A) in Image; bmp_palette and png_palette expose the embedded table (and a PNG's transparent entry) so photocraft-io can open them in Indexed Color mode.

Documented asymmetries and limitations

  • DDS. The top mip level of a 2D texture: uncompressed pixels described by channel masks (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats (BC4–BC7, float) are refused with CodecError::Unsupported. Writing is not provided.

  • FITS. The image in the primary HDU, or the first IMAGE extension, is read; NAXIS3 = 3 opens as RGB and any other plane count opens the first plane with DecodeWarning::MorePages. Rows follow ROWORDER (bottom-up by default, as Siril and DS9 do). Physical values that fit stay exact as U8/U16 (a camera's BITPIX = 16, BZERO = 32768); the rest become F32, taken as normalised when at most 2 and scaled by 65535 (or the maximum) otherwise. Header cards are kept as text (HIERARCH for long names). Tile-compressed (fpack) files are refused with CodecError::Unsupported; Bayer mosaics open undebayered.

  • DPX. SMPTE 268M frames in either byte order (SDPX, XPDS): the first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits. 10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16 round(c × 65535 / (2^b − 1)), so a frame written back at its depth keeps them exactly. No colour transform is applied: DecodeWarning::DpxEncoding names the transfer characteristic and colorimetric specification, so a log frame is reported as log. More elements than the first give DecodeWarning::MoreElements; the image orientation is applied on import. Writing is big-endian, one unencoded element, at EncodeOptions::dpx_bits (8/10/12/16; by default 8 for U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. Other descriptors, signed or run-length-encoded data and other bit depths return CodecError::Unsupported naming the field.

  • Cineon (read-only). Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word boundary. The 10-bit code values open as U16 round(c × 65535 / 1023); the file's black/white reference points are a log-conversion choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 returns CodecError::Unsupported; truncation errors rather than panicking. There is no Cineon writer (ASYMMETRIC_EXCEPTIONS); save as DPX, TIFF or EXR.

  • 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. Lossless (VP8L, via image-webp) is the default. webp_lossless: false writes a lossy file with our own clean-room VP8 key-frame encoder (codecs::vp8, from RFC 6386; webp_quality 0–100 on libwebp's scale sets the quantizer). Alpha travels losslessly in an ALPH chunk and ICC/EXIF/XMP in their own chunks of the extended (VP8X) container. The encoder makes no rate-distortion decisions yet, and a side longer than 16383 px (the VP8 frame header's limit) is refused with CodecError::Encode. We 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). Any TIFF page can still be opened with decode_tiff_page (and photocraft_io::import_tiff_page). Every frame of a GIF comes from decode_gif_frames: each frame composited onto the logical screen with the disposal methods applied, as RGBA8 with its delay in milliseconds, within Limits and MAX_GIF_FRAMES. The video-layer commands use it (photocraft_io::import_gif_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.

  • Orientation (EXIF tag 274 in JPEG, PNG eXIf and WebP; the decoded page's tag in TIFF and BigTIFF) 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).

  • Resolution (#1691, module resolution): a file without a resolution of its own format (no PNG pHYs, a WebP, HEIF, …) takes it from its XMP tiff:XResolution / YResolution / ResolutionUnit, else from EXIF IFD0 (RATIONAL X/YResolution, unit 2 inch — the default when absent — or 3 cm; unit 1 or anything malformed counts as no resolution). A JPEG uses, like Photoshop, the first usable of: APP13 Photoshop ResolutionInfo (0x03ED), XMP, EXIF, and only then the JFIF density (cameras write EXIF only, many tools write a default JFIF 72). On export the EXIF and XMP resolution are rewritten to the image's (export_exif, export_xmp): the RATIONAL values in place, ResolutionUnit set to 2, an entry that can't be rewritten removed from IFD0 without moving any other data. IFD1 (the thumbnail's) is left as it is.

  • 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 APP1 segment (65 533 bytes with its header). Metadata that doesn't fit is dropped and the export still succeeds, with a MetadataTooLarge warning ("EXIF (… bytes) is too large for the format; it will be dropped"). Before dropping XMP, the encoder first removes the layered-document properties (DocumentAncestors, TextLayers) that mean nothing in a flat JPEG. Extended XMP is not written.
  • TIFF decode

    • Classic TIFF and BigTIFF (version 43, 8-byte offsets), both byte orders, strips or tiles, planar configuration 1 or 2.
    • Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16), F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted), palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped unless the first is alpha).
    • Every directory is reachable: tiff_info lists the main IFD chain and its SubIFDs (tag 330) with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a seen-set, a depth limit and a 10 000-directory cap; decode_tiff_page opens any of them. decode opens the first full-resolution page, like Photoshop (thumbnails are skipped).
    • Banded decoding: the image is allocated once at its final size and each strip or row of tiles is decoded straight into it, in parallel on native targets, with at most one strip/tile-sized scratch buffer per worker (30 MP RGB8: Deflate 345 → 32 ms, LZW 428 → 43 ms in release). Image data cut off by the end of the file decodes with DecodeWarning::Truncated; a file far too small for its declared size (more than any compression could expand) is refused before allocating.
    • YCbCr, CIE Lab, signed integers and compressions that the tiff crate lacks (CCITT, JPEG-in-TIFF) return Unsupported.
  • TIFF encode writes classic TIFF, or BigTIFF when EncodeOptions::tiff_bigtiff is set or the file could pass 4 GiB.

  • EXR

    • Multi-part files (a Maya/Arnold render writes one part per AOV) open the part that looks most like a colour image — RGBA beats RGB beats YA beats a single channel, so a depth-only part no longer fails the file — with DecodeWarning::MoreParts naming how many parts exist. exr_info lists every part (name, view, size, channels, deep/tiled) without decoding pixels, and decode_exr_part decodes a chosen one. Deep parts stay with the deep decoder: a file that has any is refused here with a pointer to it.
    • Reads at full resolution, from the data window.
    • Channel names are matched by suffix, so layer.R counts as R.
    • Subsampled channels are unsupported.
    • Cryptomatte layers (specification 1.2, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles) are listed and decoded structurally: cryptomatte_layers reads the header attributes, channels and embedded manifest without pixels, decode_cryptomatte returns the per-pixel (ID, coverage) pairs, and cryptomatte_id/cryptomatte_key/cryptomatte_preview_color hash names to IDs the way the reference implementation does (official test vectors in the test suite).
    • Deep data (scanlines or single-level tiles, compressed NONE, RLE or ZIPS) opens as a flat image: each pixel's samples are composited into one, with DecodeWarning::DeepFlattened because per-pixel depth is not kept. Deep ZIP (16-line blocks) and deep mip-map or rip-map tiles return 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). PNG also bounds the aggregate decoded text and XMP (UTF-8 keywords and values) by max_alloc, including chunks after IDAT. This budget is separate from pixels, not a total process-memory cap.

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.