Files
photocraft/crates/codecs/src/lib.rs
T
bd632e7eb3 Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215) (#672)
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)

Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.

* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
  the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
  Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
  doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
  masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
  binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
  annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
  warning instead of passing byte-swapped data to other readers. The reader gained an optional
  little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
  bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
  BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
  import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
  options), interleaves its merged planes for the TIFF image and stores the layer section in
  the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
  documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
  and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
  Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
  TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
  byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
  `corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
  round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
  hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs

The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 17:10:35 -04:00

128 lines
6.2 KiB
Rust
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`: symmetric, depth- and metadata-preserving codecs for
//! flat raster image formats.
//!
//! * Every format we **write** we can also **read** (see
//! [`ASYMMETRIC_EXCEPTIONS`] for the documented, feature-gated exception).
//! * Bit depth (8/16-bit integer, 16/32-bit float), ICC profiles and basic
//! metadata (EXIF, XMP, DPI, text) are preserved where the format allows.
//! * [`caps`] declares per-format capabilities and [`fidelity_warnings`]
//! tells the UI exactly what an export will lose.
//! * The API is I/O free (`&[u8]` in, `Vec<u8>` out) and builds for
//! `wasm32-unknown-unknown`. Decoders enforce [`Limits`] before allocating.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod codecs;
mod error;
mod fidelity;
mod format;
mod image;
mod options;
pub mod orientation;
pub mod web;
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
pub use crate::error::CodecError;
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
pub use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, Metadata, SampleType};
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use half::f16;
use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
decode_with(bytes, &DecodeOptions::default())
}
/// Detect the format and decode.
pub fn decode_with(bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
let format = detect(bytes).ok_or(CodecError::UnknownFormat)?;
decode_as_with(format, bytes, opts)
}
/// Decode as a specific format with default [`Limits`].
pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
decode_as_with(format, bytes, &DecodeOptions::default())
}
/// Decode as a specific format.
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
if !caps(format).read && format != Format::Heif {
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
}
let l = &opts.limits;
let img = match format {
Format::Png => png::decode(bytes, l),
Format::Jpeg => jpeg::decode(bytes, l),
Format::Tiff => tiff::decode(bytes, l),
Format::WebP => webp::decode(bytes, l),
Format::Pnm => pnm::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
}?;
// Final guard for decoders whose header we could not pre-inspect.
l.check(img.width(), img.height(), img.layout(), img.sample_type())?;
if opts.keep_orientation {
return Ok(img);
}
// Turn the pixels upright, like Photoshop: a TIFF records it in its own IFD0, the others
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way. HEIF keeps it
// in its container, and its decoder has already applied it.
let o = match format {
Format::Tiff => exif_orientation(bytes),
Format::Heif => 1,
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
};
// Orientations 5–8 swap width and height: with asymmetric limits a stored landscape
// that fit can turn into a portrait that does not, so check the upright size too.
if (5..=8).contains(&o) {
l.check(img.height(), img.width(), img.layout(), img.sample_type())?;
}
img.oriented(o)
}
/// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists
/// (colour, alpha and Z per sample), for callers that composite deep data themselves —
/// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a
/// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part.
pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
let mut cur = std::io::Cursor::new(bytes);
let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?;
codecs::deep_exr::decode_deep(&meta, bytes, limits)
}
/// Encode `image` as `format`. Conversions follow the same plan that
/// [`fidelity_warnings_with`] reports; fatal conditions (unsupported
/// format, oversize image) return an error.
pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
if !caps(format).write {
return Err(CodecError::unsupported(format, "encoding is not available in this build"));
}
if image.width() == 0 || image.height() == 0 {
return Err(CodecError::InvalidImage("cannot encode an empty image".into()));
}
if let Some((mw, mh)) = format.max_dimensions()
&& (image.width() > mw || image.height() > mh)
{
return Err(CodecError::encode(format, format!("dimensions exceed {mw}x{mh}")));
}
let plan = fidelity::plan(image, format, opts);
match format {
Format::Png => png::encode(image, plan, opts),
Format::Jpeg => jpeg::encode(image, plan, opts),
Format::Tiff => tiff::encode(image, plan, opts),
Format::WebP => webp::encode(image, plan, opts),
Format::Pnm => pnm::encode(image, plan, opts),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
}
}