mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 04:57:15 +01:00
* 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>
128 lines
6.2 KiB
Rust
128 lines
6.2 KiB
Rust
//! `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),
|
||
}
|
||
}
|